Skip to content

Keeping these containers off the rest of your network

The real risk of running proxyware is not a container breaking out. It is two much more ordinary things.

Someone else's traffic leaves under your IP address. The FBI/IC3 has issued a public notice about residential proxy networks, and researchers have shown that node operators cannot see what traffic exits through them. Whatever a buyer does looks like you: to your ISP, to sites that block the address, and to law enforcement. That is not a bug in these services; it is what they sell.

A hostile image's realistic prize is your LAN. Not the bandwidth, but your Home Assistant, your NAS shares, your media server, your password manager. Those sit on the same network you just handed a closed-source container.

CashPilot cannot fix the first. Selling that egress is the product, and blocking it would break the thing you installed this to do. What it says instead is the truth, before you deploy, rather than in an FAQ nobody opens.

The second one you can reduce.

LAN isolation by default

A worker fences its earners off your LAN, the host it runs on, your tailnet and cloud metadata by itself. A new install needs no setup for it.

How it works:

  • The worker creates a Docker network, cashpilot-isolated, with bridge interface cp-isolated. Docker's own masquerade (NAT) is off on it, and so is traffic between containers on it. The network carries the label cashpilot.isolation=managed.
  • The worker writes one firewall table, table ip cashpilot, into the host's network namespace, plus table ip6 cashpilot when the host has IPv6. It does that through a short-lived helper container started from its own image, with host networking and the NET_ADMIN and NET_RAW capabilities. The helper is removed when it finishes. The worker process itself never gets those capabilities.
  • The table holds the NAT that gives the earners their internet, plus drops for 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10 (carrier-grade NAT and Tailscale), 169.254.0.0/16 (cloud metadata), any subnet directly connected to a real interface of the host (a LAN numbered in public space, a VPS's neighbours), the host itself, and your home's own public IP. That last one matters because most home routers loop a connection to their own public address straight back into the LAN (NAT loopback), where it arrives with a LAN source address. The worker learns the address from its public IP detection. With detection off (CASHPILOT_EGRESS_DETECT=off), even with CASHPILOT_EGRESS_IP set, it can neither learn nor update the drop: one already in the table stays, unchanged, until the table is lost (a reboot or a flush), and after that there is none. This holds in the on mode only; with CASHPILOT_CONTAINER_NETWORK set, the table keeps the NAT and none of the drops (see Modes). A starting worker looks the address up before it writes the first table, a new address is fenced as soon as the worker sees it, and while a lookup fails the table keeps the address it already drops. A host whose first lookup after a reboot fails has no such drop until a lookup succeeds. The worker looks again with each heartbeat, so a worker without CASHPILOT_UI_URL never retries: restart it once the lookup works. On a static IP, setting CASHPILOT_EGRESS_IP to the router's public IPv4, with detection left on, closes that gap: the worker then needs no lookup. The same value is the egress IP the fleet page and the provider-limit warnings use, so behind a VPN whose exit address differs, set it to the VPN's exit and block the router's own public address with a router rule instead. The worker also never looks again, so if the address changes the drop stays on the old one and NAT loopback through the new one is open, with nothing reported; update the value and recreate the worker whenever the address changes. An IPv6 value gives no drop. Replies to published ports still go out, so Storj, Sia and Anyone Protocol keep accepting inbound connections. Only replies pass: a connection an earner opened while the table was missing meets the drops again once the table is back.
  • The IPv6 table drops every IPv6 packet that enters from the bridge. The network has IPv6 off, but Docker engines before 26 (Unraid 6.12, Synology and QNAP ship them) still give each container an fe80:: address, and over it an earner could reach any host service listening on [::].
  • Every service the worker deploys onto a bridge joins that network, with DNS servers 1.1.1.1 and 9.9.9.9 (CASHPILOT_CONTAINER_DNS changes them). No catalog service runs on host networking. Mysterium joins the network too, in its own network namespace: see Mysterium on the fenced bridge.

It fails closed, with three conditions. The NAT and the drops live in the same table. When something removes the table (a reboot, nft flush ruleset, a firewalld reload), the earners lose their internet and do not gain your LAN for TCP. The worker writes the table again when it starts, before every deploy, and every 5 minutes. After a restart of the Docker VM on a test host, the table was back 1.4 s after the worker container started. While the table is missing:

  • An earner can reach the host itself on every port, since traffic to the host needs no NAT. That lasts as long as the worker is not running: earners come back with the Docker daemon on their own, and a worker that is stopped or crash-looping writes nothing. Keep the worker running with a restart policy, as the shipped compose files do.
  • A host that already has its own masquerade rule covering other subnets (a common WireGuard PostUp line, a UFW before.rules recipe, or a firewalld zone with masquerade on) NATs the earners' traffic with that rule. They then reach your LAN and the internet with nothing filtering them until the worker writes the table again.
  • One-way traffic, such as a UDP packet to a LAN device, still leaves without NAT; only the reply cannot come back.

If the worker cannot read the host's routes, it leaves the table as it is rather than write one without the drops for directly connected subnets, and refuses deploys until it can. An earner that Unraid's reboot left on a removed copy of the network is started again only after the table is in place.

When the table cannot be written, deploys are refused. The message says why and how to opt out. The running container is never touched first. The fleet page shows the error on the worker's row.

The worker uses nft when the kernel has nf_tables, and iptables-legacy otherwise (older NAS kernels). Both write the same rules.

Modes

Mode When What happens
on a new install, CASHPILOT_ISOLATION=on, the cashpilot-isolated network CashPilot created exists, an earner is still bound to it after Unraid removed it, or the worker recorded it in .isolation_on in its data folder (CASHPILOT_DATA_DIR, /data by default) earners join the fenced network
legacy an upgraded install whose earners already sit on Docker's default bridge nothing changes on upgrade; the fleet page offers Turn on isolation
unsupported a Podman engine, with CASHPILOT_ISOLATION not off earners deploy on Podman's default network, not fenced, and the fleet page says so; with CASHPILOT_ISOLATION=on, deploys are refused, since the fence was asked for and cannot be given
off CASHPILOT_ISOLATION=off Docker's default bridge, as before; earners left on the fenced network keep their internet until you redeploy them
your own network CASHPILOT_CONTAINER_NETWORK is set that network, with your own rules, as before; CashPilot writes no table, except the NAT alone while earners are still on cashpilot-isolated, so they keep their internet, without the fence's drops, until you redeploy them

The mode lives in Docker and in the worker's /data (.isolation_on, written the first time the worker is on, deleted by CASHPILOT_ISOLATION=off), so losing either one alone does not switch it off: a reinstall with a fresh volume keeps the network, and a reboot or docker network prune that took the network after every fenced earner was removed keeps the file. The file needs a writable /data: if the worker cannot write it, it logs a warning saying so and decides from Docker alone, as before.

Turning it on for an upgraded install

On the Fleet page, a worker in legacy mode shows "Earners on this worker can reach your LAN and this host" and a Turn on isolation button (owner only). It creates the network and the table, then redeploys each earner on Docker's default bridge once onto the new network, one at a time, from its recorded settings. Volumes, identities and settings stay the same. Storj gets its full clean stop first, so that one can take a few minutes. A redeploy starts the container, so stopped earners are left alone: the page names them, and each moves the next time you deploy it. Starting one as it is keeps it on the old network. If one redeploy fails, the page carries on with the rest and names the ones it could not move; redeploy or remove those from their service pages. The same switch is POST /api/workers/{id}/isolation.

Turning it off

Set CASHPILOT_ISOLATION=off on the worker and recreate the worker, then redeploy the earners to move them back to Docker's default bridge. There is no off button on purpose: one click should not take the fence down.

Mysterium on the fenced bridge

Mysterium is a VPN exit node. It needs NET_ADMIN to build its WireGuard interfaces, routes and NAT for its customers. Through 2.0.6 it ran on the host's network, so it held NET_ADMIN in the host's own namespace. From there a compromised node, or a malicious image update, could rewrite the host's firewall (this fence included), add routes and addresses, bind any port on every host address, and reach your LAN, your tailnet, cloud metadata and every other container. No host rule holds against a process that can rewrite the host's rules: a seccomp filter leaves netlink open, and a table owned by a process stops filtering when that process exits.

So the node now runs in its own network namespace on cashpilot-isolated, like every other earner:

  • Its NET_ADMIN reaches only its own interfaces, routes and tables. The fence matches on the bridge's interface, so whatever the node does inside its namespace (flush its tables, take a LAN address, add a LAN route), what it sends to your LAN, the host, a tailnet or cloud metadata is dropped on the host.
  • Its UDP range (UDP_PORTS, 23000:23029 by default) is published 1:1, so the forward on your router is the same as on the host's network. Docker fails the whole start when a host program holds one of its ports, so the default sits below Linux's ephemeral range (32768-60999). Each port goes to the same port in the node, with the customer's own source address. At most 300 ports: each published port slows the node's start, about 12 s for 300 and 51 s for 1,000. A node already running with a wider range keeps running; CashPilot refuses to redeploy it until the range is narrowed.
  • Its WebUI listens on 0.0.0.0 inside its namespace and is published only on the host addresses in UI_ADDRESS, 127.0.0.1 by default. The Tequila API is never published.
  • Its resolvers are the bridge's (CASHPILOT_CONTAINER_DNS), so customers no longer resolve names through a tailnet's or a LAN's DNS.
  • Its own --firewall.protected.networks list stays in place, as a second layer inside its namespace.
  • The image is pinned by digest, and the node always runs under runc, since gVisor has no iptables-nft or kernel WireGuard.

The catalog refuses any service that asks for NET_ADMIN or SYS_ADMIN with host networking, or without an image pinned by digest, or without fence_gated: true. The worker refuses host networking and any other image for it, from its own catalog, whatever the request says.

Started only behind the fence. Docker gives the node no restart policy, so it never starts it on its own after a reboot or a daemon restart, before the worker has put the table back. The worker starts it once it has read the table back with every rule in place: at deploy, when the worker starts, and after a crash. Every 30 seconds it checks again, and it stops the node when a rule is missing, or when the container was recreated privileged, with a capability or an image its catalog entry does not give it, with a restart policy, or off the network the mode puts it on. It then writes the table again, and starts the node on the next check once the table reads complete. A node you stopped from the dashboard stays stopped. The fleet page names a node held stopped and says why. In the lab, a deleted rule stopped the node within 30 seconds, and after a Docker restart with the table deleted the node stayed stopped until the worker had written the table back.

The identity is checked when it can be read. On a redeploy of a running node the worker reads the node's identity before it removes the old container, and reads it again from the new one. If they differ, the new node is not running on the data volume that holds its keystore: the worker stops it and keeps it stopped until the next redeploy. The check needs both reads: a node that was already stopped, or that did not answer, leaves nothing to compare, and the new node runs. It is a safety net, not a backup: keep a copy of the keystore (Backing up node identities) and check the data mount before a redeploy.

On your own network (CASHPILOT_CONTAINER_NETWORK) there is no fence of CashPilot's to wait for: the node starts as soon as it is deployed, under your rules, and keeping other containers off its WebUI is up to those rules too. In off and legacy mode, and on Podman, the worker does not run the node at all: on Docker's default bridge every other earner could reach its WebUI and change its payout wallet, and nothing would keep it off your LAN. A deploy is refused before the running node is touched, with what to do in that mode: in legacy, switch isolation on in Fleet first, then redeploy Mysterium; in off, set CASHPILOT_ISOLATION=auto and restart the worker first; on Podman it cannot be fenced. In on and operator mode a redeploy alone moves it. A fenced node already running in such a mode is stopped.

A node from 2.0.6 or earlier keeps running as it is. A Mysterium container on the host's network, which is what every node deployed through 2.0.6 is, is left exactly as it is in every mode until you redeploy it: the guard neither stops nor starts it, and Start, Stop and Restart work on it as before. Drift and Check isolation keep flagging it. It moves onto the fenced bridge on its redeploy; see UPGRADING.md.

Limits

  • The node still holds NET_ADMIN inside its own namespace, so the kernel's netlink, nf_tables, WireGuard and tun code is reachable from it. A kernel bug there would reach the host. Running the node in userspace mode without NET_ADMIN is the next step.
  • Docker's userland proxy starts two processes per published UDP port. Measured with 101 ports: 205 processes, about 250 MB of memory in total (proportional set size), so about 2.5 MB per port. The default range is 30 ports for that reason (about 60 processes, 75 MB), which carries about 15 sessions at once; 20 to 30 ports is enough for a home node. A node with a wider range on record keeps it until you narrow UDP_PORTS and redeploy; a router forward for the wider range keeps working.
  • UPnP on the router no longer works for the node, since the fence drops the router. Forward the range by hand.
  • IPv6: the fenced network has IPv6 off. On Docker 26 and later the earners get no IPv6 address at all; on older engines they get a link-local one, which the IPv6 table drops. A host booted with IPv6 disabled gets no IPv6 table, since it has no IPv6 to filter.
  • Unraid removes every user-defined Docker network when its Docker service starts, unless Settings > Docker > Preserve user defined networks is Yes. The worker creates the network again and moves each earner that failed to start because of it back onto the new one, then starts it, so they come back within a few minutes of the worker. This holds even when stopped earners are still on Docker's default bridge from before the switch: an earner bound to the removed network is enough to keep isolation on. Setting it to Yes avoids the gap.
  • Only the one public IPv4 the worker sees as its egress is dropped. With a second WAN or more public IPs, the others can still loop back through NAT into port-forwarded LAN services or the router's own page. Block them on the router, or put the earners on a VLAN whose firewall rule lets it reach the internet but not the LAN or the router's other public addresses.
  • A LAN on a public range behind another router (not directly connected to the host) is not dropped. Use a VLAN or your own rules.
  • Tested on Linux Docker 29 with nf_tables (colima on macOS, the same VM model as Docker Desktop), including the iptables-legacy fallback. Docker Desktop itself, rootless Docker and Unraid are not tested yet. Podman is not fenced: its Docker-compatible API drops the network settings the fence relies on (the interface name, masquerade off, inter-container traffic off), so the worker detects it and leaves earners on its default network. On Docker Desktop, the Enhanced Container Isolation setting may block the helper; the deploy is then refused with the opt-out in the message.

What CashPilot tells you per service

It tells you which services can be confined and exactly what each needs allowed through: GET /api/fleet/isolation-guide.

The three answers

Verdict Meaning
isolatable Outbound only. Confine it as-is.
needs exceptions It can be confined, but specific things must stay reachable.
not isolatable It runs on the host network, so a container bridge does not apply. Use a VLAN or a separate machine.

No catalog service is the third case today. storj is the second: it needs its inbound port and its local dashboard reachable, because that dashboard is where CashPilot reads its earnings. So is mysterium, whose UDP range must accept inbound packets.

Running your own rules

Only if you set CASHPILOT_CONTAINER_NETWORK to manage the bridge and its firewall yourself. Put the managed containers on their own bridge:

networks:
  cashpilot-isolated:
    # Without it Compose prefixes the project name to the network.
    name: cashpilot-isolated
    driver: bridge
    driver_opts:
      # Linux caps interface names at 15 characters; the rules match this one.
      com.docker.network.bridge.name: "cp-isolated"

A Docker bridge alone does not stop LAN access

Containers on a user-defined bridge can still reach your other subnets. The rule that matters is on the host firewall, applied to that bridge, denying:

  • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16: your LAN
  • 169.254.0.0/16: cloud metadata. On a VPS, a container that reaches this can often mint credentials for the whole account.

...while still allowing every exception the guide lists for the services you run. Miss one and that service silently stops earning.

Protecting your home network has the exact rules, tested, including the one most recipes miss: traffic to the Docker host itself never passes the DOCKER-USER chain. It also shows how to make the CashPilot worker deploy onto this bridge with CASHPILOT_CONTAINER_NETWORK.

Whatever you apply, test it from inside an earner: a rule that looks right can still be skipped, and the test shows it in one command.

Check it from the dashboard

On the Fleet page, the owner sees Check isolation on each worker. The same check is POST /api/workers/{id}/isolation-check. The worker has to run 1.46.0 or later, and reach its Docker socket: a worker without one does not offer the button.

The worker starts two short-lived containers from its own image, so it pulls nothing. With CashPilot's own fence on, it also reads the fence's packet counters before and after. They have no capabilities, run as nobody, and the worker removes them and their volumes when the check ends, whether it passed, failed or timed out. If Docker refuses to remove one, the result names it and gives the docker rm -fv line to clear it; the check's answer still stands. It touches no firewall rule.

  1. The control runs on the host's network. It collects addresses to try: the default gateway, the host's own addresses with ports it listens on, your private DNS resolvers, 100.100.100.100 when Tailscale runs, one tailnet peer when the host has a connection open to one, and 169.254.169.254. It keeps only the ones that answer from the host, so a closed port can never pass for a blocked one. Each of the host's addresses is tried on two paths, since a rule can close one and miss the other: a port the host serves itself, such as SSH or its web interface, which arrives on the INPUT chain, and a port Docker publishes for a container, which Docker forwards to that container through FORWARD. The result labels the second kind "forwarded to a container". Listeners on [::] count too, since a dual-stack socket also accepts IPv4. When an address has several open ports, the result names SSH, then 80, then 443 before any other, so the line is one you recognise. The host's LAN and tailnet addresses and the earner's own bridge gateway are always tried. On a host with many Docker networks, the other bridge gateways are tried up to a limit, and the result counts the ones it left out.
  2. The earner side joins a running earner's network, or the isolated network when no earner runs, and tries the same addresses. It also tries 1.1.1.1:443, to show the earner still reaches the internet. The CashPilot dashboard's own address is one of the targets: an earner that reaches it is the clearest risk there is.
  3. The fence's own proof, with CashPilot's fence on. The earner side sends one connection to an unused address in each blocked range (for example 10.255.255.254:9) and one to the host. Each drop rule's packet counter is read before and after. A counter that went up proves the fence dropped that connection, even on a host where nothing on the LAN answers, which is the case under Docker Desktop and colima. A counter that did not go up is reported: something on the host routes that traffic around the fence. Two more targets join when the table holds their drops: your home's public IP, and the host's IPv6 link-local address on the bridge. For those, a refused connection also counts as reaching, since the drop answers nothing. An earner with no IPv6 sends nothing to the second, which is not a finding.

Reading the result

  • Isolated. The earner reached none of the addresses, and it reached the internet. The one exception is an expected DNS line (see below): when one got through, the result says so and names it.
  • Not isolated. The result lists each problem with a link to its fix, grouped as your LAN, this host, overlay network and cloud metadata. It also flags three things the probes cannot see:
    • earners on a bridge other than CASHPILOT_CONTAINER_NETWORK, which your rules do not cover, including stopped ones, which are exposed again as soon as they start;
    • earners on CASHPILOT_CONTAINER_NETWORK that are also attached to another network, which is a way round the rules on its bridge;
    • a Mysterium node whose MYST chain leaves out a private range, such as 100.64.0.0/10 or 169.254.0.0/16. Redeploy it from the dashboard, since the catalog now passes the full list. The check reads the chain from inside the node, so it also sees rules written through nf_tables, which the host's iptables misses on Unraid. An empty chain means the node has carried no session since it started. Check again later. The chain name and the blackhole address come from the service's catalog entry (docker.node_firewall), not from CashPilot's code.
  • Could not test. Nothing answered from the host, the earner could not reach the internet either, or a probe could not run at all: a Docker error, no running earner on a bridge and no CASHPILOT_CONTAINER_NETWORK to test from, a network name that does not exist, or a Docker daemon that did not answer within 10 s. A "blocked" line would prove nothing then, so the check does not claim isolation, and the result shows the exact reason.

Two lines need a closer look.

  • 100.100.100.100:53 can read reachable on a fenced host with Tailscale. The DNS rewrite sends that traffic to a public resolver before the fence sees it. A rule that lets port 53 to that address through on purpose looks the same. When port 80 on the same address reads blocked, the fence works and only DNS gets through, and the result says so.
  • Port 53 on your router or a LAN resolver can read reachable when you added the router DNS exception. The check also tries another port on that address, such as its web interface. When that one reads blocked, only DNS gets through, and the result says so. When nothing else on the address answers from the host, the result says it cannot tell the exception from a missing fence.
  • A warning that earners ask a resolver the fence blocks is about speed, not a leak. Those earners were deployed before CASHPILOT_CONTAINER_DNS and wait about 4 s on every name lookup. Redeploy them.

Apart from the fence's IPv6 target above, the check covers IPv4 only. When the earners' network has IPv6 enabled, or an earner has a global IPv6 address, the result says so, since reaching the LAN over IPv6 needs its own ip6tables rules. It shows what an earner can reach right now, so run it again after any change to the host.

A VLAN is stronger

A separate VLAN with its own firewall policy contains host-networked services too, and does not depend on getting Docker's bridge rules exactly right. If you already run VLANs, that is the better answer.

Before you deploy

GET /api/services/{slug}/deploy-risk returns the attribution notice for a service, driven by its own catalog disclosure. Three outcomes, and the third matters most:

  • documented as reselling your IP: you get the full warning;
  • documented as not reselling (a storage node): no warning, because a false one teaches you to click past the real ones;
  • not documented at all: it says exactly that. Most of the catalog is undocumented, and "nobody checked" must never read as "no risk".