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.

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:

  1. Create a namespace before starting
  2. Set up the firewall within the namespace
  3. 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:

  1. 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";
          };
          ...
        }

  2. Add the inputs.wg-namespace-flake.nixosModules.default to the list of imported modules

  3. 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>";
              }
            ];
          };
        }

  4. 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>