{"id":1649,"date":"2026-01-10T14:55:43","date_gmt":"2026-01-10T12:55:43","guid":{"rendered":"https:\/\/macadmin.cz\/?p=1649"},"modified":"2026-01-10T15:40:20","modified_gmt":"2026-01-10T13:40:20","slug":"transparent-ikev2-vpn-for-a-lxc-container","status":"publish","type":"post","link":"https:\/\/macadmin.cz\/?p=1649&lang=en","title":{"rendered":"Transparent IKEv2 VPN for a LXC container"},"content":{"rendered":"\n<p>Recently, I implemented an interesting scenario involving Linux networking and VPN. These were the requirements:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Unprivileged<strong> LXC container<\/strong> running on Debian 13 host:\n<ul class=\"wp-block-list\">\n<li>All internet-bound traffic from the container is routed via remote VPN.<\/li>\n\n\n\n<li>Container must not be able to communicate with any other networks the host might be directly connected to.<\/li>\n\n\n\n<li>The container itself has no knowledge about the VPN.<\/li>\n\n\n\n<li>If the VPN does not work, the container can&#8217;t communicate with internet services.<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li><strong>Debian 13 host<\/strong>:\n<ul class=\"wp-block-list\">\n<li>strongSwan handles IPSec IKEv2 connections with the remote VPN service.<\/li>\n\n\n\n<li>Network traffic originating from the host system is not routed via the VPN service.<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>Remote IPSec IKEv2 <strong>VPN service<\/strong>:\n<ul class=\"wp-block-list\">\n<li>Not under our control.<\/li>\n\n\n\n<li>Assigns a single virtual IPv4 address.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/macadmin.cz\/wp-content\/uploads\/basenetwork.png\"><img loading=\"lazy\" decoding=\"async\" width=\"576\" height=\"316\" src=\"https:\/\/macadmin.cz\/wp-content\/uploads\/basenetwork.png\" alt=\"\" class=\"wp-image-1650\" srcset=\"https:\/\/macadmin.cz\/wp-content\/uploads\/basenetwork.png 576w, https:\/\/macadmin.cz\/wp-content\/uploads\/basenetwork-300x165.png 300w\" sizes=\"auto, (max-width: 576px) 100vw, 576px\" \/><\/a><figcaption class=\"wp-element-caption\">The initial network diagram<\/figcaption><\/figure>\n\n\n\n<p>IPv4 ranges (RFC1918) used in the example<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>192.168.99.0\/24<\/code> &#8211; network the host is connected to<\/li>\n\n\n\n<li><code>192.168.222.0\/24<\/code> &#8211; host-only network for the container(s)<\/li>\n\n\n\n<li><code>10.9.8.7\/32<\/code> &#8211; VPN virtual IP<\/li>\n<\/ul>\n\n\n\n<p>Let&#8217;s take a brief look at a base VPN configuration. It is very easy to configure a policy-based IKEv2 VPN for the host. Example <code>swanctl.conf<\/code>:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\npolicy-based-vpn {\n  version = 2\n\n  local_addrs  = 192.168.99.98\n  remote_addrs = remote-vpn-gateway.macadmin.cz\n  vips = 0.0.0.0\n\n  local {\n    auth = pubkey\n    id = vpn-client.macadmin.cz\n  }\n\n  remote {\n      auth = pubkey\n      id = remote-vpn-gateway.macadmin.cz\n  }\n\n  children {\n    netvpn {\n      remote_ts = 0.0.0.0\/0\n      start_action = trap\n    }\n  }\n}\n<\/pre><\/div>\n\n\n<p>The challenge is how to make it work for the traffic originating from the container<br>and exclude all traffic originating from the host system. The host only network <code>192.168.222.0\/24<\/code>, the container is connected to, will have to be NATed behind a single IPv4 address to match the 1:1 expectation of the VPN service. First I tried to come up with a solution using policy-based VPN configuration, but after some thinking I figured a <a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/features\/routeBasedVpn.html\">route-based VPN<\/a> might be a better fit for this situation.<\/p>\n\n\n\n<p>The plan:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Create XFRM interface <code>ipsec0<\/code> and assign IPSec policy with match-all-traffic selector <code>0.0.0.0\/0<\/code>.<\/li>\n\n\n\n<li>Create source routing table which will apply to all traffic originating from the host-only network and use it to route the traffic to <code>ipsec0<\/code> interface.<\/li>\n\n\n\n<li>Use <code>nftables<\/code> to configure Source NAT on <code>ipsec0<\/code>.<\/li>\n\n\n\n<li>Make sure virtual IPv4 address assigned by the VPN server gets assigned to <code>ipsec0<\/code> and not any another interface.<\/li>\n<\/ol>\n\n\n\n<!--more-->\n\n\n\n<h2 class=\"wp-block-heading\">Prepwork<\/h2>\n\n\n\n<p>Install packages:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>bridge-utils<\/code><\/li>\n\n\n\n<li><code>nftables<\/code><\/li>\n\n\n\n<li><code>charon-systemd<\/code><\/li>\n\n\n\n<li><code>strongswan-swanctl<\/code><\/li>\n\n\n\n<li><code>libstrongswan-extra-plugins<\/code><\/li>\n\n\n\n<li><code>libcharon-extra-plugins<\/code><\/li>\n<\/ul>\n\n\n\n<p>Enable routing in kernel:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Run <code>sysctl -w net.ipv4.ip_forward=1<\/code> <\/li>\n\n\n\n<li>Check <code>\/etc\/sysctl.conf<\/code> to see if the setting is present.<\/li>\n<\/ol>\n\n\n\n<p>Create <code>vpnrouting<\/code> source routing table:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><code>mkdir -p \/etc\/iproute2\/rt_tables.d<\/code><\/li>\n\n\n\n<li><code>echo 100 vpnrouting >> \"\/etc\/iproute2\/rt_tables.d\/vpnrouting.conf<\/code>&#8220;<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring interfaces<\/h2>\n\n\n\n<p><code>ip link add ipsec0 type xfrm if_id 42<\/code> commands create a <code>ipsec0<\/code> xfrm interface which is listed as <code>ipsec0@NONE<\/code>. However, this change is not persistent. The interface would have to be created via every boot via a hook script or a custom service.<\/p>\n\n\n\n<p>I wanted a static configuration. Turns out this is not easily doable with <code>ifupdown<\/code> configuration in <code>\/etc\/network\/interfaces<\/code>. There is a discussion <a href=\"https:\/\/github.com\/strongswan\/strongswan\/discussions\/1833#discussioncomment-6660738\">post<\/a> in the strongSwan GitHub repo which shows the static configuration can be done with <code>system-network<\/code>.<\/p>\n\n\n\n<p>Debian 13 does not use <code>systemd-network<\/code> by default. Instead, NetworkManager is used for GUI installs and <code>ifupdown<\/code> for headless setups. I followed the Debian SystemdNetworkd <a href=\"https:\/\/wiki.debian.org\/SystemdNetworkd\">article<\/a> and:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Disabled all <code>ifupdown<\/code> interface configuration files.<\/li>\n\n\n\n<li>Created configuration files in the <code>\/etc\/systemd\/network<\/code> directory (see below).<\/li>\n\n\n\n<li>Enabled (<code>systemctl enable systemd-networkd<\/code>) and started (<code>systemctl start systemd-networkd<\/code>) the <code>systemd-networkd<\/code><strong> <\/strong>service.<\/li>\n<\/ol>\n\n\n\n<p><code><strong>br-lxc0.netdev<\/strong><\/code><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;NetDev]\nName=br-lxc0\nKind=bridge\n\n&#x5B;Bridge]\nSTP=false\nForwardDelaySec=0\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><code><strong>br-lxc0.network<\/strong><\/code><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;Match]\nName=br-lxc0\n\n&#x5B;Network]\nAddress=192.168.222.1\/24\nConfigureWithoutCarrier=yes\n\n&#x5B;Link]\nActivationPolicy=always-up\n\n&#x5B;Route]\nDestination=192.168.222.0\/24\nTable=100\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong><code>loopback.network<\/code><\/strong><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;Match]\nName=lo\n\n&#x5B;Network]\nXfrm=ipsec0\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong><code>ipsec0.netdev<\/code><\/strong><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;NetDev]\nName=ipsec0\nKind=xfrm\n\n&#x5B;Xfrm]\nInterfaceId=17\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><strong><code>ipsec0.network<\/code><\/strong><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&#x5B;Match]\nName=ipsec0\n\n&#x5B;Link]\nActivationPolicy=always-up\n\n&#x5B;Route]\nGateway=0.0.0.0\nTable=100\n\n&#x5B;RoutingPolicyRule]\nFrom=192.168.222.0\/24\nTable=100\n<\/pre><\/div>\n\n\n<p>There is one more thing. Systemd will happily override <code>net.ipv4.ip_forward<\/code> during system boot, thus disabling the routing. To prevent this from happening, make sure to set <code>IPv4Forwarding=yes<\/code> in <code>\/etc\/systemd\/networkd.conf<\/code>.<\/p>\n\n\n\n<p>With the <code>systemd-networkd<\/code> running, configuration can be inspected to check <code>ipsec0<\/code> interface is present and source routing configured. <\/p>\n\n\n\n<p><code><strong>ip a<\/strong><\/code><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n1: lo: &amp;lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000\n    link\/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00\n    inet 127.0.0.1\/8 scope host lo\n\n2: eno0: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000\n    link\/ether fe:dc:ba:09:87:65 brd ff:ff:ff:ff:ff:ff\n    inet 192.168.99.98\/24 brd 192.168.99.255 scope global dynamic noprefixroute eno0\n\n3: br-lxc0: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000\n    link\/ether fe:dc:ba:09:87:64 brd ff:ff:ff:ff:ff:ff\n    inet 192.168.222.1\/24 brd 192.168.222.255 scope global br-private0\n\n4: ipsec0@lo: &amp;lt;NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000\n    link\/none \n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><code><strong>ip rule<\/strong><\/code><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n0:    from all lookup local\n220:    from all lookup 220\n32765:    from 192.168.222.0\/24 lookup vpnrouting proto static\n32766:    from all lookup main\n32767:    from all lookup default\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><code><strong>ip route list table vpnrouting<\/strong><\/code><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\ndefault dev ipsec0 proto static scope link\n192.168.222.0\/24 dev br-lxc0 proto static scope link\n<\/pre><\/div>\n\n\n<h2 class=\"wp-block-heading\">nftables configuration<\/h2>\n\n\n\n<p><strong><code>\/etc\/nftables.conf<\/code><\/strong><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\ntable inet filter {\n    chain forward {\n        type filter hook forward priority filter; policy drop;\n        iifname &quot;br-lxc0&quot; oifname &quot;ipsec0&quot; accept\n        iifname &quot;ipsec0&quot; oifname &quot;br-lxc0&quot; ct state established,related accept\n    }\n\n    chain nat {\n        type nat hook postrouting priority srcnat; policy accept;\n        oifname &quot;ipsec0&quot; masquerade\n    }\n}\n<\/pre><\/div>\n\n\n<p>Routing rules implicitly prevent the container traffic from going to undesirable places.<br>Explicit firewall configuration in the forward chain ensures this.<br><br>Source NAT configured with masquerade translates the source IPv4 of all packets leaving the <code>ipsec0<\/code> interface before they are encapsulated. The new address is the virtual IPv4 address assigned by the VPN service and attached to the interface by strongSwan.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">strongSwan configuration<\/h2>\n\n\n\n<p><strong><em>\/etc\/swanctl\/conf.d\/connections.conf<\/em><\/strong><\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nroute-based-vpn {\n  version = 2\n\n  local_addrs  = 192.168.99.98\n  remote_addrs = remote-vpn-gateway.macadmin.cz\n  vips = 0.0.0.0\n\n  local {\n    auth = pubkey\n    id = vpn-client.macadmin.cz\n  }\n\n  remote {\n      auth = pubkey\n      id = remote-vpn-gateway.macadmin.cz\n  }\n\n  children {\n    netvpn {\n      local_ts = 0.0.0.0\/0\n      remote_ts = 0.0.0.0\/0\n      if_id_in = 17\n      if_id_out = 17\n      start_action = trap\n    }\n  }\n}\n<\/pre><\/div>\n\n\n<p>Consider enabling dead pear detection (DPD) and appropriate action when that happens. Example values:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\ndpd_delay = 300s\nchildren.netvpn.dpd_action = clear\n<\/pre><\/div>\n\n\n<p>Tell strongSwan to assign the virtual IP address onto <code>ipsec0<\/code> interface by using <code>install_virtual_ip_on<\/code> key inn <code><em>\/etc\/strongswan.conf<\/em><\/code>:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\ncharon {\n    load_modular = yes\n    plugins {\n        include strongswan.d\/charon\/*.conf\n    }\n    install_virtual_ip_on = ipsec0\n}\n\ninclude strongswan.d\/*.conf\n<\/pre><\/div>\n\n\n<p>I learned about this by reading a <a href=\"https:\/\/github.com\/strongswan\/strongswan\/discussions\/1582#discussioncomment-5242797\">discussion<\/a> in strongSwan GitHub repo. This GitHub <a href=\"https:\/\/github.com\/strongswan\/strongswan\/issues\/2881\">issue<\/a> is also somewhat relevant.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p>With all the pieces in place a reboot might be required. Alternatively make sure the services are restarted\/reloaded in order:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><code>systemd-networkd<\/code><\/li>\n\n\n\n<li><code>nftables<\/code><\/li>\n\n\n\n<li><code>strongswan<\/code><\/li>\n<\/ol>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/macadmin.cz\/wp-content\/uploads\/net2.drawio.png\"><img loading=\"lazy\" decoding=\"async\" width=\"527\" height=\"821\" src=\"https:\/\/macadmin.cz\/wp-content\/uploads\/net2.drawio.png\" alt=\"\" class=\"wp-image-1651\" srcset=\"https:\/\/macadmin.cz\/wp-content\/uploads\/net2.drawio.png 527w, https:\/\/macadmin.cz\/wp-content\/uploads\/net2.drawio-193x300.png 193w\" sizes=\"auto, (max-width: 527px) 100vw, 527px\" \/><\/a><figcaption class=\"wp-element-caption\">Final configuration diagram<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Additional resources<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">strongSwan<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/howtos\/introduction.html\">Introduction to strongSwan<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/howtos\/securityRecommendations.html\">Security Recommendations<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/features\/routeBasedVpn.html\">Route-based VPN<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/config\/strongswanConf.html\">strongswan.conf<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.strongswan.org\/docs\/latest\/swanctl\/swanctlConf.html\">swanctl.conf<\/a><\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Debian<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/wiki.debian.org\/SystemdNetworkd\">SystemdNetworkd<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/manpages.debian.org\/trixie\/systemd\/systemd.network.5.en.html\">SYSTEMD.NETWORK(5)<\/a><\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">XFRM<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Cilium <a href=\"https:\/\/docs.cilium.io\/en\/latest\/reference-guides\/xfrm\/index.html\">XFRM Reference Guide<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/pchaigno.github.io\/xfrm\/2024\/10\/30\/linux-xfrm-ipsec-reference-guide.html\">Linux XFRM Reference Guide for IPsec<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/thermalcircle.de\/doku.php?id=blog:linux:nftables_ipsec_packet_flow\">Nftables &#8211; Netfilter and VPN\/IPsec packet flow<br><\/a><br><br><br><a href=\"https:\/\/pchaigno.github.io\/xfrm\/2024\/10\/30\/linux-xfrm-ipsec-reference-guide.html\"><br><\/a><br><br><br><br><br><\/li>\n<\/ul>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Recently, I implemented an interesting scenario involving Linux networking and VPN. These were the requirements: IPv4 ranges (RFC1918) used in the example Let&#8217;s take a brief look at a base VPN configuration. It is very easy to configure a policy-based IKEv2 VPN for the host. Example swanctl.conf: The challenge is how to make it work &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/macadmin.cz\/?p=1649&#038;lang=en\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;Transparent IKEv2 VPN for a LXC container&#8221;<\/span><\/a><\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"ngg_post_thumbnail":0,"footnotes":""},"categories":[1],"tags":[105,93,101,103,95,99,97],"class_list":["post-1649","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-debian","tag-linux","tag-lxc","tag-nftables","tag-strongswan","tag-vpn","tag-xfrm"],"_links":{"self":[{"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/posts\/1649","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/macadmin.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=1649"}],"version-history":[{"count":3,"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/posts\/1649\/revisions"}],"predecessor-version":[{"id":1656,"href":"https:\/\/macadmin.cz\/index.php?rest_route=\/wp\/v2\/posts\/1649\/revisions\/1656"}],"wp:attachment":[{"href":"https:\/\/macadmin.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=1649"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/macadmin.cz\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=1649"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/macadmin.cz\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=1649"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}