Skip to content

IanHollow/vpn-confinement

vpn-confinement logo

VPN Confinement

Fail-closed WireGuard confinement for selected NixOS systemd services.

vpn-confinement places selected services into dedicated network namespaces with namespace-local nftables policy, generated resolver configuration, and systemd lifecycle wiring so tunnel or namespace loss propagates cleanly to confined workloads.

The project currently targets NixOS unstable.

It is intended for the common NixOS case where only specific services should use VPN egress while the host and other workloads remain on normal networking.

Documentation

  • Docs site: https://ianhollow.github.io/vpn-confinement/
  • Architecture: site/src/content/docs/architecture.md
  • Threat model: site/src/content/docs/threat-model.md
  • Generated options reference: site/src/content/docs/reference/options-generated.md

Canonical docs live in site/src/content/docs/. Root community docs are synced into the docs site during docs builds.

Why it exists

  • Confine only the services that should use the tunnel.
  • Keep host networking unchanged for non-confined workloads.
  • Apply DNS and firewall policy at the namespace boundary.
  • Prefer fail-closed teardown when the namespace or tunnel disappears.

Quick start

Add the module and opt specific services into confinement:

{inputs, ...}:
{
  imports = [ inputs.vpn-confinement.nixosModules.default ];

  services.vpnConfinement = {
    enable = true;
    namespaces.vpnapps = {
      enable = true;
      wireguard.interface = "wg0";
      dns = {
        mode = "strict";
        servers = [ "10.64.0.1" ];
      };
      ipv6.mode = "disable";
      egress.mode = "allowAllTunnel";
    };
  };

  networking.wireguard.interfaces.wg0 = {
    privateKeyFile = "/run/keys/wg.key";
    ips = [ "10.0.0.2/32" ];
    peers = [
      {
        publicKey = "...";
        endpoint = "198.51.100.10:51820";
        allowedIPs = [ "0.0.0.0/0" ];
      }
    ];
  };

  systemd.services.my-service = {
    serviceConfig.DynamicUser = true;
    vpn = {
      enable = true;
      namespace = "vpnapps";
    };
  };
}

By default, namespace selection is explicit. Set vpn.namespace on each confined service or socket, or configure services.vpnConfinement.defaultNamespace if you intentionally want one shared default.

For exact option names and defaults, start with the generated options reference in site/src/content/docs/reference/options-generated.md.

Security model

  • Opt-in model per service or socket (systemd.services.<name>.vpn.enable, systemd.sockets.<name>.vpn.enable).
  • The trust boundary is the namespace (one namespace = one DNS/firewall policy).
  • Namespace-local nftables uses deny-by-default tunnel policy.
  • dns.mode = "strict" blocks classic DNS-like leak ports (53, 853, 5353, 5355) and pins resolver configuration.
  • IPv6 is fail-closed by default.
  • Tunnel or namespace loss propagates teardown to dependent units.

Profiles:

  • balanced: secure defaults with explicit compatibility escape hatches.
  • highAssurance: the strict preset with stronger service hardening defaults and stricter secret handling.

highAssurance requires:

  • dns.mode = "strict"
  • egress.mode = "allowList"
  • non-empty egress.allowedCidrs
  • non-root service execution by default (DynamicUser = true or non-root User), unless explicitly opted out with systemd.services.<name>.vpn.allowRootInHighAssurance = true
  • literal WireGuard endpoints (hostname endpoints rejected)
  • allowedIPsAsRoutes = true
  • no inline networking.wireguard.interfaces.<if>.privateKey

WireGuard endpoint pinning is available with services.vpnConfinement.namespaces.<name>.wireguard.endpointPinning.enable. It requires literal peer endpoint IPs and enforces in the effective WireGuard socket birthplace namespace (init by default, or custom wireguard.socketNamespace when configured). See site/src/content/docs/threat-model.md for scope and caveats.

highAssurance rejects inline WireGuard private keys because they land in the world-readable Nix store. Use privateKeyFile or generatePrivateKeyFile instead.

Development

  • Format: nix fmt
  • Validate: nix flake check --show-trace --system <x86_64-linux|aarch64-linux>
  • Regenerate options reference: bash scripts/generate-options-doc.sh x86_64-linux
  • Build docs site: bun run --cwd site build

Community

  • Contributing guide: CONTRIBUTING.md
  • Code of Conduct: CODE_OF_CONDUCT.md
  • Security reporting: site/src/content/docs/security.md

License

MIT

About

Fail-closed WireGuard confinement for selected NixOS systemd services.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors