From 2cd44cf79aed40867b172b23b8dca110dc17f2bd Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Jul 2026 18:51:09 +0000 Subject: [PATCH] fix(macos): persist gateway CA on host --- bot_bottle/backend/macos_container/infra.py | 17 +++++++++++---- docs/prds/0070-per-host-orchestrator.md | 23 ++++++++++++++------- tests/unit/test_macos_infra.py | 14 +++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/bot_bottle/backend/macos_container/infra.py b/bot_bottle/backend/macos_container/infra.py index c6fcf62..1574bdb 100644 --- a/bot_bottle/backend/macos_container/infra.py +++ b/bot_bottle/backend/macos_container/infra.py @@ -41,7 +41,7 @@ from dataclasses import dataclass from pathlib import Path from ... import log -from ...orchestrator.gateway import GATEWAY_CA_CERT +from ...orchestrator.gateway import GATEWAY_CA_CERT, MITMPROXY_HOME from ...orchestrator.lifecycle import ( DEFAULT_PORT, DEFAULT_STARTUP_TIMEOUT_SECONDS, @@ -52,6 +52,7 @@ from ...paths import ( CONTROL_PLANE_TOKEN_ENV, HOST_DB_FILENAME, host_control_plane_token, + host_gateway_ca_dir, ) from .. import util as backend_util from . import util as container_mod @@ -218,6 +219,14 @@ class MacosInfraService: # Container-only DB volume: one kernel writes bot-bottle.db, never # shared with the host or another guest. "--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}", + # The DB needs a container-only ext4 volume for coherent SQLite + # locking, but the CA has no such constraint. Keep it in the host + # app-data root so infra-container recreation and Apple Container + # volume pruning cannot silently rotate every bottle's trust + # anchor (issue #450). + "--mount", + container_mod.bind_mount_spec( + str(host_gateway_ca_dir()), MITMPROXY_HOME), # Bind-mount the control-plane source (read-only); a code change # takes effect on relaunch with no image rebuild. "--mount", @@ -261,9 +270,9 @@ class MacosInfraService: def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: """The gateway's mitmproxy CA (PEM) agents install to trust its TLS - interception. Read out of the container (the CA lives on a - container-internal path, not a host mount); polls because mitmproxy - writes it a beat after start.""" + interception. Read through the container path backed by the persistent + host CA directory; polls because mitmproxy writes it a beat after + start.""" def _fetch() -> str | None: result = container_mod.run_container_argv( ["container", "exec", self._name, "cat", GATEWAY_CA_CERT]) diff --git a/docs/prds/0070-per-host-orchestrator.md b/docs/prds/0070-per-host-orchestrator.md index ee5a8f9..40e7fb3 100644 --- a/docs/prds/0070-per-host-orchestrator.md +++ b/docs/prds/0070-per-host-orchestrator.md @@ -324,15 +324,24 @@ re-attachment blocker distinct from #443/#445). The CA lives on the **host filesystem** at `bot_bottle_root()/gateway-ca` (`host_gateway_ca_dir()`), bind-mounted into the container at mitmproxy's -confdir. This is deliberately a host bind-mount, **not a Docker named volume**: -a named volume survives `docker rm` but is silently wiped by -`docker volume prune` / `docker system prune --volumes` during routine host -maintenance, which is exactly how the ephemeral-CA symptom shows up in -practice. A path under the app-data root is never pruned by docker, and stays -directly inspectable and rotatable from the host. mitmproxy reuses an existing -CA and generates one only on first run, so the bind-mount alone gives +confdir. This is deliberately a host bind-mount, **not a container-runtime +named volume**: a named volume survives ordinary container removal but can be +silently wiped by Docker's or Apple Container's volume-prune commands during +routine host maintenance, which is exactly how the ephemeral-CA symptom shows +up in practice. A path under the app-data root is not managed or pruned by the +container runtime, and stays directly inspectable and rotatable from the host. +mitmproxy reuses an existing CA and generates one only on first run, so the +bind-mount alone gives "adopt-existing, generate-on-first-run" for free. +The macOS backend uses the same host-resident CA directory and bind-mounts it +into the consolidated Apple infra container. Its `bot-bottle-mac-db` named +volume remains container-only because that prevents incoherent cross-kernel +SQLite locking, but the CA is deliberately not stored there: Apple Container +also has a `container volume prune` operation, and the named volume is +temporarily unreferenced while the infra container is recreated. Keeping the +CA on the host makes both ordinary recreation and volume pruning safe. + **Deliberate rollover** is the explicit inverse: `rotate_gateway_ca()` removes the persisted CA material so the next start remints it, and the `python -m bot_bottle.orchestrator.rotate_ca` one-shot wires that together with diff --git a/tests/unit/test_macos_infra.py b/tests/unit/test_macos_infra.py index a026143..cf746dc 100644 --- a/tests/unit/test_macos_infra.py +++ b/tests/unit/test_macos_infra.py @@ -61,6 +61,20 @@ class TestInfraRun(unittest.TestCase): mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"] self.assertTrue(all("bot-bottle.db" not in m for m in mounts)) + def test_ca_is_persisted_on_the_host_not_the_container_volume(self) -> None: + """The CA survives infra recreation and cannot be removed by Apple + Container's volume-prune command.""" + argv = self._run_container(MacosInfraService(repo_root=Path("/r"))) + mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"] + ca_mounts = [ + m for m in mounts + if "target=/home/mitmproxy/.mitmproxy" in m + ] + self.assertEqual(1, len(ca_mounts)) + self.assertIn("source=", ca_mounts[0]) + self.assertIn("/gateway-ca", ca_mounts[0]) + self.assertNotIn(",readonly", ca_mounts[0]) + def test_nat_network_precedes_the_host_only_network(self) -> None: argv = self._run_container(MacosInfraService(repo_root=Path("/r"))) nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]