Acquisition Egress Confinement (netns exit)#

The acquisition trio — qBittorrent (peer traffic), Prowlarr (indexer/tracker queries), FlareSolverr (challenge solving) — must egress via the myon VPS, and only via it: if that tunnel is down the services must stall, never fall back to the house’s direct path. Tailscale has no per-app exit-node support (an exit node is a whole-daemon preference), so the module runs a second tailscaled inside its own network namespace and moves the services in with NetworkNamespacePath=. This is modules/nixos/tailscale/netns-exit.nix (the generic mechanism) + modules/nixos/servarr/vpn.nix (the acquisition wiring), shipped in #268.

host ──(veth-vpn, 10.100.100.1/30, NAT out vmbr0)──┐
                                                   │ netns "vpn"
                                                   ├── veth peer 10.100.100.2, default route via host
                                                   ├── tailscaled-vpn (--tun tsvpn0) ──▶ exit node myon (100.70.92.129)
                                                   └── qbittorrent-nox · prowlarr · flaresolverr

The host NATs the /30 out externalInterface so the in-ns tailscaled can reach DERP/coordination/the exit node — that veth is the bootstrap path, not an app path, which is what the kill switch enforces.

The kill switch — mechanism, not just a claim#

The namespace has its own nftables universe (no collision with host rules). An output chain on the veth allows exactly four things and drops the rest:

  1. Anything not leaving via the veth (loopback and the tailscale tun are unrestricted — the tun is the sanctioned path).
  2. established,related — replies to host-initiated proxy connections.
  3. uid 0 = tailscaled itself — DERP/coordination/WireGuard bootstrap.
  4. Traffic to hostAddress — the in-ns proxies dialing back into host services.

Everything else that tries to leave via the veth hits counter drop. So when the tunnel is down, a confined app’s packets follow the default route to the veth and are dropped: tailscale down ⇒ torrents stall, no leak. Belt and braces on the host side: networking.firewall only admits the nsProxies ports on the veth interface — the namespace is never blanket-trusted.

Confined units wants (not requires) the online gate: if myon is down at boot they still start — the kill switch guarantees they can’t leak, and they regain connectivity when the tunnel returns.

Port republication — two directions#

Confined apps and host consumers both keep using 127.0.0.1:<port> as if nothing moved; two proxy fleets make that true (values from modules/nixos/servarr/vpn.nix):

Into the namespacehostProxies (systemd-socket-proxyd, socket-activated, units vpn-proxy-<port>): connections arriving on the host are forwarded to the same port on the ns side of the veth. This is how nginx, Gatus, Homer, and the *arrs’ download-client/indexer configs keep reaching the confined apps.

Host port Confined service Host-side consumers
8080 qBittorrent WebUI nginx vhost, Gatus, the *arrs’ download client entry
9696 Prowlarr the *arrs’ indexer entries (localhost:9696), nginx, Gatus
8191 FlareSolverr anything host-side pointing at 127.0.0.1:8191

Out of the namespacensProxies (socat on the ns loopback, units vpn-nsproxy-<port>): host ports republished inside the namespace, because Prowlarr’s app-sync URLs (stored in its DB, not in Nix) point at localhost:<arr-port> and the *arrs stayed on the host.

ns-loopback port Host service
8989 Sonarr
7878 Radarr
8686 Lidarr
8787 Readarr
6969 Whisparr

The trio moves together for the same reason: the arrs point at Prowlarr on localhost:9696 and Prowlarr reaches FlareSolverr at 127.0.0.1:8191 — both links stay loopback-local inside* the namespace.

One nixpkgs-specific casualty: the stock flaresolverr unit ships PrivateUsers=true, and a service inside a private user namespace cannot join the host-owned netns (setns(2) needs CAP_SYS_ADMIN over the target namespace). vpn.nix force-disables it, only when confined.

Options#

