Compare commits

...

4 Commits

Author SHA1 Message Date
didericis 115a64c161 fix(git-gate): review — sandbox naming, ResolverLike Protocol, token-required note
test / unit (pull_request) Successful in 1m8s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m20s
lint / lint (push) Successful in 2m7s
Addresses PR #365 review:
- Type `resolve_sandbox_root`'s resolver param as a `ResolverLike` Protocol
  (structural, `resolve_bottle_id` only) — fixes the pyright errors from
  passing a duck-typed fake resolver in tests; mirrors
  egress_addon_core.PolicyResolverLike.
- Rename `_project_root`/`project_root`/`resolve_repo_root`/
  `DEFAULT_PROJECT_ROOT` -> `_sandbox_root`/`sandbox_root`/
  `resolve_sandbox_root`/`DEFAULT_REPO_ROOT`: "sandbox" names the per-tenant
  scope; "project" was too amorphous. `GIT_PROJECT_ROOT` keeps git's own
  env-var name.
- Note the single-tenant path is transitional (stripped once every backend
  runs the consolidated gateway).
- policy_resolver: document that the optional identity token is
  transitional — the consolidated end state requires it (flips at the
  /resolve boundary once token *delivery* lands, a PRD 0070 open question).
- Split the long line in main() (was 105 cols).

pyright 0 errors; pylint 9.95/10; unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 18:47:53 -04:00
didericis 08aef30d87 feat(git-gate): source-IP-keyed multi-tenant repo namespace (PRD 0070)
lint / lint (push) Failing after 2m0s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m19s
First slice of git-gate consolidation: make the smart-HTTP git backend
serve every bottle from one process, selecting each request's repo root by
the calling bottle's source IP — the same attribution invariant + resolver
the multi-tenant egress addon uses. `git daemon` can't source-IP-route per
connection, so the consolidated gateway serves git-gate over this HTTP
backend (the transport firecracker/macOS already use); wiring the docker
path onto it lands with the launch-integration slice.

- policy_resolver: factor out `_post_resolve`; add `resolve_bottle_id`
  (source IP -> bottle id, same fail-closed 403->None contract as
  `resolve`) — the git-gate has no policy blob to parse, the bottle *is*
  the namespace.
- git_http_backend: `resolve_repo_root(resolver, base, source_ip, token)`
  — single-tenant passthrough when no resolver; else `<base>/<bottle_id>`,
  fail-closed on unattributed / resolver error / namespace escape.
  `BOT_BOTTLE_ORCHESTRATOR_URL` (same env as egress) flips the shared
  gateway multi-tenant; the identity header is read for attribution and
  never forwarded to the CGI. Per-repo creds + hooks scope by repo dir, so
  isolating the root per bottle isolates its creds too.

