Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/controlplane-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ jobs:
run: pip install -r ./apps/controlplane-api/requirements.txt

- name: Run control-plane API self-check
env:
# The self-check exercises endpoint wiring, not auth. Opt out of
# fail-closed auth explicitly so the unauthenticated probes succeed.
CONTROLPLANE_API_AUTH_DISABLED: "1"
run: |
python - <<'PY'
import sys
Expand Down
16 changes: 12 additions & 4 deletions apps/controlplane-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ docker build -f apps/controlplane-api/Dockerfile -t squirrelops-controlplane-api
- `PINGTING_PYTHON_BIN` (optional explicit Python executable)
- `PINGTING_STATUS_MAX_AGE_SECONDS` (default: `120`)
- `PINGTING_STATUS_TIMEOUT_SECONDS` (default: `20`)
- `CONTROLPANE_API_AUTH_TOKEN` (optional shared API token)
- `CONTROLPANE_CORS_ALLOW_ORIGINS` (comma-separated origins)
- `CONTROLPANE_ACTION_TIMEOUT_SECONDS` (default: `900`)
- `CONTROLPANE_BOOTSTRAP_SCRIPT_PATH` (default: `scripts/bootstrap_repos.sh`)
- `CONTROLPLANE_API_AUTH_TOKEN` (shared API token; when unset the API fails closed and returns 503)
- `CONTROLPLANE_API_AUTH_DISABLED` (set to `1` to intentionally run without auth, trusted local use only)
- `CONTROLPLANE_CORS_ALLOW_ORIGINS` (comma-separated origins)
- `CONTROLPLANE_ACTION_TIMEOUT_SECONDS` (default: `900`)
- `CONTROLPLANE_ACTION_STATE_PATH` (default: `data/controlplane/actions-state.json`)
- `CONTROLPLANE_BOOTSTRAP_SCRIPT_PATH` (default: `scripts/bootstrap_repos.sh`)
- `CONTROLPLANE_SMOKE_SCRIPT_PATH` (default: `harness/smoke.sh`)
- `CONTROLPLANE_UPDATE_SCRIPT_PATH` (default: `scripts/update_repos.sh`)

> The control-plane variables were previously misspelled `CONTROLPANE_*` (missing
> the second "L"). Those legacy names still work as deprecated aliases, but the
> canonical `CONTROLPLANE_*` spelling above takes precedence and should be used.
47 changes: 35 additions & 12 deletions apps/controlplane-api/controlplane_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
from datetime import datetime, timezone
import hmac
from pathlib import Path
import sys
from typing import Any
Expand All @@ -27,6 +28,13 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()


def _token_matches(provided: str | None, expected: str) -> bool:
"""Constant-time comparison that fails closed on empty/missing tokens."""
if not provided or not expected:
return False
return hmac.compare_digest(provided, expected)


def _extract_bearer_token(raw_value: str | None) -> str | None:
if raw_value is None:
return None
Expand All @@ -41,6 +49,8 @@ def _extract_bearer_token(raw_value: str | None) -> str | None:


def _resolve_request_token(request: Request) -> str | None:
# HTTP callers must use headers. Query-string tokens are rejected here because
# they leak into access logs, browser history, and Referer headers.
bearer = _extract_bearer_token(request.headers.get("authorization"))
if bearer:
return bearer
Expand All @@ -49,11 +59,6 @@ def _resolve_request_token(request: Request) -> str | None:
token = api_key.strip()
if token:
return token
token_query = request.query_params.get("token")
if token_query:
token = token_query.strip()
if token:
return token
return None


Expand Down Expand Up @@ -119,9 +124,13 @@ def create_app(settings: ControlPlaneSettings | None = None) -> FastAPI:
)

async def relay_deception_websocket(*, websocket: WebSocket, upstream_url: str) -> None:
if settings.api_auth_token and _resolve_websocket_token(websocket) != settings.api_auth_token:
await websocket.close(code=4401, reason="authentication required")
return
if not settings.api_auth_disabled:
if not settings.api_auth_token:
await websocket.close(code=4503, reason="server auth not configured")
return
if not _token_matches(_resolve_websocket_token(websocket), settings.api_auth_token):
await websocket.close(code=4401, reason="authentication required")
return

