f77023db1d
test / integration-docker (pull_request) Successful in 11s
test / unit (pull_request) Successful in 43s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m19s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 7s
Separate the gateway (data plane) from the orchestrator (control plane) at the
module level. The gateway runtime files move out of the package root — and the
backend-neutral Gateway lifecycle ABC + GATEWAY_* constants move out of
orchestrator/ — into a new bot_bottle/gateway/ package:
gateway/__init__.py (was orchestrator/gateway.py: Gateway ABC + consts
+ rotate_gateway_ca)
gateway/gateway_init.py (the PID-1 daemon supervisor)
gateway/egress_addon.py, egress_addon_core.py, egress_dlp_config.py,
dlp_detectors.py (the egress mitmproxy daemon)
gateway/git_http_backend.py (the git-http daemon)
gateway/git_gate_render.py (the git-gate pre-receive rendering)
gateway/supervise_server.py (the supervise MCP daemon)
gateway/policy_resolver.py (the data-plane control-plane RPC client)
orchestrator/ now holds only control-plane files. The shared plan/types/auth
layer (egress.py=EgressPlan, git_gate.py=GitGatePlan, supervise.py,
supervise_types.py, control_auth.py) and the launch-time git-gate provisioning
helpers stay at root, so orchestrator/ and backend/ still own them.
Because these daemons are invoked as `python3 -m bot_bottle.<name>`, loaded flat
by mitmproxy, and referenced in Dockerfile.gateway, the move updates more than
Python imports: the `-m` invocations (firecracker/macOS infra scripts), the
Dockerfile.gateway addon shim + ENTRYPOINT, gateway_init's _DAEMONS module
paths, and the git-gate CGI heredocs all now point at bot_bottle.gateway.*.
No behavior change; full unit suite green (2251).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
5.1 KiB
Python
120 lines
5.1 KiB
Python
"""The consolidated per-host gateway (PRD 0070).
|
|
|
|
The core consolidation win: **one** persistent gateway per host, shared by
|
|
every bottle, instead of a gateway per bottle. It's safe to share
|
|
because the attribution invariant (source IP + identity token, see
|
|
`registry`) lets the gateway attribute each request to the right bottle —
|
|
so per-bottle policy lives in one long-lived process keyed on who's calling.
|
|
|
|
`Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
|
|
ensure the single instance is up, report it, tear it down. The docker
|
|
implementation (`DockerGateway`) lives in `backend/docker/gateway.py`; a
|
|
firecracker gateway VM slots in later.
|
|
|
|
The defining behaviour is **idempotent singleton**: `ensure_running` starts
|
|
the instance if absent and is a no-op if it's already up, so N bottle
|
|
launches never spawn N gateways.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from ..paths import host_gateway_ca_dir
|
|
|
|
# The gateway's mitmproxy writes its CA a beat after the container starts, so
|
|
# reads poll for it rather than assuming it's there on a fresh launch.
|
|
CA_POLL_SECONDS = 0.5
|
|
DEFAULT_CA_TIMEOUT_SECONDS = 30.0
|
|
|
|
GATEWAY_NAME = "bot-bottle-orch-gateway"
|
|
GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
|
|
# The single user-defined network the gateway and every agent bottle share.
|
|
# Agents attach here with a pinned IP and reach the gateway's egress /
|
|
# git-http / supervise ports by its address — no host port publishing, and
|
|
# 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. The host's gateway-CA dir (see
|
|
# `host_gateway_ca_dir`) is bind-mounted here so the gateway's self-generated
|
|
# CA stays 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. A host bind-mount rather than a named volume: a named
|
|
# volume is silently wiped by `docker volume prune`, minting a fresh CA that
|
|
# breaks every running bottle (issue #450).
|
|
MITMPROXY_HOME = "/home/mitmproxy/.mitmproxy"
|
|
GATEWAY_CA_CERT = f"{MITMPROXY_HOME}/mitmproxy-ca-cert.pem"
|
|
|
|
# The CA material mitmproxy writes into its confdir. mitmproxy reuses these on
|
|
# startup when present and generates them only on first run, so persisting them
|
|
# is what makes the CA stable; deleting them (see `rotate_gateway_ca`) forces a
|
|
# fresh CA on the next start. `mitmproxy-ca.pem` (cert + private key) is the
|
|
# signing identity; the rest are derived encodings agents/clients consume.
|
|
GATEWAY_CA_GLOB = "mitmproxy-ca*"
|
|
|
|
# The gateway data-plane image + its Dockerfile. Kept as a local constant
|
|
# rather than imported from the backend layer, which would drag
|
|
# the whole backend layer into the lean orchestrator (see #359); unify when
|
|
# that lands. Env override matches the backend's BOT_BOTTLE_GATEWAY_IMAGE.
|
|
GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_GATEWAY_IMAGE", "bot-bottle-gateway:latest")
|
|
GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
|
"""Delete the persisted mitmproxy CA so the next gateway start mints a
|
|
fresh one — the explicit, deliberate CA-rollover path (issue #450).
|
|
|
|
Persistence keeps the CA stable across restarts precisely because mitmproxy
|
|
reuses the on-disk CA; rotation is therefore just removing that material.
|
|
Returns the files removed (empty when there was no CA yet); idempotent.
|
|
|
|
This only clears the on-disk CA. It does NOT stop the running gateway (whose
|
|
mitmproxy still holds the old CA in memory) or re-provision agents — the
|
|
caller recreates the gateway to mint the new CA and re-attaches bottles.
|
|
`rotate-ca` on the orchestrator CLI wires those steps together."""
|
|
ca_dir = ca_dir if ca_dir is not None else host_gateway_ca_dir()
|
|
removed: list[Path] = []
|
|
for path in sorted(ca_dir.glob(GATEWAY_CA_GLOB)):
|
|
path.unlink()
|
|
removed.append(path)
|
|
return removed
|
|
|
|
|
|
class GatewayError(Exception):
|
|
"""The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
|
|
|
|
|
|
class Gateway(abc.ABC):
|
|
"""Lifecycle of the single per-host gateway. Backend-neutral."""
|
|
|
|
name: str
|
|
|
|
def ensure_built(self) -> None:
|
|
"""Ensure the gateway's image / rootfs exists, building it if needed.
|
|
Default: nothing to build (e.g. a stub or a pre-pulled image)."""
|
|
return
|
|
|
|
@abc.abstractmethod
|
|
def ensure_running(self) -> None:
|
|
"""Start the gateway if it isn't already up. Idempotent: a no-op
|
|
when it's already running (that's the whole point — one per host).
|
|
Assumes the image exists — call `ensure_built()` first."""
|
|
|
|
@abc.abstractmethod
|
|
def is_running(self) -> bool:
|
|
"""True iff the gateway instance is currently up."""
|
|
|
|
@abc.abstractmethod
|
|
def stop(self) -> None:
|
|
"""Remove the gateway. Idempotent — absent is success."""
|
|
|
|
|
|
__all__ = [
|
|
"Gateway", "GatewayError", "rotate_gateway_ca",
|
|
"GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE", "GATEWAY_NETWORK",
|
|
"GATEWAY_CA_CERT", "GATEWAY_CA_GLOB",
|
|
]
|