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
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:
@@ -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,20 +173,44 @@ 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
|
||||||
|
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
|
return infra
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user