Option Default Meaning
tsunaminoai.tailscale.netnsExit.enable false The whole mechanism.
tsunaminoai.tailscale.netnsExit.namespace "vpn" Namespace name + unit/interface suffix. veth-<ns>-ns must fit the 15-char kernel interface limit (asserted).
tsunaminoai.tailscale.netnsExit.exitNode "100.70.92.129" Tailnet IP (or MagicDNS name) of the exit node — myon.
tsunaminoai.tailscale.netnsExit.hostname <hostName>-<namespace> Tailnet hostname of the in-ns node (so: ereshkigal-vpn).
tsunaminoai.tailscale.netnsExit.authKeyFile sops tailscale/auth-key First-join auth key; must be reusable/pre-approved.
tsunaminoai.tailscale.netnsExit.hostAddress / .nsAddress 10.100.100.1 / .2 The two ends of the veth /30.
tsunaminoai.tailscale.netnsExit.externalInterface — (required) Host interface the /30 is NAT-masqueraded out of (vmbr0 on ereshkigal).
tsunaminoai.tailscale.netnsExit.tunName ts<namespace>0 TUN device of the in-ns tailscaled.
tsunaminoai.tailscale.netnsExit.resolvers ["1.1.1.1" "9.9.9.9"] Namespace nameservers. Confined services resolve through the tunnel — DNS dies with it, consistent with the kill switch; tailscaled bootstraps against them via the uid-0 exemption.
tsunaminoai.tailscale.netnsExit.confinedUnits [] systemd services (no .service suffix) moved into the namespace.
tsunaminoai.tailscale.netnsExit.hostProxies [] Confined TCP ports republished on the host.
tsunaminoai.tailscale.netnsExit.nsProxies [] Host TCP ports republished on the ns loopback.
tsunaminoai.servarr.confineIndexers true Also confine prowlarr + flaresolverr (not just qbittorrent) and wire the 9696/8191 + arr proxies above.

Units & debugging#

Unit Role
netns-vpn Creates the namespace, veth pair, routes; loads the kill-switch ruleset. Everything else is bindsTo/partOf it.
tailscaled-vpn The second tailscaled (--statedir /var/lib/tailscale-vpn, socket /run/tailscale-vpn/tailscaled.sock).
tailscale-vpn-autoconnect Joins the tailnet if needed; asserts the exit-node prefs every activation.
tailscale-vpn-online Gate: BackendState Running and ExitNodeStatus.Online == true.
vpn-proxy-<port> (+ socket) Host-side republication of a confined port.
vpn-nsproxy-<port> ns-loopback republication of a host port.
tailscale-vpn status                    # wrapper: tailscale --socket /run/tailscale-vpn/tailscaled.sock
ip netns exec vpn curl -4 ifconfig.me   # must print myon's public IP (45.61.186.219)
journalctl -u tailscaled-vpn -u tailscale-vpn-autoconnect -u tailscale-vpn-online
systemctl status 'vpn-proxy-*' 'vpn-nsproxy-*'

Kill-switch acceptance: take the tunnel down (tailscale-vpn down) — the ifconfig.me curl must hang/fail and torrents must stall; bring it back up and both recover. If the curl ever prints the house’s WAN IP, the confinement is broken — stop the trio first, then debug.

Gotchas#

  • tailscale up inside the ns resets prefs — and up without --reset refuses outright when the daemon holds non-default prefs (the exit node set by the previous generation). That exact race failed three #268 activations: a mid-restart Starting state tripped the re-auth path and the failed unit rolled back the whole deploy. The autoconnect unit now waits for the backend to settle and only runs up --reset when it is not Running; the exit-node preference (sticky daemon state) is re-asserted with tailscale set on every activation.
  • --exit-node-allow-lan-access=true is required, not a leak: the veth /30 is this namespace’s “LAN”, and without the exception tailscale’s table 52 grabs the /30 and routes even veth traffic into the tunnel — breaking both proxy directions. The nft kill switch still drops non-tailscaled veth egress.
  • --accept-dns=false is mandatory: the in-ns tailscaled shares the host mount namespace, so MagicDNS would rewrite the host’s /etc/resolv.conf.
  • ip netns exec vpn … and the confined units see different DNS plumbing: ip netns exec picks up /etc/netns/vpn/resolv.conf automatically; NetworkNamespacePath= units do not, hence the BindReadOnlyPaths resolv.conf bind on every unit placed inside. Don’t conclude “DNS works” for a service from an ip netns exec test alone.
  • Tailnet Lock: ereshkigal-vpn is a full tailnet node — it gets no peer traffic until tailscale lock sign from a trusted node. The daemon runs with --statedir deliberately: without it the TKA state is memory-only and the node re-locks on every restart.
  • myon-down symptom profile: since #268 the host’s egress is direct — host-wide useExit is gone (servarr/default.nix), so only the acquisition trio stalls. Before #268, useExit routed the entire host through myon and a myon outage took cloudflared/PKI/monitoring down with it, mimicking service failure. (The predecessor fwmark/table-100 tailscale-routing.nix only appeared to work because of that host-wide routing.)
  • socat exits 143 on SIGTERM — the nsproxy units set SuccessExitStatus = "143"; without it every namespace restart or nixos-rebuild switch registers spurious failed units.
  • Media — the acquisition stack this confines, and its port map.
  • Myon — the exit node itself (install runbook, why it never builds on-box).
  • Ereshkigal — the only servarr host, where all of this runs.