- Replace user:token cookies with opaque server-side sessions, revocable on logout, delete, recreate and expiry; bind every credential to the path user and add cross-user regression tests. - Rate-limit /users and /login, add optional signup invite codes and form same-origin checks, and unify login failures to 401. - Require explicit ALLOW_INSECURE_COOKIES for plain HTTP, use __Host-dsh_gw_session when secure, and fix secure cookie deletion. - Strip client-supplied forwarding/SSO headers, add an optional upstream allowlist, per-user in-flight and WebSocket message caps, credential-free handshake errors, and sweep-time state pruning. - Digest-pin base images, hash-pin gateway dependencies, verify DSH_COMMIT in the build, and run the gateway image as non-root with a pinned Python base. - Update manifests, production checklist, architecture/security docs and add an SSO preflight script. BREAKING CHANGE: existing dsh_gw_session=<user>:<token> cookies are invalid; users must sign in again. Unknown users on POST /login now return 401 instead of 404, and the gateway refuses COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1. Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
20 KiB
DSH agents-as-a-service gateway
A single-file, browser-usable control + data plane for
DeepSeek Harness Web (DSH)
sandboxes running behind the agent-sandbox CRDs.
It does four things:
- Users — creates a
SandboxClaimfrom a warm pool, mints a bearer token (only its SHA-256 is stored as a claim annotation), reports derived state, and deletes the claim (cascading the sandbox and PVC). - Browser sessions — exchanges a user token for an HttpOnly session
cookie whose value is only an opaque, server-side session id (user and
expiry stay in the gateway's memory) so the DSH UI works in a browser at
/u/<user>/. Automation can still useAuthorization: Bearer <token>, which never comes from the cookie. - Reverse proxy — proxies HTTP (including SSE) and WebSocket traffic to
the sandbox's DSH Web server on
:3080, including DSH's own cookie handshake. Client-supplied forwarding/identity headers are stripped. - Optional SSO — when
AUTH_PROXY_HEADERis set, trusts the identity header an authenticating reverse proxy (for example oauth2-proxy) forwards, auto-provisions that user, and exposesPOST /recreatefor a clean restart.
The DSH version pin lives in versions.env
(DSH_VERSION plus the optional repository RELEASE_REVISION); the example
manifests and published images use the release identity built from it (currently
dsh-v0.2.1-rc.5). DSH binds :3080, gates the UI and /api behind a
dsh-auth-* cookie and requires a WebSocket at /api/remote.mux.
Flow
browser / curl gateway cluster
| | |
| POST /users {"user":"alice"} | |
|------------------------------>| create SandboxClaim ---------->|
| | wait Ready |
| 201 {token} + session cookie | |
|<------------------------------| |
| GET /u/alice/ (cookie) | |
|------------------------------>| claim -> sandbox -> pod |
| | read pod logs (pods/log) |
| | find "dsh web: ...?token=..." |
| | GET http://<fqdn>:3080/?token= |
| | capture dsh-auth-* Set-Cookie |
| | proxy with Host/Origin kept |
| HTML / SSE / WebSocket |<----------------------------->|
|<------------------------------| |
The launch token is printed by DSH to its pod stdout:
dsh web: http://<podIP>:3080/?token=<base64url>
The gateway reads the tail of the pod logs, parses the last such line and
performs the ?token= handshake itself with the browser's Host header. The
resulting dsh-auth-* cookie is cached per (user, pod UID, Host) and injected
into proxied requests. The browser never sees the launch token or the bearer
token. A failed handshake returns 503 with Retry-After: 10.
API
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/healthz |
none | Liveness/readiness. Never touches the cluster. |
GET |
/ |
none | Landing page with signup + token login forms (plus an invite-code input when SIGNUP_INVITE_CODE is set). With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
POST |
/users |
none | Signup. JSON body {"user": "<dns-1123 label>"} or form field user; when SIGNUP_INVITE_CODE is set, an invite form field or X-Signup-Invite header must match it (403 otherwise, checked before the user lookup). JSON returns 201 when Ready, 202 when accepted but still Waking; form returns 303 to /u/<user>/. Both set the session cookie. 409 if the user exists, 400 if invalid, 413 if the body is too large, 429 when the auth rate limit is hit. |
POST |
/login |
none | Form (user, token) returns 303 to /u/<user>/; JSON returns 200. Sets the session cookie. 401 on a bad token and for an unknown user (no username enumeration). 429 when the auth rate limit is hit. Both auth routes reject a form post whose Origin/Referer contradicts Host (403) when the browser sent one. |
POST |
/logout |
none | Revokes the server-side session, clears the cookie and 303s to /. |
POST |
/recreate |
trusted proxy header | SSO only. Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and 303s to /u/<user>/. 401 without a valid identity header; cookie-authenticated calls must be same-origin. |
GET |
/users/{user} |
bearer or cookie | Derived state: Provisioning, Waking, Ready, Suspending, Suspended. |
DELETE |
/users/{user} |
bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin Origin/Referer. |
GET |
/u/{user} |
— | 308 redirect to /u/{user}/. |
* |
/u/{user}/{path} |
bearer or cookie | HTTP proxy to DSH. Waits for a suspended sandbox to wake. Wakes on connect. |
WS |
/u/{user}/{path} |
bearer or cookie | WebSocket proxy (e.g. /api/remote.mux). |
Auth
Authorization: Bearer <token>is checked with a timing-safe SHA-256 comparison against theaaas.example.com/token-sha256claim annotation.- The browser session cookie is opaque: its value is a random
server-side session id; the user and expiry live in gateway memory
(
GatewayState.sessions). A cookie can never be edited into another user's credentials, a session id is not accepted as a bearer token, and a bearer token never authorizes a different user. The cookie name defaults to__Host-dsh_gw_sessionwhenCOOKIE_SECURE=true(HTTPS) and todsh_gw_sessionfor plain HTTP; attributes are HttpOnly, SameSite=Lax, Path=/ and (withCOOKIE_SECURE) Secure. - Sessions are revoked on
POST /logout, onDELETE /users/{user}, onPOST /recreate, and when they expire (looked up on every request; the sweeper also drops expired entries).from_env()refuses to start withoutCOOKIE_SECURE=trueor the explicitALLOW_INSECURE_COOKIES=1opt-out for local HTTP. - Cookie-authenticated mutations (
POST,PUT,PATCH,DELETE) and cookie-authenticated WebSocket upgrades require the requestOrigin(orRefererfallback) to match theHostheader. Missing or mismatched cross-origin metadata is rejected (403HTTP, close code4401for WS). Form signup/login additionally reject a cross-originOrigin/Refererwhen the browser sent one, before the body is read. POST /usersandPOST /loginare rate limited per client:AUTH_RATE_LIMITattempts perAUTH_RATE_WINDOWseconds (default 30/60,0disables). The client key is the rightmostX-Forwarded-Forentry when present (the address the last trusted proxy saw, not a spoofable leftmost value), else the socket peer. Over-limit requests get429+Retry-Afterbefore the body is read.- Proxy requests strip client-supplied forwarding/identity headers in both
HTTP and WebSocket paths:
Forwarded, everyX-Forwarded-*(includingX-Forwarded-User), everyX-Auth-Request-*, andX-Real-IP. The gateway sets its ownX-Forwarded-For/Host/Proto/Portvalues from the connection.UPSTREAM_HEADER_ALLOWLISToptionally narrows the forwarded client headers further (comma-separated, case-insensitive); unset keeps the denylist behavior, and the gateway's own values are always added. - The gateway's bearer token, the browser
Cookieheader and any?token=query parameter are never forwarded upstream. Only the cacheddsh-auth-*cookie is injected. - Upstream
Set-Cookieheaders are dropped.Locationheaders that are absolute paths are rewritten under/u/<user>/, and anytokenquery parameter is stripped. - Concurrency is capped per user:
MAX_IN_FLIGHT_PER_USERproxied requests (default 32) before the gateway answers503+Retry-After(HTTP) or closes the WebSocket with1011. Messages received from DSH are limited toWS_MAX_MESSAGE_BYTES(default 16 MiB); browser-to-DSH frames are bounded by uvicorn's own WebSocket size limit.
SSO (opt-in)
By default the gateway is the only authenticator: users sign up with a token or
the session cookie, and the gateway ignores all identity headers. Set
AUTH_PROXY_HEADER to enable SSO behind an authenticating reverse proxy such as
oauth2-proxy:
kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-User
When set, the gateway treats a request carrying that header as authenticated as
that user, provided the value is a valid DNS-1123 label; the header is never
trusted when the variable is unset. The landing page then auto-provisions the
user (creates their claim idempotently, rotates the claim token when a new
session is needed and mints an opaque session cookie) and
redirects to /u/<user>/, and POST /recreate becomes available for a clean
restart. The base manifests leave it unset on purpose; the
SSO exposure variant documents how to
enable it on the Deployment (kubectl set env). That variant also ships
75-networkpolicies.yaml, which restricts the gateway to oauth2-proxy and
oauth2-proxy's egress to the gateway, DNS and public HTTPS.
Trust boundary — this is the security-critical part. A trusted identity header is only safe when nothing else can reach the gateway. The gateway Service must stay ClusterIP and be reachable exclusively through the authenticating proxy; if a client can reach
dsh-gateway:8080directly (a second Ingress/Gateway/Route, a port-forward on a shared host, a broad NetworkPolicy), it can sendX-Forwarded-Useritself and impersonate any user. The variant's75-networkpolicies.yamlrestricts pod traffic to the proxy; a custom exposure without an equivalent policy has no network enforcement. TreatAUTH_PROXY_HEADERas "the proxy already authenticated this request", not as an authentication mechanism of its own. Disable SSO by removing the variable: the header is then ignored and bearer-token/session auth applies again.
Proxy failure semantics
| Situation | Result |
|---|---|
| Unknown user / claim | 404 |
| Missing or invalid credentials | 401 |
Unknown user on /login |
401 (same as a bad token; no username enumeration) |
| Cross-origin cookie mutation / cross-origin form signup or login | 403 |
Auth route over AUTH_RATE_LIMIT |
429 + Retry-After, before the body is read |
Request body over MAX_BODY_MB |
413 |
| Client aborts mid-body, before the response starts | 499 (CLIENT_CLOSED_REQUEST). Nothing is forwarded to DSH and nothing can be delivered; it is not an error, so no traceback is logged. Covers the body-reading control routes (POST /users, POST /login) as well as the proxy. |
| Client aborts mid-stream (SSE, long response) | The response already started, so its upstream status stands (typically 200); the stream simply ends. No traceback, and the in-flight slot is released as the relay unwinds. |
| Sandbox not provisioned, wake timeout, handshake failure | 503 + Retry-After: 10. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
User over MAX_IN_FLIGHT_PER_USER |
503 + Retry-After (HTTP); the existing in-flight requests are untouched |
| Upstream connection/protocol failure | 502 |
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close 4401 |
| WS per-user concurrency cap | accept, then close 1011 |
| WS upstream/handshake failure | accept, then close 1011 |
WS message from DSH over WS_MAX_MESSAGE_BYTES |
the upstream/relay closes the connection (websockets' message-too-big path) |
A close code can only be delivered after the WebSocket upgrade is accepted;
Starlette would turn an early close into an HTTP 403, which is why WS auth
failures are 101, then close 4401.
An aborted request is deliberately silent: uvicorn runs with --no-access-log,
and the 499 cannot reach a peer that has already left (uvicorn's send() is
a no-op once the socket is closed). Sandbox resolution, the wake and the DSH
handshake run before the body is read, so an aborted upload can cost a wake or
a handshake but never reaches DSH.
Configuration
All values are read from the environment by Settings.from_env(); every one
can also be injected into create_app for tests.
| Variable | Default | Meaning |
|---|---|---|
NAMESPACE |
dsh-demo |
Namespace holding the claims, sandboxes and pods. |
POOL |
dsh-pool |
warmPoolRef.name for new claims. |
IDLE_TIMEOUT |
60 |
Seconds of inactivity before the sweeper suspends a sandbox. Only claims the gateway created (annotation aaas.example.com/managed-by=dsh-gateway) are ever suspended. |
WAKE_TIMEOUT |
120 |
Seconds to wait for a sandbox to become Ready (signup and wake-on-connect). |
DSH_PORT |
3080 |
Upstream DSH port. |
DSH_CONTAINER |
dsh-web |
Container whose logs carry the launch token. |
DSH_LOG_TAIL_LINES |
2000 |
How many log lines to scan for the launch token. |
COOKIE_NAME |
__Host-dsh_gw_session when secure, else dsh_gw_session |
Browser session cookie name. Only an opaque session id is stored in it. |
COOKIE_SECURE |
false |
Add Secure to the session cookie. Required unless ALLOW_INSECURE_COOKIES=1 is set for local HTTP. |
ALLOW_INSECURE_COOKIES |
0 |
Explicit opt-out for plain-HTTP demos. Without it, Settings.from_env() refuses to start unless COOKIE_SECURE=true. |
COOKIE_MAX_AGE |
604800 |
Session lifetime in seconds (server-side expiry and cookie Max-Age). |
MAX_BODY_MB |
32 |
Maximum buffered request body (and 413 threshold). |
HTTP_TIMEOUT |
300 |
Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have no read timeout, so an idle event stream is not cut every five minutes. |
SWEEP_INTERVAL |
15 |
Seconds between idle sweeps. Sweeps also prune expired sessions and rate buckets and orphaned per-user state. |
SIGNUP_INVITE_CODE |
unset | When set, POST /users requires the matching invite form field or X-Signup-Invite header (timing-safe compare); the landing page then shows an invite input. Unset leaves signup open. |
AUTH_RATE_LIMIT |
30 |
Attempts allowed per client per AUTH_RATE_WINDOW on POST /users and POST /login; 0 disables the limit. |
AUTH_RATE_WINDOW |
60 |
Rate-limit window in seconds. |
UPSTREAM_HEADER_ALLOWLIST |
unset | Comma-separated, case-insensitive allowlist of client headers to forward upstream. Unset keeps the denylist behavior; forwarding/identity headers are stripped in both modes. |
MAX_IN_FLIGHT_PER_USER |
32 |
Concurrent proxied requests per user before 503/WS 1011. 0 disables the cap. |
WS_MAX_MESSAGE_BYTES |
16777216 |
Largest WebSocket message (16 MiB) accepted from DSH. Browser-to-DSH frames are bounded by uvicorn's own limit. |
AUTH_PROXY_HEADER |
unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. X-Forwarded-User). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see SSO). |
The shipped manifests/50-gateway.yaml sets IDLE_TIMEOUT=360 and
WAKE_TIMEOUT=240 for the demo; the e2e script overrides them to 60/120 so the
smoke test does not wait minutes for the sweeper. It also sets
COOKIE_SECURE=false together with the explicit ALLOW_INSECURE_COOKIES=1
acknowledgement because the demo is reached over plain HTTP (a Secure cookie
would never be sent). HTTPS deployments must set COOKIE_SECURE=true.
Required RBAC
The gateway's ServiceAccount needs, in its namespace:
sandboxclaims(extensions.agents.x-k8s.io): get, list, create, deletesandboxes(agents.x-k8s.io): get, list, patchpods: get, listpods/log: get
Running locally
The module has no import-time cluster access and the Kubernetes adapter is
lazy, so /healthz works without a kubeconfig:
python -m pip install -r requirements-dev.txt
python -m pytest -q
python -m ruff check .
# Uvicorn factory (loads in-cluster credentials only on the first claim call).
# from_env() insists on an explicit acknowledgement for plain HTTP; behind TLS
# set COOKIE_SECURE=true instead.
ALLOW_INSECURE_COOKIES=1 uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
Container image, from the repository root:
make gateway # builds dsh-aaas-gateway:dev from this directory
The equivalent direct build is
docker build -t dsh-aaas-gateway:dev examples/dsh-agents-as-a-service/gateway.
For kind, retag it to the image reference in
examples/dsh-agents-as-a-service/manifests/50-gateway.yaml and kind load it.
Published images are nested below the main image as
<GHCR_IMAGE>/dsh-agents-as-a-service/gateway:<identity>, where the identity
comes from versions.env — see
docs/versioning.md.
The image runs as UID 10000, sets PYTHONDONTWRITEBYTECODE=1 and
HOME=/tmp (read-only-rootfs compatible), listens on 8080, and disables
uvicorn's access log so bearer tokens in URLs are not logged.
Known limitations
- Log scraping is fragile. The launch token is discovered from pod logs. Log rotation, a very chatty container, or DSH changing its startup message breaks the handshake. Prefer a first-class token/credential API when DSH offers one.
- Single replica, in-memory state. The handshake cookie cache, the opaque session table, idle activity clocks, in-flight counters and rate-limit buckets live in one process. Running more than one replica would need shared state (or sticky routing), otherwise each replica handshakes independently, the sweeper may race another replica, and a session/rate budget is only seen by the replica that created it.
- Sessions die with the process. An opaque session id is valid only in the
process that minted it and expires after
COOKIE_MAX_AGE; a restart logs every browser out (tokens are unaffected). - In-memory token cache has no TTL. It is keyed by pod UID, so pod restarts invalidate it naturally; otherwise entries survive until the pod or claim is deleted.
- No TLS termination. Run behind an ingress/load balancer and set
COOKIE_SECURE=true.Settings.from_env()refuses to start without it unlessALLOW_INSECURE_COOKIES=1explicitly acknowledges plain-HTTP sessions. - Origin-based CSRF protection only. Cookie-auth mutations require a
same-origin
Origin/Referer, and form signup/login reject a contradicting origin when the browser sends one; there is no per-form CSRF token. Auth routes have per-client rate limiting and login throttling, but the budget is in-process (per replica) and keyed by the rightmostX-Forwarded-Forvalue. - HTTP request bodies are buffered up to
MAX_BODY_MB; uploads are not streamed. Responses (including SSE) are streamed. - Upstream cookies are not forwarded. DSH cannot rotate its own
dsh-auth-*cookie through the gateway; on an upstream401the gateway re-runs the handshake once and replays the request. - Only DSH on
:3080is proxied. There is no dashboard/API-port split and no platform API key injection. - SSO trusts a header. With
AUTH_PROXY_HEADERset, any client that can reach the gateway Service directly can spoof the identity header. The gateway cannot enforce the trust boundary itself: keep the Service ClusterIP and reachable only through the authenticating proxy. The SSO exposure variant ships a NetworkPolicy that restricts pod traffic to the proxy; without it there is no mTLS or signed identity assertion.