fix(net): close #236 — route-level door reachability in TCP mode - #261
fix(net): close #236 — route-level door reachability in TCP mode#261bdelanghe wants to merge 2 commits into
Conversation
On macOS (TCP mode, the platform default) a box holding any door other than `net` got podman's default network so it could reach `host.containers.internal` — and that network carries full outbound NAT. Granting DOOR REACHABILITY incidentally granted INTERNET EGRESS: a `--keeper`-only box could reach any host, with no netd allowlist and no netd audit entry. #237 made the manifest report this honestly; this closes the hole it was reporting. The mechanism is the one ADR-NETWORK-POSTURE.md named first: a per-launch `--internal` podman network (no gateway, no NAT) plus a launcher-owned relay, dual-homed on that network and the default one, listening on exactly the granted doors' ports and forwarding each to the same port on the host. The box joins only the internal network, with `host.containers.internal` pinned to the relay — so `resolveDoor`, `planDoorMounts` and `NETD_TCP_PROXY` are untouched; the name simply resolves somewhere else. The forwarded set is derived from the grants (`relayPorts`), which is what makes this an OCAP mechanism rather than a firewall: what the box is told it holds and what it can reach are the same list by construction. Two ADR truth-table cells move to `boundary: "route"`: a non-`net` TCP box now has no egress at all, and a `--net` TCP box's netd is a real boundary rather than an advisory proxy a raw socket escapes. `NetworkPosture` gains a `mechanism` field (`netns` | `relay` | `default`) so `networkArgv` stays a total function of the one derivation now that `route` has two realizations. Fail-closed throughout: a relay that cannot be brought up tears down what it built and aborts the launch, because falling back to the default network is exactly the silent widening being fixed, and the manifest has by then promised a route boundary. `DOORS_TCP_RELAY=0` is the deliberate opt-out for a host that cannot provide an internal network — it restores the old posture and reports it as proxy/ambient, so the escape hatch cannot become a quiet lie. `--pod` is derived separately: it shares one netns with its in-pod netd on the default network, so it reports default/proxy/ambient rather than inheriting a boundary claim that would be #236 in a new place. tests/network-posture.test.ts asserted that the hole EXISTS; it now asserts the opposite by default and keeps the old assertion under the opt-out. tests/door-relay.test.ts covers the planners and fail-closed bring-up without podman. What those tests do not prove — that podman's `--internal` really removes the route on a given host — is stated as such in the ADR. Closes #236. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RzZqqmQiSPydKZmvTMqHgH
b66422d to
c69c3dd
Compare
What a floor would and would not retire from "What is NOT proven here"Recorded from The useful part is that the residue is smaller than "no podman and no macOS" suggests, and it splits cleanly. TCP mode is forceable off macOS
Against the three unproven claims
Two of three plus the live repro, from a host that already has a spec in this repo. The third genuinely needs a mac — worth deciding whether that is "accepted, unproven" and said so in Note that a Lima VM does not close the macOS half — it is Linux, so it moves the question rather than answering it. One thing not to reach forAdding a docker-in-docker feature to the devcontainer floor would not quietly fix this. Nested podman under a devcontainer is its own network platform, which is the one property a proof about No change requested to the diff — this is about what has to happen before it leaves draft, and where. Generated by Claude Code |
|
Follow-up to the note above, now that this left draft with the live check outstanding: #264 is stacked on this branch and records that fact in It also moves the 32 lines of markdown, no mechanism change, Generated by Claude Code |
) ADR-NETWORK-POSTURE said the platform properties were "checked once against a live macOS box" — the intended posture in the present tense, for a check that has not run. #261's body and the ADR disagreed about whether the mechanism is verified, and the ADR is what outlives the PR. - The not-proven bullet gains the /etc/hosts arbitration, which until now lived only in the PR body. - An explicit ACCEPTED, UNPROVEN entry states the live check is outstanding and that the #236 curl should now fail to connect. - A table names which host settles which item, so the residue is re-enterable: Linux + rootless podman with DOORS_TCP=1 covers the --internal route removal and the relay image's socat; only /etc/hosts needs a mac, because podman there is already inside a VM. A Lima VM does not close it. Markdown only; no source, test or flag changes. bun test 467 pass, 0 fail. Closes #263.
Closes #236 (and the tracker for its remaining half, #257). Carries #264 (ADR record correction, #263), merged into this branch.
The hole
On macOS (TCP mode, the platform default) a box holding any door other than
netgot podman's default network so it could reachhost.containers.internal:PORT— and that network carries full outbound NAT. Granting door reachability incidentally granted internet egress: a--keeper-only box could reach any host, with no netd allowlist and no netd audit entry.#237 closed the "the manifest actively lies about this" half. This closes the hole it was reporting.
The mechanism
The one
ADR-NETWORK-POSTURE.mdnamed first: a per-launch--internalpodman network (no gateway, no NAT) plus a launcher-owned relay, dual-homed on that network and the default one, running onesocatlistener per granted door port and forwarding each to the same port on the host. The box joins only the internal network, withhost.containers.internalpinned to the relay — soresolveDoor,planDoorMountsandNETD_TCP_PROXYare untouched; the name simply resolves somewhere else.The forwarded set is derived from the grants (
relayPorts), which is what makes this an OCAP mechanism rather than a firewall: what the box is told it holds and what it can reach are the same list by construction.The truth table
Two cells move to
boundary: "route":NetworkPosturegains amechanismfield (netns|relay|default) sonetworkArgvstays a total function of the one derivation now thatroutehas two realizations.--podis derived separately — it shares one netns with its in-pod netd on the default network, so it reportsdefault/proxy/ambientrather than inheriting a boundary claim that would be #236 in a new place.Fail-closed
A relay that cannot be brought up tears down what it built and aborts the launch. Falling back to the default network is exactly the silent widening being fixed, and the manifest has by then already promised a route boundary.
DOORS_TCP_RELAY=0is the deliberate opt-out for a host that cannot provide an internal network — it restores the old posture and reports it asproxy/ambient, so the escape hatch cannot become a quiet lie.Tests
tests/network-posture.test.tsasserted that the hole exists — that was the thing that had to change. It now asserts the opposite by default, and keeps the old assertion under the opt-out.tests/door-relay.test.tscovers the planners and fail-closed bring-up with an injected runner (no podman needed).bun test— 467 pass, 0 fail.bun x tsc --noEmitclean.What is NOT proven here
Per
docs/agentic-code-hygiene.mdrule 3, and now recorded inADR-NETWORK-POSTURE.mditself rather than only here — the ADR is what outlives the PR. Three platform properties are unproven:--internalreally removes the routesocatpresent in the relay image--add-hostvs podman's ownhost.containers.internalin/etc/hostsCorrecting an earlier version of this section, which said the branch "was developed in a Linux container with no podman and no macOS" in a way that implied all three needed a mac. Two of the three do not.
isTcpMode()is pure env and the darwin branch inmain()only supplies the default, soDOORS_TCP=1on a Linux podman host — the setupHOSTING.mdalready specifies — runs this exact path, including the internal network, the relay and therelayPortsderivation. The #236 repro (curlfrom a--keeper-only box reachingexample.com) runs there and should now fail to connect. A Lima VM does not close the third item; it is Linux, so it moves the question rather than answering it.The live check has not been run. This PR left draft with it outstanding, so it remains the thing to do before merging; the green checks above are unit-level and cannot stand in for it.
Why no session in this org could simply run it:
.github-private#613/#614 — the org has a session floor and no room floor, and the devcontainer floor is itself a Docker container with no podman. The session that implements platform-specific work structurally cannot be the one that verifies it.Also updated
netdoor'sdenyrulebook text — it can now truthfully say "no egress door ⇒ no route to any host" on both transports, without naming--network=noneas if it were the only mechanism.CAPABILITIES.md's transport caveat andNETD.md's opening claim, for the same reason.ADR-NETWORK-POSTURE.md: status, truth table, the follow-up section rewritten as the record of what landed, and (via docs(adr): record the live platform check as outstanding, not done (#263) #264) the explicitACCEPTED, UNPROVENentry plus the host-to-item table above.[settings]/[org]None. Related:
.github-private#616 adds the missingci-green-claude-boxruleset — this repo currently has no required check, so nothing mechanical would stop this PR merging red.