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:
@@ -8,6 +8,7 @@ decisions that must hold without a VM.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -15,6 +16,37 @@ from unittest.mock import MagicMock, patch
|
||||
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):
|
||||
def test_url_uses_guest_ip_and_port(self):
|
||||
infra = infra_vm.InfraVm(
|
||||
@@ -46,6 +78,14 @@ class TestBuildInfraRootfs(unittest.TestCase):
|
||||
self.assertIn("/dev/vdb", init)
|
||||
# VM backend uses git-http (9420); the git:// daemon is left out.
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user