An authenticated, per-env WebSocket relay that lets an authorized operator
attach to a running in-cluster eval pod in real time. The relay is a
transparent L4 byte pipe over Kubernetes pods/portforward — it never
parses or interprets the application protocol (e.g. Inspect ACP).
It is structured like the middleman/ service (sibling top-level dir,
src/ layout, uv + pyproject.toml, multi-stage Dockerfile, gunicorn +
UvicornWorker). The ECS Fargate / ALB / RBAC infrastructure lives in
infra/hawk/relay.py; the container listens on port 8080 and answers
GET / for health checks.
relay.gate.PreUpgradeGate is pure-ASGI middleware that runs before the
WebSocket handshake:
- Validate the Hawk token (reusing
hawk.core.auth.jwt_validator) → 401. - Validate the
Originheader against the allow-list → 403. - Authorize the run (model-group write access) → 403, then resolve its runner pod → 404 when the run has no live pod (e.g. it already finished). The authorization step runs first, so a 404 only ever reaches a caller already authorized for the run.
On failure it returns an HTTP status via the ASGI WebSocket Denial Response
extension and never sends websocket.accept, so no upgrade occurs
(JupyterHub CVE GHSA-w3vc-fx9p-wp4v). The token is read only from the
Authorization: Bearer header — no ?token= query fallback, which would
leak the token into access logs.
| Module | Responsibility | Owner |
|---|---|---|
auth.py |
Hawk-token extraction + validation → AuthContext |
this PR |
origin.py |
Pre-upgrade Origin allow-list check |
this PR |
gate.py |
Pre-upgrade authn + Origin ASGI gate (401/403 before upgrade) | this PR |
authz.py |
Per-run authz + server-side pod resolution/pin | Task 13 |
addressing.py |
Client-named port (Model A) + "acp" alias resolution |
Task 14 |
forwarder.py |
Portforward byte passthrough + isolation + keepalive | Task 15 |
audit.py |
Connection/decision audit incl. target port | Task 16 |
Environment variables are prefixed HAWK_RELAY_ (see relay/settings.py). The
JWT settings (HAWK_RELAY_TOKEN_ISSUER, HAWK_RELAY_TOKEN_AUDIENCE,
HAWK_RELAY_TOKEN_JWKS_URI, …) mirror the Hawk API's model_access_token_*
values so the relay validates the same operator tokens.
Errors report to Sentry (SENTRY_DSN), traces export via OpenTelemetry → AWS
X-Ray (gated by HAWK_OTEL_TRACING_ENABLED, using the shared
hawk.core.tracing), and logs are emitted as JSON carrying the active trace id.
Each accepted connection becomes one relay.attach span (run id, pod, target
port, principal, close reason); gate denials (401/403) are spans too. All three
signals scrub operator tokens before anything leaves the process
(relay.observability.scrubbing). Datadog ingests the X-Ray traces downstream.
Session-cap metrics land in CloudWatch (namespace Hawk/Relay) via EMF log
lines (relay.observability.metrics): active sessions (global and
per-principal, the limiter's live ZCARDs), admission rejections by reason, and
session duration by close reason. A CloudWatch alarm (infra/hawk/relay.py)
fires when the session limiter fails open (Valkey error or unconfigured).
uv sync
uv run pytest
uv run ruff check . && uv run ruff format . --check && uv run basedpyright