docs(egress): the remediation was applied, failed twice, and what holds
deploy / test (push) Successful in 5m14s
deploy / build (push) Successful in 1m4s

MISSION-EGRESS.md said the fix was deliberately not applied and prescribed
DOCKER-USER. Both would now mislead. Appended what happened on 2026-09-18:
missions shared the server's subnet, DOCKER-USER loses to ts-forward on every
tailscaled restart, raw PREROUTING dropped the server's replies — and the
design that holds, verified from inside a real mission container.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-18 11:50:54 -05:00
co-authored by Claude Opus 5
parent 869c3adcb7
commit a0f6cd7175
+87 -1
View File
@@ -1,5 +1,11 @@
# What a mission container can reach — 2026-08-27 # What a mission container can reach — 2026-08-27
> **Applied 2026-09-18.** The lateral path this document measured is closed.
> The remediation below was applied, failed twice in ways worth reading, and
> was replaced by a different design. See [Applied — what actually
> worked](#applied--what-actually-worked-2026-09-18) at the end before
> acting on anything above it.
Measured, not modelled. Nothing in this document changes production; it exists Measured, not modelled. Nothing in this document changes production; it exists
because plan item 4 named a fix that cannot reach the problem, and the problem because plan item 4 named a fix that cannot reach the problem, and the problem
turned out to be larger than the one it was written for. turned out to be larger than the one it was written for.
@@ -99,7 +105,7 @@ Stated because a report that lists only the bad half is not a measurement:
- `169.254.169.254` is link-local on bare metal, not a cloud metadata service. - `169.254.169.254` is link-local on bare metal, not a cloud metadata service.
Reachable, but there is no credential endpoint behind it on gw-04. Reachable, but there is no credential endpoint behind it on gw-04.
## Remediation — described, deliberately NOT applied ## Remediation — described, deliberately NOT applied (superseded — see the end)
The operator's call was to measure and report. The smallest change that closes The operator's call was to measure and report. The smallest change that closes
the lateral path, for whenever that decision is made: the lateral path, for whenever that decision is made:
@@ -150,3 +156,83 @@ says what it means. The container's networks are the fact, not the return code.
- `architect` and the fleet nodes were offline, so they were not probed. - `architect` and the fleet nodes were offline, so they were not probed.
- The probe container ran `alpine`, not `clawmates-runtime`. Same two networks - The probe container ran `alpine`, not `clawmates-runtime`. Same two networks
and the same route table; a different image cannot have more access. and the same route table; a different image cannot have more access.
## Applied — what actually worked (2026-09-18)
Everything measured above was re-measured first, from a container on the
mission egress network, and was still true: tank's SSH, web-01's registry,
this host's SSH, link-local — all open. Then the rules above were applied, and
the positive/negative control pair this document asked for found three things
the rules could not have known.
### 1. Missions shared a subnet with the server
Missions egressed from `clawmates_edge` (172.23/16). So does
`clawmates_server_1`, and the server **needs** the tailnet: the Beszel hub on
architect (`:8090`), Ollama for the local-model backend (`:11434`), and the
node daemons (`:8088`) for exec-test and node-placed terminals. The tailnet
drop scoped to 172.23/16 cut the server off from architect:8090 within the
minute it was applied. No rule on that subnet could ever be right for both.
**Fix:** missions egress from a network of their own, `clawmates_missions`,
pinned at **172.25.0.0/16** (`mission_runtime.rs` `EDGE_NETWORK`, commit
`869c3ad`; declared in both compose files). `core` is unchanged — the skills
door and the API are still reached over 172.20. Compose v1 will not create a
network no service uses, so on gw-04 it was created by hand with compose's own
labels (`com.docker.compose.network=missions`, `…project=clawmates`), which
compose then accepts as its own.
### 2. `DOCKER-USER` loses to Tailscale
`tailscaled` inserts `-j ts-forward` at the head of FORWARD on every restart,
above `-j DOCKER-USER`, and `ts-forward` ends in `-o tailscale0 -j ACCEPT`. A
tailnet drop in `DOCKER-USER` worked until `systemctl restart tailscaled`, and
then the tailnet was open again — verified by doing exactly that. Re-asserting
the rule from an `ExecStartPost` on `tailscaled` ran before Tailscale had
installed its chains and lost the same race. Link-local, which does not go
through `tailscale0`, stayed blocked throughout; that is what made the cause
legible.
### 3. `raw PREROUTING` cannot see connection state
Moving the drops to the raw table put them ahead of every chain Tailscale
touches — and ahead of conntrack. A drop on `-d 100.64.0.0/10` then matched
the server's **replies** to tailnet clients (tank's node daemon, the operator's
`curl`) and took the API off `100.102.112.85:8088` while every container
reported healthy. The public site kept serving, because Traefik reaches the
server over the Docker network.
### What holds
`/usr/local/sbin/clawmates-egress.sh` on gw-04, run by
`clawmates-egress.service` at boot and by `ExecStartPost` drop-ins on
`docker.service` and `tailscaled.service`. Idempotent (`-C` before `-I`). The
rules live in **`mangle PREROUTING` with `-m conntrack --ctstate NEW`**: after
conntrack, so replies pass; before FORWARD and INPUT, so ordering against
`ts-forward` is moot; and neither Docker nor Tailscale writes to that table.
```
missions (172.25/16): DROP NEW → 100.64/10, 10/8, 192.168/16, 169.254/16, tcp/22 anywhere
edge (172.23/16): DROP NEW → 10/8, 192.168/16, 169.254/16, tcp/22 anywhere (tailnet allowed)
```
Verified three ways, then verified again after restarting both daemons:
| from | tailnet | host ssh | link-local | public | core door |
|---|---|---|---|---|---|
| mission subnet (throwaway container) | blocked | blocked | blocked | open | 200 |
| **inside a real mission container** (`01a0b550`) | blocked | blocked | blocked | open | 200 |
| edge (the server) | **open** | blocked | blocked | open | — |
Inbound to the API on the tailnet port: 200 from the host and from tank. The
mission itself ran normally behind the policy — 19 `curl` fetches, 2 skill
reads through the door, judge `met=true`, independent.
### Limits
- gw-04 only. The laptop has the network (`clawmates_missions`, same subnet)
and no firewall; a local mission can still reach the LAN and tailnet.
- Reachability of a port, as before. A mission can still `curl` any public host.
- The microVM tier has its own egress model (per-backend rootfs, vsock) and is
not covered by any of this.