await websocket.accept()
upstream_token = settings.clownpeanuts_ws_token
Expand Down Expand Up @@ -153,14 +162,28 @@ async def relay_deception_websocket(*, websocket: WebSocket, upstream_url: str)

@app.middleware("http")
async def auth_middleware(request: Request, call_next: Any) -> Response:
if not settings.api_auth_token:
return await call_next(request)
# CORS preflight and the unauthenticated health probe always pass.
if request.method.upper() == "OPTIONS":
return await call_next(request)
if request.url.path == "/health":
return await call_next(request)
token = _resolve_request_token(request)
if token != settings.api_auth_token:
# Fail closed: authentication may only be skipped when an operator has
# explicitly opted out via CONTROLPANE_API_AUTH_DISABLED. An unset token
# is treated as a misconfiguration, not as "allow everyone".
if settings.api_auth_disabled:
return await call_next(request)
if not settings.api_auth_token:
return JSONResponse(
status_code=503,
content={
"detail": (
"server authentication is not configured; set "
"CONTROLPANE_API_AUTH_TOKEN, or set "
"CONTROLPANE_API_AUTH_DISABLED=1 to run without auth"
)
},
)
if not _token_matches(_resolve_request_token(request), settings.api_auth_token):
return JSONResponse(
status_code=401,
content={"detail": "authentication required"},
Expand Down
54 changes: 44 additions & 10 deletions apps/controlplane-api/controlplane_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,33 @@
import os


def _env_raw(name: str) -> str | None:
"""Read an env var, preferring the canonical name.

The control-plane variables were historically misspelled "CONTROLPANE_"
(missing the second "L"). The correct "CONTROLPLANE_" spelling is now
canonical; the misspelled name is still honored as a deprecated alias so
"fixing the typo" can never silently change behavior (for example, leaving
authentication unconfigured).
"""
value = os.getenv(name)
if value is not None:
return value
if name.startswith("CONTROLPLANE_"):
legacy = "CONTROLPANE_" + name[len("CONTROLPLANE_"):]
legacy_value = os.getenv(legacy)
if legacy_value is not None:
return legacy_value
return None


def _env_str(name: str, default: str) -> str:
value = _env_raw(name)
return value if value is not None else default


def _parse_int_env(name: str, default: int) -> int:
raw = os.getenv(name)
raw = _env_raw(name)
if raw is None:
return default
try:
Expand All @@ -15,6 +40,13 @@ def _parse_int_env(name: str, default: int) -> int:
return default


def _parse_bool_env(name: str, default: bool = False) -> bool:
raw = _env_raw(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}


def _parse_origins(raw: str) -> list[str]:
items = [item.strip() for item in raw.split(",")]
return [item for item in items if item]
Expand Down Expand Up @@ -43,6 +75,7 @@ class ControlPlaneSettings:
update_script_path: Path
cors_allow_origins: list[str]
api_auth_token: str
api_auth_disabled: bool


def load_settings() -> ControlPlaneSettings:
Expand Down Expand Up @@ -81,26 +114,27 @@ def load_settings() -> ControlPlaneSettings:
pingting_status_max_age_seconds=_parse_int_env("PINGTING_STATUS_MAX_AGE_SECONDS", 120),
pingting_command_timeout_seconds=_parse_int_env("PINGTING_STATUS_TIMEOUT_SECONDS", 20),
orchestration_state_path=Path(
os.getenv(
"CONTROLPANE_ACTION_STATE_PATH",
_env_str(
"CONTROLPLANE_ACTION_STATE_PATH",
str(repo_root / "data" / "controlplane" / "actions-state.json"),
)
).expanduser(),
orchestration_action_timeout_seconds=_parse_int_env("CONTROLPANE_ACTION_TIMEOUT_SECONDS", 900),
orchestration_action_timeout_seconds=_parse_int_env("CONTROLPLANE_ACTION_TIMEOUT_SECONDS", 900),
bootstrap_script_path=Path(
os.getenv("CONTROLPANE_BOOTSTRAP_SCRIPT_PATH", str(repo_root / "scripts" / "bootstrap_repos.sh"))
_env_str("CONTROLPLANE_BOOTSTRAP_SCRIPT_PATH", str(repo_root / "scripts" / "bootstrap_repos.sh"))
).expanduser(),
smoke_script_path=Path(
os.getenv("CONTROLPANE_SMOKE_SCRIPT_PATH", str(repo_root / "harness" / "smoke.sh"))
_env_str("CONTROLPLANE_SMOKE_SCRIPT_PATH", str(repo_root / "harness" / "smoke.sh"))
).expanduser(),
update_script_path=Path(
os.getenv("CONTROLPANE_UPDATE_SCRIPT_PATH", str(repo_root / "scripts" / "update_repos.sh"))
_env_str("CONTROLPLANE_UPDATE_SCRIPT_PATH", str(repo_root / "scripts" / "update_repos.sh"))
).expanduser(),
cors_allow_origins=_parse_origins(
os.getenv(
"CONTROLPANE_CORS_ALLOW_ORIGINS",
_env_str(
"CONTROLPLANE_CORS_ALLOW_ORIGINS",
"http://127.0.0.1:4317,http://localhost:4317,http://127.0.0.1:3001,http://localhost:3001,http://127.0.0.1:3000,http://localhost:3000",
)
),
api_auth_token=os.getenv("CONTROLPANE_API_AUTH_TOKEN", "").strip(),
api_auth_token=_env_str("CONTROLPLANE_API_AUTH_TOKEN", "").strip(),
api_auth_disabled=_parse_bool_env("CONTROLPLANE_API_AUTH_DISABLED", default=False),
)
98 changes: 98 additions & 0 deletions apps/controlplane-dashboard/app/api/cp/[...slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { NextRequest } from "next/server"

// Server-side proxy (BFF) to the control-plane API.
//
// The browser talks only to this same-origin route; the control-plane auth token
// is read here from a SERVER-ONLY env var (no NEXT_PUBLIC prefix) and never ships
// to the client bundle. The upstream host is fixed by configuration, so callers
// cannot redirect this proxy at an arbitrary destination.

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

const PROXY_PREFIX = "/api/cp"

const upstreamBase = (
process.env.CONTROLPLANE_API_INTERNAL_BASE ??
process.env.CONTROLPANE_API_INTERNAL_BASE ??
"http://127.0.0.1:8199"
).replace(/\/+$/, "")

const upstreamToken = (
process.env.CONTROLPLANE_API_TOKEN ??
process.env.CONTROLPANE_API_TOKEN ??
""
).trim()

// Hop-by-hop and identity headers that must not be forwarded upstream.
const STRIPPED_REQUEST_HEADERS = new Set([
"host",
"connection",
"content-length",
"authorization",
"cookie",
"x-api-key",
])

const buildUpstreamUrl = (request: NextRequest): string => {
const pathname = request.nextUrl.pathname
const suffix = pathname.startsWith(PROXY_PREFIX) ? pathname.slice(PROXY_PREFIX.length) : pathname
const normalized = suffix.startsWith("/") ? suffix : `/${suffix}`
return `${upstreamBase}${normalized}${request.nextUrl.search}`
}

const proxy = async (request: NextRequest): Promise<Response> => {
const url = buildUpstreamUrl(request)

const headers = new Headers()
request.headers.forEach((value, key) => {
if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) {
headers.set(key, value)
}
})
if (upstreamToken) {
headers.set("authorization", `Bearer ${upstreamToken}`)
}

const method = request.method.toUpperCase()
const hasBody = method !== "GET" && method !== "HEAD"
const body = hasBody ? await request.arrayBuffer() : undefined

let upstream: Response
try {
upstream = await fetch(url, {
method,
headers,
body,
redirect: "manual",
cache: "no-store",
})
} catch {
return new Response(JSON.stringify({ detail: "control-plane API unreachable" }), {
status: 502,
headers: { "content-type": "application/json" },
})
}

const responseHeaders = new Headers()
const contentType = upstream.headers.get("content-type")
if (contentType) {
responseHeaders.set("content-type", contentType)
}
const wwwAuth = upstream.headers.get("www-authenticate")
if (wwwAuth) {
responseHeaders.set("www-authenticate", wwwAuth)
}

return new Response(upstream.body, {
status: upstream.status,
headers: responseHeaders,
})
}

export const GET = proxy
export const POST = proxy
export const PUT = proxy
export const PATCH = proxy
export const DELETE = proxy
export const OPTIONS = proxy
51 changes: 19 additions & 32 deletions apps/controlplane-dashboard/app/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,32 @@
const CONTROLPLANE_BASE = process.env.NEXT_PUBLIC_CONTROLPANE_API ?? "http://127.0.0.1:8199"
const CONTROLPLANE_WS_BASE = process.env.NEXT_PUBLIC_CONTROLPANE_WS ?? "ws://127.0.0.1:8199"
const API_BASE =
process.env.NEXT_PUBLIC_DECEPTION_API ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_API ??
`${CONTROLPLANE_BASE}/deception`
// REST calls go through the same-origin BFF at /api/cp/*, which injects the
// control-plane auth token server-side (see app/api/cp/[...slug]/route.ts).
// No API token is shipped to the browser for REST traffic.
const API_BASE = "/api/cp/deception"

// WebSockets cannot be proxied by `next start`, so they connect directly to the
// control-plane API. A browser WebSocket cannot send headers, so when the API
// requires auth the token must travel as a query parameter and is therefore
// visible to the client. This is opt-in: leave NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN
// unset to keep all secrets off the client (the dashboard transparently falls
// back to REST polling through the BFF), or set it to the API token to enable
// the live WebSocket stream and accept that it is client-visible.
const CONTROLPLANE_WS_BASE =
process.env.NEXT_PUBLIC_CONTROLPLANE_WS ??
process.env.NEXT_PUBLIC_CONTROLPANE_WS ??
"ws://127.0.0.1:8199"
const WS_BASE =
process.env.NEXT_PUBLIC_DECEPTION_WS ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS ??
`${CONTROLPLANE_WS_BASE}/deception/ws/events`
const WS_THEATER_BASE =
process.env.NEXT_PUBLIC_DECEPTION_WS_THEATER ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS_THEATER ??
`${CONTROLPLANE_WS_BASE}/deception/ws/theater/live`
const API_AUTH_TOKEN = (
process.env.NEXT_PUBLIC_CONTROLPANE_API_TOKEN ??
process.env.NEXT_PUBLIC_DECEPTION_API_TOKEN ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_API_TOKEN ??
""
).trim()
const WS_AUTH_TOKEN = (
process.env.NEXT_PUBLIC_CONTROLPANE_API_TOKEN ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_WS_TOKEN ??
process.env.NEXT_PUBLIC_DECEPTION_WS_TOKEN ??
process.env.NEXT_PUBLIC_DECEPTION_API_TOKEN ??
process.env.NEXT_PUBLIC_CLOWNPEANUTS_API_TOKEN ??
process.env.NEXT_PUBLIC_CONTROLPLANE_WS_TOKEN ??
process.env.NEXT_PUBLIC_CONTROLPANE_WS_TOKEN ??
""
).trim()

const withApiAuthHeaders = (headers?: HeadersInit): Headers => {
const merged = new Headers(headers)
if (API_AUTH_TOKEN && !merged.has("Authorization") && !merged.has("X-API-Key")) {
merged.set("Authorization", `Bearer ${API_AUTH_TOKEN}`)
}
return merged
}

const cpFetch = (url: string, init?: RequestInit): Promise<Response> => {
const nextInit = { ...(init ?? {}) }
nextInit.headers = withApiAuthHeaders(init?.headers)
return fetch(url, nextInit)
}
const cpFetch = (url: string, init?: RequestInit): Promise<Response> => fetch(url, init)

const withApiTokenQuery = (url: string): string => {
if (!WS_AUTH_TOKEN) {
Expand Down
Loading
Loading