Consolidated per-host gateway for the macOS (Apple container) backend #399
Reference in New Issue
Block a user
Delete Branch "macos-consolidated-gateway"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #351 (PRD 0070). The last backend in the roadmap — macOS (Apple container), which #385 left failing closed after the per-bottle companion container was removed.
Summary
Re-enables
macos-containeron the shared per-host orchestrator + gateway. The agent attaches to one shared host-only network and proxies egress through the single gateway, attributed by its source address.Apple Container 1.0.0 forced three departures from the docker shape. Each is a platform constraint I verified against the live CLI on this host, not a style choice — they're recorded with transcripts in the networking spike.
No
--ip.--networktakes only<name>[,mac=…][,mtu=…]; the address comes from vmnet's DHCP and is knowable only once the container runs. So the order inverts:That is why
consolidated_launch.pyexposesensure_gateway()+register_agent()where docker has one function — the caller starts the agent in between.No container DNS. Containers can't resolve each other by name, so the gateway is handed the control plane's IP. That IP doesn't exist until the orchestrator runs, so
orchestrator_service.pystarts the orchestrator first and then points the gateway at it — the reverse of docker's order. It also means no--publish: the host reaches the host-only network directly, so the host CLI and the gateway share one URL.No
network connect. Networks are fixed atcontainer run, so the shared network is created up front. Per-bottle networks would force a gateway restart per launch and defeat the consolidation.The identity token moved
Registration mints the token after the container exists, so it can't be baked into the run-time env the way docker's compose spec does. It's applied at
container exectime instead, which works because exec inherits run-time env and--envoverrides it. This is load-bearing rather than cosmetic:/resolverequires a matching(source_ip, identity_token)pair and fail-closes with no source-IP-only fallback (#366 has landed). The agent's init is a baresleepand every real command arrives via exec, so everything carries the token; anything that didn't would be denied, which is the safe direction.The token rides bare
--envnames so its value never lands on argv (psis world-readable).Security: dropping CAP_NET_RAW
Apple grants NET_RAW by default. It does not grant NET_ADMIN, so an agent can't change its own address or route:
But NET_RAW permits raw sockets — i.e. forging a neighbour's source address on the shared segment, directly against PRD 0070's invariant ("a packet's source address, as seen by the orchestrator, provably identifies the originating bottle"). The agent is therefore run with
--cap-drop CAP_NET_RAW, which I confirmed clears the bit and blocks raw sockets.Worth noting for the PRD: 0070 lists the Apple invariant as simply "the host-only network". That isn't sufficient on its own — the host-only network alone leaves NET_RAW spoofing open. The cap drop is what closes it.
Verification
Driven end-to-end against real Apple Container 1.0.0 (
container 1.0.0, macOS 26.5.1, arm64), not just mocked:Dockerfile.orchestratorandDockerfile.gatewaybuild undercontainer build.Two real bugs were caught during review and fixed, both with regression tests:
Bottle.exec()didn't carry the token, so a provider whose provision step fetches anything would have egressed token-less and been denied.1815unit tests pass, pyright clean, pylint 9.92.Notes for review
test_sandbox_escapesuite on macOS. I proved the egress-policy path end-to-end; the git-gate/DLP attacks aren't exercised here.bot-bottle.dbwith the docker/fc orchestrators, consistent with the existing multi-backend coexistence the docker lifecycle already documents.containerCLI965ee67c46toa5910696a5Ran a review of the new integration test (
test_orchestrator_docker_control_plane_auth.py) and fixed everything it found. Summary for anyone reviewing:Verified the fix itself first, live, before writing anything: brought up a real Docker orchestrator + gateway, hit the control-plane port directly.
/healthstays open with no token;/bottlesand/resolveboth return 401 with no token or a wrong one (the enumeration and credential-lift attacks from #400);/bottlessucceeds with the real per-host secret. Docker backend now has the same live verification macOS got.Then reviewed the new test itself and fixed 8 findings:
host_control_plane_token()reads/writes the ambientBOT_BOTTLE_ROOTenv var, not thehost_rootconstructor kwarg (that kwarg only controls the DB bind-mount destination). The "isolated" test was actually reading/writing my real~/.bot-bottle/control-plane-token— confirmed directly on disk. Fixed by pointing the env var at the same throwaway dir for the test's duration._running_image_is_current()keys gateway staleness off the image tag's ID, not per-instance identity, and the test didn't overrideimage/gateway_image. A test run rebuilding the shared:latesttag from local disk state could make a real host's running production gateway look stale and get force-recreated mid-flight. Fixed with fixed (non-per-run) test-only tags (:itest), decoupled from production, that don't accumulate across runs.stop()only removes containers, never the networkensure_running()creates. Found and removed 5 leaked networks from earlier runs. Fixed with explicitdocker network rmin cleanup.GITEA_ACTIONSskip guard every sibling Docker-bind-mount integration test has (test_multitenant_isolation.py,test_gateway_image.py,test_sandbox_escape.py). This one wasn't theoretical: pushing surfaced a real failing run (mkdir /workspace: read-only file system, all 5 tests erroring insetUp). Added the same guard.USERdirective, so it writes the registry DB as root into the throwawayhost_root;TemporaryDirectory.cleanup()would then hitPermissionErroron real Linux Docker (not reproducible on macOS, where Docker Desktop's VM remaps ownership). Added the samechownworkaroundtest_multitenant_isolation.pyalready uses.OrchestratorClient— the test hand-rolled its own urllib request helper instead of reusing the real host-side client, so it wasn't actually exercising the code path production callers use. Swapped toOrchestratorClient.setUpClass, so all 5 read-only test methods each paid their own container-start + image-build + health-poll cycle. Converted tosetUpClass/addClassCleanup; cut the file's wall-clock from ~11.5s to ~3-4s.OrchestratorServiceand overrode its private_gateway()just to give the gateway a unique name. Added a realgateway_name: str = GATEWAY_NAMEconstructor param (mirrors the existingorchestrator_name) so any caller needing this can use the public constructor — backward compatible, deleted the subclass entirely.Verified end to end: ran the suite twice in a row (idempotency — fixed tags don't accumulate), confirmed zero leaked networks/containers and an untouched real token file after each run, exactly 2
:itestimages (not growing), the full orchestrator unit suite (93 tests) and the sibling docker gateway/broker integration tests still green, pyright clean, pylint 10.00/10 on every changed file. CI is green on the current head.