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:
- Anything not leaving via the veth (loopback and the tailscale tun are unrestricted — the tun is the sanctioned path).
established,related— replies to host-initiated proxy connections.- uid 0 = tailscaled itself — DERP/coordination/WireGuard bootstrap.
- 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 namespace — hostProxies (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 namespace — nsProxies (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 upinside the ns resets prefs — andupwithout--resetrefuses 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-restartStartingstate 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 runsup --resetwhen it is notRunning; the exit-node preference (sticky daemon state) is re-asserted withtailscale seton every activation.--exit-node-allow-lan-access=trueis 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=falseis 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 execpicks up/etc/netns/vpn/resolv.confautomatically;NetworkNamespacePath=units do not, hence theBindReadOnlyPathsresolv.conf bind on every unit placed inside. Don’t conclude “DNS works” for a service from anip netns exectest alone.- Tailnet Lock:
ereshkigal-vpnis a full tailnet node — it gets no peer traffic untiltailscale lock signfrom a trusted node. The daemon runs with--statedirdeliberately: 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
useExitis gone (servarr/default.nix), so only the acquisition trio stalls. Before #268,useExitrouted the entire host through myon and a myon outage took cloudflared/PKI/monitoring down with it, mimicking service failure. (The predecessor fwmark/table-100tailscale-routing.nixonly 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 ornixos-rebuild switchregisters spurious failed units.
Related#
- 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.