fix(macos): persist gateway CA on host
test / integration-docker (push) Successful in 19s
Update Quality Badges / update-badges (push) Failing after 42s
lint / lint (push) Successful in 52s
test / unit (push) Successful in 1m43s
test / integration-firecracker (push) Successful in 4m56s
test / coverage (push) Successful in 17s
test / publish-infra (push) Successful in 1m49s

This commit was merged in pull request #454.
This commit is contained in:
2026-07-21 18:51:09 +00:00
committed by didericis
parent 8a1b833aaa
commit 2cd44cf79a
3 changed files with 43 additions and 11 deletions
+13 -4
View File
@@ -41,7 +41,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ... import log from ... import log
from ...orchestrator.gateway import GATEWAY_CA_CERT from ...orchestrator.gateway import GATEWAY_CA_CERT, MITMPROXY_HOME
from ...orchestrator.lifecycle import ( from ...orchestrator.lifecycle import (
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_STARTUP_TIMEOUT_SECONDS, DEFAULT_STARTUP_TIMEOUT_SECONDS,
@@ -52,6 +52,7 @@ from ...paths import (
CONTROL_PLANE_TOKEN_ENV, CONTROL_PLANE_TOKEN_ENV,
HOST_DB_FILENAME, HOST_DB_FILENAME,
host_control_plane_token, host_control_plane_token,
host_gateway_ca_dir,
) )
from .. import util as backend_util from .. import util as backend_util
from . import util as container_mod from . import util as container_mod
@@ -218,6 +219,14 @@ class MacosInfraService:
# Container-only DB volume: one kernel writes bot-bottle.db, never # Container-only DB volume: one kernel writes bot-bottle.db, never
# shared with the host or another guest. # shared with the host or another guest.
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}", "--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 # Bind-mount the control-plane source (read-only); a code change
# takes effect on relaunch with no image rebuild. # takes effect on relaunch with no image rebuild.
"--mount", "--mount",
@@ -261,9 +270,9 @@ class MacosInfraService:
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str: def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS """The gateway's mitmproxy CA (PEM) agents install to trust its TLS
interception. Read out of the container (the CA lives on a interception. Read through the container path backed by the persistent
container-internal path, not a host mount); polls because mitmproxy host CA directory; polls because mitmproxy writes it a beat after
writes it a beat after start.""" start."""
def _fetch() -> str | None: def _fetch() -> str | None:
result = container_mod.run_container_argv( result = container_mod.run_container_argv(
["container", "exec", self._name, "cat", GATEWAY_CA_CERT]) ["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
+16 -7
View File
@@ -324,15 +324,24 @@ re-attachment blocker distinct from #443/#445).
The CA lives on the **host filesystem** at `bot_bottle_root()/gateway-ca` 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 (`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**: confdir. This is deliberately a host bind-mount, **not a container-runtime
a named volume survives `docker rm` but is silently wiped by named volume**: a named volume survives ordinary container removal but can be
`docker volume prune` / `docker system prune --volumes` during routine host silently wiped by Docker's or Apple Container's volume-prune commands during
maintenance, which is exactly how the ephemeral-CA symptom shows up in routine host maintenance, which is exactly how the ephemeral-CA symptom shows
practice. A path under the app-data root is never pruned by docker, and stays up in practice. A path under the app-data root is not managed or pruned by the
directly inspectable and rotatable from the host. mitmproxy reuses an existing container runtime, and stays directly inspectable and rotatable from the host.
CA and generates one only on first run, so the bind-mount alone gives 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. "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 **Deliberate rollover** is the explicit inverse: `rotate_gateway_ca()` removes
the persisted CA material so the next start remints it, and the 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 `python -m bot_bottle.orchestrator.rotate_ca` one-shot wires that together with
+14
View File
@@ -61,6 +61,20 @@ class TestInfraRun(unittest.TestCase):
mounts = [argv[i + 1] for i, a in enumerate(argv) if a == "--mount"] 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)) 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: def test_nat_network_precedes_the_host_only_network(self) -> None:
argv = self._run_container(MacosInfraService(repo_root=Path("/r"))) argv = self._run_container(MacosInfraService(repo_root=Path("/r")))
nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"] nets = [argv[i + 1] for i, a in enumerate(argv) if a == "--network"]