mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 06:28:58 +00:00
fix(sandbox): the sandbox provisioner api exposes endpoints need to check API KEY (#4116)
* fix: V-001 security vulnerability Automated security fix generated by OrbisAI Security * fix(sandbox): wire provisioner API key through backend client and config RemoteSandboxBackend now accepts an api_key parameter and sends it as X-API-Key on all five provisioner HTTP calls (list, create, destroy, is_alive, discover). AioSandboxProvider reads provisioner_api_key from SandboxConfig and forwards it at construction time. SandboxConfig formally declares the field; config.example.yaml documents it under Option 4; docker-compose-dev.yaml threads PROVISIONER_API_KEY into both the provisioner and gateway containers so a single .env entry covers both sides. Tests: monkeypatch PROVISIONER_API_KEY and send X-API-Key headers in the five parametrized threading tests (previously 401-failing); new test_auth_middleware asserts /health is open, /api/* rejects no-header and wrong-key with 401, and accepts the correct key. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(provisioner): correct fail-closed auth docs and fix test mock signatures The first round of PR #4116 fixes left four blocking issues: - Mock signatures in test_remote_sandbox_backend.py (17) and test_aio_sandbox_provider.py (2) didn't accept the new headers= kwarg, causing TypeError on every provisioner HTTP call test. - sandbox_config.py and config.example.yaml described the auth as optional ("leave unset to disable") but the middleware is fail-closed: an unset PROVISIONER_API_KEY causes 401 on every /api/* request. - .env.example had no PROVISIONER_API_KEY entry, leaving users with an empty value and silent 401s. - No test covered the PROVISIONER_API_KEY="" fail-closed path. Fix all four: add headers=None to all mock signatures, correct the field description and example comment to state that both sides must have the same key set, add PROVISIONER_API_KEY to .env.example with generation guidance, add test_auth_middleware_unset_key, and add a logger.warning on auth rejection for observability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e32456d337
commit
8cc4b3abeb
@ -60,6 +60,11 @@ INFOQUEST_API_KEY=your-infoquest-api-key
|
|||||||
# when you run Gateway workers outside the bundled deploy script.
|
# when you run Gateway workers outside the bundled deploy script.
|
||||||
# DEER_FLOW_INTERNAL_AUTH_TOKEN=your-shared-internal-token
|
# 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 ─────────────────────────────────────────────
|
# ── Frontend SSR → Gateway wiring ─────────────────────────────────────────────
|
||||||
# The Next.js server uses these to reach the Gateway during SSR (auth checks,
|
# 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
|
# /api/* rewrites). They default to localhost values that match `make dev` and
|
||||||
|
|||||||
@ -190,7 +190,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
|||||||
provisioner_url = self._config.get("provisioner_url")
|
provisioner_url = self._config.get("provisioner_url")
|
||||||
if provisioner_url:
|
if provisioner_url:
|
||||||
logger.info(f"Using remote sandbox backend with provisioner at {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")
|
logger.info("Using local container sandbox backend")
|
||||||
return LocalContainerBackend(
|
return LocalContainerBackend(
|
||||||
@ -221,6 +222,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
|
|||||||
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
|
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
|
||||||
# provisioner URL for dynamic pod management (e.g. http://provisioner:8002)
|
# provisioner URL for dynamic pod management (e.g. http://provisioner:8002)
|
||||||
"provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "",
|
"provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "",
|
||||||
|
"provisioner_api_key": getattr(sandbox_config, "provisioner_api_key", None) or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -41,21 +41,28 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
sandbox:
|
sandbox:
|
||||||
use: deerflow.community.aio_sandbox:AioSandboxProvider
|
use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||||
provisioner_url: http://provisioner:8002
|
provisioner_url: http://provisioner:8002
|
||||||
|
provisioner_api_key: $PROVISIONER_API_KEY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, provisioner_url: str):
|
def __init__(self, provisioner_url: str, api_key: str = ""):
|
||||||
"""Initialize with the provisioner service URL.
|
"""Initialize with the provisioner service URL and optional API key.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provisioner_url: URL of the provisioner service
|
provisioner_url: URL of the provisioner service
|
||||||
(e.g., ``http://provisioner:8002``).
|
(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._provisioner_url = provisioner_url.rstrip("/")
|
||||||
|
self._api_key = api_key
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provisioner_url(self) -> str:
|
def provisioner_url(self) -> str:
|
||||||
return self._provisioner_url
|
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 ──────────────────────────────────────────
|
# ── SandboxBackend interface ──────────────────────────────────────────
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
@ -106,7 +113,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
def _provisioner_list(self) -> list[SandboxInfo]:
|
def _provisioner_list(self) -> list[SandboxInfo]:
|
||||||
"""GET /api/sandboxes → list all running sandboxes."""
|
"""GET /api/sandboxes → list all running sandboxes."""
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@ -156,6 +163,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
"user_id": effective_user_id,
|
"user_id": effective_user_id,
|
||||||
"include_legacy_skills": include_legacy_skills,
|
"include_legacy_skills": include_legacy_skills,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
@ -174,6 +182,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
try:
|
try:
|
||||||
resp = requests.delete(
|
resp = requests.delete(
|
||||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@ -188,6 +197,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
@ -206,6 +216,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.status_code == 404:
|
if resp.status_code == 404:
|
||||||
|
|||||||
@ -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")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|||||||
@ -368,7 +368,7 @@ def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
|
|||||||
def json(self):
|
def json(self):
|
||||||
return {"sandbox_url": "http://sandbox.local"}
|
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})
|
posted.update({"url": url, "json": json, "timeout": timeout})
|
||||||
return _Response()
|
return _Response()
|
||||||
|
|
||||||
@ -402,7 +402,7 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
|
|||||||
def json(self):
|
def json(self):
|
||||||
return {"sandbox_url": "http://sandbox.local"}
|
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})
|
posted.update({"url": url, "json": json, "timeout": timeout})
|
||||||
return _Response()
|
return _Response()
|
||||||
|
|
||||||
|
|||||||
@ -144,14 +144,16 @@ async def test_sandbox_business_routes_run_k8s_client_off_event_loop_thread(
|
|||||||
ready_after_service_reads={"sandbox-new": 3},
|
ready_after_service_reads={"sandbox-new": 3},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
|
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):
|
with _detect_provisioner_blocking_io(provisioner_module):
|
||||||
transport = httpx.ASGITransport(app=provisioner_module.app)
|
transport = httpx.ASGITransport(app=provisioner_module.app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
headers = {"X-API-Key": "test-secret"}
|
||||||
if json_body is None:
|
if json_body is None:
|
||||||
response = await client.request(method, path)
|
response = await client.request(method, path, headers=headers)
|
||||||
else:
|
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 response.status_code == 200
|
||||||
assert fake_core_v1.thread_ids
|
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].port == 8080
|
||||||
assert service.spec.ports[0].target_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")
|
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
|
||||||
|
|||||||
@ -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):
|
def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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 url == "http://provisioner:8002/api/sandboxes"
|
||||||
assert timeout == 10
|
assert timeout == 10
|
||||||
|
assert headers == {}
|
||||||
return _StubResponse(
|
return _StubResponse(
|
||||||
payload={
|
payload={
|
||||||
"sandboxes": [
|
"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"
|
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):
|
def test_provisioner_list_returns_empty_on_request_exception(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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")
|
raise requests.RequestException("network down")
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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"}])
|
return _StubResponse(payload=[{"sandbox_id": "abc", "sandbox_url": "http://k3s:31001"}])
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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"}})
|
return _StubResponse(payload={"sandboxes": {"sandbox_id": "abc"}})
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_list_skips_non_dict_sandbox_entries(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||||
|
|
||||||
def mock_get(url: str, timeout: int):
|
def mock_get(url: str, timeout: int, headers=None):
|
||||||
return _StubResponse(
|
return _StubResponse(
|
||||||
payload={
|
payload={
|
||||||
"sandboxes": [
|
"sandboxes": [
|
||||||
@ -172,7 +187,7 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
|
|||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||||
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: True)
|
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 url == "http://provisioner:8002/api/sandboxes"
|
||||||
assert json == {
|
assert json == {
|
||||||
"sandbox_id": "abc123",
|
"sandbox_id": "abc123",
|
||||||
@ -194,7 +209,7 @@ def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
|
|||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||||
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
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 url == "http://provisioner:8002/api/sandboxes"
|
||||||
assert json == {
|
assert json == {
|
||||||
"sandbox_id": "anon123",
|
"sandbox_id": "anon123",
|
||||||
@ -216,7 +231,7 @@ def test_provisioner_create_raises_runtime_error_on_request_exception(monkeypatc
|
|||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
||||||
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
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")
|
raise requests.RequestException("boom")
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "post", mock_post)
|
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):
|
def test_provisioner_destroy_calls_delete(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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 url == "http://provisioner:8002/api/sandboxes/abc123"
|
||||||
assert timeout == 15
|
assert timeout == 15
|
||||||
return _StubResponse(status_code=200)
|
return _StubResponse(status_code=200)
|
||||||
@ -254,7 +269,7 @@ def test_provisioner_destroy_calls_delete(monkeypatch):
|
|||||||
def test_provisioner_destroy_swallows_request_exception(monkeypatch):
|
def test_provisioner_destroy_swallows_request_exception(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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")
|
raise requests.RequestException("network down")
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "delete", mock_delete)
|
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):
|
def test_provisioner_is_alive_true_only_when_status_running(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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"})
|
return _StubResponse(payload={"status": "Running"})
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get_running)
|
monkeypatch.setattr(requests, "get", mock_get_running)
|
||||||
assert backend._provisioner_is_alive("abc123") is True
|
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"})
|
return _StubResponse(payload={"status": "Pending"})
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get_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):
|
def test_provisioner_is_alive_returns_false_on_404(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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)
|
return _StubResponse(status_code=404)
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_is_alive_raises_on_request_exception(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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")
|
raise requests.RequestException("boom")
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_is_alive_raises_on_server_error(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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 = _StubResponse(status_code=503)
|
||||||
response.text = "unavailable"
|
response.text = "unavailable"
|
||||||
return response
|
return response
|
||||||
@ -342,7 +357,7 @@ def test_discover_delegates_to_provisioner_discover(monkeypatch):
|
|||||||
def test_provisioner_discover_returns_none_on_404(monkeypatch):
|
def test_provisioner_discover_returns_none_on_404(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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)
|
return _StubResponse(status_code=404)
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_discover_returns_info_on_success(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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"})
|
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
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):
|
def test_provisioner_discover_returns_none_on_request_exception(monkeypatch):
|
||||||
backend = RemoteSandboxBackend("http://provisioner:8002")
|
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")
|
raise requests.RequestException("boom")
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "get", mock_get)
|
monkeypatch.setattr(requests, "get", mock_get)
|
||||||
|
|||||||
@ -1167,6 +1167,11 @@ sandbox:
|
|||||||
# sandbox:
|
# sandbox:
|
||||||
# use: deerflow.community.aio_sandbox:AioSandboxProvider
|
# use: deerflow.community.aio_sandbox:AioSandboxProvider
|
||||||
# provisioner_url: http://provisioner:8002
|
# 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
|
# # Note: provisioner-created Pods use the provisioner's SANDBOX_IMAGE
|
||||||
# # environment variable, not sandbox.image from this config file.
|
# # environment variable, not sandbox.image from this config file.
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,9 @@ services:
|
|||||||
# Override K8S API server URL since kubeconfig uses 127.0.0.1
|
# Override K8S API server URL since kubeconfig uses 127.0.0.1
|
||||||
# which is unreachable from inside the container
|
# which is unreachable from inside the container
|
||||||
- K8S_API_SERVER=https://host.docker.internal:26443
|
- 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_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@ -191,6 +194,9 @@ services:
|
|||||||
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
|
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
|
||||||
- DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_ROOT}/skills
|
- DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_ROOT}/skills
|
||||||
- DEER_FLOW_SANDBOX_HOST=host.docker.internal
|
- 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.
|
# 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.
|
# 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
|
- NO_PROXY=${NO_PROXY:-}${NO_PROXY:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,host.docker.internal
|
||||||
|
|||||||
@ -32,11 +32,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import urllib3
|
import urllib3
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from kubernetes import client as k8s_client
|
from kubernetes import client as k8s_client
|
||||||
from kubernetes import config as k8s_config
|
from kubernetes import config as k8s_config
|
||||||
from kubernetes.client.rest import ApiException
|
from kubernetes.client.rest import ApiException
|
||||||
@ -81,6 +82,7 @@ DEFAULT_USER_ID = "default"
|
|||||||
# Path to the kubeconfig *inside* the provisioner container.
|
# Path to the kubeconfig *inside* the provisioner container.
|
||||||
# Typically the host's ~/.kube/config is mounted here.
|
# Typically the host's ~/.kube/config is mounted here.
|
||||||
KUBECONFIG_PATH = os.environ.get("KUBECONFIG_PATH", "/root/.kube/config")
|
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
|
# 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
|
# 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 = 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 ───────────────────────────────────────────
|
# ── Request / Response models ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user