To have all internet-bound traffic go out through a Wireguard tunnel—and if Wireguard goes down, to not not have any internet-bound traffic leak through other interfaces—the easiest option is to use a container with a private network stack.
There are two options:
- You can move the Wireguard interface into the container. This takes some work to set up and prevents the host from using the interface.
- If the host needs connectivity over the same Wireguard tunnel, the host can create virtual interfaces and perform network address translation (NAT) to forward traffic to and from the child.
I have most confidence in the first option, especially as the surrounding configuration evolves. You’re less likely to subtly break the configuration and start leaking traffic in the future.
Both options are shown here. NixOS is my preferred system, so I’ve specified the configuration using NixOS here. Translation to a non-NixOS system should be straightforward.
Option 1: move the Wireguard interface into the container
Here we set up Wireguard in a new network namespace and move that namespace into the container. The Wireguard interface must be created in the main (host) network namespace and subsequently moved into the new namespace: this allows Wireguard to communicate with the endpoint over the main network interface.
The following sets up the Wireguard interface in the container as the default
route, and allows some traffic between the LAN and the container through a
virtual ethernet interface. This configuration assumes the host’s main network
interface is called enp3s0
.
systemd-networkd does not yet provide the required tools to do this. Instead, this configuration defines systemd units to perform the network setup.
{ lib, pkgs, ... }:
let
wgIpv4 = "1.2.3.4";
wgIpv6 = "1:2:3:4::0";
natIpv4Host = "192.168.10.10";
natIpv4Ns = "192.168.10.11";
in
{
networking.useDHCP = false;
networking.interfaces."enp3s0" = {
useDHCP = true;
};
environment.etc."netns-wgns-wg-ips".text = ''
IPV4=${wgIpv4}
IPV6=${wgIpv6}
'';
environment.etc."netns-wgns-veth-ips".text = ''
IPV4_HOST=${natIpv4Host}
IPV4_NS=${natIpv4Ns}
'';
systemd.services = {
"netns@" = {
description = "%i network namespace";
serviceConfig = with pkgs; {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${iproute}/bin/ip netns add %i";
ExecStop = "${iproute}/bin/ip netns del %i";
};
};
"lo@" = {
description = "loopback in %i network namespace";
bindsTo = [ "netns@.service" ];
after = [ "netns@.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart =
let
start = with pkgs; writeShellScript "lo-up" ''
set -e
${iproute}/bin/ip -n $1 addr add 127.0.0.1/8 dev lo
${iproute}/bin/ip -n $1 link set lo up
'';
in
"${start} %i";
ExecStopPost = with pkgs; "${iproute}/bin/ip -n %i link del lo";
};
};
# This unit assumes a file named /etc/netns-<network namespace>-veth-ips
# exists to set IPV4_HOST and IPV4_NS environment variables to valid
# IPv4 addresses to be used for the two sides of the veth interface.
"veth@" = {
description = "virtual ethernet network interface between the main and %i network namespaces";
bindsTo = [ "netns@.service" ];
after = [ "netns@.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart =
let
start = with pkgs; writeShellScript "veth-up" ''
set -e
. /etc/netns-$1-veth-ips
# As this templated unit potentially has multiple units running
# simultaneously, the interface is created with a unique name in
# the main network namespace, then moved to the new network
# namespace and renamed.
${iproute}/bin/ip link add ve-$1 type veth peer name eth-lan-$1
${iproute}/bin/ip link set eth-lan-$1 netns $1
${iproute}/bin/ip -n $1 link set dev eth-lan-$1 name eth-lan
${iproute}/bin/ip addr add $IPV4_HOST/32 dev ve-$1
${iproute}/bin/ip -n $1 addr add $IPV4_NS/32 dev eth-lan
${iproute}/bin/ip link set ve-$1 up
${iproute}/bin/ip -n $1 link set eth-lan up
${iproute}/bin/ip route add $IPV4_NS/32 dev ve-$1
${iproute}/bin/ip -n $1 route add $IPV4_HOST/32 dev eth-lan
'';
in
"${start} %i";
ExecStopPost =
let
stop = with pkgs; writeShellScript "veth-down" ''
${iproute}/bin/ip -n $1 link del eth-lan
${iproute}/bin/ip link del ve-$1
'';
in
"${stop} %i";
};
};
# This unit assumes a Wireguard configuration file exists at
# /etc/wg-<network namespace>.conf. Note this is a Wireguard configuration
# file, not a wg-quick configuration file. See "CONFIGURATION FILE FORMAT
# EXAMPLE" in wg(8).
#
# This further assumes a file exists at /etc/netns-<network
# namespace>-wg-ips to set IPV4 and IPV6 environment variables to valid IP
# addresses to be used by the Wireguard interface.
"wg@" = {
description = "wg network interface in %i network namespace";
bindsTo = [ "netns@.service" ];
wants = [
"network-online.target"
"nss-lookup.target"
];
after = [
"netns@.service"
"network-online.target"
"nss-lookup.target"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 3;
ExecStart =
let
start = with pkgs; writeShellScript "wg-up" ''
set -e
. /etc/netns-$1-wg-ips
# As this templated unit potentially has multiple units running
# simultaneously, the interface is created with a unique name in
# the main network namespace, then moved to the new network
# namespace and renamed.
${iproute}/bin/ip link add wg-$1 type wireguard
${iproute}/bin/ip link set wg-$1 netns $1
${iproute}/bin/ip -n $1 link set dev wg-$1 name wg0
${iproute}/bin/ip -n $1 addr add $IPV4 dev wg0
${iproute}/bin/ip -n $1 -6 addr add $IPV6 dev wg0
${iproute}/bin/ip netns exec $1 \
${wireguard-tools}/bin/wg setconf wg0 /etc/wg-$1.conf
${iproute}/bin/ip -n $1 link set wg0 up
${iproute}/bin/ip -n $1 route add default dev wg0
${iproute}/bin/ip -n $1 -6 route add default dev wg0
'';
in
"${start} %i";
ExecStopPost =
let
stop = with pkgs; writeShellScript "wg-down" ''
${iproute}/bin/ip -n $1 route del default dev wg0
${iproute}/bin/ip -n $1 -6 route del default dev wg0
${iproute}/bin/ip -n $1 link del wg0
'';
in
"${stop} %i";
};
};
};
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
};
networking.nftables = {
enable = true;
ruleset = ''
table inet nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
iifname "enp3s0" oifname "ve-wgns" masquerade
}
chain prerouting {
type nat hook prerouting priority -100; policy accept;
iifname "enp3s0" tcp dport { ssh, http } dnat ip to ${natIpv4Ns}
}
}
'';
};
systemd.services."container@wgcontainer" = {
requires = [
"lo@wgns.service"
"veth@wgns.service"
"wg@wgns.service"
];
after = [
"lo@wgns.service"
"veth@wgns.service"
"wg@wgns.service"
];
};
containers = {
wgcontainer = {
specialArgs = { inherit inputs; };
autoStart = true;
extraFlags = [ "--network-namespace-path=/run/netns/wgns" ];
config = { ... }: {
networking.useHostResolvConf = lib.mkForce false;
networking.nameservers = [
# Alternatively, use a DNS server provided by the VPN
"1.1.1.1"
"9.9.9.9"
];
networking.useDHCP = false;
networking.useNetworkd = true;
networking.firewall.enable = false;
networking.nftables = {
enable = true;
ruleset = ''
table inet filter {
chain output {
# Drop everything by default
type filter hook output priority 100; policy drop;
oif lo accept comment "Accept all packets sent to loopback"
oifname "wg0" accept comment "Accept all packets that go out over Wireguard"
ct state established,related accept comment "Accept all packets of established traffic"
}
chain input {
# Drop everything by default
type filter hook input priority filter; policy drop;
iif lo accept
iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
iifname "eth-lan" accept comment "Accept all packets coming from lan"
iifname "wg0" tcp dport { 1111, 2222 } accept comment "Accept specific ports coming from Wireguard"
ct state established,related accept
}
}
'';
};
};
};
};
}
This configuration defines systemd units to bring up and take down the network
parts required for the container’s network. In this configuration the units are
templated (i.e., their name ends with the @
-suffix) to allow multiple
Wireguard network namespaces to be created simultaneously. If you’d start units
wg@wgns1.service
and wg@wgns2.service
, two network namespaces would be
created (wgns1
and wgns2
), each with their own Wireguard interfaces. This
aspect of the configuration might not be necessary for your use-case.
The important details here are:
- The container is started using
--network-namespace-path=/run/netns/<namespace name>
to bind the network namespace to the container. This makes the namespace unavailable to the host and sets the namespace up as the main namespace in the container. - The
wg@.service
unit sets up the Wireguard interface in the main network namespace and moves it to the namespace to be used by the container. The interface is set up as the default route. - The
veth@.service
unit sets up a virtual ethernet interface pair between the host’s main network namespace and the namespace to be used by the container. This allows communication between the host and the container. IPv4 forwarding is enabled using sysctl and nftables rules are set up to destination-NAT some incoming traffic from the host to the container. - The
lo@.service
unit creates a loopback device in the network namespace. This is required to allow processes on the container to communicate locally with other processes on the container using the network stack. - The
veth@.service
andwg@.service
units read some configuration from disk based on the network namespace name. The units are only passed the network namespace name as systemd template argument, but need more configuration information (such as IP addresses and Wireguard configuration files). If you don’t use templated units, the values can be hardcoded instead. - For hardening, the container’s firewall allows outbound traffic to go out of only the Wireguard interface, except for traffic that is related to earlier accepted traffic. Incoming LAN traffic is accepted (but only traffic forwarded by the host can reach the container). The container cannot initiate LAN connections. It also does not have routes set up to reach other systems on the host’s network.
Option 2: NAT to and from the container
This assumes the host’s main network interface is called enp3s0
and a
Wireguard interface is already set up named wg0
. This further assumes the
wg0
interface is set as the default route. This configuration ensures
container traffic to the internet only goes out through the Wireguard
interface; the traffic is dropped otherwise. See here how you might
set up Wireguard on the host.
{ ... }:
{
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
"net.ipv6.ip_forward" = 1;
};
networking.nftables = {
enable = true;
ruleset = ''
table inet wg-wg0 {
chain forward {
type filter hook forward priority filter; policy drop;
# Dynamically rewrite the TCP max segment size to the MTU discovered on this path
tcp flags syn tcp option maxseg size set rt mtu
iifname "ve-wgcontainer" oifname "wg0" accept
ct state established,related accept comment "Accept all packets of established traffic"
ct status dnat accept
}
chain postrouting {
type nat hook postrouting priority 100; policy accept;
iifname "ve-wgcontainer" oifname "wg0" masquerade
iifname "enp3s0" oifname "eth-lan" masquerade
}
chain prerouting {
type nat hook prerouting priority -100; policy accept;
iifname "wg0" meta nfproto ipv4 tcp dport { 1111, 2222 } dnat to 192.168.100.11
iifname "wg0" meta nfproto ipv6 tcp dport { 1111, 2222 } dnat to fc00:100::2
iifname "enp3s0" meta nfproto ipv4 tcp dport { http } dnat to 192.168.105.11
}
}
'';
};
containers = {
wgcontainer = {
autoStart = true;
privateNetwork = true;
hostAddress = "192.168.100.10";
localAddress = "192.168.100.11";
hostAddress6 = "fc00:100::1";
localAddress6 = "fc00:100::2";
extraVeths."eth-lan" = {
hostAddress = "192.168.105.10";
localAddress = "192.168.105.11";
};
config = { ... }:
{
networking.firewall.enable = false;
networking.nftables = {
enable = true;
tables."nixos-fw".enable = false;
ruleset = ''
table inet filter {
chain output {
# Drop everything by default
type filter hook output priority 100; policy drop;
oif lo accept comment "Accept all packets sent to loopback"
oifname "eth0" accept comment "Accept all packets that go out over Wireguard"
ct state established,related accept comment "Accept all packets of established traffic"
}
chain input {
# Drop everything by default
type filter hook input priority filter; policy drop;
iif lo accept
iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
iifname "eth-lan" accept comment "Accept all packets coming from lan"
iifname "eth0" tcp dport { 1111, 2222 } accept comment "Accept specific ports coming from Wireguard"
ct state established,related accept
}
}
'';
};
};
};
};
}
An interface pair (named eth0
on the container and ve-wgcontainer
on the
host) is set up by the containers.wgcontainer.{host,local}Address
rules. It
is automatically set up as the default route in the container and is used
exclusively to send traffic through the Wireguard tunnel (i.e., to reach the
internet and potentially to reach a network on the other side of the tunnel).
The configuration further sets up eth-lan
(identically named on the container
and host) to forward some LAN traffic to the container. In this configuration
the container cannot initiate connections to the LAN; it can only respond to
them.
The important details on the host are:
- Allow IP forwarding using
sysctl
. This is required to perform destination-NAT to the container. - For security, don’t forward any traffic by default.
- Accept forwarding all traffic coming from the container going out through Wireguard.
- Accept forwarding established/related traffic: this allows forwarding all traffic necessary for flows that were initiated and accepted earlier.
- Accept forwarding destination-NAT’ed traffic. All traffic that is explicitly “port-forwarded” by destination-NAT rules is accepted.
- Masquerade traffic from the container interface
ve-wgcontainer
to the Wireguard interfacewg0
—this rewrites the source IP address from 192.168.100.11 or fc00:100::2 to the IP on thewg0
interface, so the other side of the tunnel knows where to send responses (it is unaware of this local network setup). The remote peer will most likely perform its own NAT on this traffic if it is to reach the internet. - Masquerade traffic from the LAN interface
enp3s0
to theeth-lan
interface. This pretends all LAN traffic comes from the host, effectively hiding the LAN from the container. The container has a route set up to the other side of theeth-lan
interface, but only for the host’s IP192.168.105.10
. - As an example, forward some traffic from the Wireguard interface addressing ports 1111 and 2222 to the container, and the HTTP port (80) is forwarded from the LAN to the container. This enables some specific traffic that is not initiated by the container itself.
The container configuration is simpler. Some hardening is applied:
- All outgoing traffic is dropped by default. Traffic going to the loopback interface is allowed, traffic that goes out to the interface that the host NATs to Wireguard is allowed, and traffic that is established/related to existing flows is allowed. This means the container cannot initiate any network connection except for those that go out through Wireguard.
- All incoming traffic from the LAN interface is allowed, and some traffic to ports from the Wireguard interface is allowed. Again, established/related traffic is allowed.