fix(firecracker): provision role-scoped control-plane auth in the infra VM
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / integration-docker (pull_request) Successful in 16s
lint / lint (push) Successful in 58s
test / unit (pull_request) Successful in 2m4s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 16s
test / publish-infra (pull_request) Has been skipped

The Firecracker infra VM started the control plane with no signing key, so it
ran open and granted every unauthenticated caller the `cli` role. The nft
boundary only fences off the separate agent VM — the egress / supervise /
git-http daemons run in this same VM and reach the orchestrator over 127.0.0.1,
so a compromised data-plane daemon could still drive the operator routes
(approve its own supervise proposals, rewrite policy, read injected tokens).

Generate the signing key on the persistent volume, hand it only to the
orchestrator process, mint a `gateway` JWT for the data-plane daemons, and
mirror the key back to the host token file so the CLI signs `cli` tokens the VM
verifies — the same per-process scoping the docker / macOS launchers use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 07:03:05 +00:00
parent d8b61b3658
commit aac27d8a40
2 changed files with 88 additions and 4 deletions
+48 -4
View File
@@ -33,11 +33,17 @@ from pathlib import Path
from typing import Generator from typing import Generator
from ...log import die, info from ...log import die, info
from ...paths import CONTROL_PLANE_TOKEN_FILENAME, bot_bottle_root
from .. import util as backend_util from .. import util as backend_util
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.gateway_provision import GatewayProvisionError from ..docker.gateway_provision import GatewayProvisionError
from . import firecracker_vm, infra_artifact, netpool, util from . import firecracker_vm, infra_artifact, netpool, util
# Where the infra VM keeps its control-plane signing key (generated on the
# persistent /dev/vdb volume mounted at BOT_BOTTLE_ROOT). The host mirrors it
# back so the CLI signs `cli` tokens the VM verifies (issue #469 review).
_GUEST_SIGNING_KEY_PATH = "/var/lib/bot-bottle/control-plane-token"
# The single infra-VM image: gateway data plane + baked control-plane source # The single infra-VM image: gateway data plane + baked control-plane source
# (Dockerfile.infra FROM the gateway image). Built from source by default; # (Dockerfile.infra FROM the gateway image). Built from source by default;
# a pull-from-registry mode lands later. # a pull-from-registry mode lands later.
@@ -167,21 +173,45 @@ def ensure_running() -> InfraVm:
want = _expected_version() want = _expected_version()
if _adoptable(key, url, want): if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}") info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key) return _with_signing_key(InfraVm(guest_ip=slot.guest_ip, private_key=key))
with _singleton_lock(): with _singleton_lock():
# Re-check under the lock: another launcher may have booted it while # Re-check under the lock: another launcher may have booted it while
# we waited for the lock (double-checked, so we adopt not re-boot). # we waited for the lock (double-checked, so we adopt not re-boot).
if _adoptable(key, url, want): if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}") info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key) return _with_signing_key(InfraVm(guest_ip=slot.guest_ip, private_key=key))
# Clear a stale/hung/OUTDATED VM holding the link before booting fresh. # Clear a stale/hung/OUTDATED VM holding the link before booting fresh.
stop() stop()
ensure_built() ensure_built()
infra = boot() infra = boot()
wait_for_health(infra) wait_for_health(infra)
_record_booted_version(want) _record_booted_version(want)
return _with_signing_key(infra)
def _with_signing_key(infra: InfraVm) -> InfraVm:
"""Mirror the infra VM's control-plane signing key (generated on its
persistent volume) into the host's control-plane-token file, so the host CLI
signs `cli` tokens the VM verifies (issue #469 review). Best-effort: an
unreadable key is logged, not fatal — the VM still enforces auth, but the CLI
may then be rejected until the key is readable. Returns `infra` for chaining."""
proc = subprocess.run(
util.ssh_base_argv(infra.private_key, infra.guest_ip)
+ [f"cat {_GUEST_SIGNING_KEY_PATH}"],
capture_output=True, text=True, check=False,
)
signing_key = proc.stdout.strip()
if proc.returncode != 0 or not signing_key:
info("infra signing key not yet readable; control-plane auth may fail")
return infra return infra
path = bot_bottle_root() / CONTROL_PLANE_TOKEN_FILENAME
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(signing_key)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
return infra
@contextmanager @contextmanager
@@ -487,7 +517,18 @@ mount -t ext4 /dev/vdb /var/lib/bot-bottle 2>/dev/null || true
# Control plane. Source is baked at /app; the package is stdlib-only. # Control plane. Source is baked at /app; the package is stdlib-only.
cd /app cd /app
BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\ # Control-plane signing key + a pre-minted `gateway` JWT, scoped per-process
# (issue #469 review). The key is generated once on the persistent volume and
# handed ONLY to the orchestrator (to verify tokens); the data-plane daemons get
# the `gateway` JWT they present, never the key. Without this the control plane
# would run OPEN and a compromised egress / supervise / git-http daemon in this
# same VM — reaching the orchestrator over 127.0.0.1, past the nft boundary that
# only fences off the separate agent VM — could drive the operator routes
# (approve its own supervise proposals, rewrite policy, read injected tokens).
CP_KEY=$(BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -c 'from bot_bottle.paths import host_control_plane_token as t; print(t())')
GW_JWT=$(BB_SIGNING_KEY="$CP_KEY" python3 -c 'import os; from bot_bottle.control_auth import mint, ROLE_GATEWAY; print(mint(ROLE_GATEWAY, os.environ["BB_SIGNING_KEY"]))')
BOT_BOTTLE_ROOT=/var/lib/bot-bottle BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator \\
--host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub & --host 0.0.0.0 --port {CONTROL_PLANE_PORT} --broker stub &
# Gateway data plane, multi-tenant: each request resolves source-IP -> # Gateway data plane, multi-tenant: each request resolves source-IP ->
@@ -495,9 +536,12 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle # git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
# entrypoint the consolidated model doesn't use) is left out. No # entrypoint the consolidated model doesn't use) is left out. No
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the # SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469). # control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469). It presents
# the pre-minted `gateway` JWT; gateway_init keeps the signing key out of the
# data-plane daemons' env.
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\ BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\ BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT" \\
python3 -m bot_bottle.gateway_init & python3 -m bot_bottle.gateway_init &
# Reap as PID 1; children are backgrounded, so `wait` blocks. # Reap as PID 1; children are backgrounded, so `wait` blocks.
+40
View File
@@ -8,6 +8,7 @@ decisions that must hold without a VM.
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -15,6 +16,37 @@ from unittest.mock import MagicMock, patch
from bot_bottle.backend.firecracker import infra_vm from bot_bottle.backend.firecracker import infra_vm
class TestSigningKeySync(unittest.TestCase):
"""`_with_signing_key` mirrors the VM's control-plane signing key into the
host token file so the CLI signs `cli` tokens the VM verifies (issue #469)."""
def _infra(self) -> infra_vm.InfraVm:
return infra_vm.InfraVm(guest_ip="10.0.0.1", private_key=Path("/k"))
def test_mirrors_guest_key_to_host_file(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc = MagicMock(returncode=0, stdout="the-signing-key\n")
with patch.object(infra_vm.subprocess, "run", return_value=proc) as run, \
patch.object(infra_vm, "bot_bottle_root", return_value=root):
out = infra_vm._with_signing_key(self._infra())
self.assertIsInstance(out, infra_vm.InfraVm) # returned for chaining
token = root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME
self.assertEqual("the-signing-key", token.read_text())
self.assertEqual(0o600, token.stat().st_mode & 0o777)
# It cat'd the guest volume path over SSH.
self.assertIn(infra_vm._GUEST_SIGNING_KEY_PATH, run.call_args.args[0][-1])
def test_unreadable_key_is_not_fatal(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
proc = MagicMock(returncode=255, stdout="")
with patch.object(infra_vm.subprocess, "run", return_value=proc), \
patch.object(infra_vm, "bot_bottle_root", return_value=root):
infra_vm._with_signing_key(self._infra()) # no raise
self.assertFalse((root / infra_vm.CONTROL_PLANE_TOKEN_FILENAME).exists())
class TestControlPlaneUrl(unittest.TestCase): class TestControlPlaneUrl(unittest.TestCase):
def test_url_uses_guest_ip_and_port(self): def test_url_uses_guest_ip_and_port(self):
infra = infra_vm.InfraVm( infra = infra_vm.InfraVm(
@@ -46,6 +78,14 @@ class TestBuildInfraRootfs(unittest.TestCase):
self.assertIn("/dev/vdb", init) self.assertIn("/dev/vdb", init)
# VM backend uses git-http (9420); the git:// daemon is left out. # VM backend uses git-http (9420); the git:// daemon is left out.
self.assertIn("BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise", init) self.assertIn("BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise", init)
# Role-scoped control-plane auth (issue #469 review): the orchestrator
# gets the signing key, the gateway daemons get a pre-minted `gateway`
# JWT — never open mode in the infra VM.
self.assertIn("host_control_plane_token", init) # key generated on the volume
self.assertIn("mint, ROLE_GATEWAY", init) # gateway JWT minted from it
self.assertIn('BOT_BOTTLE_CONTROL_PLANE_TOKEN="$CP_KEY" python3 -m bot_bottle.orchestrator',
init) # key -> orchestrator only
self.assertIn('BOT_BOTTLE_CONTROL_AUTH_JWT="$GW_JWT"', init) # JWT -> gateway daemons
class TestSshGatewayTransport(unittest.TestCase): class TestSshGatewayTransport(unittest.TestCase):