WireGuard network namespace on NixOS

We configure a network namespace (as described in the WireGuard documentation) containing solely a WireGuard interface whereto systemd services can be confined.

Network namespace

systemd does not support running services in a particular network namespace. A naïve solution would be using ip netns exec to start the process (see man 5 systemd.unit):

{ ... }: {
  systemd.services.<service>.serviceConfig.ExecStart = ''
    ip netns exec <namespace> <command>
  '';
}

However, that requires root privileges, and for security reasons we might want to run the process as unpriviledged user.

While systemd does not support confining services to a named system-wide network namespace, it will create a new network namespace ad hoc if the PrivateNetwork service option is set. Multiple units can share such a namespace with the JoinsNamespaceOf unit option.

We write a systemd instance template netns@.service that creates a network namespace with a given name.

{ pkgs, ... }: {
  config.systemd.services."netns@" = {
    description = "%I network namespace";
    before = [ "network.target" ];
    serviceConfig = {
      Type = "oneshot";
      RemainAfterExit = true;
      PrivateNetwork = true;
      ExecStart = "${pkgs.writers.writeDash "netns-up" ''
        ${pkgs.iproute}/bin/ip netns add $1
        ${pkgs.utillinux}/bin/umount /var/run/netns/$1
        ${pkgs.utillinux}/bin/mount --bind /proc/self/ns/net /var/run/netns/$1
      ''} %I";
      ExecStop = "${pkgs.iproute}/bin/ip netns del %I";
    };
  };
}

WireGuard interface

Next, we write a service wg requiring netns@wg.service (thus, a wg namespace will be created) and setting up a WireGuard interface in the wg network namespace. We define some options in our module for configuring the interface.

{ config, lib, pkgs, ... }: let
  cfg = config.my.wireguard;
in {

  options.my.wireguard = with lib; {
    enable = mkEnableOption ""; # I'm too lazy to fill those out
    address = mkOption {};
    peer = mkOption {};
    endpoint = mkOption {};
    privateKey = mkOption {
      type = types.path;
    };
  };

  config.systemd.services.wg = {
    description = "wg network interface";
    bindsTo = [ "netns@wg.service" ];
    requires = [ "network-online.target" ];
    after = [ "netns@wg.service" ];
    serviceConfig = {
      Type = "oneshot";
      RemainAfterExit = true;
      ExecStart = pkgs.writers.writeDash "wg-up" ''
        ${pkgs.iproute}/bin/ip link add wg type wireguard
        ${pkgs.wireguard}/bin/wg set wg \
          private-key ${cfg.privateKey} \
          peer ${cfg.peer} \
          allowed-ips 0.0.0.0/0,::/0 \
          endpoint ${cfg.endpoint}
        ${pkgs.iproute}/bin/ip link set wg netns wg up
        ${pkgs.iproute}/bin/ip -n wg address add ${cfg.address.IPv4} dev wg
        ${pkgs.iproute}/bin/ip -n wg -6 address add ${cfg.address.IPv6} dev wg
        ${pkgs.iproute}/bin/ip -n wg route add default dev wg
        ${pkgs.iproute}/bin/ip -n wg -6 route add default dev wg
      '';
      ExecStop =  pkgs.writers.writeDash "wg-down" ''
        ${pkgs.iproute}/bin/ip -n wg link del wg
        ${pkgs.iproute}/bin/ip -n wg route del default dev wg
      '';
    };
  };
}

Usage

Services can be confined to the wg network namespace with JoinsNamespaceOf = netns@wg.service. To require the WireGuard interface be up and running, we bind to wg.service. Also, remember to set the options under my.wireguard.

{ ... }: {
  systemd.services.<service> = {
    bindsTo = [ "wg.service" ];
    after = [ "wg.service" ];
    unitConfig.JoinsNamespaceOf = "netns@wg.service";
    serviceConfig.PrivateNetwork = true;
  };
}