Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 115a64c161 | |||
| 08aef30d87 | |||
| e0b0429cd1 | |||
| d3e08cf039 | |||
| de9da29027 | |||
| 72ea500342 | |||
| f754c575d7 |
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ 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"], ["metadata"]}
|
body: {"source_ip", ["image_ref"],
|
||||||
|
["metadata"], ["policy"]}
|
||||||
|
PUT /bottles/<bottle_id>/policy -> 200 {"updated": true} | 404 (live reload)
|
||||||
|
body: {"policy"}
|
||||||
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
||||||
POST /attribute -> 200 {"bottle_id"} | 403
|
POST /attribute -> 200 {"bottle_id"} | 403
|
||||||
|
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
||||||
body: {"source_ip","identity_token"}
|
body: {"source_ip","identity_token"}
|
||||||
|
|
||||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||||
@@ -49,7 +53,7 @@ def _parse_json_object(body: bytes) -> Json:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def dispatch( # pylint: disable=too-many-return-statements
|
def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||||
orch: Orchestrator, method: str, path: str, body: bytes
|
orch: Orchestrator, method: str, path: str, body: bytes
|
||||||
) -> tuple[int, Json]:
|
) -> tuple[int, Json]:
|
||||||
"""Route one control-plane request to a (status, payload) pair. Pure —
|
"""Route one control-plane request to a (status, payload) pair. Pure —
|
||||||
@@ -59,8 +63,8 @@ def dispatch( # pylint: disable=too-many-return-statements
|
|||||||
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()]}
|
||||||
@@ -75,13 +79,28 @@ def dispatch( # pylint: disable=too-many-return-statements
|
|||||||
return 400, {"error": "source_ip (string) is required"}
|
return 400, {"error": "source_ip (string) is required"}
|
||||||
image_ref = data.get("image_ref")
|
image_ref = data.get("image_ref")
|
||||||
metadata = data.get("metadata")
|
metadata = data.get("metadata")
|
||||||
|
policy = data.get("policy")
|
||||||
rec = orch.launch_bottle(
|
rec = orch.launch_bottle(
|
||||||
source_ip,
|
source_ip,
|
||||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||||
metadata=metadata if isinstance(metadata, str) else "",
|
metadata=metadata if isinstance(metadata, str) else "",
|
||||||
|
policy=policy if isinstance(policy, str) else "",
|
||||||
)
|
)
|
||||||
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
return 201, {"bottle_id": rec.bottle_id, "identity_token": rec.identity_token}
|
||||||
|
|
||||||
|
if method == "PUT" and route.startswith("/bottles/") and route.endswith("/policy"):
|
||||||
|
bottle_id = route[len("/bottles/"):-len("/policy")]
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
policy = data.get("policy")
|
||||||
|
if not isinstance(policy, str):
|
||||||
|
return 400, {"error": "policy (string) is required"}
|
||||||
|
if orch.set_policy(bottle_id, policy):
|
||||||
|
return 200, {"updated": True}
|
||||||
|
return 404, {"error": "no such bottle"}
|
||||||
|
|
||||||
if method == "DELETE" and route.startswith("/bottles/"):
|
if method == "DELETE" and route.startswith("/bottles/"):
|
||||||
bottle_id = route[len("/bottles/"):]
|
bottle_id = route[len("/bottles/"):]
|
||||||
if orch.teardown_bottle(bottle_id):
|
if orch.teardown_bottle(bottle_id):
|
||||||
@@ -102,6 +121,23 @@ def dispatch( # pylint: disable=too-many-return-statements
|
|||||||
return 403, {"error": "unattributed"}
|
return 403, {"error": "unattributed"}
|
||||||
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"}
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +170,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
self._serve("POST")
|
self._serve("POST")
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
self._serve("PUT")
|
||||||
|
|
||||||
def do_DELETE(self) -> None:
|
def do_DELETE(self) -> None:
|
||||||
self._serve("DELETE")
|
self._serve("DELETE")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -64,9 +64,13 @@ class BottleRecord:
|
|||||||
identity_token: str
|
identity_token: str
|
||||||
state: str = "active"
|
state: str = "active"
|
||||||
created_at: float = 0.0
|
created_at: float = 0.0
|
||||||
# Opaque JSON blob for forward-compat (policy refs, pool slot, ...) —
|
# Opaque JSON blob for forward-compat (pool slot, ...) — the registry
|
||||||
# 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 gateway policy (opaque JSON — egress allowlist / routes /
|
||||||
|
# git config). The registry stores and serves it verbatim, keyed by
|
||||||
|
# source IP; the multi-tenant gateway interprets it.
|
||||||
|
policy: str = ""
|
||||||
|
|
||||||
def redacted(self) -> dict[str, object]:
|
def redacted(self) -> dict[str, object]:
|
||||||
"""Public view for listing — never exposes the identity token."""
|
"""Public view for listing — never exposes the identity token."""
|
||||||
@@ -76,6 +80,7 @@ class BottleRecord:
|
|||||||
"state": self.state,
|
"state": self.state,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
|
"policy": self.policy,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +102,10 @@ _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 gateway interprets): the
|
||||||
|
# egress allowlist / routes / git config selected by source IP. The
|
||||||
|
# multi-tenant gateway resolves it per request via `attribute`.
|
||||||
|
"ALTER TABLE orchestrator_bottles ADD COLUMN policy TEXT NOT NULL DEFAULT ''",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,6 +119,7 @@ def _row_to_record(row: sqlite3.Row) -> BottleRecord:
|
|||||||
state=row["state"],
|
state=row["state"],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
metadata=row["metadata"],
|
metadata=row["metadata"],
|
||||||
|
policy=row["policy"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +146,7 @@ class RegistryStore(DbStore):
|
|||||||
bottle_id: str | None = None,
|
bottle_id: str | None = None,
|
||||||
identity_token: str | None = None,
|
identity_token: str | None = None,
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
|
policy: str = "",
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
"""Register (or replace) a bottle. Mints a bottle_id / identity token
|
||||||
when not supplied. Live — this is the control-plane reload path."""
|
when not supplied. Live — this is the control-plane reload path."""
|
||||||
@@ -146,12 +157,13 @@ class RegistryStore(DbStore):
|
|||||||
state="active",
|
state="active",
|
||||||
created_at=time.time(),
|
created_at=time.time(),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
policy=policy,
|
||||||
)
|
)
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO orchestrator_bottles "
|
"INSERT OR REPLACE INTO orchestrator_bottles "
|
||||||
"(bottle_id, source_ip, identity_token, state, created_at, metadata) "
|
"(bottle_id, source_ip, identity_token, state, created_at, metadata, policy) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
rec.bottle_id,
|
rec.bottle_id,
|
||||||
rec.source_ip,
|
rec.source_ip,
|
||||||
@@ -159,11 +171,23 @@ class RegistryStore(DbStore):
|
|||||||
rec.state,
|
rec.state,
|
||||||
rec.created_at,
|
rec.created_at,
|
||||||
rec.metadata,
|
rec.metadata,
|
||||||
|
rec.policy,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._chmod()
|
self._chmod()
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
|
"""Update a bottle's policy in place (live reload). Returns True if
|
||||||
|
the bottle exists."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
|
||||||
|
(policy, bottle_id),
|
||||||
|
)
|
||||||
|
self._chmod()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
def deregister(self, bottle_id: str) -> bool:
|
def deregister(self, bottle_id: str) -> bool:
|
||||||
"""Remove a bottle. Returns True if a row was deleted."""
|
"""Remove a bottle. Returns True if a row was deleted."""
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
@@ -188,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 "
|
||||||
@@ -204,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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -46,10 +46,12 @@ class Orchestrator:
|
|||||||
image_ref: str = "",
|
image_ref: str = "",
|
||||||
slot: int | None = None,
|
slot: int | None = None,
|
||||||
metadata: str = "",
|
metadata: str = "",
|
||||||
|
policy: str = "",
|
||||||
) -> BottleRecord:
|
) -> BottleRecord:
|
||||||
"""Register a bottle and broker its launch. Rolls the registry entry
|
"""Register a bottle (with its gateway policy) and broker its launch.
|
||||||
back if the launch doesn't take, so a failure leaves no orphan."""
|
Rolls the registry entry back if the launch doesn't take, so a
|
||||||
rec = self.registry.register(source_ip, metadata=metadata)
|
failure leaves no orphan."""
|
||||||
|
rec = self.registry.register(source_ip, metadata=metadata, policy=policy)
|
||||||
req = LaunchRequest(
|
req = LaunchRequest(
|
||||||
op="launch",
|
op="launch",
|
||||||
bottle_id=rec.bottle_id,
|
bottle_id=rec.bottle_id,
|
||||||
@@ -80,23 +82,39 @@ class Orchestrator:
|
|||||||
"""Fail-closed attribution (delegates to the registry)."""
|
"""Fail-closed attribution (delegates to the registry)."""
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|
||||||
# --- consolidated sidecar ----------------------------------------------
|
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 ensure_sidecar(self) -> None:
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
"""Ensure the single per-host sidecar is built and up (idempotent).
|
"""Update a bottle's gateway policy in place (live reload). False if
|
||||||
No-op when no sidecar is configured."""
|
the bottle is unknown."""
|
||||||
if self._sidecar is not None:
|
return self.registry.set_policy(bottle_id, policy)
|
||||||
self._sidecar.ensure_built()
|
|
||||||
self._sidecar.ensure_running()
|
|
||||||
|
|
||||||
def sidecar_status(self) -> dict[str, object]:
|
# --- consolidated gateway ----------------------------------------------
|
||||||
"""Report the shared sidecar for the control plane / console."""
|
|
||||||
if self._sidecar is None:
|
def ensure_gateway(self) -> None:
|
||||||
|
"""Ensure the single per-host gateway is built and up (idempotent).
|
||||||
|
No-op when no gateway is configured."""
|
||||||
|
if self._gateway is not None:
|
||||||
|
self._gateway.ensure_built()
|
||||||
|
self._gateway.ensure_running()
|
||||||
|
|
||||||
|
def gateway_status(self) -> dict[str, object]:
|
||||||
|
"""Report the shared gateway for the control plane / console."""
|
||||||
|
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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Gateway-side per-client policy resolver (PRD 0070).
|
||||||
|
|
||||||
|
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
|
||||||
|
IP the attribution invariant makes unspoofable. This resolves that policy
|
||||||
|
from the orchestrator's control plane (`POST /resolve`), keyed on the
|
||||||
|
`(source_ip, identity_token)` pair.
|
||||||
|
|
||||||
|
**Always fresh — no cache.** The resolver is called rarely enough that a
|
||||||
|
round-trip doesn't matter, and correctness matters more than speed: every
|
||||||
|
resolve reflects the orchestrator's *current* view, so a revocation, a
|
||||||
|
policy change, or a bottle teardown the orchestrator knows about is honored
|
||||||
|
immediately rather than lingering for a cache TTL. (If this ever becomes a
|
||||||
|
hot path, add caching with orchestrator-driven invalidation — not a blind
|
||||||
|
TTL.)
|
||||||
|
|
||||||
|
**Fail-closed:** an unattributed client (the orchestrator answers `403`)
|
||||||
|
resolves to `None`, and the caller (the egress addon, git-gate) must then
|
||||||
|
deny — exactly as an unknown bottle should be treated. Orchestrator
|
||||||
|
*errors* (unreachable / unexpected status) raise, so the caller can fail
|
||||||
|
closed too rather than silently serving stale or empty policy.
|
||||||
|
|
||||||
|
The resolved value is the policy blob the orchestrator stores verbatim; the
|
||||||
|
consumer parses it (e.g. the egress addon's `load_config`). This module is
|
||||||
|
stdlib-only and free of bot-bottle imports so it can be COPYed flat into
|
||||||
|
the sidecar bundle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT_SECONDS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyResolveError(RuntimeError):
|
||||||
|
"""The orchestrator was unreachable or returned an unexpected status —
|
||||||
|
distinct from a clean `403` (unattributed), which returns None."""
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyResolver:
|
||||||
|
"""Resolves each client's policy from the orchestrator, fresh per call."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
|
||||||
|
"""The orchestrator's `/resolve` payload for this client, or None if
|
||||||
|
unattributed (a clean `403`). Raises `PolicyResolveError` on an
|
||||||
|
unreachable / unexpected-status / malformed response so every caller
|
||||||
|
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
|
||||||
|
body = json.dumps(
|
||||||
|
{"source_ip": source_ip, "identity_token": identity_token}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._base}/resolve", data=body, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||||
|
payload = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 403:
|
||||||
|
return None # unattributed → fail closed (caller denies)
|
||||||
|
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||||
|
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
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"]
|
||||||
@@ -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).
|
||||||
|
|||||||
+6
-6
@@ -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:
|
||||||
+5
-5
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -118,11 +118,75 @@ 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"])
|
||||||
|
|
||||||
|
def test_launch_stores_policy_and_resolve_returns_it(self) -> None:
|
||||||
|
_, reg = dispatch(
|
||||||
|
self.orch, "POST", "/bottles",
|
||||||
|
_body({"source_ip": "10.243.0.5", "policy": '{"a":1}'}),
|
||||||
|
)
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "POST", "/resolve",
|
||||||
|
_body({"source_ip": "10.243.0.5", "identity_token": reg["identity_token"]}),
|
||||||
|
)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
|
||||||
|
self.assertEqual('{"a":1}', payload["policy"])
|
||||||
|
|
||||||
|
def test_resolve_forbidden_on_bad_token(self) -> None:
|
||||||
|
dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5"}))
|
||||||
|
status, _ = dispatch(
|
||||||
|
self.orch, "POST", "/resolve",
|
||||||
|
_body({"source_ip": "10.243.0.5", "identity_token": "nope"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(403, status)
|
||||||
|
|
||||||
|
def test_put_policy_updates_live(self) -> None:
|
||||||
|
_, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
|
||||||
|
bid = reg["bottle_id"]
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "PUT", f"/bottles/{bid}/policy", _body({"policy": '{"v":9}'})
|
||||||
|
)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual(True, payload["updated"])
|
||||||
|
_, resolved = dispatch(
|
||||||
|
self.orch, "POST", "/resolve",
|
||||||
|
_body({"source_ip": "10.243.0.1", "identity_token": reg["identity_token"]}),
|
||||||
|
)
|
||||||
|
self.assertEqual('{"v":9}', resolved["policy"])
|
||||||
|
|
||||||
|
def test_put_policy_missing_bottle_404(self) -> None:
|
||||||
|
status, _ = dispatch(
|
||||||
|
self.orch, "PUT", "/bottles/ghost/policy", _body({"policy": "{}"})
|
||||||
|
)
|
||||||
|
self.assertEqual(404, status)
|
||||||
|
|
||||||
|
def test_put_policy_requires_string(self) -> None:
|
||||||
|
_, reg = dispatch(self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.1"}))
|
||||||
|
status, _ = dispatch(
|
||||||
|
self.orch, "PUT", f"/bottles/{reg['bottle_id']}/policy", _body({})
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +97,42 @@ class TestRegistryStore(unittest.TestCase):
|
|||||||
self.assertNotIn("identity_token", rec.redacted())
|
self.assertNotIn("identity_token", rec.redacted())
|
||||||
self.assertEqual("10.243.0.1", rec.redacted()["source_ip"])
|
self.assertEqual("10.243.0.1", rec.redacted()["source_ip"])
|
||||||
|
|
||||||
|
def test_register_with_policy_resolves_via_attribution(self) -> None:
|
||||||
|
rec = self.store.register("10.243.0.1", policy='{"allow":["x"]}')
|
||||||
|
self.assertEqual('{"allow":["x"]}', rec.policy)
|
||||||
|
got = self.store.attribute("10.243.0.1", rec.identity_token)
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual('{"allow":["x"]}', got.policy)
|
||||||
|
|
||||||
|
def test_set_policy_updates_live(self) -> None:
|
||||||
|
rec = self.store.register("10.243.0.1")
|
||||||
|
self.assertEqual("", rec.policy)
|
||||||
|
self.assertTrue(self.store.set_policy(rec.bottle_id, '{"v":2}'))
|
||||||
|
got = self.store.get(rec.bottle_id)
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual('{"v":2}', got.policy)
|
||||||
|
|
||||||
|
def test_set_policy_missing_is_false(self) -> None:
|
||||||
|
self.assertFalse(self.store.set_policy("ghost", "{}"))
|
||||||
|
|
||||||
|
def test_policy_defaults_empty_and_persists(self) -> None:
|
||||||
|
rec = self.store.register("10.243.0.1")
|
||||||
|
got = RegistryStore(self.db).get(rec.bottle_id)
|
||||||
|
assert got is not None
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -86,29 +86,57 @@ class TestOrchestrator(unittest.TestCase):
|
|||||||
def test_teardown_unknown_is_false(self) -> None:
|
def test_teardown_unknown_is_false(self) -> None:
|
||||||
self.assertFalse(self.orch.teardown_bottle("ghost"))
|
self.assertFalse(self.orch.teardown_bottle("ghost"))
|
||||||
|
|
||||||
|
def test_launch_with_policy_is_resolvable(self) -> None:
|
||||||
|
rec = self.orch.launch_bottle("10.243.0.1", policy='{"routes":[]}')
|
||||||
|
got = self.orch.attribute("10.243.0.1", rec.identity_token)
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual('{"routes":[]}', got.policy)
|
||||||
|
|
||||||
|
def test_set_policy_live_reload(self) -> None:
|
||||||
|
rec = self.orch.launch_bottle("10.243.0.3")
|
||||||
|
self.assertTrue(self.orch.set_policy(rec.bottle_id, '{"x":1}'))
|
||||||
|
got = self.orch.attribute("10.243.0.3", rec.identity_token)
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual('{"x":1}', got.policy)
|
||||||
|
|
||||||
|
def test_set_policy_unknown_is_false(self) -> None:
|
||||||
|
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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Unit tests for the gateway-side PolicyResolver (PRD 0070). HTTP mocked."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
|
|
||||||
|
_URLOPEN = "bot_bottle.policy_resolver.urllib.request.urlopen"
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(payload: object) -> MagicMock:
|
||||||
|
"""A urlopen() return value: a context manager whose read() yields JSON."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(code: int) -> urllib.error.HTTPError:
|
||||||
|
return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolicyResolver(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.r = PolicyResolver("http://orch:8080")
|
||||||
|
|
||||||
|
def test_resolve_returns_policy(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})):
|
||||||
|
self.assertEqual("P", self.r.resolve("10.243.0.1", "tok"))
|
||||||
|
|
||||||
|
def test_resolve_always_fetches_fresh(self) -> None:
|
||||||
|
# No cache — every resolve hits the orchestrator so revocations /
|
||||||
|
# policy changes are honored immediately.
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
|
||||||
|
self.r.resolve("10.243.0.1", "tok")
|
||||||
|
self.r.resolve("10.243.0.1", "tok")
|
||||||
|
self.assertEqual(2, m.call_count)
|
||||||
|
|
||||||
|
def test_unattributed_403_is_none_fail_closed(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(403)):
|
||||||
|
self.assertIsNone(self.r.resolve("10.243.0.9", "tok"))
|
||||||
|
|
||||||
|
def test_other_http_error_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(500)):
|
||||||
|
with self.assertRaises(PolicyResolveError):
|
||||||
|
self.r.resolve("10.243.0.1", "tok")
|
||||||
|
|
||||||
|
def test_unreachable_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
with self.assertRaises(PolicyResolveError):
|
||||||
|
self.r.resolve("10.243.0.1", "tok")
|
||||||
|
|
||||||
|
def test_missing_policy_field_is_empty(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1"})):
|
||||||
|
self.assertEqual("", self.r.resolve("10.243.0.1", "tok"))
|
||||||
|
|
||||||
|
def test_posts_source_ip_and_token(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
|
||||||
|
self.r.resolve("10.243.0.7", "the-token")
|
||||||
|
req = m.call_args.args[0]
|
||||||
|
self.assertTrue(req.full_url.endswith("/resolve"))
|
||||||
|
sent = json.loads(req.data)
|
||||||
|
self.assertEqual("10.243.0.7", sent["source_ip"])
|
||||||
|
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__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user