# Protecting your home network

CashPilot runs closed-source containers from companies you have never met, on a machine inside your home. This page is about keeping that from costing you more than the earnings are worth.

Three things can go wrong, in order of how likely they are:

1. **Someone else's traffic leaves under your IP address.** That is what most bandwidth-sharing services sell, and nothing on your side can filter it. [Network isolation](<https://geiserx.github.io/CashPilot/isolation/index.md>) explains the attribution risk, and the dashboard warns you before deploying a service that does this.
2. **A container reaches the rest of your network.** Your NAS, your Home Assistant, your router's admin page, the machine CashPilot runs on. For a hostile image, those are the prize, not your bandwidth.
3. **A container escapes to the host** through a kernel bug. CashPilot's defaults already make this hard, as [what every deploy gets](<#what-every-deploy-already-gets>) lists, and gVisor can make it harder on some hosts.

You cannot fix the first one, only decide whether to accept it. The rest of this page is about the second and third.

## The short version

1. [Put the earning machine on its own network segment](<#1-give-it-its-own-network-segment>), a guest network or VLAN.
2. [Firewall the containers off your LAN and off the host](<#2-firewall-the-containers-off-your-lan-and-the-host>). CashPilot does this by default; check that it holds.
3. [Keep CashPilot's own dashboard and worker private](<#3-keep-cashpilots-control-plane-private>).
4. [Don't run it next to your valuable data](<#4-dont-run-it-next-to-what-you-cannot-lose>), or put it in a VM.
5. [Tidy up the router](<#5-router-hygiene>).
6. [Protect the accounts and the money](<#6-accounts-and-payouts>).
7. Optionally, [run the containers under gVisor](<#7-gvisor-optional>) on a host that supports it.

Do the first two and you have removed most of the risk.

## 1\. Give it its own network segment

The strongest and simplest protection is a network the earning machine shares with nothing else you care about.

- **A guest network** on your router is often enough. Most consumer routers keep guest devices off the main LAN. To check yours, open your NAS or another computer from a device on the guest network. It should fail.
- **A VLAN** does the same thing on a wired network, with a firewall rule on the router that lets the VLAN reach the internet but not your other VLANs.

A segment protects against everything on that machine, including a service that uses the host's network directly or breaks out of its container. The container firewall below cannot confine those.

Storj, Sia (hostd) and Anyone Protocol need an inbound port forwarded, and Mysterium earns more with its UDP port range forwarded. Point those forwards into the new segment and nothing else changes.

## 2\. Firewall the containers off your LAN and the host

**CashPilot does this for you.** A worker puts every earner it deploys onto a bridge into its own network, `cashpilot-isolated`, and writes one firewall table that drops traffic from it to private ranges, your tailnet, cloud metadata and the host itself. Nothing to set up on a new install. An upgraded install keeps its earners where they were until you press **Turn on isolation** on the Fleet page. [LAN isolation by default](<https://geiserx.github.io/CashPilot/isolation/#lan-isolation-by-default>) explains how it works, its modes, how to turn it off and what it does not cover. [Check isolation](<https://geiserx.github.io/CashPilot/isolation/#check-it-from-the-dashboard>) on the Fleet page proves it on your host.

The rest of this section is for running your own rules instead, with `CASHPILOT_CONTAINER_NETWORK`. Then CashPilot writes no firewall rule at all.

When you manage the firewall yourself, two separate things need blocking:

- **Other machines on your networks.** Traffic from a container to another machine is *forwarded* by the host, and Docker's `DOCKER-USER` chain is where you filter it.
- **The host itself.** Traffic from a container to the machine it runs on never reaches `DOCKER-USER`; it arrives on the host's `INPUT` chain. On a NAS this is the part that matters most, because the host *is* the NAS.

A Docker network on its own does neither. It only keeps containers off *other Docker networks*.

### Put the containers on their own bridge

Create a bridge whose interface name the rules can match. Linux limits interface names to 15 characters, so the network is `cashpilot-isolated` and its interface is `cp-isolated`:

```
docker network create --driver bridge \
  -o com.docker.network.bridge.name=cp-isolated \
  cashpilot-isolated
```

Then tell the CashPilot worker to use it, by adding this to the worker's environment and recreating the worker. This turns off CashPilot's own fence on that worker:

```
CASHPILOT_CONTAINER_NETWORK: cashpilot-isolated
```

From then on, every service the worker deploys onto a bridge joins `cashpilot-isolated` instead of Docker's default bridge. Services that use host networking are unaffected. The worker never creates the network or any firewall rule itself. If the network is missing, it refuses the deploy and tells you the command above, and it checks before it touches the running container.

Already-deployed services move over on their next redeploy. To move one now without recreating it, keeping its configuration and identity as they are:

```
docker network connect cashpilot-isolated cashpilot-honeygain
docker network disconnect bridge cashpilot-honeygain
```

Published ports move with it and survive a restart. A service that holds a long-lived connection may keep trying the old one for a while; Bitping did, and a `docker restart` of that one service fixed it.

If you run the services from an exported compose file instead, add `networks: [cashpilot-isolated]` to each service and declare the network as `external: true` at the bottom of the file.

### The rules

```
#!/bin/sh
# Containers on the cp-isolated bridge may reach the internet, but not your
# private networks and not this host. Safe to run more than once.
BR=cp-isolated
iptables -N DOCKER-USER 2>/dev/null || true   # Docker keeps it if it exists
add() { iptables -C "$@" 2>/dev/null || iptables -I "$@"; }
for net in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10; do
  add DOCKER-USER -i "$BR" -d "$net" -j REJECT
done
add DOCKER-USER -i "$BR" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
add INPUT -i "$BR" -j REJECT
add INPUT -i "$BR" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
```

What each part does:

- The five ranges are the private networks, link-local addresses (including the `169.254.169.254` metadata address a cloud VPS exposes), and `100.64.0.0/10`, which carrier-grade NAT and Tailscale use.
- The `ESTABLISHED,RELATED` rules let replies through. Without them a port you published, such as Storj's dashboard, stops answering: the request gets in and the reply is rejected on its way out.
- Each rule is inserted at the top, and the reply rules are inserted last, so they end up first. `iptables -C` skips a rule that already exists, so running the script again adds nothing.

We ran this script twice on a Docker host and checked the result:

| From a container on `cp-isolated` | Result |
| --- | --- |
| A web service on the host itself | blocked |
| Another machine on the LAN | blocked |
| The internet, by address and by name | works |
| A port published from the container, reached from the host | works |
| The same port with the reply rule removed | times out |

### Name lookups need a resolver the rules allow

Docker answers a container's lookups and forwards them to the DNS servers it was given, **from inside the container's network**. On most home networks that server is the router, which the rules above block. With a VPN such as Tailscale on the host, it is often `100.100.100.100`, which the `100.64.0.0/10` rule blocks. Every lookup then fails, or waits about four seconds for Docker to fall through to the next server on its list.

The worker handles this for you. A container it puts on CashPilot's own network or on `CASHPILOT_CONTAINER_NETWORK` gets its own DNS servers, `1.1.1.1` and `9.9.9.9` by default, which the rules allow. Containers on other networks, and the host itself, keep the Docker daemon's list. To pick other servers, give the worker their addresses:

```
CASHPILOT_CONTAINER_DNS: 9.9.9.9,149.112.112.112
```

Set it empty to give the isolated containers the daemon's list instead. A value that is not a list of IP addresses is refused before the running container is touched. The setting applies when a container is next deployed: a container keeps the DNS servers it was created with until then.

To use your router's DNS instead, set `CASHPILOT_CONTAINER_DNS` to the router's address and let the containers reach its DNS port and nothing else on it, by adding this to the script:

```
ROUTER=192.168.1.1
add DOCKER-USER -i "$BR" -d "$ROUTER" -p udp --dport 53 -j RETURN
add DOCKER-USER -i "$BR" -d "$ROUTER" -p tcp --dport 53 -j RETURN
```

We tested the exception: lookups through the router work and the router's web interface stays blocked.

If you enabled IPv6 on the bridge and your router also answers DNS on an IPv6 address, add the same exception to the [IPv6 script](<#the-rules>) below, with that address. We tested the exception only over IPv4.

```
ROUTER6=fe80::1   # your router's IPv6 DNS address
add DOCKER-USER -i "$BR" -d "$ROUTER6" -p udp --dport 53 -j RETURN
add DOCKER-USER -i "$BR" -d "$ROUTER6" -p tcp --dport 53 -j RETURN
```

**When to run it.** The rules survive a Docker restart but not a reboot, so run the script at every boot. It creates the `DOCKER-USER` chain if Docker has not started yet, and Docker keeps an existing chain and its rules, so the order does not matter. A systemd unit works, as does Unraid's User Scripts plugin set to run at array start.

**IPv6.** Docker bridges have IPv6 off unless you enabled it. If you did, the IPv6 rules need your LAN's own prefix as well as the private and link-local ranges, because a home network with IPv6 usually has a globally routed prefix that `fc00::/7` does not cover. `ip -6 route` lists it; the `2001:db8:1:2::/64` below is only an example:

```
#!/bin/sh
BR=cp-isolated
LAN6=2001:db8:1:2::/64   # replace with your LAN's prefix
ip6tables -N DOCKER-USER 2>/dev/null || true
add() { ip6tables -C "$@" 2>/dev/null || ip6tables -I "$@"; }
for net in fc00::/7 fe80::/10 "$LAN6"; do
  add DOCKER-USER -i "$BR" -d "$net" -j REJECT
done
add DOCKER-USER -i "$BR" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
add INPUT -i "$BR" -j REJECT
add INPUT -i "$BR" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
```

We checked that these rules load, but tested the blocking only for IPv4.

**What it cannot cover.** A service on host networking uses the host's own interfaces, so a rule on a bridge does not apply to it. No catalog service runs that way: after 2.0.6 Mysterium sits on the bridge in its own network namespace, with its UDP range published ([Mysterium on the fenced bridge](<https://geiserx.github.io/CashPilot/isolation/#mysterium-on-the-fenced-bridge>)). For a node you run yourself on host networking, use a separate segment, or fence its session interfaces as in [Mysterium](<https://geiserx.github.io/CashPilot/isolation-check/#mysterium>).

> [!WARNING]
>
> **Tailscale on the host can bypass these rules**
>
> `tailscaled` puts its own chain at the top of `FORWARD` when it starts, and that chain accepts everything bound for `tailscale0` before `DOCKER-USER` runs. Containers then reach your tailnet and its subnet routes despite the `100.64.0.0/10` rule above. [Test your isolation](<https://geiserx.github.io/CashPilot/isolation-check/index.md>) shows how to check it and a version of the rules that holds in either order.

### Check it yourself

A fuller test, with a control that proves the test itself works, is in [Test your isolation](<https://geiserx.github.io/CashPilot/isolation-check/index.md>).

After applying the rules, from the host:

```
# should FAIL: another machine on your LAN (use a real address of yours)
docker run --rm --network cashpilot-isolated busybox wget -T3 -qO- http://192.168.1.1/
# should work: the internet, by name
docker run --rm --network cashpilot-isolated busybox wget -T5 -qO- http://example.com/ | head -3
```

If the first command succeeds, the rules are not in place. Check that `iptables -S DOCKER-USER` shows them and that the interface really is `cp-isolated` (`ip link show cp-isolated`). If the second fails with a name error but `wget http://1.1.1.1/` works, it is DNS: see [name lookups](<#name-lookups-need-a-resolver-the-rules-allow>).

## 3\. Keep CashPilot's control plane private

The dashboard can command the worker, and the worker can start containers, which is close to root on that machine: through the socket proxy with the shipped compose files, and with the raw Docker socket, which is root, in the [app-store listings](<https://geiserx.github.io/CashPilot/app-stores/index.md>). Both bind to `127.0.0.1` out of the box ([security defaults](<https://geiserx.github.io/CashPilot/security-defaults/index.md>)). Keep it that way:

- **Never port-forward** the dashboard (8080) or the worker (8081) from your router.
- **Use a VPN** such as Tailscale or WireGuard to reach the dashboard from elsewhere, or put an authenticating reverse proxy in front of it.
- **Bind the worker to one address**, on a multi-machine setup, with `CASHPILOT_WORKER_BIND_ADDR`: the one the dashboard reaches it on, never `0.0.0.0` on a machine with a public address.
- **Don't publish a node's own management UI to your LAN** unless you need it. Mysterium's WebUI is published on `127.0.0.1` only by default, and its API never leaves the node. Reach the WebUI over an SSH tunnel, or add to `UI_ADDRESS` only the one address of this machine your reverse proxy connects to: a Docker network gateway such as `172.18.0.1` for a proxy on the same machine, or this machine's LAN address for a proxy elsewhere. `UI_ADDRESS` names addresses on this machine, never the proxy's own. A published port goes through Docker's forwarding, so a host `INPUT` rule does not restrict it: limit it with the address you publish on, or a rule in `DOCKER-USER`.

## 4\. Don't run it next to what you cannot lose

The machine that runs these containers should hold nothing you would hate to see leak. On a NAS or a server with your photos, documents and password vault, the best move is a **virtual machine**:

- Proxmox, Unraid and most NAS systems can run a small Linux VM.
- Install Docker and the CashPilot worker inside it, and give the VM its own network segment if you can.
- A container that escapes lands in the VM, not on your NAS. A VM is a stronger boundary than any container runtime, and it works everywhere, including on Unraid, where gVisor cannot run.

A Raspberry Pi or an old laptop dedicated to earning does the same job for very little money.

## 5\. Router hygiene

- **Turn off UPnP.** It lets any device on your network open ports on your router without asking you. Forward the few ports a service documents by hand.
- **Change the router's admin password** and turn off remote administration.
- **Keep the router's firmware updated.**
- **Watch the traffic.** CashPilot shows network use per container. A service that suddenly moves far more than usual deserves a look.

## 6\. Accounts and payouts

- **Use a unique password, and two-factor authentication where offered,** for every earning account. These accounts hold money.
- **Pay out to a wallet you control,** never to an exchange deposit address unless the provider says that is supported. See each service's guide.
- **Back up CashPilot's encryption key.** It decrypts every stored credential. See [backing up the encryption key](<https://geiserx.github.io/CashPilot/security-defaults/#backing-up-the-encryption-key>).
- **Check your ISP's terms.** Many consumer contracts forbid reselling the connection, and the account that gets suspended is yours.

## 7\. gVisor, optional

[gVisor](<https://gvisor.dev/>) runs a container against a kernel written in user space instead of the host's kernel. A container that exploits a kernel bug hits gVisor's kernel, not yours. It adds a layer against risk 3 above. It does nothing for risks 1 and 2: traffic still leaves under your IP address, and a container can still reach your LAN unless you firewall it.

### What it costs

We measured it on an x86\_64 Ubuntu 24.04 server with kernel 6.8, running gVisor `release-20260921.0` on its default platform. Each run downloaded the same 1 GiB file in a container under each runtime:

|  | runc (Docker's default) | runsc (gVisor) |
| --- | --- | --- |
| Throughput, three runs | 64, 61, 57 MB/s | 53, 44, 45 MB/s |
| CPU time for 1 GiB | 3.3 s | 20 to 30 s |
| Connection setup, median of 10 | 10.8 ms | 11.4 ms |

gVisor reached about 78 % of runc's throughput and used six to nine times the CPU for the same traffic. For a typical bandwidth-sharing service moving a few tens of gigabytes a month, that is minutes of CPU a month. For a busy storage node, or on a Raspberry Pi, it adds up.

With network passthrough, the setup that works on a CashPilot network (see below), throughput matched runc's in two runs each, 38 to 44 MB/s on a link that was the limit that day, and CPU stayed at about six times runc's: 19 to 20 s for 1 GiB against 3 s.

### What breaks

- **Docker's DNS on your own networks.** Plain `runsc` cannot resolve names on any network you create, `cashpilot-isolated` included. Docker answers lookups at `127.0.0.11` through firewall rules inside the container's network, and gVisor's own network stack does not apply them. This is [gVisor issue 7469](<https://github.com/google/gvisor/issues/7469>), still open. The fix that works is to register gVisor with `--network=host`, which, despite the name, uses the kernel network stack *inside the container's own network*: Docker's DNS works, and the container stays on the firewalled bridge. It weakens gVisor's network isolation, and every other system call is still handled by gVisor.
- **Raw sockets are off by default.** `ping` fails under gVisor even when the container is granted `NET_RAW`. Bitping declares `NET_RAW` to probe the network, so it needs gVisor registered with the `--net-raw` flag, which gives that capability back.
- **A service on host networking gains little.** gVisor's own documentation says network passthrough "decreases the isolation to the host", and a service that already shares the host's network has nothing left to confine it there.
- **Mysterium does not run under it.** gVisor gives it a TUN device, but not `iptables-nft` or kernel WireGuard, which the node uses for its customers. Its catalog entry requires `runc`, so `CASHPILOT_CONTAINER_RUNTIME=runsc` does not reach it.
- **Stock Unraid cannot run it at all.** Unraid runs from its initial RAM filesystem. That root has no parent mount, so gVisor cannot `pivot_root` out of it, and working around that with a chroot hits a second kernel rule: no new user namespace inside a chroot, which gVisor needs. We tested this on Unraid 7.3.2. On Unraid, use [a VM](<#4-dont-run-it-next-to-what-you-cannot-lose>) instead.
- **Docker Desktop** on macOS and Windows already runs every container inside a Linux VM, so gVisor there adds a second layer rather than the first.

### Installing it on a standard Linux host

gVisor supports x86\_64 and ARM64 on Linux 5.6 or newer. The [official install guide](<https://gvisor.dev/docs/user_guide/install/>) installs a checksummed release and registers it with Docker, without restarting Docker or your running containers:

```
(
  set -e
  ARCH=$(uname -m)
  URL=https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}
  wget ${URL}/gvisor.tar.zstd ${URL}/gvisor.tar.zstd.sha512
  sha512sum -c gvisor.tar.zstd.sha512
  sudo tar --zstd -xf gvisor.tar.zstd -C /usr/local/bin
  rm -f gvisor.tar.zstd gvisor.tar.zstd.sha512
)
sudo /usr/local/bin/runsc install
sudo systemctl reload docker
docker run --rm --runtime=runsc hello-world
```

For CashPilot's networks, add the passthrough runtimes to `/etc/docker/daemon.json`, next to the `runsc` entry the install wrote, and run `sudo systemctl reload docker`:

```
"runtimes": {
  "runsc": { "path": "/usr/local/bin/runsc" },
  "runsc-hostnet": { "path": "/usr/local/bin/runsc", "runtimeArgs": ["--network=host"] },
  "runsc-hostnet-raw": { "path": "/usr/local/bin/runsc", "runtimeArgs": ["--network=host", "--net-raw"] }
}
```

`runsc-hostnet-raw` is for a service that needs raw sockets, such as Bitping.

### What we ran under it

On an Ubuntu 24.04 host, each of these ran under `runsc-hostnet` on `cashpilot-isolated` without restarting and without name-lookup errors: EarnApp, Earn.fm, Honeygain, IPRoyal Pawns, PacketStream, ProxyBase, ProxyLite, ProxyRack, Repocket and Traffmonetizer, plus Bitping under `runsc-hostnet-raw`. Mysterium cannot run under it (see above). We did not try Storj, whose disk traffic would pay gVisor's overhead on every read and write.

To make that stick across redeploys, set it on the worker, next to `CASHPILOT_CONTAINER_NETWORK`:

```
CASHPILOT_CONTAINER_NETWORK: cashpilot-isolated
CASHPILOT_CONTAINER_RUNTIME: runsc-hostnet
CASHPILOT_CONTAINER_RUNTIME_OVERRIDES: bitping=runsc-hostnet-raw
```

The runtime applies to containers on bridge networking when they are next deployed; a service whose catalog entry names a runtime, such as Mysterium (`runc`), keeps that one. `slug=runc` in the overrides runs one service under `runc`, the runtime Docker ships with; Storj is a good candidate. If the daemon does not provide a runtime you named, the deploy is refused and the running container is left alone. Nothing selects gVisor unless you set it ([container runtimes](<https://geiserx.github.io/CashPilot/security-defaults/#container-runtimes-gvisor-where-it-works>)).

## What every deploy already gets

You do not have to configure any of this; it is always on ([security defaults](<https://geiserx.github.io/CashPilot/security-defaults/index.md>)):

- every Linux capability dropped, and only the ones a service's own catalog entry declares added back;
- `no-new-privileges`, and a limit on the number of processes;
- no privileged containers, and no host device unless the service's own entry declares it and it is on a fixed allow-list, which today holds only `/dev/net/tun`, for Mysterium;
- no bind mounts of system paths, including the Docker socket;
- credentials encrypted at rest;
- for a [proxied copy](<https://geiserx.github.io/CashPilot/proxies/index.md>), a tunnel container that drops every packet not bound for the proxy, so the copy can never fall back to your own connection or reach your LAN.
