ca91fc4d91
The control plane had no caller authentication — dispatch() routed every request straight through, and the only guards anywhere were the (source_ip, identity_token) checks inside /resolve and /attribute, which authenticate the *bottle* a request is about, not the *caller*. On the consolidated backends the agent shares a network with the control-plane port (macOS: the infra container runs 8099 on the agent's --internal network; docker: the orchestrator container sits on the shared gateway network), so a prompt-injected agent could GET /bottles (enumerate siblings + policies), PUT /bottles/<id>/policy (rewrite its own egress allowlist to allow-all), POST /resolve (read the injected upstream credentials it's never meant to see), and POST /supervise/respond (self-approve its own proposals). Every README guarantee collapsed to the agent's discretion. Fix: a per-host control-plane secret required on every route but GET /health, compared with hmac.compare_digest. It is held only by the trusted callers and never handed to an agent: - minted + persisted 0600 at <root>/control-plane-token (paths.host_control_plane_token); - injected as $BOT_BOTTLE_CONTROL_PLANE_TOKEN into the orchestrator + gateway containers via bare `--env NAME` (value inherited from the launch process, so it never lands on argv or in `container/docker inspect`); - presented by the gateway's PolicyResolver (reads the env) on /resolve, and by the host CLI's OrchestratorClient (reads the host file) on every call. The agent container is never given the env var or the host file, so from a bottle every /bottles*, /resolve, /attribute, and /supervise/* call now returns 401 — closing the enumeration, allowlist-rewrite, credential-lift, and self-approval. The existing (source_ip, identity_token) checks stay as defense-in-depth. Enforced when configured: macOS + docker inject the secret (→ enforced). With no secret set the server runs open and warns loudly at startup — a fail-visible fallback for the unit suite and for Firecracker, whose port-scoped nft already blocks agents from 8099 (wiring the secret into its infra-VM init is a clean fast-follow, left out here to avoid churning the prebuilt-artifact hash). Verified end-to-end on real Apple Container: infra comes up healthy, the host CLI (with the secret) lists bottles while an unauthenticated GET /bottles gets 401, all five issue-#400 attacks from inside the agent get 401, and egress policy still works (200 allowed / 403 denied) — proving the gateway authenticates to /resolve with the secret. 1829 unit tests pass, pyright clean, pylint 9.91. Refs #400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
306 lines
13 KiB
Python
306 lines
13 KiB
Python
"""The per-host infra container for the macOS backend (PRD 0070).
|
|
|
|
A single persistent Apple container that runs BOTH the orchestrator control
|
|
plane and the gateway data plane — the macOS analogue of the Firecracker infra
|
|
VM (`backend/firecracker/infra_vm.py`), not the docker backend's two separate
|
|
containers.
|
|
|
|
Why one container, not two: Apple Containers are lightweight VMs, each with its
|
|
own kernel. The docker backend runs the orchestrator and gateway as two
|
|
containers safely because they share the host kernel, so their concurrent
|
|
writes to the one `bot-bottle.db` (the orchestrator's registry + the gateway
|
|
supervise daemon's queue) are serialized by coherent `fcntl` locks. Across two
|
|
*guest* kernels sharing a virtiofs-mounted DB those locks are not coherent, and
|
|
concurrent writers can corrupt the file. Firecracker solved this by putting
|
|
both services in one guest with the DB on a device only that guest mounts; this
|
|
does the same with Apple primitives.
|
|
|
|
Two consequences fall out of the single container, both simplifications:
|
|
|
|
- **No DNS dance.** The control plane and the gateway daemons reach each other
|
|
over `127.0.0.1`, so nothing depends on Apple's (absent) container DNS and
|
|
there is no orchestrator-before-gateway ordering to get right.
|
|
- **The DB is never host-shared.** It lives on a container-only volume, so no
|
|
host process opens the live file. The host CLI reaches registry + supervise
|
|
state through the control-plane HTTP surface (`cli/supervise.py` already uses
|
|
`OrchestratorClient`), exactly as it does for firecracker.
|
|
|
|
The control-plane source is bind-mounted (like the docker orchestrator), so a
|
|
code change takes effect on the next launch without an image rebuild; the
|
|
gateway daemons are baked in the gateway image and rebuild through its own
|
|
digest check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ... import log
|
|
from ...orchestrator.gateway import GATEWAY_CA_CERT
|
|
from ...orchestrator.lifecycle import (
|
|
DEFAULT_PORT,
|
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
OrchestratorStartError,
|
|
source_hash,
|
|
)
|
|
from ...paths import (
|
|
CONTROL_PLANE_TOKEN_ENV,
|
|
HOST_DB_FILENAME,
|
|
host_control_plane_token,
|
|
)
|
|
from . import util as container_mod
|
|
from .gateway import (
|
|
DEFAULT_CA_TIMEOUT_SECONDS,
|
|
GATEWAY_EGRESS_NETWORK,
|
|
GATEWAY_IMAGE,
|
|
GATEWAY_NETWORK,
|
|
GatewayError,
|
|
ensure_networks,
|
|
)
|
|
|
|
# The one per-host infra container: control plane + gateway data plane.
|
|
INFRA_NAME = "bot-bottle-mac-infra"
|
|
INFRA_LABEL = "bot-bottle-mac-infra=1"
|
|
# Container-only volume holding bot-bottle.db. No host bind-mount, so the DB is
|
|
# written by exactly one kernel (this container's). Survives recreation.
|
|
INFRA_DB_VOLUME = "bot-bottle-mac-db"
|
|
|
|
# BOT_BOTTLE_ROOT inside the container; host_db_path() resolves the DB to
|
|
# <root>/db/<filename> and the supervise daemon writes the same file.
|
|
_DB_ROOT_IN_CONTAINER = "/var/lib/bot-bottle"
|
|
_DB_PATH_IN_CONTAINER = f"{_DB_ROOT_IN_CONTAINER}/db/{HOST_DB_FILENAME}"
|
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
_HEALTH_POLL_SECONDS = 0.25
|
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
_CA_POLL_SECONDS = 0.5
|
|
|
|
# The gateway subset the consolidated model runs (no per-bottle git:// daemon).
|
|
_GATEWAY_DAEMONS = "egress,git-http,supervise"
|
|
|
|
|
|
def _init_script(port: int) -> str:
|
|
"""PID-1 init: start the control plane and the gateway daemons, both in
|
|
this container, reaching each other over loopback. Backgrounded so `wait`
|
|
reaps as PID 1. No `set -e` — a transient daemon failure must not kill the
|
|
whole container (gateway_init applies the same 'stay up' policy)."""
|
|
return (
|
|
"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n"
|
|
f"mkdir -p $(dirname {_DB_PATH_IN_CONTAINER})\n"
|
|
# Control plane, from the bind-mounted source (stdlib-only package).
|
|
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
|
|
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
|
|
"--broker stub ) &\n"
|
|
# Gateway data plane, multi-tenant against the local control plane.
|
|
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
|
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
|
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 /app/gateway_init.py ) &\n"
|
|
"while : ; do wait ; done\n"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InfraEndpoint:
|
|
"""How to reach the running infra container. The control plane and the
|
|
gateway are the same container, so one address serves both."""
|
|
|
|
control_plane_url: str # http://<infra ip>:8099 — host CLI + registration
|
|
gateway_ip: str # same container; agents' proxy / git-http / MCP target
|
|
|
|
|
|
class MacosInfraService:
|
|
"""Manages the single per-host infra container. Callers use
|
|
`ensure_running()` (returns the endpoint) and `ca_cert_pem()`."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
port: int = DEFAULT_PORT,
|
|
network: str = GATEWAY_NETWORK,
|
|
egress_network: str = GATEWAY_EGRESS_NETWORK,
|
|
image: str = GATEWAY_IMAGE,
|
|
repo_root: Path = _REPO_ROOT,
|
|
name: str = INFRA_NAME,
|
|
db_volume: str = INFRA_DB_VOLUME,
|
|
) -> None:
|
|
self.port = port
|
|
self.network = network
|
|
self.egress_network = egress_network
|
|
self.image = image
|
|
self._repo_root = repo_root
|
|
self._name = name
|
|
self._db_volume = db_volume
|
|
|
|
def _resolve_url(self) -> str:
|
|
"""The control-plane URL, or "" while the container has no address."""
|
|
ip = container_mod.try_container_ipv4_on_network(self._name, self.network)
|
|
return f"http://{ip}:{self.port}" if ip else ""
|
|
|
|
def is_healthy(
|
|
self, url: str, *, timeout: float = _HEALTH_REQUEST_TIMEOUT_SECONDS,
|
|
) -> bool:
|
|
if not url:
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(f"{url}/health", timeout=timeout) as resp:
|
|
return resp.status == 200
|
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
return False
|
|
|
|
def _source_current(self, current_hash: str) -> bool:
|
|
"""True iff the running infra container was created from the current
|
|
bind-mounted control-plane source. The control-plane process loads that
|
|
code at startup and won't reload it, so a stale container keeps serving
|
|
OLD code."""
|
|
if not container_mod.container_is_running(self._name):
|
|
return False
|
|
env = container_mod.container_env(self._name)
|
|
if not env:
|
|
return True # can't compare → don't churn a working container
|
|
return env.get("BOT_BOTTLE_SOURCE_HASH") == current_hash
|
|
|
|
def _running_healthy_endpoint(self, current_hash: str) -> InfraEndpoint | None:
|
|
"""The endpoint if the running container is BOTH source-current and
|
|
answering /health, else None (→ recreate). Health, not just the source
|
|
label, is what lets a wedged-but-current container self-heal instead of
|
|
being polled to death forever."""
|
|
if not self._source_current(current_hash):
|
|
return None
|
|
url = self._resolve_url()
|
|
if url and self.is_healthy(url):
|
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
|
return None
|
|
|
|
def ensure_built(self) -> None:
|
|
"""Ensure the gateway data-plane image exists. The control-plane source
|
|
is bind-mounted, not baked, so only the gateway image needs building."""
|
|
container_mod.build_image(
|
|
self.image, str(self._repo_root), dockerfile="Dockerfile.gateway",
|
|
)
|
|
|
|
def ensure_running(
|
|
self, *, startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
|
) -> InfraEndpoint:
|
|
"""Ensure the single infra container is up; return how to reach it.
|
|
Idempotent per-host singleton — a healthy container on current source
|
|
is left untouched, so N launches share the one control plane + gateway.
|
|
Raises `OrchestratorStartError` on startup timeout."""
|
|
current_hash = source_hash(self._repo_root)
|
|
endpoint = self._running_healthy_endpoint(current_hash)
|
|
if endpoint is not None:
|
|
return endpoint
|
|
self.ensure_built()
|
|
log.info("starting infra container", context={"name": self._name})
|
|
self._run_container(current_hash)
|
|
return self._wait_healthy(startup_timeout)
|
|
|
|
def _run_container(self, current_hash: str) -> None:
|
|
ensure_networks(self.network, self.egress_network)
|
|
container_mod.force_remove_container(self._name)
|
|
argv = [
|
|
"container", "run", "--detach",
|
|
"--name", self._name,
|
|
"--label", "bot-bottle.backend=macos-container",
|
|
"--label", INFRA_LABEL,
|
|
# NAT network FIRST so the gateway's egress has a default route;
|
|
# the host-only network is where agents (and the host CLI) reach it.
|
|
"--network", self.egress_network,
|
|
"--network", self.network,
|
|
"--dns", container_mod.dns_server(),
|
|
# Container-only DB volume: one kernel writes bot-bottle.db, never
|
|
# shared with the host or another guest.
|
|
"--volume", f"{self._db_volume}:{_DB_ROOT_IN_CONTAINER}",
|
|
# Bind-mount the control-plane source (read-only); a code change
|
|
# takes effect on relaunch with no image rebuild.
|
|
"--mount",
|
|
container_mod.bind_mount_spec(
|
|
str(self._repo_root), _SRC_IN_CONTAINER, readonly=True),
|
|
# Baked onto the container so `_source_current` can detect a real
|
|
# control-plane code change and recreate.
|
|
"--env", f"BOT_BOTTLE_SOURCE_HASH={current_hash}",
|
|
# The control-plane secret, for BOTH the control plane (to require
|
|
# it) and the gateway's PolicyResolver (to present it) — they share
|
|
# this one container. Bare `--env NAME` inherits the value from the
|
|
# run process below, so the secret never lands on argv or in
|
|
# `container inspect`'s command line. The agent runs in a SEPARATE
|
|
# container that is never given this var, which is the whole point.
|
|
"--env", CONTROL_PLANE_TOKEN_ENV,
|
|
"--entrypoint", "sh",
|
|
self.image,
|
|
"-c", _init_script(self.port),
|
|
]
|
|
run_env = {**os.environ, CONTROL_PLANE_TOKEN_ENV: host_control_plane_token()}
|
|
result = container_mod.run_container_argv(argv, env=run_env)
|
|
if result.returncode != 0:
|
|
raise OrchestratorStartError(
|
|
f"infra container failed to start: "
|
|
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
)
|
|
|
|
def _wait_healthy(self, startup_timeout: float) -> InfraEndpoint:
|
|
deadline = time.monotonic() + startup_timeout
|
|
while True:
|
|
url = self._resolve_url()
|
|
if url and self.is_healthy(url):
|
|
log.info("infra container healthy", context={"url": url})
|
|
return InfraEndpoint(control_plane_url=url, gateway_ip=_ip_of(url))
|
|
if time.monotonic() >= deadline:
|
|
raise OrchestratorStartError(
|
|
f"infra container did not become healthy within "
|
|
f"{startup_timeout:g}s"
|
|
)
|
|
time.sleep(_HEALTH_POLL_SECONDS)
|
|
|
|
def ca_cert_pem(self, *, timeout: float = DEFAULT_CA_TIMEOUT_SECONDS) -> str:
|
|
"""The gateway's mitmproxy CA (PEM) agents install to trust its TLS
|
|
interception. Read out of the container (the CA lives on a
|
|
container-internal path, not a host mount); polls because mitmproxy
|
|
writes it a beat after start."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
result = container_mod.run_container_argv(
|
|
["container", "exec", self._name, "cat", GATEWAY_CA_CERT])
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return result.stdout
|
|
if time.monotonic() >= deadline:
|
|
raise GatewayError(
|
|
f"gateway CA not available in {self._name} after {timeout:g}s: "
|
|
f"{(result.stderr or '').strip() or 'empty'}"
|
|
)
|
|
time.sleep(_CA_POLL_SECONDS)
|
|
|
|
def stop(self) -> None:
|
|
"""Remove the infra container (idempotent). The DB volume persists."""
|
|
container_mod.force_remove_container(self._name)
|
|
|
|
|
|
def _ip_of(url: str) -> str:
|
|
"""The host from an http://host:port URL."""
|
|
return url.split("://", 1)[-1].rsplit(":", 1)[0]
|
|
|
|
|
|
def probe_control_plane_url(port: int = DEFAULT_PORT) -> str:
|
|
"""The running infra container's control-plane URL, or "" if it isn't up.
|
|
Used by host-side control-plane discovery (`discover_orchestrator_url`);
|
|
safe to call on any host — returns "" when the container or the `container`
|
|
CLI isn't present."""
|
|
ip = container_mod.try_container_ipv4_on_network(INFRA_NAME, GATEWAY_NETWORK)
|
|
return f"http://{ip}:{port}" if ip else ""
|
|
|
|
|
|
__all__ = [
|
|
"MacosInfraService",
|
|
"InfraEndpoint",
|
|
"OrchestratorStartError",
|
|
"GatewayError",
|
|
"INFRA_NAME",
|
|
"INFRA_DB_VOLUME",
|
|
]
|