Wireguard namespace flake
Published on
For a while I have been playing with the idea of running services in Linux namespaces to achieve network isolation from the “host” system on Linux. This project, written in literate programming style, is an Nix-based implementation of a configurable network namespace that uses Wireguard.
The page is structured to match the implementation of the flake. If you want to just use the flake – jump straight to Usage example section.
Flake #
The flake will not have any specific inputs beyond nixpkgs
.
{
description = "A flake that implements Wireguard adapter that gets moved into a VPN namespace";
outputs = { self, nixpkgs }:
The whole functionality of this flake will be expressed in a single module.
As of Nix 2.8 , nixosModules.default
replaces nixosModule
:
{
nixosModules.default = import ./modules;
};
}
Module #
By definition, Nix modules are functions that return attribute sets. This module will take as parameters:
- pkgs
- reference to current version of nixpkgs repository
- lib
- a collection of helper functions
- config
- reference to the system configuration
{ pkgs, lib, config, ... }:
The latter parameter will be used to retrieve the service’s configuration if it’s defined in the rest of the system configuration:
let
cfg = config.services.wireguard-namespace;
in
{
This module will have two keys in the attrset:
- options
- where user-configurable options are exposed
- config
- where the configuration is implemented
Options #
This module will be passing a lot of options directly to nixpkgs
’ default wireguard module. The order of the options does not matter, but I will be listing the module-specific options first, and the options taken from wireguard module at the end.
options.services.wireguard-namespace = with lib; {
namespace_name = mkOption {
type = types.str;
description = "The name of the VPN network namespace";
default = "vpn";
};
dns_server = mkOption {
type = types.str;
description = "IP address of the DNS server to be used in the network namespace";
};
extraFirewallRules = mkOption {
type = types.str;
default = "";
};
# Default options follow
ips = mkOption {
example = [ "192.168.2.1/24" ];
default = [];
type = with types; listOf str;
description = "The IP addresses of the interface.";
};
privateKeyFile = mkOption {
example = "/private/wireguard_key";
type = with types; nullOr str;
description = ''
Private key file as generated by <command>wg genkey</command>.
'';
};
peers = let
peerOpts = {
options = {
publicKey = mkOption {
example = "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=";
type = types.str;
description = "The base64 public key of the peer.";
};
presharedKey = mkOption {
default = null;
example = "rVXs/Ni9tu3oDBLS4hOyAUAa1qTWVA3loR8eL20os3I=";
type = with types; nullOr str;
description = ''
Base64 preshared key generated by <command>wg genpsk</command>.
Optional, and may be omitted. This option adds an additional layer of
symmetric-key cryptography to be mixed into the already existing
public-key cryptography, for post-quantum resistance.
Warning: Consider using presharedKeyFile instead if you do not
want to store the key in the world-readable Nix store.
'';
};
presharedKeyFile = mkOption {
default = null;
example = "/private/wireguard_psk";
type = with types; nullOr str;
description = ''
File pointing to preshared key as generated by <command>wg genpsk</command>.
Optional, and may be omitted. This option adds an additional layer of
symmetric-key cryptography to be mixed into the already existing
public-key cryptography, for post-quantum resistance.
'';
};
allowedIPs = mkOption {
example = [ "10.192.122.3/32" "10.192.124.1/24" ];
type = with types; listOf str;
description = ''List of IP (v4 or v6) addresses with CIDR masks from
which this peer is allowed to send incoming traffic and to which
outgoing traffic for this peer is directed. The catch-all 0.0.0.0/0 may
be specified for matching all IPv4 addresses, and ::/0 may be specified
for matching all IPv6 addresses.'';
};
endpoint = mkOption {
default = null;
example = "demo.wireguard.io:12913";
type = with types; nullOr str;
description = ''Endpoint IP or hostname of the peer, followed by a colon,
and then a port number of the peer.
Warning for endpoints with changing IPs:
The WireGuard kernel side cannot perform DNS resolution.
Thus DNS resolution is done once by the <literal>wg</literal> userspace
utility, when setting up WireGuard. Consequently, if the IP address
behind the name changes, WireGuard will not notice.
This is especially common for dynamic-DNS setups, but also applies to
any other DNS-based setup.
If you do not use IP endpoints, you likely want to set
<option>networking.wireguard.dynamicEndpointRefreshSeconds</option>
to refresh the IPs periodically.
'';
};
dynamicEndpointRefreshSeconds = mkOption {
default = 0;
example = 5;
type = with types; int;
description = ''
Periodically re-execute the <literal>wg</literal> utility every
this many seconds in order to let WireGuard notice DNS / hostname
changes.
Setting this to <literal>0</literal> disables periodic reexecution.
'';
};
persistentKeepalive = mkOption {
default = null;
type = with types; nullOr int;
example = 25;
description = ''This is optional and is by default off, because most
users will not need it. It represents, in seconds, between 1 and 65535
inclusive, how often to send an authenticated empty packet to the peer,
for the purpose of keeping a stateful firewall or NAT mapping valid
persistently. For example, if the interface very rarely sends traffic,
but it might at anytime receive traffic from a peer, and it is behind
NAT, the interface might benefit from having a persistent keepalive
interval of 25 seconds; however, most users will not need this.'';
};
};
};
in
mkOption {
default = [];
description = "Peers linked to the interface.";
type = with types; listOf (submodule peerOpts);
};
};
Config #
Now, for the implementation, that is wrapped in config
attribute:
config = {
Preventing DNS leaks #
Linux network namespaces allow bind-mounting files in /etc/netns/<NAMESPACE_NAME>
over files in /etc
. Processes within the namespace will only see the contents of the bind-mounted files.
This approach does have drawbacks, as bind-mounts disappear if the original file’s inode changes. See this SO thread.
environment.etc = {
"netns/${cfg.namespace_name}/resolv.conf".text = ''nameserver ${cfg.dns_server}'';
# This setting forces the use of resolv.conf instead of dbus interface provided by systemd-resolved
"netns/${cfg.namespace_name}/nsswitch.conf".text = ''
passwd: files systemd
group: files systemd
shadow: files
hosts: dns
networks: files
ethers: files
services: files
protocols: files
rpc: files
'';
};
But it’s better than nothing. Alternative approach to consider is using the nftables
to reroute all outgoing traffic on port 53 to the desired DNS server.
- TODO add DNS leak prevention to nftables feat
Firewall #
Speaking of, the namespace also has its own firewall. On a generic client machine typically no ports should be listening in the namespace, so the firewall should just disallow all forwarding and input:
# Sourced from archwiki on 2022-03-20
# https://wiki.archlinux.org/title/Nftables#Workstation
# Adapted to not having any LAN or IPv6
flush ruleset
table inet my_table {
chain my_input {
type filter hook input priority filter; policy drop;
iif lo accept comment "Accept any localhost traffic"
ct state invalid drop comment "Drop invalid connections"
ct state established,related accept comment "Accept traffic originated from us"
meta l4proto icmp accept comment "Accept ICMP"
ip protocol igmp accept comment "Accept IGMP"
counter comment "Count any other traffic"
}
chain my_forward {
type filter hook forward priority filter; policy drop;
# Drop everything forwarded to us. We do not forward. That is routers job.
}
chain my_output {
type filter hook output priority filter; policy accept;
# Accept every outbound connection
}
}
For convenience, the repository also contains nftables mode for emacs.
Putting it all together #
The file with the firewall rules will be placed inside the etc/nftables.d/
and the namespace will be configured to use that set of rules. If cfg.extraFirewallRules
value is specified - it will be appended to the default rules.
environment.etc."nftables.d/${cfg.namespace_name}-namespace/${cfg.namespace_name}.nft".text = ''
${builtins.readFile ./namespace_default_fw.nft}
${cfg.extraFirewallRules}
'';
The interface itself will be configured through the standard networking.wireguard.interfaces
module, but it will perform some additional namespace configuration.
networking.wireguard.interfaces."${cfg.namespace_name}" = {
ips = cfg.ips;
privateKeyFile = cfg.privateKeyFile;
interfaceNamespace = cfg.namespace_name;
peers = cfg.peers;
The module will configure the network adapter to:
- Create a namespace before starting
- Set up the firewall within the namespace
- After destroying the adapter (e.g. service is stopped) - namespace will be removed
preSetup = [
''${pkgs.iproute2}/bin/ip netns add ${cfg.namespace_name}''
''${pkgs.iproute2}/bin/ip netns exec ${cfg.namespace_name} ${pkgs.nftables}/bin/nft --file /etc/nftables.d/${cfg.namespace_name}-namespace/${cfg.namespace_name}.nft''
];
postShutdown = [ ''${pkgs.iproute2}/bin/ip netns del ${cfg.namespace_name}'' ];
};
};
}
Usage example #
To use this flake:
-
Add it as an input for the system configuration flake, for example:
inputs = { ... wg-namespace-flake = { url = "github:VTimofeenko/wg-namespace-flake"; inputs.nixpkgs.follows = "nixpkgs"; }; ... }
-
Add the
inputs.wg-namespace-flake.nixosModules.default
to the list of imported modules -
Configure the service by importing a module like this (assumes using agenix for secret management):
{ config, ... }: { services.wireguard-namespace = { dns_server = "<SOME_DNS_SERVER>"; ips = [ "<INTERFACE-SPECIFIC-IP-ADDRESS>" ]; privateKeyFile = config.age.secrets.my_vpn_key.path; peers = [ { publicKey = "<PUBLIC_KEY>"; allowedIPs = [ "0.0.0.0/0" ]; # To route all traffic through this peer endpoint = "<ENDPOINT>"; } ]; }; }
-
Run
nixos-rebuild switch
As a result, the commands running the namespace with the VPN will route the traffic through the peer:
# outside the namespace
❯ curl ifconfig.co
<local IP>
# inside the namespace
❯ firejail --noprofile --blacklist=/var/run/nscd/socket --netns=vpn --dns=10.2.1.2 curl ifconfig.co 2>/dev/null
<Outgoing IP of the peer>