diff --git a/bot_bottle/orchestrator/gateway.py b/bot_bottle/orchestrator/gateway.py index e153721..0b7bb50 100644 --- a/bot_bottle/orchestrator/gateway.py +++ b/bot_bottle/orchestrator/gateway.py @@ -31,6 +31,14 @@ GATEWAY_LABEL = "bot-bottle-orch-gateway=1" # the source IP the gateway attributes by is the address on this network. GATEWAY_NETWORK = "bot-bottle-gateway" +# mitmproxy's CA dir in the bundle. A persistent named volume here keeps the +# gateway's self-generated CA STABLE across container recreation — every agent +# installs this one CA to trust the shared gateway's TLS interception, so it +# must not rotate when the gateway restarts. +MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy" +GATEWAY_CA_VOLUME = "bot-bottle-gateway-mitmproxy" +GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem" + # The real sidecar-bundle image + its Dockerfile. Kept as a local constant # rather than imported from backend.docker.sidecar_bundle, which would drag # the whole backend layer into the lean orchestrator (see #359); unify when @@ -143,11 +151,26 @@ class DockerGateway(Gateway): "--name", self.name, "--label", GATEWAY_LABEL, "--network", self.network, + # Persist the self-generated CA so it survives restarts (agents + # trust it) — see GATEWAY_CA_VOLUME. + "--volume", f"{GATEWAY_CA_VOLUME}:{MITMPROXY_HOME}", self.image_ref, ]) if proc.returncode != 0: raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}") + def ca_cert_pem(self) -> str: + """The gateway's CA certificate (PEM) that agents install to trust its + TLS interception. Read from the running container; raises if the + gateway hasn't generated it yet (it's created on first mitmproxy + start).""" + proc = run_docker(["docker", "exec", self.name, "cat", GATEWAY_CA_CERT]) + if proc.returncode != 0 or not proc.stdout.strip(): + raise GatewayError( + f"gateway CA cert not available: {proc.stderr.strip() or 'empty'}" + ) + return proc.stdout + def stop(self) -> None: proc = run_docker(["docker", "rm", "--force", self.name]) if proc.returncode != 0 and "No such container" not in proc.stderr: @@ -157,4 +180,5 @@ class DockerGateway(Gateway): __all__ = [ "Gateway", "DockerGateway", "GatewayError", "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK", + "GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT", ] diff --git a/tests/unit/test_orchestrator_gateway.py b/tests/unit/test_orchestrator_gateway.py index 6a9d56a..4af3aaa 100644 --- a/tests/unit/test_orchestrator_gateway.py +++ b/tests/unit/test_orchestrator_gateway.py @@ -6,11 +6,15 @@ import unittest from unittest.mock import Mock, patch from bot_bottle.orchestrator.gateway import ( + GATEWAY_CA_CERT, GATEWAY_NAME, DockerGateway, GatewayError, ) + +_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n" + _RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker" @@ -53,6 +57,8 @@ class TestDockerGateway(unittest.TestCase): self.assertIn("bot-bottle-sidecars:latest", runs[0]) # Runs on the shared gateway network so agents can reach it by IP. self.assertEqual(self.sc.network, runs[0][runs[0].index("--network") + 1]) + # Persists its CA on a named volume so agents keep trusting it. + self.assertTrue(any("mitmproxy" in a for a in runs[0])) def test_ensure_running_creates_network_when_missing(self) -> None: calls: list[list[str]] = [] @@ -68,6 +74,17 @@ class TestDockerGateway(unittest.TestCase): creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] self.assertEqual([["docker", "network", "create", self.sc.network]], creates) + def test_ca_cert_pem_reads_from_container(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m: + self.assertEqual(_CA_PEM, self.sc.ca_cert_pem()) + argv = m.call_args.args[0] + self.assertEqual(["docker", "exec", self.sc.name, "cat", GATEWAY_CA_CERT], argv) + + def test_ca_cert_pem_raises_when_absent(self) -> None: + with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="No such file")): + with self.assertRaises(GatewayError): + self.sc.ca_cert_pem() + def test_ensure_running_reuses_existing_network(self) -> None: calls: list[list[str]] = []