diff --git a/.env.example b/.env.example index c71296a8b..5e651598b 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,11 @@ INFOQUEST_API_KEY=your-infoquest-api-key # when you run Gateway workers outside the bundled deploy script. # DEER_FLOW_INTERNAL_AUTH_TOKEN=your-shared-internal-token +# Provisioner API key for sandbox authentication (required when using provisioner/K8s sandbox mode). +# The same value must be set on the provisioner container and in config.yaml sandbox.provisioner_api_key. +# Generate: openssl rand -hex 32 +# PROVISIONER_API_KEY=your-provisioner-api-key + # ── Frontend SSR → Gateway wiring ───────────────────────────────────────────── # The Next.js server uses these to reach the Gateway during SSR (auth checks, # /api/* rewrites). They default to localhost values that match `make dev` and diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py index 663d1cbd3..c6abc87d7 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py @@ -190,7 +190,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): provisioner_url = self._config.get("provisioner_url") if provisioner_url: logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}") - return RemoteSandboxBackend(provisioner_url=provisioner_url) + api_key = self._config.get("provisioner_api_key", "") + return RemoteSandboxBackend(provisioner_url=provisioner_url, api_key=api_key) logger.info("Using local container sandbox backend") return LocalContainerBackend( @@ -221,6 +222,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider): "environment": self._resolve_env_vars(sandbox_config.environment or {}), # provisioner URL for dynamic pod management (e.g. http://provisioner:8002) "provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "", + "provisioner_api_key": getattr(sandbox_config, "provisioner_api_key", None) or "", } @staticmethod diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py index 9c448c4a5..fb2692199 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py @@ -41,21 +41,28 @@ class RemoteSandboxBackend(SandboxBackend): sandbox: use: deerflow.community.aio_sandbox:AioSandboxProvider provisioner_url: http://provisioner:8002 + provisioner_api_key: $PROVISIONER_API_KEY """ - def __init__(self, provisioner_url: str): - """Initialize with the provisioner service URL. + def __init__(self, provisioner_url: str, api_key: str = ""): + """Initialize with the provisioner service URL and optional API key. Args: provisioner_url: URL of the provisioner service (e.g., ``http://provisioner:8002``). + api_key: Value sent as ``X-API-Key`` header on every request. + Leave empty to send no authentication header. """ self._provisioner_url = provisioner_url.rstrip("/") + self._api_key = api_key @property def provisioner_url(self) -> str: return self._provisioner_url + def _auth_headers(self) -> dict[str, str]: + return {"X-API-Key": self._api_key} if self._api_key else {} + # ── SandboxBackend interface ────────────────────────────────────────── def create( @@ -106,7 +113,7 @@ class RemoteSandboxBackend(SandboxBackend): def _provisioner_list(self) -> list[SandboxInfo]: """GET /api/sandboxes → list all running sandboxes.""" try: - resp = requests.get(f"{self._provisioner_url}/api/sandboxes", timeout=10) + resp = requests.get(f"{self._provisioner_url}/api/sandboxes", headers=self._auth_headers(), timeout=10) resp.raise_for_status() data = resp.json() if not isinstance(data, dict): @@ -156,6 +163,7 @@ class RemoteSandboxBackend(SandboxBackend): "user_id": effective_user_id, "include_legacy_skills": include_legacy_skills, }, + headers=self._auth_headers(), timeout=30, ) resp.raise_for_status() @@ -174,6 +182,7 @@ class RemoteSandboxBackend(SandboxBackend): try: resp = requests.delete( f"{self._provisioner_url}/api/sandboxes/{sandbox_id}", + headers=self._auth_headers(), timeout=15, ) if resp.ok: @@ -188,6 +197,7 @@ class RemoteSandboxBackend(SandboxBackend): try: resp = requests.get( f"{self._provisioner_url}/api/sandboxes/{sandbox_id}", + headers=self._auth_headers(), timeout=10, ) except requests.RequestException as exc: @@ -206,6 +216,7 @@ class RemoteSandboxBackend(SandboxBackend): try: resp = requests.get( f"{self._provisioner_url}/api/sandboxes/{sandbox_id}", + headers=self._auth_headers(), timeout=10, ) if resp.status_code == 404: diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index 428335329..992f8a7eb 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -111,4 +111,14 @@ class SandboxConfig(BaseModel): ), ) + provisioner_api_key: str | None = Field( + default=None, + description=( + "API key sent as X-API-Key header to the provisioner service. " + "Must match PROVISIONER_API_KEY on the provisioner container. " + "Both sides must be set to the same value; " + "the provisioner rejects all /api/* requests when the key is unset or mismatched." + ), + ) + model_config = ConfigDict(extra="allow") diff --git a/backend/tests/test_aio_sandbox_provider.py b/backend/tests/test_aio_sandbox_provider.py index df1745873..ae7503b12 100644 --- a/backend/tests/test_aio_sandbox_provider.py +++ b/backend/tests/test_aio_sandbox_provider.py @@ -368,7 +368,7 @@ def test_remote_backend_create_forwards_effective_user_id(monkeypatch): def json(self): return {"sandbox_url": "http://sandbox.local"} - def _post(url, json, timeout): # noqa: A002 - mirrors requests.post kwarg + def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg posted.update({"url": url, "json": json, "timeout": timeout}) return _Response() @@ -402,7 +402,7 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch): def json(self): return {"sandbox_url": "http://sandbox.local"} - def _post(url, json, timeout): # noqa: A002 - mirrors requests.post kwarg + def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg posted.update({"url": url, "json": json, "timeout": timeout}) return _Response() diff --git a/backend/tests/test_provisioner_request_threading.py b/backend/tests/test_provisioner_request_threading.py index 955f9e11d..b93ed0467 100644 --- a/backend/tests/test_provisioner_request_threading.py +++ b/backend/tests/test_provisioner_request_threading.py @@ -144,14 +144,16 @@ async def test_sandbox_business_routes_run_k8s_client_off_event_loop_thread( ready_after_service_reads={"sandbox-new": 3}, ) monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1) + monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret") with _detect_provisioner_blocking_io(provisioner_module): transport = httpx.ASGITransport(app=provisioner_module.app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + headers = {"X-API-Key": "test-secret"} if json_body is None: - response = await client.request(method, path) + response = await client.request(method, path, headers=headers) else: - response = await client.request(method, path, json=json_body) + response = await client.request(method, path, json=json_body, headers=headers) assert response.status_code == 200 assert fake_core_v1.thread_ids @@ -250,3 +252,50 @@ def test_sandbox_service_supports_cluster_ip_with_dns_url(provisioner_module) -> assert service.spec.ports[0].port == 8080 assert service.spec.ports[0].target_port == 8080 assert provisioner_module._sandbox_url("abc123") == ("http://sandbox-abc123-svc.mdv-sit.svc.cluster.local:8080") + + +@pytest.mark.asyncio +async def test_auth_middleware(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None: + """Verify the X-API-Key middleware: /health is open; /api/* requires a correct key.""" + monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret") + fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1) + monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1) + + transport = httpx.ASGITransport(app=provisioner_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + # /health is always open — no key needed + r = await client.get("/health") + assert r.status_code == 200 + + # /api/* with no header → 401 + r = await client.get("/api/sandboxes") + assert r.status_code == 401 + + # /api/* with wrong key → 401 + r = await client.get("/api/sandboxes", headers={"X-API-Key": "wrong-key"}) + assert r.status_code == 401 + + # /api/* with correct key → not 401 (auth passed; handler runs with the K8s mock) + r = await client.get("/api/sandboxes", headers={"X-API-Key": "test-secret"}) + assert r.status_code != 401 + + +@pytest.mark.asyncio +async def test_auth_middleware_unset_key(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None: + """When PROVISIONER_API_KEY is unset/empty, all /api/* routes return 401.""" + monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "") + fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1) + monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1) + + transport = httpx.ASGITransport(app=provisioner_module.app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + # /health is always open even when key is unset + r = await client.get("/health") + assert r.status_code == 200 + + # /api/* is always 401 when key is unset — even with a header + r = await client.get("/api/sandboxes") + assert r.status_code == 401 + + r = await client.get("/api/sandboxes", headers={"X-API-Key": "anything"}) + assert r.status_code == 401 diff --git a/backend/tests/test_remote_sandbox_backend.py b/backend/tests/test_remote_sandbox_backend.py index f6abe4e08..c784b25e9 100644 --- a/backend/tests/test_remote_sandbox_backend.py +++ b/backend/tests/test_remote_sandbox_backend.py @@ -49,9 +49,10 @@ def test_list_running_delegates_to_provisioner_list(monkeypatch): def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): assert url == "http://provisioner:8002/api/sandboxes" assert timeout == 10 + assert headers == {} return _StubResponse( payload={ "sandboxes": [ @@ -70,10 +71,24 @@ def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monk assert infos[0].sandbox_url == "http://k3s:31001" +def test_provisioner_list_sends_auth_header_when_api_key_set(monkeypatch): + backend = RemoteSandboxBackend("http://provisioner:8002", api_key="secret") + captured: list[dict] = [] + + def mock_get(url: str, timeout: int, headers=None): + captured.append({"headers": headers}) + return _StubResponse(payload={"sandboxes": []}) + + monkeypatch.setattr(requests, "get", mock_get) + + backend._provisioner_list() + assert captured[0]["headers"] == {"X-API-Key": "secret"} + + def test_provisioner_list_returns_empty_on_request_exception(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): raise requests.RequestException("network down") monkeypatch.setattr(requests, "get", mock_get) @@ -84,7 +99,7 @@ def test_provisioner_list_returns_empty_on_request_exception(monkeypatch): def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse(payload=[{"sandbox_id": "abc", "sandbox_url": "http://k3s:31001"}]) monkeypatch.setattr(requests, "get", mock_get) @@ -95,7 +110,7 @@ def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch): def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse(payload={"sandboxes": {"sandbox_id": "abc"}}) monkeypatch.setattr(requests, "get", mock_get) @@ -106,7 +121,7 @@ def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch): def test_provisioner_list_skips_non_dict_sandbox_entries(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse( payload={ "sandboxes": [ @@ -172,7 +187,7 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: True) - def mock_post(url: str, json: dict, timeout: int): + def mock_post(url: str, json: dict, timeout: int, headers=None): assert url == "http://provisioner:8002/api/sandboxes" assert json == { "sandbox_id": "abc123", @@ -194,7 +209,7 @@ def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False) - def mock_post(url: str, json: dict, timeout: int): + def mock_post(url: str, json: dict, timeout: int, headers=None): assert url == "http://provisioner:8002/api/sandboxes" assert json == { "sandbox_id": "anon123", @@ -216,7 +231,7 @@ def test_provisioner_create_raises_runtime_error_on_request_exception(monkeypatc backend = RemoteSandboxBackend("http://provisioner:8002") monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False) - def mock_post(url: str, json: dict, timeout: int): + def mock_post(url: str, json: dict, timeout: int, headers=None): raise requests.RequestException("boom") monkeypatch.setattr(requests, "post", mock_post) @@ -241,7 +256,7 @@ def test_destroy_delegates_to_provisioner_destroy(monkeypatch): def test_provisioner_destroy_calls_delete(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_delete(url: str, timeout: int): + def mock_delete(url: str, timeout: int, headers=None): assert url == "http://provisioner:8002/api/sandboxes/abc123" assert timeout == 15 return _StubResponse(status_code=200) @@ -254,7 +269,7 @@ def test_provisioner_destroy_calls_delete(monkeypatch): def test_provisioner_destroy_swallows_request_exception(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_delete(url: str, timeout: int): + def mock_delete(url: str, timeout: int, headers=None): raise requests.RequestException("network down") monkeypatch.setattr(requests, "delete", mock_delete) @@ -278,13 +293,13 @@ def test_is_alive_delegates_to_provisioner_is_alive(monkeypatch): def test_provisioner_is_alive_true_only_when_status_running(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get_running(url: str, timeout: int): + def mock_get_running(url: str, timeout: int, headers=None): return _StubResponse(payload={"status": "Running"}) monkeypatch.setattr(requests, "get", mock_get_running) assert backend._provisioner_is_alive("abc123") is True - def mock_get_pending(url: str, timeout: int): + def mock_get_pending(url: str, timeout: int, headers=None): return _StubResponse(payload={"status": "Pending"}) monkeypatch.setattr(requests, "get", mock_get_pending) @@ -294,7 +309,7 @@ def test_provisioner_is_alive_true_only_when_status_running(monkeypatch): def test_provisioner_is_alive_returns_false_on_404(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse(status_code=404) monkeypatch.setattr(requests, "get", mock_get) @@ -304,7 +319,7 @@ def test_provisioner_is_alive_returns_false_on_404(monkeypatch): def test_provisioner_is_alive_raises_on_request_exception(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): raise requests.RequestException("boom") monkeypatch.setattr(requests, "get", mock_get) @@ -315,7 +330,7 @@ def test_provisioner_is_alive_raises_on_request_exception(monkeypatch): def test_provisioner_is_alive_raises_on_server_error(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): response = _StubResponse(status_code=503) response.text = "unavailable" return response @@ -342,7 +357,7 @@ def test_discover_delegates_to_provisioner_discover(monkeypatch): def test_provisioner_discover_returns_none_on_404(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse(status_code=404) monkeypatch.setattr(requests, "get", mock_get) @@ -353,7 +368,7 @@ def test_provisioner_discover_returns_none_on_404(monkeypatch): def test_provisioner_discover_returns_info_on_success(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"}) monkeypatch.setattr(requests, "get", mock_get) @@ -367,7 +382,7 @@ def test_provisioner_discover_returns_info_on_success(monkeypatch): def test_provisioner_discover_returns_none_on_request_exception(monkeypatch): backend = RemoteSandboxBackend("http://provisioner:8002") - def mock_get(url: str, timeout: int): + def mock_get(url: str, timeout: int, headers=None): raise requests.RequestException("boom") monkeypatch.setattr(requests, "get", mock_get) diff --git a/config.example.yaml b/config.example.yaml index 8d5ed6d21..d0ac8fdaa 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1167,6 +1167,11 @@ sandbox: # sandbox: # use: deerflow.community.aio_sandbox:AioSandboxProvider # provisioner_url: http://provisioner:8002 +# # API key for provisioner authentication. Must match PROVISIONER_API_KEY +# # set on the provisioner container. Both sides must have the same value set; +# # the provisioner rejects all /api/* requests when PROVISIONER_API_KEY is unset. +# # Generate a strong key: openssl rand -hex 32 +# # provisioner_api_key: $PROVISIONER_API_KEY # # Note: provisioner-created Pods use the provisioner's SANDBOX_IMAGE # # environment variable, not sandbox.image from this config file. diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index f7f11169f..a623a32f3 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -64,6 +64,9 @@ services: # Override K8S API server URL since kubeconfig uses 127.0.0.1 # which is unreachable from inside the container - K8S_API_SERVER=https://host.docker.internal:26443 + # Optional: set PROVISIONER_API_KEY in .env to enable provisioner auth. + # The same value must be set on the gateway side via config.yaml sandbox.provisioner_api_key. + - PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-} env_file: - ../.env extra_hosts: @@ -191,6 +194,9 @@ services: - DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow - DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_ROOT}/skills - DEER_FLOW_SANDBOX_HOST=host.docker.internal + # Pass PROVISIONER_API_KEY into the gateway container so config.yaml can reference it + # as sandbox.provisioner_api_key: $PROVISIONER_API_KEY + - PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-} # Proxy values (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) are inherited from ../.env via env_file. # Only NO_PROXY is declared here so internal service hostnames are always exempt from the proxy. - NO_PROXY=${NO_PROXY:-}${NO_PROXY:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,host.docker.internal diff --git a/docker/provisioner/app.py b/docker/provisioner/app.py index ccbbc39d1..8f3dfc83e 100644 --- a/docker/provisioner/app.py +++ b/docker/provisioner/app.py @@ -32,11 +32,12 @@ from __future__ import annotations import logging import os import re +import secrets import time from contextlib import asynccontextmanager import urllib3 -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request, Response from kubernetes import client as k8s_client from kubernetes import config as k8s_config from kubernetes.client.rest import ApiException @@ -81,6 +82,7 @@ DEFAULT_USER_ID = "default" # Path to the kubeconfig *inside* the provisioner container. # Typically the host's ~/.kube/config is mounted here. KUBECONFIG_PATH = os.environ.get("KUBECONFIG_PATH", "/root/.kube/config") +PROVISIONER_API_KEY = os.environ.get("PROVISIONER_API_KEY", "") # The hostname / IP that the backend uses to reach NodePort services. On Docker # Desktop for macOS this is ``host.docker.internal``; on Linux it may be the @@ -204,6 +206,16 @@ async def lifespan(_app: FastAPI): app = FastAPI(title="DeerFlow Sandbox Provisioner", lifespan=lifespan) +@app.middleware("http") +async def verify_api_key(request: Request, call_next): + if request.url.path.startswith("/api/"): + key = request.headers.get("X-API-Key", "") + if not PROVISIONER_API_KEY or not secrets.compare_digest(key, PROVISIONER_API_KEY): + logger.warning("provisioner auth rejected: %s %s", request.method, request.url.path) + return Response(status_code=401, content="Unauthorized") + return await call_next(request) + + # ── Request / Response models ───────────────────────────────────────────