feat(orchestrator): slice 13c(vii) — stable shared gateway CA
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
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user