feat(secrets): encrypt egress tokens at rest with per-bottle ENV_VAR_SECRET
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 20s
test / integration-firecracker (pull_request) Successful in 3m25s
test / unit (pull_request) Failing after 13m4s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
lint / lint (push) Has been cancelled
tracker-policy-pr / check-pr (pull_request) Successful in 13s
test / integration-docker (pull_request) Successful in 20s
test / integration-firecracker (pull_request) Successful in 3m25s
test / unit (pull_request) Failing after 13m4s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
lint / lint (push) Has been cancelled
Implements the interim secret-provider design (PRD prd-new-secret-provider): each agent receives a random ENV_VAR_SECRET injected into its container env at launch. The host uses this key to encrypt each egress auth token value (HMAC-SHA256 CTR mode, stdlib-only) and store it in a new bottled_agent_secrets table (one row per env var, key column plaintext for auditing). The key never touches the DB. On infra container restart the in-memory token map is lost. launch_consolidated now calls _reprovision_running_bottles after ensure_running: for each registered bottle still alive on the gateway network it execs `printenv ENV_VAR_SECRET` into the agent container and posts the result to the new POST /bottles/<id>/reprovision_gateway control-plane endpoint, which decrypts the stored rows and restores _tokens — no manual intervention needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,13 @@ imports it rather than re-implementing it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..orchestrator.client import OrchestratorClient
|
||||
from ..orchestrator.client import OrchestratorClient, RegisteredBottle
|
||||
from ..orchestrator.registration import registration_inputs
|
||||
from ..orchestrator.secret_store import new_env_var_secret
|
||||
from .docker.gateway_provision import GatewayTransport, deprovision_git_gate, provision_git_gate
|
||||
|
||||
|
||||
@@ -23,21 +26,26 @@ def provision_bottle(
|
||||
*,
|
||||
image_ref: str = "",
|
||||
tokens: dict[str, str] | None = None,
|
||||
):
|
||||
) -> RegisteredBottle:
|
||||
"""Register the bottle and provision its git-gate state. Rolls back the
|
||||
registration if provisioning fails so no orphan is left. Returns the
|
||||
`RegisteredBottle` from the orchestrator."""
|
||||
registration if provisioning fails so no orphan is left.
|
||||
|
||||
Generates a fresh ENV_VAR_SECRET, passes it to the orchestrator so it can
|
||||
encrypt the token values at rest, and stamps the secret onto the returned
|
||||
``RegisteredBottle`` so callers can inject it into the agent container's
|
||||
environment."""
|
||||
inputs = registration_inputs(egress_plan)
|
||||
env_var_secret = new_env_var_secret()
|
||||
reg = client.register_bottle(
|
||||
source_ip, image_ref=image_ref, policy=inputs.policy,
|
||||
metadata=inputs.metadata, tokens=tokens,
|
||||
metadata=inputs.metadata, tokens=tokens, env_var_secret=env_var_secret,
|
||||
)
|
||||
try:
|
||||
provision_git_gate(transport, reg.bottle_id, git_gate_plan)
|
||||
except Exception:
|
||||
client.teardown_bottle(reg.bottle_id)
|
||||
raise
|
||||
return reg
|
||||
return dataclasses.replace(reg, env_var_secret=env_var_secret)
|
||||
|
||||
|
||||
def teardown_consolidated(
|
||||
|
||||
@@ -39,6 +39,10 @@ class DockerBottlePlan(BottlePlan):
|
||||
# (egress proxy credentials, git-gate/supervise headers); set by launch
|
||||
# from the orchestrator registration. Empty pre-registration.
|
||||
identity_token: str = ""
|
||||
# Encryption key for the agent's stored egress secrets; injected into the
|
||||
# agent container as ENV_VAR_SECRET via the compose subprocess env (bare
|
||||
# name — value never written to the compose file). Empty pre-registration.
|
||||
env_var_secret: str = ""
|
||||
|
||||
@property
|
||||
def container_name(self) -> str:
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from ...egress import egress_agent_env_entries
|
||||
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .bottle_plan import DockerBottlePlan
|
||||
from .egress import EGRESS_PORT
|
||||
@@ -58,6 +59,10 @@ def consolidated_agent_compose(
|
||||
# the secret value never lands on argv or in the compose file.
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
# ENV_VAR_SECRET: bare name so the value comes from the compose subprocess
|
||||
# env (set in launch.py) and is never written to the compose file on disk.
|
||||
if getattr(plan, "env_var_secret", ""):
|
||||
env.append(ENV_VAR_SECRET_NAME)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
|
||||
@@ -15,12 +15,14 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ... import log
|
||||
from ...docker_cmd import run_docker
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...orchestrator.client import OrchestratorClient
|
||||
from ...orchestrator.gateway import GATEWAY_NETWORK
|
||||
from ...orchestrator.lifecycle import INFRA_NAME, OrchestratorService
|
||||
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
|
||||
from ..consolidated_util import provision_bottle
|
||||
from ..consolidated_util import teardown_consolidated as _teardown_util
|
||||
from .gateway_provision import DockerGatewayTransport
|
||||
@@ -41,6 +43,7 @@ class LaunchContext:
|
||||
network: str # the shared gateway network to attach to
|
||||
gateway_ip: str # the gateway's address — the agent's proxy target
|
||||
orchestrator_url: str
|
||||
env_var_secret: str = "" # encryption key injected into the agent's env
|
||||
|
||||
|
||||
def _network_cidr(network: str) -> str:
|
||||
@@ -85,6 +88,66 @@ def _network_container_ips(network: str) -> list[str]:
|
||||
return ips
|
||||
|
||||
|
||||
def _reprovision_running_bottles(
|
||||
orchestrator_url: str,
|
||||
network: str = GATEWAY_NETWORK,
|
||||
infra_name: str = INFRA_NAME,
|
||||
) -> None:
|
||||
"""Re-inject egress tokens for any registered bottles that lost their
|
||||
in-memory tokens (e.g., after an infra container restart).
|
||||
|
||||
For each registered bottle whose source IP maps to a live container on the
|
||||
gateway network, reads ENV_VAR_SECRET via ``docker exec … printenv`` and
|
||||
calls ``POST /bottles/<id>/reprovision_gateway``. Idempotent — a no-op
|
||||
when the orchestrator already has all tokens loaded. Best-effort: a single
|
||||
container exec failure never blocks a new bottle launch."""
|
||||
client = OrchestratorClient(orchestrator_url)
|
||||
bottles = client.list_bottles()
|
||||
if not bottles:
|
||||
return
|
||||
|
||||
# Build {source_ip: container_name} from live containers on the gateway
|
||||
# network, excluding the infra container itself.
|
||||
proc = run_docker([
|
||||
"docker", "network", "inspect",
|
||||
"--format", "{{range .Containers}}{{.Name}} {{.IPv4Address}}\n{{end}}",
|
||||
network,
|
||||
])
|
||||
ip_to_container: dict[str, str] = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and parts[0] != infra_name:
|
||||
ip = parts[1].split("/", 1)[0]
|
||||
if ip:
|
||||
ip_to_container[ip] = parts[0]
|
||||
|
||||
reprovisioned = 0
|
||||
for bottle in bottles:
|
||||
bottle_id = bottle.get("bottle_id")
|
||||
source_ip = bottle.get("source_ip")
|
||||
if not isinstance(bottle_id, str) or not isinstance(source_ip, str):
|
||||
continue
|
||||
container_name = ip_to_container.get(source_ip)
|
||||
if not container_name:
|
||||
continue
|
||||
proc = run_docker(
|
||||
["docker", "exec", container_name, "printenv", ENV_VAR_SECRET_NAME]
|
||||
)
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
continue
|
||||
try:
|
||||
if client.reprovision_gateway(bottle_id, proc.stdout.strip()):
|
||||
reprovisioned += 1
|
||||
except Exception: # noqa: BLE001 — best-effort, never block a launch
|
||||
pass
|
||||
|
||||
if reprovisioned:
|
||||
log.info(
|
||||
"reprovisioned egress tokens",
|
||||
context={"count": reprovisioned},
|
||||
)
|
||||
|
||||
|
||||
def launch_consolidated(
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
@@ -96,9 +159,14 @@ def launch_consolidated(
|
||||
network: str = GATEWAY_NETWORK,
|
||||
) -> LaunchContext:
|
||||
"""Ensure the infra container is up, allocate + register the bottle, and
|
||||
provision its git-gate state. Returns the agent's attach context."""
|
||||
provision its git-gate state. Returns the agent's attach context.
|
||||
|
||||
Also reprovisiones egress tokens for any already-running bottles that lost
|
||||
their in-memory credentials (e.g. after an infra container restart), so
|
||||
they regain egress access before the new bottle is registered."""
|
||||
service = service or OrchestratorService()
|
||||
url = service.ensure_running()
|
||||
_reprovision_running_bottles(url, network=network, infra_name=infra_name)
|
||||
client = OrchestratorClient(url)
|
||||
|
||||
cidr = _network_cidr(network)
|
||||
@@ -117,6 +185,7 @@ def launch_consolidated(
|
||||
network=network,
|
||||
gateway_ip=gateway_ip,
|
||||
orchestrator_url=url,
|
||||
env_var_secret=reg.env_var_secret,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ def launch(
|
||||
agent_git_gate_url=git_gate_url,
|
||||
agent_supervise_url=supervise_url,
|
||||
identity_token=ctx.identity_token,
|
||||
env_var_secret=ctx.env_var_secret,
|
||||
)
|
||||
|
||||
# Step 5: render + up the agent-only compose, pinned on the shared
|
||||
@@ -198,7 +199,12 @@ def launch(
|
||||
project = compose_project_name(plan.slug)
|
||||
# Forwarded vars (OAuth token, host interpolations) flow through the
|
||||
# subprocess env as bare names so values never land in the file.
|
||||
# ENV_VAR_SECRET follows the same pattern: bare name in the compose
|
||||
# spec, value only in the subprocess env so it is never written to disk.
|
||||
compose_env: dict[str, str] = {**os.environ, **plan.forwarded_env}
|
||||
if plan.env_var_secret:
|
||||
from ...orchestrator.secret_store import ENV_VAR_SECRET_NAME
|
||||
compose_env[ENV_VAR_SECRET_NAME] = plan.env_var_secret
|
||||
info(
|
||||
f"docker compose up -d (project {project}, agent on shared "
|
||||
f"gateway {ctx.gateway_ip}, ip {ctx.source_ip})"
|
||||
|
||||
@@ -50,6 +50,7 @@ class LaunchContext:
|
||||
source_ip: str # the VM's guest IP — the attribution key
|
||||
gateway_ca_pem: str # the shared gateway CA the provisioner installs
|
||||
orchestrator_url: str
|
||||
env_var_secret: str = "" # encryption key injected into the agent's env
|
||||
|
||||
|
||||
def launch_consolidated(
|
||||
@@ -80,6 +81,7 @@ def launch_consolidated(
|
||||
source_ip=guest_ip,
|
||||
gateway_ca_pem=infra.gateway_ca_pem(),
|
||||
orchestrator_url=url,
|
||||
env_var_secret=reg.env_var_secret,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class LaunchContext:
|
||||
gateway_ip: str
|
||||
network: str
|
||||
orchestrator_url: str
|
||||
env_var_secret: str = "" # encryption key injected into the agent's env
|
||||
|
||||
|
||||
def ensure_gateway(
|
||||
@@ -152,6 +153,7 @@ def register_agent(
|
||||
gateway_ip=endpoint.gateway_ip,
|
||||
network=endpoint.network,
|
||||
orchestrator_url=endpoint.orchestrator_url,
|
||||
env_var_secret=reg.env_var_secret,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user