Single-tenant path unchanged (existing real-git-push tests green). New unit
coverage for the resolver + repo-root selection matrix. Full unit suite
green (1690 tests; the 13 test_sidecar_init /bin/sleep errors are the
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 18:31:03 -04:00
didericis e0b0429cd1 refactor(orchestrator): rename Sidecar -> Gateway for the consolidated data plane
test / unit (pull_request) Successful in 1m10s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Successful in 1m17s
lint / lint (push) Successful in 2m11s
Retire "sidecar" for the consolidated per-host path (PRD 0070 naming
decision): the orchestrator is the umbrella/control plane, and the
egress/git/supervise data-plane unit it runs is the "gateway".

- git mv sidecar.py -> gateway.py and the two integration + one unit test
  files; DockerSidecar->DockerGateway, Sidecar->Gateway,
  SidecarError->GatewayError, SIDECAR_*->GATEWAY_*, ensure_sidecar->
  ensure_gateway, sidecar_status->gateway_status, container name
  bot-bottle-orch-sidecar->bot-bottle-orch-gateway.
- Prose rename across broker/registry/egress/policy_resolver + PRD 0070.
- Preserved: the image name bot-bottle-sidecars, the
  BOT_BOTTLE_SIDECAR_IMAGE env var, Dockerfile.sidecars, and PRD 0069's
  own stage-name cross-references (that doc still uses "sidecar").

No behavior change. Full unit suite green (1679 tests; 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 18:12:17 -04:00
didericis d3e08cf039 feat(orchestrator+egress): slice 8 — multi-tenant egress via the resolver (#352)
lint / lint (push) Successful in 2m8s
test / unit (pull_request) Successful in 1m1s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Successful in 1m25s
The egress addon now selects each request's Config by the calling bottle's
source IP, so one shared sidecar serves every bottle. Opt-in and fail-closed;
single-tenant behaviour is unchanged.

Orchestrator side (source-IP-primary attribution, per the PRD invariant):
  * registry: `by_source_ip` (the single active bottle at a source IP —
    network-layer attribution); `attribute` now composes it + the token.
  * service: `resolve(source_ip, token="")` — with a token, strict
    attribution; without, source IP alone.
  * control_plane: `POST /resolve`'s identity_token is now OPTIONAL (absent
    → source-IP-only); split cleanly from the token-required `/attribute`.
  * policy_resolver: `resolve` token now optional.

Egress side:
  * egress_addon_core: `resolve_client_config(resolver, client_ip, token)` —
    fetches + parses the client's Config, **fail-closed**: unattributed, a
    resolver error, or an unparseable policy all yield deny-all (no routes).
    Host-testable; `PolicyResolverLike` Protocol keeps it import-free.
  * egress_addon: consolidated mode when `BOT_BOTTLE_ORCHESTRATOR_URL` is
    set → `_active_config(flow)` resolves per client IP (reads + strips the
    `x-bot-bottle-identity` header); `request()` uses it. Unset → the static
    routes file, exactly as before. `PolicyResolver` added to the bundle.

Security note: source-IP-only resolution is safe where the IP is unspoofable
(Firecracker /31 + nft) AND the control plane is reachable only by the
trusted sidecar; the identity token, when the agent injects it, strengthens
it on weaker backends.

Scope note: the egress data plane is now multi-tenant. Remaining to be fully
live: the network topology routing every bottle's proxy to the one shared
sidecar, git-gate multitenancy, and agent-side identity-token injection.

Tests: registry by_source_ip; orchestrator resolve (with/without token);
control-plane /resolve token-optional; resolver token-optional;
resolve_client_config fail-closed matrix. All 182 egress tests still pass
(single-tenant unchanged). Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-13 17:48:46 -04:00
22 changed files with 593 additions and 180 deletions
+1
View File
@@ -64,6 +64,7 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/paths.py /app/paths.py COPY bot_bottle/paths.py /app/paths.py
+46 -6
View File
@@ -1,4 +1,4 @@
"""mitmproxy addon entrypoint for the egress sidecar (PRD 0017, PRD 0053). """mitmproxy addon entrypoint for the egress gateway (PRD 0017, PRD 0053).
Loaded by `mitmdump -s /app/egress_addon.py` inside the Loaded by `mitmdump -s /app/egress_addon.py` inside the
egress container.""" egress container."""
@@ -32,6 +32,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
is_git_push_request, is_git_push_request,
load_config, load_config,
match_route, match_route,
resolve_client_config,
outbound_scan_headers, outbound_scan_headers,
route_to_yaml_dict, route_to_yaml_dict,
scan_inbound, scan_inbound,
@@ -51,11 +52,26 @@ try:
except ImportError: # pragma: no cover - host-side path except ImportError: # pragma: no cover - host-side path
from bot_bottle import supervise as _sv # type: ignore[import-not-found] from bot_bottle import supervise as _sv # type: ignore[import-not-found]
try:
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
except ImportError: # pragma: no cover - host-side path
from bot_bottle.policy_resolver import PolicyResolver
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml" DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
INTROSPECT_HOST = "_egress.local" INTROSPECT_HOST = "_egress.local"
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the addon resolves each client's Config by
# source IP per request instead of using a single static routes file. Unset
# → legacy per-bottle single-tenant mode (unchanged).
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant);
# the agent injects it, the addon strips it so it never leaks upstream.
IDENTITY_HEADER = "x-bot-bottle-identity"
# Seconds the egress proxy holds a token-blocked request open waiting for the # Seconds the egress proxy holds a token-blocked request open waiting for the
# operator's supervisor decision (PRD 0062), overridable via env. # operator's supervisor decision (PRD 0062), overridable via env.
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
@@ -72,9 +88,17 @@ _TOKEN_ALLOW_JUSTIFICATION = (
class EgressAddon: class EgressAddon:
# Class default so addons built via __new__ (e.g. in tests) default to
# single-tenant; __init__ sets the instance attribute for real runs.
_resolver: "PolicyResolver | None" = None
def __init__(self) -> None: def __init__(self) -> None:
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH) self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
self.config: Config = Config(routes=()) self.config: Config = Config(routes=())
# Consolidated mode: resolve per-client Config from the orchestrator.
# Absent → single-tenant (static routes file); behaviour unchanged.
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
self._resolver = PolicyResolver(orch_url) if orch_url else None
# Tokens the operator has approved this session (PRD 0062). In-memory # Tokens the operator has approved this session (PRD 0062). In-memory
# only — a restart re-prompts. Mutated only from the asyncio loop that # only — a restart re-prompts. Mutated only from the asyncio loop that
# runs the addon hooks, so no lock is needed. # runs the addon hooks, so no lock is needed.
@@ -194,6 +218,20 @@ class EgressAddon:
+ "\n" + "\n"
) )
def _active_config(self, flow: http.HTTPFlow) -> Config:
"""The Config to apply to this request. Single-tenant → the static
`self.config`. Consolidated → the calling bottle's Config, resolved
by source IP (fail-closed to deny-all if unattributed). The identity
token, if the agent injected one, is read then stripped so it never
leaks upstream."""
if self._resolver is None:
return self.config
conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else ""
token = flow.request.headers.get(IDENTITY_HEADER, "")
flow.request.headers.pop(IDENTITY_HEADER, None)
return resolve_client_config(self._resolver, client_ip, token)
async def request(self, flow: http.HTTPFlow) -> None: async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
@@ -201,10 +239,12 @@ class EgressAddon:
self._serve_introspection(flow, request_path) self._serve_introspection(flow, request_path)
return return
config = self._active_config(flow)
# DLP outbound scan BEFORE stripping auth — catches tokens the # DLP outbound scan BEFORE stripping auth — catches tokens the
# agent tried to smuggle in any header, path, query param, or body. # agent tried to smuggle in any header, path, query param, or body.
# Hostname is included to catch DNS-tunnelling exfiltration attempts. # Hostname is included to catch DNS-tunnelling exfiltration attempts.
route = match_route(self.config.routes, flow.request.pretty_host) route = match_route(config.routes, flow.request.pretty_host)
if route is not None: if route is not None:
if not await self._handle_outbound_dlp(flow, route): if not await self._handle_outbound_dlp(flow, route):
return return
@@ -224,7 +264,7 @@ class EgressAddon:
if is_git_fetch_request(request_path, query): if is_git_fetch_request(request_path, query):
git_decision = decide_git_fetch( git_decision = decide_git_fetch(
self.config.routes, flow.request.pretty_host, config.routes, flow.request.pretty_host,
) )
if git_decision.action == "block": if git_decision.action == "block":
self._block( self._block(
@@ -235,14 +275,14 @@ class EgressAddon:
return return
# Strip agent-set Authorization after DLP scan so smuggled tokens # Strip agent-set Authorization after DLP scan so smuggled tokens
# are caught above; the route may inject sidecar-owned auth below. # are caught above; the route may inject gateway-owned auth below.
flow.request.headers.pop("authorization", None) flow.request.headers.pop("authorization", None)
# Build headers mapping for match evaluation # Build headers mapping for match evaluation
req_headers = {k.lower(): v for k, v in flow.request.headers.items()} req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
decision = decide( decision = decide(
self.config.routes, config.routes,
flow.request.pretty_host, flow.request.pretty_host,
request_path, request_path,
os.environ, os.environ,
@@ -257,7 +297,7 @@ class EgressAddon:
if decision.inject_authorization is not None: if decision.inject_authorization is not None:
flow.request.headers["authorization"] = decision.inject_authorization flow.request.headers["authorization"] = decision.inject_authorization
if self.config.log >= LOG_FULL: if config.log >= LOG_FULL:
self._log_request(flow) self._log_request(flow)
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None: def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
+35 -5
View File
@@ -3,7 +3,7 @@
Split out of `egress_addon.py` so the host's unit tests can Split out of `egress_addon.py` so the host's unit tests can
exercise the parse + decision functions without depending on the exercise the parse + decision functions without depending on the
`mitmproxy` package. The companion module wraps these with the `mitmproxy` package. The companion module wraps these with the
`mitmproxy.http.HTTPFlow` API and is loaded inside the sidecar `mitmproxy.http.HTTPFlow` API and is loaded inside the gateway
container. container.
Imports: stdlib + `yaml_subset` (which is itself stdlib-only and Imports: stdlib + `yaml_subset` (which is itself stdlib-only and
@@ -22,7 +22,7 @@ except ImportError: # pragma: no cover - host-side path
from .yaml_subset import YamlSubsetError, parse_yaml_subset from .yaml_subset import YamlSubsetError, parse_yaml_subset
# DLP detector-config parsing lives in a sibling module (also flat-bundled # DLP detector-config parsing lives in a sibling module (also flat-bundled
# into the sidecar — see Dockerfile.sidecars). Re-exported below so existing # into the gateway — see Dockerfile.sidecars). Re-exported below so existing
# `from egress_addon_core import ON_MATCH_*` callers keep working. # `from egress_addon_core import ON_MATCH_*` callers keep working.
try: try:
from egress_dlp_config import ( # type: ignore[import-not-found] from egress_dlp_config import ( # type: ignore[import-not-found]
@@ -120,7 +120,7 @@ class ScanResult:
reason: str reason: str
location: str = "" # where the match was found, e.g. "body", "authorization header" location: str = "" # where the match was found, e.g. "body", "authorization header"
context: str = "" # surrounding text with the match replaced by REDACT context: str = "" # surrounding text with the match replaced by REDACT
# Raw substring the detector matched. Used inside the sidecar to key the # Raw substring the detector matched. Used inside the gateway to key the
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written # supervisor-approved "safe tokens" set (PRD 0062); never logged or written
# to a proposal file. Empty for structural detectors (CRLF) that carry no # to a proposal file. Empty for structural detectors (CRLF) that carry no
# safelist-able value. # safelist-able value.
@@ -413,6 +413,34 @@ def load_config(text: str) -> "Config":
return parse_config(payload) return parse_config(payload)
class PolicyResolverLike(typing.Protocol):
"""The bit of `policy_resolver.PolicyResolver` this module needs — kept a
Protocol so egress_addon_core stays free of that import."""
def resolve(self, source_ip: str, identity_token: str = ...) -> "str | None":
...
def resolve_client_config(
resolver: PolicyResolverLike, client_ip: str, identity_token: str = ""
) -> "Config":
"""The calling client's egress Config, resolved from the orchestrator via
`resolver` and parsed — **fail-closed**. An unattributed client (None), a
resolver error, or an unparseable policy all yield a deny-all Config (no
routes → every request blocked). A compromised, absent, or confused
orchestrator must never *widen* a bottle's egress."""
try:
policy = resolver.resolve(client_ip, identity_token)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return Config(routes=()) # orchestrator unreachable/errored → deny
if not policy:
return Config(routes=()) # unattributed or empty → deny-all
try:
return load_config(policy)
except ValueError:
return Config(routes=()) # unparseable policy → deny
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Match evaluation # Match evaluation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -613,7 +641,7 @@ def outbound_scan_headers(
) -> dict[str, str]: ) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP. """Return request headers that should be included in outbound DLP.
Routes that inject sidecar-owned auth always strip the agent's Routes that inject gateway-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what their own bearer-shaped placeholder, while still not changing what
@@ -664,7 +692,7 @@ def scan_outbound(
crlf_text: str | None = None, crlf_text: str | None = None,
) -> ScanResult | None: ) -> ScanResult | None:
# Lazy import to avoid circular deps and keep dlp_detectors optional # Lazy import to avoid circular deps and keep dlp_detectors optional
# at import time (the sidecar copies it flat alongside this file). # at import time (the gateway copies it flat alongside this file).
try: try:
from dlp_detectors import ( # type: ignore[import-not-found] from dlp_detectors import ( # type: ignore[import-not-found]
scan_crlf_injection, scan_crlf_injection,
@@ -804,6 +832,8 @@ __all__ = [
"is_git_push_request", "is_git_push_request",
"is_git_fetch_request", "is_git_fetch_request",
"load_config", "load_config",
"resolve_client_config",
"PolicyResolverLike",
"match_route", "match_route",
"outbound_scan_headers", "outbound_scan_headers",
"parse_config", "parse_config",
+108 -5
View File
@@ -6,6 +6,15 @@ where the guest reaches the sidecar over the point-to-point TAP). The
wrapper serves the same `/git/*.git` bare repos through wrapper serves the same `/git/*.git` bare repos through
`git http-backend`, so pre-receive and upstream forwarding remain the `git http-backend`, so pre-receive and upstream forwarding remain the
git-gate enforcement point. git-gate enforcement point.
Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one
shared gateway serves every bottle, and each request is served from the
calling bottle's repo namespace (`<root>/<bottle_id>`), attributed from
the unspoofable source IP via the orchestrator. Per-repo credentials +
hooks scope by repo directory, so isolating the *root* per bottle isolates
its creds too. Unattributed clients fail closed (404). Unset → the legacy
per-bottle single-tenant flat root, unchanged — a transitional path that
gets stripped out once every backend runs the consolidated gateway.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,13 +22,86 @@ from __future__ import annotations
import os import os
import subprocess import subprocess
import sys import sys
import typing
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit from urllib.parse import urlsplit
# policy_resolver ships flat alongside this file in the sidecar bundle
# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the
# host-side / test path. Mirrors egress_addon's import shape.
try:
from policy_resolver import ( # type: ignore[import-not-found]
PolicyResolveError,
PolicyResolver,
)
except ImportError: # pragma: no cover - host-side path
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
DEFAULT_PORT = 9420 DEFAULT_PORT = 9420
# Consolidated (multi-tenant) mode: when this points at the per-host
# orchestrator's control plane, the backend serves each request from the
# *calling* bottle's repo namespace, selected by source IP, instead of a
# single flat repo root. Unset → legacy per-bottle single-tenant mode
# (unchanged). Same env the egress addon reads, so one orchestrator setting
# flips the whole shared gateway multi-tenant.
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant);
# the agent injects it, the backend reads it for attribution and never
# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER
# (duplicated, not imported: egress_addon pulls in mitmproxy).
IDENTITY_HEADER = "x-bot-bottle-identity"
# Default flat repo root (single-tenant, and the base under which
# consolidated mode nests each sandbox's namespace).
DEFAULT_REPO_ROOT = "/git"
class ResolverLike(typing.Protocol):
"""Structural type for the resolver `resolve_sandbox_root` needs — just
`resolve_bottle_id`. A Protocol so tests can pass a fake without
importing PolicyResolver (mirrors egress_addon_core.PolicyResolverLike)."""
def resolve_bottle_id(
self, source_ip: str, identity_token: str = ...,
) -> "str | None": ...
def resolve_sandbox_root(
resolver: "ResolverLike | None",
base_root: Path,
source_ip: str,
identity_token: str = "",
) -> Path | None:
"""The per-sandbox repo root to serve this request from, or None to
deny (404).
Single-tenant (`resolver is None`): the flat `base_root`, unchanged.
NOTE: this legacy per-bottle single-tenant path is transitional — it
will be stripped out once every backend runs the consolidated gateway
(PRD 0070), leaving only the source-IP-attributed path below.
Consolidated: `base_root/<bottle_id>`, where the sandbox is attributed
from the source IP via the orchestrator. Fail-closed — an unattributed
client, a resolver error, or a namespace that would escape `base_root`
all deny, so one sandbox can never reach another's repos."""
if resolver is None:
return base_root
try:
bottle_id = resolver.resolve_bottle_id(source_ip, identity_token)
except PolicyResolveError:
return None # orchestrator unreachable/errored → deny
if not bottle_id:
return None # unattributed → deny
base = base_root.resolve()
namespace = (base / bottle_id).resolve()
if base not in namespace.parents:
return None # bottle_id tried to escape the root → deny
return namespace
# Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than
# imported: this module ships as a flat top-level sibling in the sidecar # imported: this module ships as a flat top-level sibling in the sidecar
# bundle image (see Dockerfile.sidecars), not as part of the bot_bottle # bundle image (see Dockerfile.sidecars), not as part of the bot_bottle
@@ -40,10 +122,25 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: def do_POST(self) -> None:
self._run_backend() self._run_backend()
def _sandbox_root(self) -> Path | None:
"""This request's per-sandbox repo root, or None to deny. Single-tenant
unless the server was started with a resolver (consolidated mode), in
which case the root is the calling sandbox's source-IP-selected
namespace. `GIT_PROJECT_ROOT` keeps git's own env-var name."""
base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_REPO_ROOT))
resolver = getattr(self.server, "policy_resolver", None)
token = self.headers.get(IDENTITY_HEADER, "")
return resolve_sandbox_root(resolver, base, self.client_address[0], token)
def _run_backend(self) -> None: def _run_backend(self) -> None:
sandbox_root = self._sandbox_root()
if sandbox_root is None:
# Unattributed / resolver error: deny before touching any repo.
self.send_error(404)
return
parsed = urlsplit(self.path) parsed = urlsplit(self.path)
if self._is_upload_pack(parsed.path, parsed.query): if self._is_upload_pack(parsed.path, parsed.query):
repo_dir = self._repo_dir(parsed.path) repo_dir = self._repo_dir(sandbox_root, parsed.path)
if repo_dir is None: if repo_dir is None:
self.send_error(404) self.send_error(404)
return return
@@ -77,7 +174,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
return return
env = os.environ.copy() env = os.environ.copy()
env.update({ env.update({
"GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"), "GIT_PROJECT_ROOT": str(sandbox_root),
"GIT_HTTP_EXPORT_ALL": "1", "GIT_HTTP_EXPORT_ALL": "1",
"REQUEST_METHOD": self.command, "REQUEST_METHOD": self.command,
"PATH_INFO": parsed.path, "PATH_INFO": parsed.path,
@@ -123,8 +220,8 @@ class GitHttpHandler(BaseHTTPRequestHandler):
) )
self._write_cgi_response(proc.stdout) self._write_cgi_response(proc.stdout)
def _repo_dir(self, path: str) -> Path | None: def _repo_dir(self, sandbox_root: Path, path: str) -> Path | None:
root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve() root = sandbox_root.resolve()
relative = path.lstrip("/").split(".git", 1)[0] + ".git" relative = path.lstrip("/").split(".git", 1)[0] + ".git"
candidate = (root / relative).resolve() candidate = (root / relative).resolve()
if root not in (candidate, *candidate.parents): if root not in (candidate, *candidate.parents):
@@ -181,7 +278,13 @@ class GitHttpHandler(BaseHTTPRequestHandler):
def main() -> int: def main() -> int:
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT))) port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n") orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
# Consolidated mode: resolve each request's sandbox namespace by source
# IP. Absent → single-tenant (flat repo root); behaviour unchanged.
resolver = PolicyResolver(orch_url) if orch_url else None
server.policy_resolver = resolver # type: ignore[attr-defined]
mode = "multi-tenant" if orch_url else "single-tenant"
sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n")
sys.stdout.flush() sys.stdout.flush()
server.serve_forever() server.serve_forever()
return 0 return 0
+9 -9
View File
@@ -1,6 +1,6 @@
"""Per-host orchestrator service (PRD 0070). """Per-host orchestrator service (PRD 0070).
A single persistent per-host service that will run the sidecar functions A single persistent per-host service that will run the gateway functions
(egress / git-gate / supervise), coordinate with the console, and broker (egress / git-gate / supervise), coordinate with the console, and broker
agent launches. This package is being built bottom-up, starting with the agent launches. This package is being built bottom-up, starting with the
backend-neutral "consolidation core" that needs no VM packaging: backend-neutral "consolidation core" that needs no VM packaging:
@@ -10,15 +10,15 @@ backend-neutral "consolidation core" that needs no VM packaging:
* `broker` — the signed, structured launch-request contract + a * `broker` — the signed, structured launch-request contract + a
`LaunchBroker` (stub for the harness) that verifies `LaunchBroker` (stub for the harness) that verifies
provenance before acting. provenance before acting.
* `sidecar` — the consolidated per-host sidecar: a `Sidecar` * `gateway` — the consolidated per-host gateway: a `Gateway`
lifecycle contract (idempotent singleton) + a lifecycle contract (idempotent singleton) + a
`DockerSidecar` impl. One sidecar shared by all `DockerGateway` impl. One gateway shared by all
bottles instead of one per bottle. bottles instead of one per bottle.
* `service` — the `Orchestrator`: owns the registry, brokers the * `service` — the `Orchestrator`: owns the registry, brokers the
launch lifecycle (launch/teardown), manages the launch lifecycle (launch/teardown), manages the
shared sidecar, attributes. shared gateway, attributes.
* `control_plane` — the HTTP control-plane RPC (launch / teardown / * `control_plane` — the HTTP control-plane RPC (launch / teardown /
list / attribute / sidecar / health). list / attribute / gateway / health).
The actual backend-native launch (a real docker/firecracker broker) and The actual backend-native launch (a real docker/firecracker broker) and
the egress/git/supervise data plane land in later slices once this core is the egress/git/supervise data plane land in later slices once this core is
@@ -38,7 +38,7 @@ from .broker import (
verify_request, verify_request,
) )
from .docker_broker import DockerBroker, DockerBrokerError from .docker_broker import DockerBroker, DockerBrokerError
from .sidecar import DockerSidecar, Sidecar, SidecarError from .gateway import DockerGateway, Gateway, GatewayError
from .service import Orchestrator from .service import Orchestrator
from .control_plane import ControlPlaneServer, dispatch, make_server from .control_plane import ControlPlaneServer, dispatch, make_server
@@ -52,9 +52,9 @@ __all__ = [
"StubBroker", "StubBroker",
"DockerBroker", "DockerBroker",
"DockerBrokerError", "DockerBrokerError",
"Sidecar", "Gateway",
"DockerSidecar", "DockerGateway",
"SidecarError", "GatewayError",
"sign_request", "sign_request",
"verify_request", "verify_request",
"Orchestrator", "Orchestrator",
+8 -8
View File
@@ -21,7 +21,7 @@ from .control_plane import make_server
from .docker_broker import DockerBroker from .docker_broker import DockerBroker
from .registry import RegistryStore, default_db_path from .registry import RegistryStore, default_db_path
from .service import Orchestrator from .service import Orchestrator
from .sidecar import DockerSidecar, Sidecar from .gateway import DockerGateway, Gateway
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -38,7 +38,7 @@ def main(argv: list[str] | None = None) -> int:
help="launch broker: 'stub' records requests; 'docker' runs containers", help="launch broker: 'stub' records requests; 'docker' runs containers",
) )
parser.add_argument( parser.add_argument(
"--sidecar", action="store_true", "--gateway", action="store_true",
help="run one consolidated per-host sidecar bundle (build-if-missing)", help="run one consolidated per-host sidecar bundle (build-if-missing)",
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -51,14 +51,14 @@ def main(argv: list[str] | None = None) -> int:
# anything; 'docker' runs real containers (firecracker drops in later). # anything; 'docker' runs real containers (firecracker drops in later).
secret = secrets.token_bytes(32) secret = secrets.token_bytes(32)
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret) broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
sidecar: Sidecar | None = DockerSidecar() if args.sidecar else None gateway: Gateway | None = DockerGateway() if args.gateway else None
orchestrator = Orchestrator(registry, broker, secret, sidecar) orchestrator = Orchestrator(registry, broker, secret, gateway)
# One persistent per-host sidecar, shared by every bottle: build the # One persistent per-host gateway, shared by every bottle: build the
# bundle image if missing, then bring the singleton up (idempotent). # bundle image if missing, then bring the singleton up (idempotent).
if sidecar is not None: if gateway is not None:
orchestrator.ensure_sidecar() orchestrator.ensure_gateway()
log.info("consolidated sidecar ensured", context={"name": sidecar.name}) log.info("consolidated gateway ensured", context={"name": gateway.name})
server = make_server(orchestrator, host=args.host, port=args.port) server = make_server(orchestrator, host=args.host, port=args.port)
bound_host, bound_port = server.server_address[0], server.server_address[1] bound_host, bound_port = server.server_address[0], server.server_address[1]
+1 -1
View File
@@ -9,7 +9,7 @@ The request it sends is:
request can't be coerced into launching an arbitrary payload; and request can't be coerced into launching an arbitrary payload; and
* **signed** — wrapped as a compact JWS/JWT the broker verifies before * **signed** — wrapped as a compact JWS/JWT the broker verifies before
acting, so a *compromised co-located component* (an agent-facing acting, so a *compromised co-located component* (an agent-facing
sidecar, say) can't forge a launch. This is the concrete form of the gateway, say) can't forge a launch. This is the concrete form of the
"structured requests only" rule in the PRD security review. "structured requests only" rule in the PRD security review.
Signing is **HS256** over a secret shared by the orchestrator (signer) and Signing is **HS256** over a secret shared by the orchestrator (signer) and
+21 -9
View File
@@ -5,7 +5,7 @@ The backend-agnostic control-plane RPC (CLI / console -> orchestrator) over
vsock / unix-socket portability caveats): vsock / unix-socket portability caveats):
GET /health -> 200 {"status": "ok"} GET /health -> 200 {"status": "ok"}
GET /sidecar -> 200 {"configured", ["name","running"]} GET /gateway -> 200 {"configured", ["name","running"]}
GET /bottles -> 200 {"bottles": [ <redacted record>, ...]} GET /bottles -> 200 {"bottles": [ <redacted record>, ...]}
POST /bottles -> 201 {"bottle_id","identity_token"} (launch) POST /bottles -> 201 {"bottle_id","identity_token"} (launch)
body: {"source_ip", ["image_ref"], body: {"source_ip", ["image_ref"],
@@ -63,8 +63,8 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
if method == "GET" and route == "/health": if method == "GET" and route == "/health":
return 200, {"status": "ok"} return 200, {"status": "ok"}
if method == "GET" and route == "/sidecar": if method == "GET" and route == "/gateway":
return 200, orch.sidecar_status() return 200, orch.gateway_status()
if method == "GET" and route == "/bottles": if method == "GET" and route == "/bottles":
return 200, {"bottles": [r.redacted() for r in orch.registry.all()]} return 200, {"bottles": [r.redacted() for r in orch.registry.all()]}
@@ -107,7 +107,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
return 200, {"torn_down": True} return 200, {"torn_down": True}
return 404, {"error": "no such bottle"} return 404, {"error": "no such bottle"}
if method == "POST" and route in ("/attribute", "/resolve"): if method == "POST" and route == "/attribute":
try: try:
data = _parse_json_object(body) data = _parse_json_object(body)
except ValueError as e: except ValueError as e:
@@ -119,13 +119,25 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
rec = orch.attribute(source_ip, token) rec = orch.attribute(source_ip, token)
if rec is None: if rec is None:
return 403, {"error": "unattributed"} return 403, {"error": "unattributed"}
# /attribute is the lightweight identity check; /resolve additionally
# returns the bottle's policy — the source-IP-keyed lookup the
# multi-tenant sidecar makes per request.
if route == "/resolve":
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
return 200, {"bottle_id": rec.bottle_id} return 200, {"bottle_id": rec.bottle_id}
if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. identity_token is OPTIONAL — absent means resolve
# by source IP alone (network-layer attribution).
try:
data = _parse_json_object(body)
except ValueError as e:
return 400, {"error": f"invalid JSON: {e}"}
source_ip = data.get("source_ip")
token = data.get("identity_token")
if not isinstance(source_ip, str) or not source_ip:
return 400, {"error": "source_ip (string) is required"}
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
if rec is None:
return 403, {"error": "unattributed"}
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
return 404, {"error": "not found"} return 404, {"error": "not found"}
@@ -1,18 +1,18 @@
"""The consolidated per-host sidecar (PRD 0070). """The consolidated per-host gateway (PRD 0070).
The core consolidation win: **one** persistent sidecar per host, shared by The core consolidation win: **one** persistent gateway per host, shared by
every bottle, instead of a sidecar bundle per bottle. It's safe to share every bottle, instead of a sidecar bundle per bottle. It's safe to share
because the attribution invariant (source IP + identity token, see because the attribution invariant (source IP + identity token, see
`registry`) lets the sidecar attribute each request to the right bottle `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. so per-bottle policy lives in one long-lived process keyed on who's calling.
`Sidecar` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`): `Gateway` is the backend-neutral lifecycle contract (mirrors `LaunchBroker`):
ensure the single instance is up, report it, tear it down. `DockerSidecar` ensure the single instance is up, report it, tear it down. `DockerGateway`
is the docker implementation; a firecracker sidecar VM slots in later. is the docker implementation; a firecracker gateway VM slots in later.
The defining behaviour is **idempotent singleton**: `ensure_running` starts 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 the instance if absent and is a no-op if it's already up, so N bottle
launches never spawn N sidecars. launches never spawn N gateways.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,49 +23,49 @@ from pathlib import Path
from ..docker_cmd import run_docker from ..docker_cmd import run_docker
SIDECAR_NAME = "bot-bottle-orch-sidecar" GATEWAY_NAME = "bot-bottle-orch-gateway"
SIDECAR_LABEL = "bot-bottle-orch-sidecar=1" GATEWAY_LABEL = "bot-bottle-orch-gateway=1"
# 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
# that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE. # that lands. Env override matches the backend's BOT_BOTTLE_SIDECAR_IMAGE.
SIDECAR_BUNDLE_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest") GATEWAY_IMAGE = os.environ.get("BOT_BOTTLE_SIDECAR_IMAGE", "bot-bottle-sidecars:latest")
SIDECAR_DOCKERFILE = "Dockerfile.sidecars" GATEWAY_DOCKERFILE = "Dockerfile.sidecars"
_REPO_ROOT = Path(__file__).resolve().parents[2] _REPO_ROOT = Path(__file__).resolve().parents[2]
class SidecarError(Exception): class GatewayError(Exception):
"""The shared sidecar failed to build/start/stop (non-zero `docker` exit).""" """The shared gateway failed to build/start/stop (non-zero `docker` exit)."""
class Sidecar(abc.ABC): class Gateway(abc.ABC):
"""Lifecycle of the single per-host sidecar. Backend-neutral.""" """Lifecycle of the single per-host gateway. Backend-neutral."""
name: str name: str
def ensure_built(self) -> None: def ensure_built(self) -> None:
"""Ensure the sidecar's image / rootfs exists, building it if needed. """Ensure the gateway's image / rootfs exists, building it if needed.
Default: nothing to build (e.g. a stub or a pre-pulled image).""" Default: nothing to build (e.g. a stub or a pre-pulled image)."""
return return
@abc.abstractmethod @abc.abstractmethod
def ensure_running(self) -> None: def ensure_running(self) -> None:
"""Start the sidecar if it isn't already up. Idempotent: a no-op """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). when it's already running (that's the whole point one per host).
Assumes the image exists call `ensure_built()` first.""" Assumes the image exists call `ensure_built()` first."""
@abc.abstractmethod @abc.abstractmethod
def is_running(self) -> bool: def is_running(self) -> bool:
"""True iff the sidecar instance is currently up.""" """True iff the gateway instance is currently up."""
@abc.abstractmethod @abc.abstractmethod
def stop(self) -> None: def stop(self) -> None:
"""Remove the sidecar. Idempotent — absent is success.""" """Remove the gateway. Idempotent — absent is success."""
class DockerSidecar(Sidecar): class DockerGateway(Gateway):
"""The consolidated sidecar as a single, fixed-name Docker container. """The consolidated gateway as a single, fixed-name Docker container.
`image_ref` defaults to the real sidecar-bundle image; `ensure_built` `image_ref` defaults to the real sidecar-bundle image; `ensure_built`
builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5 builds it from `Dockerfile.sidecars` when it's missing. (Note: slice 5
@@ -74,11 +74,11 @@ class DockerSidecar(Sidecar):
def __init__( def __init__(
self, self,
image_ref: str = SIDECAR_BUNDLE_IMAGE, image_ref: str = GATEWAY_IMAGE,
*, *,
name: str = SIDECAR_NAME, name: str = GATEWAY_NAME,
build_context: Path | None = None, build_context: Path | None = None,
dockerfile: str | None = SIDECAR_DOCKERFILE, dockerfile: str | None = GATEWAY_DOCKERFILE,
) -> None: ) -> None:
self.image_ref = image_ref self.image_ref = image_ref
self.name = name self.name = name
@@ -101,7 +101,7 @@ class DockerSidecar(Sidecar):
str(self._build_context), str(self._build_context),
]) ])
if proc.returncode != 0: if proc.returncode != 0:
raise SidecarError(f"sidecar image build failed: {proc.stderr.strip()}") raise GatewayError(f"gateway image build failed: {proc.stderr.strip()}")
def is_running(self) -> bool: def is_running(self) -> bool:
proc = run_docker([ proc = run_docker([
@@ -121,19 +121,19 @@ class DockerSidecar(Sidecar):
proc = run_docker([ proc = run_docker([
"docker", "run", "--detach", "docker", "run", "--detach",
"--name", self.name, "--name", self.name,
"--label", SIDECAR_LABEL, "--label", GATEWAY_LABEL,
self.image_ref, self.image_ref,
]) ])
if proc.returncode != 0: if proc.returncode != 0:
raise SidecarError(f"sidecar failed to start: {proc.stderr.strip()}") raise GatewayError(f"gateway failed to start: {proc.stderr.strip()}")
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:
raise SidecarError(f"sidecar failed to stop: {proc.stderr.strip()}") raise GatewayError(f"gateway failed to stop: {proc.stderr.strip()}")
__all__ = [ __all__ = [
"Sidecar", "DockerSidecar", "SidecarError", "Gateway", "DockerGateway", "GatewayError",
"SIDECAR_NAME", "SIDECAR_LABEL", "SIDECAR_BUNDLE_IMAGE", "GATEWAY_NAME", "GATEWAY_LABEL", "GATEWAY_IMAGE",
] ]
+22 -13
View File
@@ -67,9 +67,9 @@ class BottleRecord:
# Opaque JSON blob for forward-compat (pool slot, ...) — the registry # Opaque JSON blob for forward-compat (pool slot, ...) — the registry
# doesn't interpret it, so new fields need no migration. # doesn't interpret it, so new fields need no migration.
metadata: str = "" metadata: str = ""
# The bottle's sidecar policy (opaque JSON — egress allowlist / routes / # The bottle's gateway policy (opaque JSON — egress allowlist / routes /
# git config). The registry stores and serves it verbatim, keyed by # git config). The registry stores and serves it verbatim, keyed by
# source IP; the multi-tenant sidecar interprets it. # source IP; the multi-tenant gateway interprets it.
policy: str = "" policy: str = ""
def redacted(self) -> dict[str, object]: def redacted(self) -> dict[str, object]:
@@ -102,9 +102,9 @@ _MIGRATIONS = TableMigrations(
# the "one active bottle per source IP" check cheap. # the "one active bottle per source IP" check cheap.
"CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip " "CREATE INDEX IF NOT EXISTS idx_orchestrator_bottles_source_ip "
"ON orchestrator_bottles (source_ip)", "ON orchestrator_bottles (source_ip)",
# v3 — per-bottle policy (opaque JSON the sidecar interprets): the # v3 — per-bottle policy (opaque JSON the gateway interprets): the
# egress allowlist / routes / git config selected by source IP. The # egress allowlist / routes / git config selected by source IP. The
# multi-tenant sidecar resolves it per request via `attribute`. # multi-tenant gateway resolves it per request via `attribute`.
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''", "ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
], ],
) )
@@ -212,14 +212,13 @@ class RegistryStore(DbStore):
).fetchall() ).fetchall()
return [_row_to_record(r) for r in rows] return [_row_to_record(r) for r in rows]
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None: def by_source_ip(self, source_ip: str) -> BottleRecord | None:
"""Fail-closed attribution. Returns the bottle only when exactly one """Network-layer attribution: the single active bottle at this source
active record has this source IP AND its identity token matches IP, or None if unknown or ambiguous (more than one — a
(constant-time). Either signal alone is insufficient: an unknown IP, misconfiguration). Safe as the *sole* attributor only where the
an ambiguous IP (more than one active bottle — a misconfiguration), source IP is unspoofable (Firecracker `/31` + nft) and the control
an empty token, or a token mismatch all deny.""" plane is reachable only by the trusted gateway; pair with the
if not identity_token: identity token (`attribute`) elsewhere."""
return None
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles " "SELECT * FROM orchestrator_bottles "
@@ -228,7 +227,17 @@ class RegistryStore(DbStore):
).fetchall() ).fetchall()
if len(rows) != 1: if len(rows) != 1:
return None return None
rec = _row_to_record(rows[0]) return _row_to_record(rows[0])
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution: `by_source_ip` AND a matching identity
token (constant-time). Either signal alone is insufficient here — an
unknown/ambiguous IP, an empty token, or a token mismatch all deny."""
if not identity_token:
return None
rec = self.by_source_ip(source_ip)
if rec is None:
return None
if not hmac.compare_digest(rec.identity_token, identity_token): if not hmac.compare_digest(rec.identity_token, identity_token):
return None return None
return rec return rec
+30 -21
View File
@@ -19,12 +19,12 @@ from __future__ import annotations
from .broker import LaunchBroker, LaunchRequest, sign_request from .broker import LaunchBroker, LaunchRequest, sign_request
from .registry import BottleRecord, RegistryStore from .registry import BottleRecord, RegistryStore
from .sidecar import Sidecar from .gateway import Gateway
class Orchestrator: class Orchestrator:
"""Owns the registry + brokers launches, and manages the single """Owns the registry + brokers launches, and manages the single
consolidated per-host sidecar. Backend-neutral (broker and sidecar consolidated per-host gateway. Backend-neutral (broker and gateway
abstract the backend-native pieces).""" abstract the backend-native pieces)."""
def __init__( def __init__(
@@ -32,12 +32,12 @@ class Orchestrator:
registry: RegistryStore, registry: RegistryStore,
broker: LaunchBroker, broker: LaunchBroker,
sign_secret: bytes, sign_secret: bytes,
sidecar: Sidecar | None = None, gateway: Gateway | None = None,
) -> None: ) -> None:
self.registry = registry self.registry = registry
self._broker = broker self._broker = broker
self._secret = sign_secret self._secret = sign_secret
self._sidecar = sidecar self._gateway = gateway
def launch_bottle( def launch_bottle(
self, self,
@@ -48,7 +48,7 @@ class Orchestrator:
metadata: str = "", metadata: str = "",
policy: str = "", policy: str = "",
) -> BottleRecord: ) -> BottleRecord:
"""Register a bottle (with its sidecar policy) and broker its launch. """Register a bottle (with its gateway policy) and broker its launch.
Rolls the registry entry back if the launch doesn't take, so a Rolls the registry entry back if the launch doesn't take, so a
failure leaves no orphan.""" failure leaves no orphan."""
rec = self.registry.register(source_ip, metadata=metadata, policy=policy) rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
@@ -79,33 +79,42 @@ class Orchestrator:
return True return True
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None: def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Fail-closed attribution (delegates to the registry). The returned """Fail-closed attribution (delegates to the registry)."""
record carries the bottle's `policy` — this is the source-IP-keyed
resolution the multi-tenant sidecar makes per request."""
return self.registry.attribute(source_ip, identity_token) return self.registry.attribute(source_ip, identity_token)
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None:
"""Resolve the bottle behind a request — the source-IP-keyed lookup
the multi-tenant gateway makes per request; the returned record
carries its `policy`. With a token, full attribution (source IP +
token); without, network-layer attribution by source IP alone
(valid where the IP is unspoofable and the control plane is
gateway-only)."""
if identity_token:
return self.registry.attribute(source_ip, identity_token)
return self.registry.by_source_ip(source_ip)
def set_policy(self, bottle_id: str, policy: str) -> bool: def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's sidecar policy in place (live reload). False if """Update a bottle's gateway policy in place (live reload). False if
the bottle is unknown.""" the bottle is unknown."""
return self.registry.set_policy(bottle_id, policy) return self.registry.set_policy(bottle_id, policy)
# --- consolidated sidecar ---------------------------------------------- # --- consolidated gateway ----------------------------------------------
def ensure_sidecar(self) -> None: def ensure_gateway(self) -> None:
"""Ensure the single per-host sidecar is built and up (idempotent). """Ensure the single per-host gateway is built and up (idempotent).
No-op when no sidecar is configured.""" No-op when no gateway is configured."""
if self._sidecar is not None: if self._gateway is not None:
self._sidecar.ensure_built() self._gateway.ensure_built()
self._sidecar.ensure_running() self._gateway.ensure_running()
def sidecar_status(self) -> dict[str, object]: def gateway_status(self) -> dict[str, object]:
"""Report the shared sidecar for the control plane / console.""" """Report the shared gateway for the control plane / console."""
if self._sidecar is None: if self._gateway is None:
return {"configured": False} return {"configured": False}
return { return {
"configured": True, "configured": True,
"name": self._sidecar.name, "name": self._gateway.name,
"running": self._sidecar.is_running(), "running": self._gateway.is_running(),
} }
+37 -8
View File
@@ -1,6 +1,6 @@
"""Sidecar-side per-client policy resolver (PRD 0070). """Gateway-side per-client policy resolver (PRD 0070).
The consolidated sidecar serves every bottle from one process, so for each The consolidated gateway serves every bottle from one process, so for each
request it must apply the *calling* bottle's policy, selected by the source request it must apply the *calling* bottle's policy, selected by the source
IP the attribution invariant makes unspoofable. This resolves that policy IP the attribution invariant makes unspoofable. This resolves that policy
from the orchestrator's control plane (`POST /resolve`), keyed on the from the orchestrator's control plane (`POST /resolve`), keyed on the
@@ -47,11 +47,11 @@ class PolicyResolver:
self._base = base_url.rstrip("/") self._base = base_url.rstrip("/")
self._timeout = timeout self._timeout = timeout
def resolve(self, source_ip: str, identity_token: str) -> str | None: def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
"""The calling bottle's policy blob, or None if unattributed. Always """The orchestrator's `/resolve` payload for this client, or None if
fetches from the orchestrator so revocations / changes / teardowns unattributed (a clean `403`). Raises `PolicyResolveError` on an
are honored immediately. Raises `PolicyResolveError` if the unreachable / unexpected-status / malformed response so every caller
orchestrator can't be reached / errors.""" can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
body = json.dumps( body = json.dumps(
{"source_ip": source_ip, "identity_token": identity_token} {"source_ip": source_ip, "identity_token": identity_token}
).encode() ).encode()
@@ -68,8 +68,37 @@ class PolicyResolver:
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
policy = payload.get("policy") if isinstance(payload, dict) else None return payload if isinstance(payload, dict) else {}
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's policy blob, or None if unattributed. Always
fetches from the orchestrator so revocations / changes / teardowns
are honored immediately.
`identity_token` is optional *transitionally* — omitting it resolves
by source IP alone (the network-layer attribution, sound by
construction on Firecracker's `/31`+nft, weaker elsewhere). The
consolidated end state makes the token mandatory so the app-layer
defense is always enforced, not silently degraded; that flips at the
`/resolve` boundary once identity-token *delivery* lands (a PRD 0070
open question — we can't require what the agent can't yet present).
Raises `PolicyResolveError` if the orchestrator can't be reached."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None
policy = payload.get("policy")
return policy if isinstance(policy, str) else "" return policy if isinstance(policy, str) else ""
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
"""The calling bottle's id, or None if unattributed. This is the
source-IP-keyed identity the git-gate uses to select the bottle's
repo namespace (there is no per-bottle policy blob to parse — the
bottle *is* the namespace). Same fail-closed contract as `resolve`."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None
bottle_id = payload.get("bottle_id")
return bottle_id if isinstance(bottle_id, str) and bottle_id else None
__all__ = ["PolicyResolver", "PolicyResolveError"] __all__ = ["PolicyResolver", "PolicyResolveError"]
+20 -20
View File
@@ -10,8 +10,8 @@
## Summary ## Summary
Replace the **per-bottle sidecar bundle** with a single **persistent, Replace the **per-bottle gateway bundle** with a single **persistent,
per-host orchestrator**: one long-lived service that runs the sidecar per-host orchestrator**: one long-lived service that runs the gateway
functions (egress / git-gate / supervise), coordinates with the console, functions (egress / git-gate / supervise), coordinates with the console,
and brokers agent launches and teardown. It is **virtualized from the and brokers agent launches and teardown. It is **virtualized from the
start** using each backend's native isolation primitive — a Firecracker start** using each backend's native isolation primitive — a Firecracker
@@ -22,13 +22,13 @@ container on the legacy backend — and is fronted by a single
## Motivation ## Motivation
Today each bottle spins up its own sidecar bundle (egress mitmproxy + Today each bottle spins up its own gateway bundle (egress mitmproxy +
git-gate + supervise). That costs: git-gate + supervise). That costs:
- **Resources.** N bottles → N heavy bundles booting and idling. - **Resources.** N bottles → N heavy bundles booting and idling.
- **Operational churn.** Per-launch container/VM lifecycle for the - **Operational churn.** Per-launch container/VM lifecycle for the
sidecars, a control path baked at launch and torn down at exit. gateways, a control path baked at launch and torn down at exit.
- **A blurry contract.** "How a bottle talks to its sidecar" is - **A blurry contract.** "How a bottle talks to its gateway" is
re-implemented per backend instead of being one agreed interface. re-implemented per backend instead of being one agreed interface.
A per-host orchestrator collapses the first two and forces the third to A per-host orchestrator collapses the first two and forces the third to
@@ -52,11 +52,11 @@ weaken, and the weakened ones must be mitigated by design, not hand-waved.
structured, auditable privileged core instead of a fat socket. structured, auditable privileged core instead of a fat socket.
- **Attribution is enforced, not assumed.** Making source-IP identity a - **Attribution is enforced, not assumed.** Making source-IP identity a
first-class contract invariant (below) means each backend must *prove* first-class contract invariant (below) means each backend must *prove*
it, rather than the sidecar implicitly trusting network position. it, rather than the gateway implicitly trusting network position.
### What gets weaker, and the mitigation ### What gets weaker, and the mitigation
1. **Secret concentration.** Per-bottle sidecars isolate secrets at the 1. **Secret concentration.** Per-bottle gateways isolate secrets at the
process boundary — each holds only its bottle's tokens/keys. A host process boundary — each holds only its bottle's tokens/keys. A host
orchestrator concentrates **every bottle's** egress tokens, git deploy orchestrator concentrates **every bottle's** egress tokens, git deploy
keys, and the console credential in one long-lived process. A single keys, and the console credential in one long-lived process. A single
@@ -75,7 +75,7 @@ weaken, and the weakened ones must be mitigated by design, not hand-waved.
fleet, plus launch authority, plus the console token. fleet, plus launch authority, plus the console token.
- *Mitigation:* the orchestrator is itself confined (its own VM/container - *Mitigation:* the orchestrator is itself confined (its own VM/container
with its own fail-closed egress); make it **restartable without killing with its own fail-closed egress); make it **restartable without killing
running agent VMs** (agents keep running; they briefly lose sidecar running agent VMs** (agents keep running; they briefly lose gateway
connectivity until it's back); persist state to a host volume so a connectivity until it's back); persist state to a host volume so a
restart re-adopts live bottles rather than losing them. restart re-adopts live bottles rather than losing them.
@@ -114,13 +114,13 @@ responsibility** and part of the contract:
- **Apple** — the host-only network. - **Apple** — the host-only network.
If a backend can't honor the invariant, source-IP consolidation is not If a backend can't honor the invariant, source-IP consolidation is not
safe there and that backend keeps per-bottle sidecars. The invariant is a safe there and that backend keeps per-bottle gateways. The invariant is a
hard precondition, not an aspiration. hard precondition, not an aspiration.
**Defense-in-depth — a per-bottle identity token.** On top of the **Defense-in-depth — a per-bottle identity token.** On top of the
network-layer invariant, inject a per-bottle secret token into every network-layer invariant, inject a per-bottle secret token into every
request the agent makes to the orchestrator (the agent already egresses request the agent makes to the orchestrator (the agent already egresses
through the sidecar proxy, so this is cheap to add). It gives an through the gateway proxy, so this is cheap to add). It gives an
**application-layer** proof of identity independent of the network layer: **application-layer** proof of identity independent of the network layer:
- On **Firecracker** the `/31` + nft already make source IP unspoofable - On **Firecracker** the `/31` + nft already make source IP unspoofable
@@ -133,7 +133,7 @@ Requirements: the token is **per-bottle, unguessable, and
non-cross-leakable** — a bottle can only ever prove it is *itself* (it non-cross-leakable** — a bottle can only ever prove it is *itself* (it
can't learn another bottle's token, given bottle isolation), so a hostile can't learn another bottle's token, given bottle isolation), so a hostile
agent gains nothing by presenting it. The orchestrator provisions the agent gains nothing by presenting it. The orchestrator provisions the
token at launch and the sidecar requires it to attribute + authorize. token at launch and the gateway requires it to attribute + authorize.
Note this hardens *attribution*, not *secret exposure*: it's app-layer, so Note this hardens *attribution*, not *secret exposure*: it's app-layer, so
a compromised orchestrator still sees every token (that's the a compromised orchestrator still sees every token (that's the
concentration problem, addressed separately under Secret handling). concentration problem, addressed separately under Secret handling).
@@ -178,7 +178,7 @@ manifest anywhere a raw token value is accepted, discovered from
`AgentProvider`s are — because maintaining every forge/cloud/OAuth `AgentProvider`s are — because maintaining every forge/cloud/OAuth
provider in-tree is untenable. The orchestrator's vault mints via these provider in-tree is untenable. The orchestrator's vault mints via these
providers; but the abstraction is shippable independently and today's providers; but the abstraction is shippable independently and today's
per-bottle sidecars can use it too. per-bottle gateways can use it too.
## Design ## Design
@@ -195,7 +195,7 @@ Three surfaces; only one is per-backend.
every host (no vsock/unix-socket portability caveats); a local unix every host (no vsock/unix-socket portability caveats); a local unix
socket is a fine optimization, but HTTP is the wire contract. socket is a fine optimization, but HTTP is the wire contract.
2. **Data plane (agent → orchestrator)** — the egress / git / supervise 2. **Data plane (agent → orchestrator)** — the egress / git / supervise
endpoints. Already agnostic today (agents dial `http://sidecar:9099`); endpoints. Already agnostic today (agents dial `http://gateway:9099`);
only the *address* and *how packets get there* are per-backend. only the *address* and *how packets get there* are per-backend.
3. **Launch / wire (orchestrator → backend)** — the irreducibly 3. **Launch / wire (orchestrator → backend)** — the irreducibly
backend-specific part; lives on `BottleBackend`. backend-specific part; lives on `BottleBackend`.
@@ -249,7 +249,7 @@ forged one from a compromised co-located component. The orchestrator signs;
the broker verifies with the orchestrator's public key (provisioned at the broker verifies with the orchestrator's public key (provisioned at
broker install). This is the concrete form of security #3's "structured broker install). This is the concrete form of security #3's "structured
requests only." Same JSON-with-ids + JWT shape for all requests only." Same JSON-with-ids + JWT shape for all
orchestrator↔broker/sidecar communication; cases that don't fit get orchestrator↔broker/gateway communication; cases that don't fit get
handled as they arise, with this as the default. handled as they arise, with this as the default.
If `BottleBackend` bloats, the pressure valve is composition one level If `BottleBackend` bloats, the pressure valve is composition one level
@@ -299,10 +299,10 @@ and integrate a console against.
the data plane and the console reach state through the control-plane RPC, the data plane and the console reach state through the control-plane RPC,
never a direct file handle.** No agent-facing component gets the file, so never a direct file handle.** No agent-facing component gets the file, so
none can forge attribution. (This supersedes the earlier `ro`-mount idea.) none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
- *Transitional caveat:* today the per-bottle **supervise sidecar - *Transitional caveat:* today the per-bottle **supervise gateway
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
pattern the orchestrator removes (supervise consolidates into the pattern the orchestrator removes (supervise consolidates into the
orchestrator; sidecar writes become RPC calls). Until that lands, don't orchestrator; gateway writes become RPC calls). Until that lands, don't
put the attribution registry behind a data-plane-writable mount. put the attribution registry behind a data-plane-writable mount.
Implementation note for the VM slices: SQLite **WAL** over a guest share Implementation note for the VM slices: SQLite **WAL** over a guest share
@@ -327,7 +327,7 @@ orchestrator becomes a VM. Decouple the two risks instead:
Backend order (cheapest proof → hardest → last): Backend order (cheapest proof → hardest → last):
1. **Docker orchestrator** — nearly free (the sidecar bundle is already 1. **Docker orchestrator** — nearly free (the gateway bundle is already
containers; collapse N bundles into one persistent container). Proves containers; collapse N bundles into one persistent container). Proves
consolidation + the `BottleBackend` seam with the least moving parts. consolidation + the `BottleBackend` seam with the least moving parts.
2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM 2. **Firecracker orchestrator** — the real work: the shim + VM-to-VM
@@ -336,13 +336,13 @@ Backend order (cheapest proof → hardest → last):
the dev-harness so the app logic is already proven. the dev-harness so the app logic is already proven.
3. **macOS (Apple container)** — last (container-to-container networking). 3. **macOS (Apple container)** — last (container-to-container networking).
Keep the sidecar **service one shared thing** throughout. Keep the gateway **service one shared thing** throughout.
## Non-goals ## Non-goals
- Removing OCI/Dockerfile support for agent images (0069's concern). - Removing OCI/Dockerfile support for agent images (0069's concern).
- A single database for *all* config (see the three-tier table). - A single database for *all* config (see the three-tier table).
- Changing the per-bottle isolation of agent workloads — only the sidecar - Changing the per-bottle isolation of agent workloads — only the gateway
is consolidated; agents stay one-VM/container-each. is consolidated; agents stay one-VM/container-each.
## Relationship to other work ## Relationship to other work
@@ -393,7 +393,7 @@ Keep the sidecar **service one shared thing** throughout.
`BottleBackend.wire()` (DNAT+forward for fc, shared-net for `BottleBackend.wire()` (DNAT+forward for fc, shared-net for
docker/apple), not orchestrator logic. **Not a blocker** — resolvable at docker/apple), not orchestrator logic. **Not a blocker** — resolvable at
implementation time (may require a bit of host modification, as the pool implementation time (may require a bit of host modification, as the pool
setup already does on NixOS); an earlier "sidecars in VMs" spike showed setup already does on NixOS); an earlier "gateways in VMs" spike showed
it's feasible. it's feasible.
- **Live-reload protocol** for per-bottle policy over the HTTP control - **Live-reload protocol** for per-bottle policy over the HTTP control
plane (add/remove routes/keys/proposals without a restart). plane (add/remove routes/keys/proposals without a restart).
@@ -1,7 +1,7 @@
"""Integration: the consolidated Docker sidecar is an idempotent singleton. """Integration: the consolidated Docker gateway is an idempotent singleton.
Gated on a reachable Docker daemon. Uses a unique name so it never Gated on a reachable Docker daemon. Uses a unique name so it never
collides with a real per-host sidecar or a leftover from another run. collides with a real per-host gateway or a leftover from another run.
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,17 +10,17 @@ import secrets
import subprocess import subprocess
import unittest import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar from bot_bottle.orchestrator.gateway import DockerGateway
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
IMAGE = "busybox" IMAGE = "busybox"
@skip_unless_docker() @skip_unless_docker()
class TestDockerSidecarIntegration(unittest.TestCase): class TestDockerGatewayIntegration(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.name = "bot-bottle-orch-sidecar-itest-" + secrets.token_hex(4) self.name = "bot-bottle-orch-gateway-itest-" + secrets.token_hex(4)
self.sc = DockerSidecar(IMAGE, name=self.name) self.sc = DockerGateway(IMAGE, name=self.name)
self.addCleanup(self.sc.stop) self.addCleanup(self.sc.stop)
def _count(self) -> int: def _count(self) -> int:
@@ -1,4 +1,4 @@
"""Integration: DockerSidecar.image_exists reflects real docker state. """Integration: DockerGateway.image_exists reflects real docker state.
Gated on a reachable Docker daemon. Deliberately does NOT build the full Gated on a reachable Docker daemon. Deliberately does NOT build the full
sidecar bundle (a heavy, slow image build) it exercises the `image_exists` sidecar bundle (a heavy, slow image build) it exercises the `image_exists`
@@ -11,14 +11,14 @@ from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from bot_bottle.orchestrator.sidecar import DockerSidecar from bot_bottle.orchestrator.gateway import DockerGateway
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
IMAGE = "busybox" IMAGE = "busybox"
@skip_unless_docker() @skip_unless_docker()
class TestDockerSidecarImageExists(unittest.TestCase): class TestDockerGatewayImageExists(unittest.TestCase):
def test_image_exists_true_for_present_false_for_absent(self) -> None: def test_image_exists_true_for_present_false_for_absent(self) -> None:
# Ensure the tiny image is present (build_if_missing is disabled here # Ensure the tiny image is present (build_if_missing is disabled here
# so this never triggers a Dockerfile.sidecars build). # so this never triggers a Dockerfile.sidecars build).
@@ -26,9 +26,9 @@ class TestDockerSidecarImageExists(unittest.TestCase):
["docker", "pull", IMAGE], ["docker", "pull", IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
) )
self.assertTrue(DockerSidecar(IMAGE, dockerfile=None).image_exists()) self.assertTrue(DockerGateway(IMAGE, dockerfile=None).image_exists())
self.assertFalse( self.assertFalse(
DockerSidecar("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists() DockerGateway("bot-bottle-nonexistent:doesnotexist", dockerfile=None).image_exists()
) )
+53
View File
@@ -0,0 +1,53 @@
"""Unit: resolve_client_config — fail-closed per-client egress config (PRD 0070)."""
from __future__ import annotations
import unittest
from bot_bottle.egress_addon_core import resolve_client_config
from bot_bottle.policy_resolver import PolicyResolveError
class _FakeResolver:
def __init__(self, result: str | None = None, raises: bool = False) -> None:
self._result = result
self._raises = raises
self.calls: list[tuple[str, str]] = []
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
self.calls.append((source_ip, identity_token))
if self._raises:
raise PolicyResolveError("orchestrator down")
return self._result
class TestResolveClientConfig(unittest.TestCase):
def test_valid_policy_is_parsed(self) -> None:
cfg = resolve_client_config(
_FakeResolver(result="routes:\n - host: example.com\n"), "10.243.0.1"
)
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
def test_unattributed_none_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result=None), "10.243.0.9")
self.assertEqual((), cfg.routes) # no routes → default-deny
def test_empty_policy_denies_all(self) -> None:
self.assertEqual((), resolve_client_config(_FakeResolver(result=""), "10.243.0.1").routes)
def test_resolver_error_denies_all(self) -> None:
# Orchestrator unreachable/errored must never widen egress.
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes)
def test_unparseable_policy_denies_all(self) -> None:
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
self.assertEqual((), cfg.routes)
def test_forwards_source_ip_and_token(self) -> None:
r = _FakeResolver(result=None)
resolve_client_config(r, "10.243.0.1", "tok")
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Unit: resolve_sandbox_root — source-IP-keyed git-gate repo namespace (PRD 0070).
The consolidated git-http backend serves every bottle from one process and
selects each request's repo root by the calling bottle's source IP. This is
the fail-closed selection logic, tested without a live server.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from bot_bottle.git_http_backend import resolve_sandbox_root
from bot_bottle.policy_resolver import PolicyResolveError
_BASE = Path("/git")
class _FakeResolver:
def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None:
self._bottle_id = bottle_id
self._raises = raises
self.calls: list[tuple[str, str]] = []
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
self.calls.append((source_ip, identity_token))
if self._raises:
raise PolicyResolveError("orchestrator down")
return self._bottle_id
class TestResolveRepoRoot(unittest.TestCase):
def test_single_tenant_passthrough(self) -> None:
# No resolver → the flat base root, unchanged (legacy per-bottle mode).
self.assertEqual(_BASE, resolve_sandbox_root(None, _BASE, "10.243.0.1"))
def test_attributed_bottle_gets_namespaced_root(self) -> None:
root = resolve_sandbox_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1")
self.assertEqual(Path("/git/ab12cd34"), root)
def test_unattributed_denies(self) -> None:
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9"))
def test_empty_bottle_id_denies(self) -> None:
self.assertIsNone(resolve_sandbox_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1"))
def test_resolver_error_denies(self) -> None:
# Orchestrator unreachable/errored must never serve repos.
self.assertIsNone(resolve_sandbox_root(_FakeResolver(raises=True), _BASE, "10.243.0.1"))
def test_namespace_escape_denies(self) -> None:
# A bottle_id that would climb out of the root is rejected (defense in
# depth — registry ids are token_hex, but never trust the namespace).
self.assertIsNone(
resolve_sandbox_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1")
)
def test_forwards_source_ip_and_token(self) -> None:
r = _FakeResolver(bottle_id="b1")
resolve_sandbox_root(r, _BASE, "10.243.0.1", "tok")
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
if __name__ == "__main__":
unittest.main()
+18 -2
View File
@@ -118,8 +118,8 @@ class TestDispatch(unittest.TestCase):
status, _ = dispatch(self.orch, "GET", "/health/", b"") status, _ = dispatch(self.orch, "GET", "/health/", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
def test_sidecar_status_unconfigured(self) -> None: def test_gateway_status_unconfigured(self) -> None:
status, payload = dispatch(self.orch, "GET", "/sidecar", b"") status, payload = dispatch(self.orch, "GET", "/gateway", b"")
self.assertEqual(200, status) self.assertEqual(200, status)
self.assertEqual(False, payload["configured"]) self.assertEqual(False, payload["configured"])
@@ -171,6 +171,22 @@ class TestDispatch(unittest.TestCase):
) )
self.assertEqual(400, status) self.assertEqual(400, status)
def test_resolve_without_token_by_source_ip(self) -> None:
_, reg = dispatch(
self.orch, "POST", "/bottles",
_body({"source_ip": "10.243.0.5", "policy": "P"}),
)
status, payload = dispatch(
self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"})
)
self.assertEqual(200, status)
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
self.assertEqual("P", payload["policy"])
def test_resolve_requires_source_ip(self) -> None:
status, _ = dispatch(self.orch, "POST", "/resolve", _body({}))
self.assertEqual(400, status)
class TestServerRoundTrip(unittest.TestCase): class TestServerRoundTrip(unittest.TestCase):
def test_http_register_health_attribute(self) -> None: def test_http_register_health_attribute(self) -> None:
@@ -1,29 +1,29 @@
"""Unit tests for the consolidated Docker sidecar (PRD 0070). Docker mocked.""" """Unit tests for the consolidated Docker gateway (PRD 0070). Docker mocked."""
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bot_bottle.orchestrator.sidecar import ( from bot_bottle.orchestrator.gateway import (
SIDECAR_NAME, GATEWAY_NAME,
DockerSidecar, DockerGateway,
SidecarError, GatewayError,
) )
_RUN_DOCKER = "bot_bottle.orchestrator.sidecar.run_docker" _RUN_DOCKER = "bot_bottle.orchestrator.gateway.run_docker"
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock: def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> Mock:
return Mock(returncode=returncode, stdout=stdout, stderr=stderr) return Mock(returncode=returncode, stdout=stdout, stderr=stderr)
class TestDockerSidecar(unittest.TestCase): class TestDockerGateway(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.sc = DockerSidecar("bot-bottle-sidecars:latest") self.sc = DockerGateway("bot-bottle-sidecars:latest")
def test_default_name(self) -> None: def test_default_name(self) -> None:
self.assertEqual(SIDECAR_NAME, self.sc.name) self.assertEqual(GATEWAY_NAME, self.sc.name)
def test_is_running_reads_docker_ps(self) -> None: def test_is_running_reads_docker_ps(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")): with patch(_RUN_DOCKER, return_value=_proc(stdout=self.sc.name + "\n")):
@@ -61,7 +61,7 @@ class TestDockerSidecar(unittest.TestCase):
return _proc() return _proc()
with patch(_RUN_DOCKER, side_effect=fake): with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError): with self.assertRaises(GatewayError):
self.sc.ensure_running() self.sc.ensure_running()
def test_stop_is_idempotent_on_missing(self) -> None: def test_stop_is_idempotent_on_missing(self) -> None:
@@ -71,13 +71,13 @@ class TestDockerSidecar(unittest.TestCase):
def test_stop_raises_on_other_failure(self) -> None: def test_stop_raises_on_other_failure(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")): with patch(_RUN_DOCKER, return_value=_proc(returncode=1, stderr="daemon down")):
with self.assertRaises(SidecarError): with self.assertRaises(GatewayError):
self.sc.stop() self.sc.stop()
class TestDockerSidecarBuild(unittest.TestCase): class TestDockerGatewayBuild(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.sc = DockerSidecar() # defaults to the real bundle image + dockerfile self.sc = DockerGateway() # defaults to the real bundle image + dockerfile
def test_image_exists_reads_docker_inspect(self) -> None: def test_image_exists_reads_docker_inspect(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(returncode=0)): with patch(_RUN_DOCKER, return_value=_proc(returncode=0)):
@@ -109,7 +109,7 @@ class TestDockerSidecarBuild(unittest.TestCase):
self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0])) self.assertTrue(any(a.endswith("Dockerfile.sidecars") for a in builds[0]))
def test_ensure_built_noop_when_no_dockerfile(self) -> None: def test_ensure_built_noop_when_no_dockerfile(self) -> None:
sc = DockerSidecar("busybox", dockerfile=None) sc = DockerGateway("busybox", dockerfile=None)
with patch(_RUN_DOCKER) as m: with patch(_RUN_DOCKER) as m:
sc.ensure_built() sc.ensure_built()
m.assert_not_called() m.assert_not_called()
@@ -121,7 +121,7 @@ class TestDockerSidecarBuild(unittest.TestCase):
return _proc(returncode=1, stderr="build boom") return _proc(returncode=1, stderr="build boom")
with patch(_RUN_DOCKER, side_effect=fake): with patch(_RUN_DOCKER, side_effect=fake):
with self.assertRaises(SidecarError): with self.assertRaises(GatewayError):
self.sc.ensure_built() self.sc.ensure_built()
+12
View File
@@ -121,6 +121,18 @@ class TestRegistryStore(unittest.TestCase):
assert got is not None assert got is not None
self.assertEqual("", got.policy) self.assertEqual("", got.policy)
def test_by_source_ip_returns_single_active(self) -> None:
rec = self.store.register("10.243.0.1")
got = self.store.by_source_ip("10.243.0.1")
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
self.store.register("10.243.0.1", bottle_id="a")
self.store.register("10.243.0.1", bottle_id="b")
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+28 -16
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
from bot_bottle.orchestrator.registry import RegistryStore from bot_bottle.orchestrator.registry import RegistryStore
from bot_bottle.orchestrator.service import Orchestrator from bot_bottle.orchestrator.service import Orchestrator
from bot_bottle.orchestrator.sidecar import Sidecar from bot_bottle.orchestrator.gateway import Gateway
class _FailingBroker(LaunchBroker): class _FailingBroker(LaunchBroker):
@@ -24,11 +24,11 @@ class _FailingBroker(LaunchBroker):
pass pass
class _FakeSidecar(Sidecar): class _FakeGateway(Gateway):
"""In-memory sidecar for wiring tests.""" """In-memory gateway for wiring tests."""
def __init__(self) -> None: def __init__(self) -> None:
self.name = "fake-sidecar" self.name = "fake-gateway"
self.ensured = 0 self.ensured = 0
self.built = 0 self.built = 0
self._running = False self._running = False
@@ -102,29 +102,41 @@ class TestOrchestrator(unittest.TestCase):
def test_set_policy_unknown_is_false(self) -> None: def test_set_policy_unknown_is_false(self) -> None:
self.assertFalse(self.orch.set_policy("ghost", "{}")) self.assertFalse(self.orch.set_policy("ghost", "{}"))
def test_resolve_by_source_ip_without_token(self) -> None:
rec = self.orch.launch_bottle("10.243.0.1", policy="P")
got = self.orch.resolve("10.243.0.1") # network-layer, no token
assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id)
self.assertEqual("P", got.policy)
def test_resolve_with_token_stays_strict(self) -> None:
rec = self.orch.launch_bottle("10.243.0.3")
self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token))
self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-token"))
def test_launch_rolls_back_registry_on_broker_failure(self) -> None: def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret) orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
orch.launch_bottle("10.243.0.9") orch.launch_bottle("10.243.0.9")
self.assertEqual([], self.store.all()) # no orphan self.assertEqual([], self.store.all()) # no orphan
def test_sidecar_unconfigured_by_default(self) -> None: def test_gateway_unconfigured_by_default(self) -> None:
self.assertEqual({"configured": False}, self.orch.sidecar_status()) self.assertEqual({"configured": False}, self.orch.gateway_status())
self.orch.ensure_sidecar() # no-op, must not raise self.orch.ensure_gateway() # no-op, must not raise
def test_sidecar_wired_and_ensured(self) -> None: def test_gateway_wired_and_ensured(self) -> None:
sc = _FakeSidecar() sc = _FakeGateway()
orch = Orchestrator(self.store, self.broker, self.secret, sidecar=sc) orch = Orchestrator(self.store, self.broker, self.secret, gateway=sc)
self.assertEqual( self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": False}, {"configured": True, "name": "fake-gateway", "running": False},
orch.sidecar_status(), orch.gateway_status(),
) )
orch.ensure_sidecar() orch.ensure_gateway()
self.assertEqual(1, sc.built) # ensure_sidecar builds first, self.assertEqual(1, sc.built) # ensure_gateway builds first,
self.assertEqual(1, sc.ensured) # then runs self.assertEqual(1, sc.ensured) # then runs
self.assertEqual( self.assertEqual(
{"configured": True, "name": "fake-sidecar", "running": True}, {"configured": True, "name": "fake-gateway", "running": True},
orch.sidecar_status(), orch.gateway_status(),
) )
+23 -1
View File
@@ -1,4 +1,4 @@
"""Unit tests for the sidecar-side PolicyResolver (PRD 0070). HTTP mocked.""" """Unit tests for the gateway-side PolicyResolver (PRD 0070). HTTP mocked."""
from __future__ import annotations from __future__ import annotations
@@ -66,6 +66,28 @@ class TestPolicyResolver(unittest.TestCase):
self.assertEqual("10.243.0.7", sent["source_ip"]) self.assertEqual("10.243.0.7", sent["source_ip"])
self.assertEqual("the-token", sent["identity_token"]) self.assertEqual("the-token", sent["identity_token"])
def test_resolve_without_token_sends_empty(self) -> None:
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
self.r.resolve("10.243.0.7") # token optional
self.assertEqual("", json.loads(m.call_args.args[0].data)["identity_token"])
def test_resolve_bottle_id_returns_id(self) -> None:
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})):
self.assertEqual("b1", self.r.resolve_bottle_id("10.243.0.1", "tok"))
def test_resolve_bottle_id_403_is_none_fail_closed(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.resolve_bottle_id("10.243.0.9", "tok"))
def test_resolve_bottle_id_missing_field_is_none(self) -> None:
with patch(_URLOPEN, return_value=_resp({"policy": "P"})):
self.assertIsNone(self.r.resolve_bottle_id("10.243.0.1", "tok"))
def test_resolve_bottle_id_error_raises(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(500)):
with self.assertRaises(PolicyResolveError):
self.r.resolve_bottle_id("10.243.0.1", "tok")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()