test(orchestrator): live Docker coverage for the control-plane auth fix (#400)
ca91fc4 fixed the control plane's missing caller authentication but only
verified it end-to-end on Apple Container; Docker had the same code change
(docker_cmd.py's env injection) backed only by the in-process dispatch()
unit tests, which never exercise the real HTTP server or a real container.
Ran it for real first: brought up the actual orchestrator + gateway via
Docker, hit the published control-plane port directly. Confirms /health
stays open, /bottles and /resolve both 401 with no token or a wrong one
(the enumeration and credential-lift vectors from #400), and /bottles
succeeds with the real per-host secret.
Added as a proper integration test so this stays covered: a subclass of
OrchestratorService giving the gateway its own unique name too (the base
class only parameterizes the orchestrator's), plus a throwaway
BOT_BOTTLE_ROOT and a random port, so a run can never collide with a real
host's orchestrator or gateway. Gated on a reachable Docker daemon, same
as the existing docker gateway/broker integration tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
"""Integration: the Docker orchestrator's control plane enforces the
|
||||||
|
per-host auth secret against a real container (issue #400).
|
||||||
|
|
||||||
|
Unit tests exercise `dispatch()` in-process, socket-free. This starts the
|
||||||
|
actual orchestrator + gateway as Docker containers and drives the real HTTP
|
||||||
|
server over its published loopback port, the same path an agent sharing the
|
||||||
|
gateway network — or the trusted host CLI — would use.
|
||||||
|
|
||||||
|
Gated on a reachable Docker daemon. Uses unique container/network names and
|
||||||
|
a throwaway `BOT_BOTTLE_ROOT` so it never collides with a real per-host
|
||||||
|
orchestrator or gateway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle.orchestrator.control_plane import CONTROL_AUTH_HEADER
|
||||||
|
from bot_bottle.orchestrator.gateway import DockerGateway
|
||||||
|
from bot_bottle.orchestrator.lifecycle import OrchestratorService
|
||||||
|
from bot_bottle.paths import host_control_plane_token
|
||||||
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
|
class _IsolatedOrchestratorService(OrchestratorService):
|
||||||
|
"""`OrchestratorService`, but with a uniquely-named gateway too (the base
|
||||||
|
class only parameterizes the orchestrator's own name/network/port) so a
|
||||||
|
test run can never touch a real host's orchestrator or gateway."""
|
||||||
|
|
||||||
|
def __init__(self, *, gateway_name: str, **kwargs: object) -> None:
|
||||||
|
super().__init__(**kwargs) # type: ignore[arg-type]
|
||||||
|
self._test_gateway_name = gateway_name
|
||||||
|
|
||||||
|
def _gateway(self) -> DockerGateway:
|
||||||
|
return DockerGateway(
|
||||||
|
self._gateway_image,
|
||||||
|
name=self._test_gateway_name,
|
||||||
|
network=self.network,
|
||||||
|
orchestrator_url=self.internal_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skip_unless_docker()
|
||||||
|
class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
suffix = secrets.token_hex(4)
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
|
||||||
|
self.svc = _IsolatedOrchestratorService(
|
||||||
|
orchestrator_name=f"bot-bottle-orch-itest-{suffix}",
|
||||||
|
gateway_name=f"bot-bottle-gw-itest-{suffix}",
|
||||||
|
network=f"bot-bottle-net-itest-{suffix}",
|
||||||
|
port=20000 + secrets.randbelow(10000),
|
||||||
|
host_root=Path(self._tmp.name),
|
||||||
|
)
|
||||||
|
self.addCleanup(self.svc.stop)
|
||||||
|
self.svc.ensure_running()
|
||||||
|
self.token = host_control_plane_token()
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self, method: str, path: str, *, token: str | None = None
|
||||||
|
) -> tuple[int, dict[str, object]]:
|
||||||
|
headers = {CONTROL_AUTH_HEADER: token} if token is not None else {}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.svc.url + path, method=method, headers=headers
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
|
||||||
|
def test_health_is_open_without_a_token(self) -> None:
|
||||||
|
status, payload = self._request("GET", "/health")
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual("ok", payload["status"])
|
||||||
|
|
||||||
|
def test_bottles_rejects_a_caller_with_no_token(self) -> None:
|
||||||
|
"""The enumeration attack from issue #400: an agent sharing the
|
||||||
|
gateway network could list every bottle + its policy with no
|
||||||
|
credential at all."""
|
||||||
|
status, _ = self._request("GET", "/bottles")
|
||||||
|
self.assertEqual(401, status)
|
||||||
|
|
||||||
|
def test_bottles_rejects_a_wrong_token(self) -> None:
|
||||||
|
status, _ = self._request("GET", "/bottles", token="not-the-real-secret")
|
||||||
|
self.assertEqual(401, status)
|
||||||
|
|
||||||
|
def test_bottles_accepts_the_real_token(self) -> None:
|
||||||
|
status, payload = self._request("GET", "/bottles", token=self.token)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual([], payload["bottles"])
|
||||||
|
|
||||||
|
def test_resolve_rejects_a_caller_with_no_token(self) -> None:
|
||||||
|
"""The credential-lift attack from issue #400: an agent could POST
|
||||||
|
/resolve directly and read back the upstream tokens it's never meant
|
||||||
|
to see."""
|
||||||
|
status, _ = self._request("POST", "/resolve")
|
||||||
|
self.assertEqual(401, status)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user