feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
Codex review: the /31 TAP doesn't make source IP unspoofable, and the app-layer token was returned by launch but never delivered or enforced, so a spoofed source could select a victim bottle's policy/tokens. Make the token mandatory and deliver it on each attributed plane (anti-spoof landed separately as the network boundary). Enforcement (control plane): - `Orchestrator.resolve` now requires a matching (source_ip, identity_token) pair (constant-time) — no source-IP-only fallback. `/resolve` fail-closes (403) on a missing/empty/mismatched token. Delivery, per plane (the token is `token_urlsafe`, safe in a URL): - egress: proxy credentials (`HTTPS_PROXY=http://bottle:<token>@gw`). The addon reads `Proxy-Authorization` — from the request (HTTP) or captured at the CONNECT for HTTPS tunnels (keyed by client conn, cleared on disconnect) — validates, and strips it (+ the legacy header) before upstream. - git-http: a URL-scoped `http.<gate>/.extraHeader: x-bot-bottle-identity` in the agent's git config (only over the http transport). - supervise: `mcp add --header x-bot-bottle-identity: <token>` (claude + codex); the server reads the header and passes it to resolve. Wiring: thread `ctx.identity_token` onto the firecracker + docker plans and into the agent env/config at launch. Verified on a KVM host: egress with the correct proxy-cred token returns 200 (HTTP and HTTPS/CONNECT), and no-token / wrong-token return 403; a real `cli.py start --backend=firecracker` launch provisions git config + the supervise MCP header and reaches the agent session, all under mandatory enforcement. Fixed a `claude mcp add` arg-order bug (--header must follow the positional name/url) found by that launch. Transparent proxy for tools that ignore proxy env is deferred to a follow-up (see thread); anti-spoof + host firewall remain the fail-closed boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -266,6 +266,7 @@ class AgentProvider(ABC):
|
|||||||
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
|
||||||
content = git_gate_render_gitconfig(
|
content = git_gate_render_gitconfig(
|
||||||
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
manifest_bottle.git, gate_host, scheme=gate_scheme,
|
||||||
|
identity_token=getattr(plan, "identity_token", ""),
|
||||||
)
|
)
|
||||||
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
guest_gitconfig = f"{plan.guest_home}/.gitconfig"
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
|
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
|
||||||
# empty → the single-tenant `supervise` alias.
|
# empty → the single-tenant `supervise` alias.
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
# Per-bottle identity token the agent presents on every attributed request
|
||||||
|
# (egress proxy credentials, git-gate/supervise headers); set by launch
|
||||||
|
# from the orchestrator registration. Empty pre-registration.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ def consolidated_agent_compose(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""A compose spec with only the agent service, on the external gateway
|
"""A compose spec with only the agent service, on the external gateway
|
||||||
network at `source_ip`, proxying egress through `gateway_ip`."""
|
network at `source_ip`, proxying egress through `gateway_ip`."""
|
||||||
proxy_url = f"http://{gateway_ip}:{EGRESS_PORT}"
|
# Deliver the identity token as egress proxy credentials — the gateway
|
||||||
|
# reads Proxy-Authorization, validates the (source_ip, token) pair, and
|
||||||
|
# strips it before upstream. git-http/supervise get it via their own
|
||||||
|
# headers (git config extraHeader / MCP header).
|
||||||
|
token = getattr(plan, "identity_token", "")
|
||||||
|
cred = f"bottle:{token}@" if token else ""
|
||||||
|
proxy_url = f"http://{cred}{gateway_ip}:{EGRESS_PORT}"
|
||||||
# git-http + supervise live on the gateway too and must NOT go through the
|
# git-http + supervise live on the gateway too and must NOT go through the
|
||||||
# egress proxy — the agent reaches them directly by the gateway address.
|
# egress proxy — the agent reaches them directly by the gateway address.
|
||||||
no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
|
no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ def launch(
|
|||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
agent_git_gate_url=git_gate_url,
|
agent_git_gate_url=git_gate_url,
|
||||||
agent_supervise_url=supervise_url,
|
agent_supervise_url=supervise_url,
|
||||||
|
identity_token=ctx.identity_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 5: render + up the agent-only compose, pinned on the shared
|
# Step 5: render + up the agent-only compose, pinned on the shared
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class FirecrackerBottlePlan(BottlePlan):
|
|||||||
agent_proxy_url: str = ""
|
agent_proxy_url: str = ""
|
||||||
agent_git_gate_url: str = ""
|
agent_git_gate_url: str = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
|
# Per-bottle identity token the agent presents on every attributed request
|
||||||
|
# (egress proxy credentials, git-gate/supervise headers); set by launch
|
||||||
|
# from the orchestrator registration. Empty pre-registration.
|
||||||
|
identity_token: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -147,7 +147,15 @@ def launch(
|
|||||||
plan,
|
plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
agent_proxy_url=f"http://{slot.host_ip}:{EGRESS_PORT}",
|
identity_token=ctx.identity_token,
|
||||||
|
# Deliver the identity token as egress proxy credentials — clients
|
||||||
|
# honor `HTTPS_PROXY=http://id:token@gw` without app changes; the
|
||||||
|
# gateway reads Proxy-Authorization, validates the (source_ip,
|
||||||
|
# token) pair, and strips it before upstream.
|
||||||
|
agent_proxy_url=(
|
||||||
|
f"http://bottle:{ctx.identity_token}"
|
||||||
|
f"@{slot.host_ip}:{EGRESS_PORT}"
|
||||||
|
),
|
||||||
agent_git_gate_url=git_gate_url,
|
agent_git_gate_url=git_gate_url,
|
||||||
agent_supervise_url=supervise_url,
|
agent_supervise_url=supervise_url,
|
||||||
)
|
)
|
||||||
@@ -226,7 +234,8 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
|
|||||||
"""Env injected into every agent/exec call over SSH. The VM has no
|
"""Env injected into every agent/exec call over SSH. The VM has no
|
||||||
baked process env (it just runs init), so the proxy/CA/git/supervise
|
baked process env (it just runs init), so the proxy/CA/git/supervise
|
||||||
wiring is applied per-invocation."""
|
wiring is applied per-invocation."""
|
||||||
proxy_url = f"http://{host_ip}:{EGRESS_PORT}"
|
# Carries the identity token as proxy credentials (set in `launch`).
|
||||||
|
proxy_url = plan.agent_proxy_url or f"http://{host_ip}:{EGRESS_PORT}"
|
||||||
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
no_proxy = f"localhost,127.0.0.1,{host_ip}"
|
||||||
env: dict[str, str] = {
|
env: dict[str, str] = {
|
||||||
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
_SUPERVISE_MCP_NAME = "supervise"
|
_SUPERVISE_MCP_NAME = "supervise"
|
||||||
|
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||||
|
_IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
|
|
||||||
def _skills_dir(guest_home: str) -> str:
|
def _skills_dir(guest_home: str) -> str:
|
||||||
@@ -301,9 +303,15 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
||||||
|
# Deliver the identity token as an MCP request header — the supervise
|
||||||
|
# daemon requires it (mandatory (source_ip, token) attribution).
|
||||||
|
token = getattr(plan, "identity_token", "")
|
||||||
|
header = (
|
||||||
|
f" --header {shlex.quote(f'{_IDENTITY_HEADER}: {token}')}" if token else ""
|
||||||
|
)
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"claude mcp add --scope user --transport http "
|
f"claude mcp add --scope user --transport http "
|
||||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
f"{_SUPERVISE_MCP_NAME} {supervise_url}{header}",
|
||||||
user="node",
|
user="node",
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
@@ -274,9 +274,17 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
if plan.supervise_plan is None:
|
if plan.supervise_plan is None:
|
||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
|
# Deliver the identity token as an MCP request header — supervise
|
||||||
|
# requires it (mandatory (source_ip, token) attribution). If the codex
|
||||||
|
# CLI's header flag differs, mcp add just warns (non-fatal) and
|
||||||
|
# supervise fail-closes for this bottle until the flag is corrected.
|
||||||
|
token = getattr(plan, "identity_token", "")
|
||||||
|
header = (
|
||||||
|
f"--header {shlex.quote(f'x-bot-bottle-identity: {token}')} " if token else ""
|
||||||
|
)
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
|
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url "
|
||||||
f"{shlex.quote(supervise_url)}",
|
f"{shlex.quote(supervise_url)} {header}".rstrip(),
|
||||||
user="node",
|
user="node",
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ egress container."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -69,10 +71,28 @@ INTROSPECT_HOST = "_egress.local"
|
|||||||
# → legacy per-bottle single-tenant mode (unchanged).
|
# → legacy per-bottle single-tenant mode (unchanged).
|
||||||
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
# App-layer identity token. Delivered as proxy credentials
|
||||||
# the agent injects it, the addon strips it so it never leaks upstream.
|
# (`HTTPS_PROXY=http://<bottle_id>:<token>@gw`): clients honor it as part of
|
||||||
|
# the proxy protocol without app changes, and the addon reads + strips it so
|
||||||
|
# it never leaks upstream. The legacy `x-bot-bottle-identity` request header
|
||||||
|
# is still stripped defensively (git-http uses that header on its own port).
|
||||||
IDENTITY_HEADER = "x-bot-bottle-identity"
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_proxy_auth(header: str) -> str:
|
||||||
|
"""Extract the identity token (the password) from a `Proxy-Authorization:
|
||||||
|
Basic base64(<bottle_id>:<token>)` header. Empty on any malformed value —
|
||||||
|
the mandatory `/resolve` then fail-closes on the empty token."""
|
||||||
|
scheme, _, encoded = header.partition(" ")
|
||||||
|
if scheme.lower() != "basic" or not encoded:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
|
||||||
|
except (binascii.Error, ValueError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
_, _, password = decoded.partition(":")
|
||||||
|
return password
|
||||||
|
|
||||||
# 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
|
||||||
@@ -92,6 +112,10 @@ class EgressAddon:
|
|||||||
# Class default so addons built via __new__ (e.g. in tests) default to
|
# Class default so addons built via __new__ (e.g. in tests) default to
|
||||||
# single-tenant; __init__ sets the instance attribute for real runs.
|
# single-tenant; __init__ sets the instance attribute for real runs.
|
||||||
_resolver: "PolicyResolver | None" = None
|
_resolver: "PolicyResolver | None" = None
|
||||||
|
# Class default so __new__-built addons have it (real runs get a fresh
|
||||||
|
# per-instance dict in __init__; only http_connect mutates it, which the
|
||||||
|
# request-flow tests don't exercise).
|
||||||
|
_conn_tokens: "dict[str, str]" = {}
|
||||||
|
|
||||||
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)
|
||||||
@@ -106,6 +130,10 @@ class EgressAddon:
|
|||||||
# scan. In-memory only (a restart re-prompts); mutated only from the
|
# scan. In-memory only (a restart re-prompts); mutated only from the
|
||||||
# asyncio loop that runs the addon hooks, so no lock is needed.
|
# asyncio loop that runs the addon hooks, so no lock is needed.
|
||||||
self._safe_tokens: dict[str, set[str]] = {}
|
self._safe_tokens: dict[str, set[str]] = {}
|
||||||
|
# Per-client-connection identity token captured from the CONNECT's
|
||||||
|
# `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped
|
||||||
|
# inner requests). Keyed by client_conn.id; cleared on disconnect.
|
||||||
|
self._conn_tokens: dict[str, str] = {}
|
||||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||||
self._reload(initial=True)
|
self._reload(initial=True)
|
||||||
@@ -247,12 +275,42 @@ class EgressAddon:
|
|||||||
return self.config, self._supervise_slug, os.environ
|
return self.config, self._supervise_slug, os.environ
|
||||||
conn = flow.client_conn
|
conn = flow.client_conn
|
||||||
client_ip = conn.peername[0] if conn and conn.peername else ""
|
client_ip = conn.peername[0] if conn and conn.peername else ""
|
||||||
token = flow.request.headers.get(IDENTITY_HEADER, "")
|
token = self._request_token(flow)
|
||||||
flow.request.headers.pop(IDENTITY_HEADER, None)
|
|
||||||
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
|
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
|
||||||
env = {**os.environ, **tokens} if tokens else os.environ
|
env = {**os.environ, **tokens} if tokens else os.environ
|
||||||
return config, slug, env
|
return config, slug, env
|
||||||
|
|
||||||
|
def _request_token(self, flow: http.HTTPFlow) -> str:
|
||||||
|
"""The per-bottle identity token for this request, from the proxy
|
||||||
|
credentials — the delivery mechanism (`HTTPS_PROXY=http://id:token@gw`)
|
||||||
|
that clients honor without app changes. Plain-HTTP requests carry
|
||||||
|
`Proxy-Authorization` directly; HTTPS bumped requests inherit the token
|
||||||
|
captured from their tunnel's CONNECT. Read then stripped so it never
|
||||||
|
leaks upstream (also strips the legacy header, if present)."""
|
||||||
|
token = _token_from_proxy_auth(
|
||||||
|
flow.request.headers.get("Proxy-Authorization", ""))
|
||||||
|
flow.request.headers.pop("Proxy-Authorization", None)
|
||||||
|
flow.request.headers.pop(IDENTITY_HEADER, None)
|
||||||
|
conn = flow.client_conn
|
||||||
|
if not token and conn is not None:
|
||||||
|
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
|
||||||
|
return token
|
||||||
|
|
||||||
|
def http_connect(self, flow: http.HTTPFlow) -> None:
|
||||||
|
"""Capture the identity token from an HTTPS tunnel's CONNECT (the inner
|
||||||
|
bumped requests won't carry `Proxy-Authorization`), keyed by client
|
||||||
|
connection, and strip it so it never reaches upstream."""
|
||||||
|
token = _token_from_proxy_auth(
|
||||||
|
flow.request.headers.get("Proxy-Authorization", ""))
|
||||||
|
flow.request.headers.pop("Proxy-Authorization", None)
|
||||||
|
conn = flow.client_conn
|
||||||
|
if conn is not None and getattr(conn, "id", ""):
|
||||||
|
self._conn_tokens[conn.id] = token
|
||||||
|
|
||||||
|
def client_disconnected(self, client: typing.Any) -> None:
|
||||||
|
"""Drop the per-connection token when the client goes away."""
|
||||||
|
self._conn_tokens.pop(getattr(client, "id", ""), None)
|
||||||
|
|
||||||
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("?")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from .manifest import ManifestBottle, ManifestGitEntry
|
|||||||
# Short network alias for git-gate inside the gateway. The
|
# Short network alias for git-gate inside the gateway. The
|
||||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||||
GIT_GATE_HOSTNAME = "git-gate"
|
GIT_GATE_HOSTNAME = "git-gate"
|
||||||
|
# App-layer identity token header the agent's git sends to git-http and the
|
||||||
|
# gateway validates (mirrors egress_addon / git_http_backend IDENTITY_HEADER).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||||
# git_http_backend, and the git http-backend CGI subprocess.
|
# git_http_backend, and the git http-backend CGI subprocess.
|
||||||
@@ -75,6 +78,7 @@ def _gitconfig_validate_value(field: str, value: str) -> None:
|
|||||||
|
|
||||||
def git_gate_render_gitconfig(
|
def git_gate_render_gitconfig(
|
||||||
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||||
|
identity_token: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the agent's ~/.gitconfig content for git-gate
|
"""Render the agent's ~/.gitconfig content for git-gate
|
||||||
`insteadOf` rewrites. Pure host-side, no docker / VM;
|
`insteadOf` rewrites. Pure host-side, no docker / VM;
|
||||||
@@ -96,6 +100,15 @@ def git_gate_render_gitconfig(
|
|||||||
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
"# the upstream bidirectionally (gitleaks-scanned push;\n",
|
||||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||||
]
|
]
|
||||||
|
# Over the smart-HTTP transport (VM backends), attach the per-bottle
|
||||||
|
# identity token as a request header on requests to the gate, scoped to
|
||||||
|
# its URL so it never goes to any other remote. git-http requires it (the
|
||||||
|
# gateway's mandatory (source_ip, token) attribution). git:// (single-tenant
|
||||||
|
# docker) carries no header — attribution there is the network alias.
|
||||||
|
if identity_token and scheme == "http":
|
||||||
|
_gitconfig_validate_value("identity_token", identity_token)
|
||||||
|
out.append(f'[http "http://{gate_host}/"]\n')
|
||||||
|
out.append(f"\textraHeader = {IDENTITY_HEADER}: {identity_token}\n")
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
|
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
|
||||||
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
|
|
||||||
if method == "POST" and route == "/resolve":
|
if method == "POST" and route == "/resolve":
|
||||||
# The per-request lookup the multi-tenant gateway makes: returns the
|
# The per-request lookup the multi-tenant gateway makes: returns the
|
||||||
# bottle's policy. identity_token is OPTIONAL — absent means resolve
|
# bottle's policy. Requires a matching (source_ip, identity_token)
|
||||||
# by source IP alone (network-layer attribution).
|
# pair — a missing/empty/mismatched token fail-closes (403), no
|
||||||
|
# source-IP-only fallback.
|
||||||
try:
|
try:
|
||||||
data = _parse_json_object(body)
|
data = _parse_json_object(body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -99,16 +99,18 @@ 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)
|
||||||
|
|
||||||
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None:
|
def resolve(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
"""Resolve the bottle behind a request — the source-IP-keyed lookup
|
"""Resolve the bottle behind a request — the per-request lookup the
|
||||||
the multi-tenant gateway makes per request; the returned record
|
multi-tenant gateway makes; the returned record carries its `policy`.
|
||||||
carries its `policy`. With a token, full attribution (source IP +
|
|
||||||
token); without, network-layer attribution by source IP alone
|
**Mandatory pair**: requires a matching `(source_ip, identity_token)`
|
||||||
(valid where the IP is unspoofable and the control plane is
|
(constant-time). There is no source-IP-only fallback — the app-layer
|
||||||
gateway-only)."""
|
token is delivered on every attributed data plane (egress proxy
|
||||||
if identity_token:
|
credentials, git-gate/supervise headers), so a missing or mismatched
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
token fail-closes. This keeps a spoofed source IP (which the /31 TAP
|
||||||
return self.registry.by_source_ip(source_ip)
|
alone does not prevent) from selecting another bottle's policy/tokens
|
||||||
|
without also holding that bottle's unguessable token."""
|
||||||
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|
||||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
"""Update a bottle's gateway policy in place (live reload). False if
|
"""Update a bottle's gateway policy in place (live reload). False if
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ except ModuleNotFoundError:
|
|||||||
|
|
||||||
|
|
||||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
# App-layer identity token header (mirrors egress_addon / git_http_backend).
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
SERVER_NAME = "bot-bottle-supervise"
|
SERVER_NAME = "bot-bottle-supervise"
|
||||||
SERVER_VERSION = "0.1.0"
|
SERVER_VERSION = "0.1.0"
|
||||||
|
|
||||||
@@ -541,8 +543,13 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
resolver = getattr(self.server, "policy_resolver", None)
|
resolver = getattr(self.server, "policy_resolver", None)
|
||||||
if resolver is None:
|
if resolver is None:
|
||||||
return config
|
return config
|
||||||
|
# The agent's MCP client sends the identity token as a request header
|
||||||
|
# (provisioned via `mcp add --header`); the orchestrator requires the
|
||||||
|
# (source_ip, token) pair, so a missing/wrong token fail-closes below.
|
||||||
|
headers = getattr(self, "headers", None)
|
||||||
|
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
||||||
try:
|
try:
|
||||||
bottle_id = resolver.resolve_bottle_id(self.client_address[0])
|
bottle_id = resolver.resolve_bottle_id(self.client_address[0], token)
|
||||||
except PolicyResolveError as e:
|
except PolicyResolveError as e:
|
||||||
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
|
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
|
||||||
if not bottle_id:
|
if not bottle_id:
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ class TestRenderGitconfig(unittest.TestCase):
|
|||||||
out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http")
|
out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http")
|
||||||
self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out)
|
self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out)
|
||||||
|
|
||||||
|
def test_identity_token_extraheader_over_http(self) -> None:
|
||||||
|
# Delivered as a URL-scoped http.extraHeader so git-http can enforce
|
||||||
|
# the mandatory (source_ip, token) pair; only over the http transport.
|
||||||
|
out = git_gate_render_gitconfig(
|
||||||
|
(_entry(),), "1.2.3.4:9420", scheme="http", identity_token="TOK123")
|
||||||
|
self.assertIn('[http "http://1.2.3.4:9420/"]', out)
|
||||||
|
self.assertIn("extraHeader = x-bot-bottle-identity: TOK123", out)
|
||||||
|
|
||||||
|
def test_identity_token_omitted_over_git_scheme(self) -> None:
|
||||||
|
out = git_gate_render_gitconfig(
|
||||||
|
(_entry(),), "git-gate", scheme="git", identity_token="TOK123")
|
||||||
|
self.assertNotIn("extraHeader", out)
|
||||||
|
|
||||||
def test_remote_key_alias_with_nondefault_port(self) -> None:
|
def test_remote_key_alias_with_nondefault_port(self) -> None:
|
||||||
out = git_gate_render_gitconfig(
|
out = git_gate_render_gitconfig(
|
||||||
(_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate",
|
(_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate",
|
||||||
|
|||||||
@@ -171,14 +171,26 @@ class TestDispatch(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(400, status)
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
def test_resolve_without_token_by_source_ip(self) -> None:
|
def test_resolve_without_token_denies(self) -> None:
|
||||||
_, reg = dispatch(
|
# Mandatory token: source-IP alone no longer resolves (fail-closed 403).
|
||||||
|
dispatch(
|
||||||
self.orch, "POST", "/bottles",
|
self.orch, "POST", "/bottles",
|
||||||
_body({"source_ip": "10.243.0.5", "policy": "P"}),
|
_body({"source_ip": "10.243.0.5", "policy": "P"}),
|
||||||
)
|
)
|
||||||
status, payload = dispatch(
|
status, _ = dispatch(
|
||||||
self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"})
|
self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"})
|
||||||
)
|
)
|
||||||
|
self.assertEqual(403, status)
|
||||||
|
|
||||||
|
def test_resolve_with_matching_token(self) -> None:
|
||||||
|
_, reg = dispatch(
|
||||||
|
self.orch, "POST", "/bottles",
|
||||||
|
_body({"source_ip": "10.243.0.6", "policy": "P"}),
|
||||||
|
)
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "POST", "/resolve",
|
||||||
|
_body({"source_ip": "10.243.0.6", "identity_token": reg["identity_token"]}),
|
||||||
|
)
|
||||||
self.assertEqual(200, status)
|
self.assertEqual(200, status)
|
||||||
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
|
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
|
||||||
self.assertEqual("P", payload["policy"])
|
self.assertEqual("P", payload["policy"])
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ class TestOrchestrator(unittest.TestCase):
|
|||||||
def test_set_policy_unknown_is_false(self) -> None:
|
def test_set_policy_unknown_is_false(self) -> None:
|
||||||
self.assertFalse(self.orch.set_policy("ghost", "{}"))
|
self.assertFalse(self.orch.set_policy("ghost", "{}"))
|
||||||
|
|
||||||
def test_resolve_by_source_ip_without_token(self) -> None:
|
def test_resolve_requires_matching_token(self) -> None:
|
||||||
|
# Mandatory (source_ip, token) pair — no source-IP-only fallback.
|
||||||
rec = self.orch.launch_bottle("10.243.0.1", policy="P")
|
rec = self.orch.launch_bottle("10.243.0.1", policy="P")
|
||||||
got = self.orch.resolve("10.243.0.1") # network-layer, no token
|
self.assertIsNone(self.orch.resolve("10.243.0.1", "")) # empty token denies
|
||||||
|
self.assertIsNone(self.orch.resolve("10.243.0.1", "wrong")) # mismatch denies
|
||||||
|
got = self.orch.resolve("10.243.0.1", rec.identity_token) # exact pair
|
||||||
assert got is not None
|
assert got is not None
|
||||||
self.assertEqual(rec.bottle_id, got.bottle_id)
|
self.assertEqual(rec.bottle_id, got.bottle_id)
|
||||||
self.assertEqual("P", got.policy)
|
self.assertEqual("P", got.policy)
|
||||||
|
|||||||
Reference in New Issue
Block a user