# One tunnel per proxy: what proxied copies cost, and the cheapest safe design

Design note behind the shared tunnel of [proxied copies](<https://geiserx.github.io/CashPilot/proxies/#one-tunnel-per-proxy>). Date: 2026-11-09.

## The problem

A proxied copy runs an earner on one box while its traffic leaves from another public IP. Until this change every copy was two containers: the earner and a tunnel of its own, the earner living in the tunnel's network namespace. A Tailscale tunnel runs its own `tailscaled` and joins the tailnet as its own ephemeral device.

Copies through the same proxy already share its exit IP, so a tunnel each buys them nothing. But the copies are what a user adds, and memory grew with them.

## What we measured

A box running seven copies through one Tailscale exit node (Docker on btrfs):

|  | per tunnel | 7 tunnels |
| --- | --- | --- |
| cgroup `memory.current` | 43.7 to 53.4 MB | **339 MB** (147 anon, 175 file, about 17 kernel) |
| `docker stats` | 35.3 to 41.2 MiB | about 271 MiB |
| `tailscaled` anonymous heap | 20.0 to 22.0 MB, **flat across traffic** (138 MB moved: 20.8 MB; 10 MB moved: 20.4 MB) |  |
| startup peak | 79 to 114 MB (`tailscale up`, the 34 MB CLI paged in, the probes) |  |
| the 7 earners they carried |  | 190 MB (101 anon) |

The tunnels cost more than the earners they carried, and nearly all of a tunnel's cost is fixed. On btrfs the page cache is not shared between containers: every `tailscaled` mapping is private, so each container pays for its own copy of the binary. On overlay2 those pages would be shared.

The exit box served one tailnet peer per copy (7 of its 28 peers were these tunnels), and each tunnel was a device in the tailnet admin console.

The lab (`scripts/proxy_shared_tunnel_lab.sh`, a local headscale tailnet, Docker on overlayfs, an idle stand-in earner) drives the worker's own code. Sum of the proxy's tunnels' cgroup `memory.current`, 25 seconds after the last deploy:

|  | N=1 | N=7 |
| --- | --- | --- |
| one tunnel per copy (the base branch) | 1 tunnel, 15.6 MiB | 7 tunnels, 110.1 MiB |
| one tunnel per proxy (this change) | 1 tunnel, 15.0 MiB | 1 tunnel, 15.8 MiB |

The lab tunnel is smaller than a production one: overlayfs shares the binaries' page cache between containers, the test tailnet has two nodes, and the stand-in earner moves almost nothing. The shape is the point: the tunnel side stops growing with the copies.

## The options

All memory figures are the tunnel side only; earners cost the same in every option.

|  | Design | Per copy | Per exit, per box | Fail closed | Runs on |
| --- | --- | --- | --- | --- | --- |
| **A** | One tunnel per (box, proxy); every copy joins it with `container:` | 0 | one tunnel (about 45 to 50 MB for Tailscale) | Unchanged: same nftables table, kernel TUN, fwmark rule, ready gate | Everywhere the tunnel runs today, Docker Desktop included. Podman pods are this model natively. |
| **C** | Kernel WireGuard: a `wg` interface created in the host namespace and moved into an otherwise empty holder namespace the earner joins | about 0.5 to 1 MB (estimate, not measured) | no daemon on either side | By construction: the namespace holds only `lo` and `wg0`, and the UDP socket stays in the host namespace | Rootful Linux with the `wireguard` module. Not rootless Docker or rootless Podman. |
| **B** | A gateway container per exit; earners on an `--internal` bridge with their default route pointed at it | 0 | about the same as A | The gateway's forward chain allows only the tunnel; a dead gateway means no next hop | Needs route injection into each earner, an events watcher, its own DNS handling |

Not ranked:

- **Host policy routing with no container at all.** Tailscale cannot choose an exit node per source address (an upstream request for it was declined, and a node cannot use an exit node while advertising one). It would need our own tunnel plus `ip rule` edits on the host, which only works on rootful Linux and can break the host's own networking. Option C gets the same memory inside a namespace, without touching the host's routes.
- **A shared tun2proxy for SOCKS5 and HTTP proxies.** That is option A for those kinds, and comes with it.
- **Routing at the routers.** Needs capable routers at both ends; nothing we can ship.
- **tsnet, or tailscaled in userspace mode with a SOCKS listener.** Both cost more memory, not less, and userspace mode dials from the box itself when the exit node's route is missing ([why the tunnel runs in kernel mode](<https://geiserx.github.io/CashPilot/proxies/#tailscale-exit-nodes>)).

## The decision

1. **Option A now.** One tunnel per (box, proxy), named `cashpilot-tunnel-p<id>`, tailnet host name `cashpilot-p<id>`. It saves almost all of the memory with no new requirement on anyone's host, and nothing new to set up.
2. **Option C later, as an advanced mode,** once a lab has measured it, offered only where the worker finds a rootful Linux engine with the `wireguard` module. Option A stays the fallback everywhere.

## How option A works

- **Who belongs to which tunnel.** An earner's tunnel is the container its `NetworkMode` names, never a name lookup. That one rule makes legacy pairs (one tunnel per copy, from older workers), a half-finished move and the shared tunnel all read the same way in the heartbeat.
- **Adding copy N+1.** If the proxy's tunnel runs, is ready, and was started on the same settings (a digest kept in its root-only secret volume, never a label) and the same image, only the earner is created. No other copy is touched.
- **Copies are not recreated for a change.** Several earners keep a device id in their writable layer (`docker diff` shows, for example, `/.config/traffmonetizer/cli_device_ids.json`, `/etc/earnfm/deviceid`, `/mnt/app/InstallationId.ini`, `/root/.packetstream/data`), and proxied copies have no volumes. A recreated copy is a new device to its provider, so the tunnel changes in place wherever it can:
- **an edited proxy** while the tunnel runs: the new settings first prove an exit in a throwaway Test tunnel, then are written into the running tunnel, which restarts. Its copies keep their `NetworkMode` (the container id does not change) and are restarted into the new namespace. If the tunnel then fails on settings the test passed, the old settings go back;
- **a stopped tunnel** is started in place with the settings rewritten;
- **a new tunnel image** (each worker release) does not replace a tunnel copies live in. A deploy takes it only when no other copy is in the tunnel.
- **Replacing the tunnel** is left for when there is no other way: Docker cannot start the container at all, or the image changes on a deploy of the only copy. A new tunnel starts under a staged name and proves its exit, then each copy is recreated inside it from `docker inspect` (image, environment, command, labels, capabilities, limits), because a container's namespace is fixed when it is created. Each move creates the clone under `<name>-next`, renames the copy aside to `<name>-moving`, gives the clone the name, starts it, and only then removes the old container; a failure puts it back. A worker that dies midway leaves both, and `recover_moves` (worker start, and the heartbeat) finishes or undoes the move. A staged tunnel copies live in is finished, never swept.
- **The settings volume is a `Mount`, not `volumes=[path]`.** docker-py turns the latter into a `Binds` entry with no source. On Docker 29 such a container comes back from a daemon restart as `get: no such volume` and cannot be started again; the `Mount` form (`HostConfig.Mounts`, type volume, no source) comes back with its files.
- **Stops are written down.** With a tunnel per copy, a stopped earner beside a ready tunnel could only mean a reboot or a crash, so the heartbeat started it. With a shared tunnel that is also what a stopped copy looks like while its siblings run. So a stop writes the earner's name to `/data/.proxied_held` before anything is stopped (and refuses if it cannot), a start or a removal clears it, and the heartbeat never starts a held copy. The tunnel stops when no copy that should run is left in it, and is removed with its secret volume when no copy is left at all.
- **Traffic and the cap.** Every copy reports its tunnel's counters and id. The cap already keys each baseline by `worker:tunnel id`, so N entries with one id count once. Per-copy traffic is gone; per-proxy-per-box traffic is what the UI shows. That loss is accepted.
- **Migration.** At worker start, per proxy, the settings are read back from a running, ready legacy tunnel's secret files (nothing is asked of the UI), the shared tunnel is started and proves its exit, and each legacy copy is recreated inside it before its old tunnel is removed. A legacy pair that was stopped moves held, so it stays stopped. Any failure leaves the pairs not moved yet as they are: each is valid and fails closed. A proxy with no running tunnel moves on its next start. Running it again does nothing.

## The fail-closed argument

1. The firewall loads before anything else. Before `tailscaled` or `tun2proxy` starts, `cp-tunnel` loads `table inet cashpilot` with an output policy of drop. Egress on the real interface is allowed only for tailscaled's own fwmark, or for the proxy's address and port.
2. Earners cannot forge that mark: they get no `NET_ADMIN`, `NET_RAW` or `SYS_ADMIN`. Sharing changes nothing here. N earners in one namespace are N unprivileged processes behind the same table.
3. Exit down, `tailscaled` crashed, re-authentication failed: no route through `tailscale0`, so the drop policy applies. Kernel mode means no userspace dialer.
4. The tunnel container dies or restarts: the earners stay in the old namespace, which keeps only `lo`. The heartbeat restarts them into the new one only after `/run/ready` is written, which happens only after the firewall is loaded and a probe saw an exit that is not the box's own.
5. Reboot or Docker restart: earners have restart policy `no`, so Docker never starts one before the tunnel. Only the heartbeat gate does.
6. The LAN and tailnet fence (private and tailnet ranges dropped on the way into the tunnel) applies to the whole namespace, so to every copy in it.
7. New: copies in one namespace see each other's sockets in `/proc/net` and can reach each other's `127.0.0.1` listeners. They still cannot sniff traffic or change the firewall. One `tailscaled` failure cuts every copy on that proxy on that box; it fails closed, and those copies already shared the exit.

## How it is tested

- Unit tests (`tests/test_proxy_shared_tunnel.py`) run the real module against an in-memory Docker that tracks labels, status, `NetworkMode`, the secret volume and the ready file: reuse, replacement, a tunnel that cannot start, stop and start of one copy among several, held copies and the heartbeat, a reboot, the cap counting one tunnel once, `remove_all` keeping a tunnel another service uses, and migration (idempotent, untouched on failure, stopped pairs stay stopped).
- `scripts/proxy_tunnel_mutants.py` applies twelve mutants, one per promise above, and requires every one to turn those tests red.
- `scripts/proxy_shared_tunnel_lab.sh` runs against a real Docker daemon and a local headscale tailnet. With two earners in one tunnel it checks egress through the exit only, no interruption of one copy by another's stop, no tailnet access, no DNS name on the tunnel's interface, no interface during a tunnel restart, and no escape or packet with the exit node stopped, `tailscaled` frozen or killed, or no exit node set. Three negative controls must turn those checks red: the firewall deleted, a copy created on the bridge instead of inside the tunnel, and a tunnel image whose output policy is accept. Then it drives the worker's own code with 1 and 7 copies, on this tree and on the base branch, counts the tunnels, measures cgroup memory, and migrates copies the base branch deployed.

## Follow-ups

- **Recover in place.** Supervise `tailscaled` and `tun2proxy` inside the tunnel and restart the process instead of exiting, removing `/run/ready` during the gap. The namespace and the firewall survive, so the copies need no restart: a gap of seconds instead of a heartbeat.
- **Shrink the tunnel.** Measure `GOMEMLIMIT` and `GOGC` for `tailscaled` (start at 64 to 96 MiB), build one multicall binary so the 34 MB CLI is not paged in at start, and set a memory limit above the startup peak.
- **A copy that wants its own tunnel,** for owners who do not want two providers' software in one namespace.
- **CashPilot Desktop** still runs one tunnel per copy and gets the same change separately.
- **Kernel WireGuard (option C).** A one-shot privileged helper creates the interface and moves it into a `--network none` holder; on the exit side, a one-shot helper sets up the interface, forwarding and NAT, and exits. The same leak suite and negative controls as option A.
- **Your tailnet policy.** Grant the tunnels' tag only `autogroup:internet` through the exit node, and nothing into the tag ([before you add one](<https://geiserx.github.io/CashPilot/proxies/#tailscale-exit-nodes>)).
