Compare commits

...

1 Commits

Author SHA1 Message Date
didericis 389a2c10ce feat(orchestrator): slice 13c(vii) — stable shared gateway CA
lint / lint (push) Successful in 2m4s
test / unit (pull_request) Successful in 1m2s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
The consolidated gateway TLS-intercepts every bottle, so all agents must
trust ONE CA. Rather than host-generate + mount a CA, let the gateway's own
mitmproxy generate its CA into a persistent named volume and extract the
cert for agents — no host openssl, keeps gateway.py lean.

- DockerGateway mounts GATEWAY_CA_VOLUME at /home/mitmproxy/.mitmproxy so the
  self-generated CA survives container recreation (it must not rotate, or
  already-launched agents would stop trusting the gateway).
- ca_cert_pem(): read the CA cert (PEM) out of the running gateway
  (docker exec cat mitmproxy-ca-cert.pem) — the agent installs this into its
  trust store to accept the gateway's bumped certs.

pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init
/bin/sleep errors are pre-existing NixOS-local noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 22:12:18 -04:00
2 changed files with 41 additions and 0 deletions
+24
View File
@@ -31,6 +31,14 @@ GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# the source IP the gateway attributes by is the address on this network. # the source IP the gateway attributes by is the address on this network.
GATEWAY_NETWORK = "bot-bottle-gateway" 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 # The real sidecar-bundle image + its Dockerfile. Kept as a local constant
# rather than imported from backend.docker.sidecar_bundle, which would drag # rather than imported from backend.docker.sidecar_bundle, which would drag
# the whole backend layer into the lean orchestrator (see #359); unify when # the whole backend layer into the lean orchestrator (see #359); unify when
@@ -143,11 +151,26 @@ class DockerGateway(Gateway):
"--name", self.name, "--name", self.name,
"--label", GATEWAY_LABEL, "--label", GATEWAY_LABEL,
"--network", self.network, "--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, self.image_ref,
]) ])
if proc.returncode != 0: if proc.returncode != 0:
raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}") 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: def stop(self) -> None:
proc = run_docker(["docker", "rm", "--force", self.name]) proc = run_docker(["docker", "rm", "--force", self.name])
if proc.returncode != 0 and "No such container" not in proc.stderr: if proc.returncode != 0 and "No such container" not in proc.stderr:
@@ -157,4 +180,5 @@ class DockerGateway(Gateway):
__all__ = [ __all__ = [
"Gateway", "DockerGateway", "GatewayError", "Gateway", "DockerGateway", "GatewayError",
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK", "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
"GATEWAY_CA_VOLUME", "GATEWAY_CA_CERT",
] ]
+17
View File
@@ -6,11 +6,15 @@ import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bot_bottle.orchestrator.gateway import ( from bot_bottle.orchestrator.gateway import (
GATEWAY_CA_CERT,
GATEWAY_NAME, GATEWAY_NAME,
DockerGateway, DockerGateway,
GatewayError, GatewayError,
) )
_CA_PEM = "-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----\n"
_RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker" _RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
@@ -53,6 +57,8 @@ class TestDockerGateway(unittest.TestCase):
self.assertIn("bot-bottle-sidecars:latest", runs[0]) self.assertIn("bot-bottle-sidecars:latest", runs[0])
# Runs on the shared gateway network so agents can reach it by IP. # 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]) 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: def test_ensure_running_creates_network_when_missing(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []
@@ -68,6 +74,17 @@ class TestDockerGateway(unittest.TestCase):
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]] creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
self.assertEqual([["docker", "network", "create", self.sc.network]], creates) 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: def test_ensure_running_reuses_existing_network(self) -> None:
calls: list[list[str]] = [] calls: list[list[str]] = []