mirror of
https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git
synced 2026-11-03 09:23:25 +00:00
* fix(gateway): answer client-aborted requests with 499, not a traceback A browser reload, back navigation or proxy timeout can abort a request while its body is still arriving. Starlette then raises ClientDisconnect from request.stream(); nothing caught it, so ServerErrorMiddleware logged a full ERROR traceback per aborted request and buried real faults. The request never reaches DSH (the body is buffered before the upstream send) and the peer can no longer be answered, so this is not an error. - Register a ClientDisconnect exception handler that answers 499 (CLIENT_CLOSED_REQUEST). One handler covers the proxy and the body-reading control routes (signup, login). A request already streaming keeps its upstream status because the response has started. - Make _release await-free, and reach it even when the upstream close is cancelled. CancelledError is a BaseException, so suppress(Exception) around upstream.aclose() skipped the release and stranded the in-flight slot, leaving that user's sandbox looking busy forever (the idle sweeper then never suspends it). - Add raw-ASGI regression tests: mid-body abort on the proxy and on signup, mid-stream abort, a cancelled release, and a cancelled upstream close. - Document the 499 semantics in the gateway README failure table and the architecture failure modes. Verified: make check, make test (114 passed), make lint. Against the pinned uvicorn 0.54 the abort reproduces the original traceback before the fix and logs no traceback after it. * chore(deps): bump gateway starlette to 1.10.0 and ruff to 0.17.2 Absorbs Dependabot PR #27 against current main: that branch predates the dsh-v0.2.1-rc.5 changelog/release commits, so merging it as-is would have reverted them. starlette 1.10.0 reworks URL-encoded form parsing (fields are built in the parser callbacks, and the last field is kept when the stream ends without an empty chunk) and clears tracebacks of handled exceptions. The gateway reads JSON and form bodies, so the existing suite exercises signup/login forms and the 499 client-abort path against the new release. ruff 0.17.2 only adds preview and fix-safety changes; the configured rule set reports nothing new. Verified: make check, make test (114 passed), make lint, make gateway (image builds on python:3.12-slim and imports starlette 1.10.0), plus a direct ASGI abort probe under 1.10.0 (499, in-flight slot released, nothing forwarded). --------- Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
250 lines
14 KiB
Markdown
250 lines
14 KiB
Markdown
# DSH agents-as-a-service gateway
|
|
|
|
A single-file, browser-usable control + data plane for
|
|
[DeepSeek Harness Web (DSH)](https://github.com/deepseek-ai/deepseek-harness)
|
|
sandboxes running behind the `agent-sandbox` CRDs.
|
|
|
|
It does four things:
|
|
|
|
1. **Users** — creates a `SandboxClaim` from 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).
|
|
2. **Browser sessions** — exchanges a user token for an HttpOnly session
|
|
cookie so the DSH UI works in a browser at `/u/<user>/`. Automation can
|
|
still use `Authorization: Bearer <token>`.
|
|
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
|
|
the sandbox's DSH Web server on `:3080`, including DSH's own cookie
|
|
handshake.
|
|
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
|
|
header an authenticating reverse proxy (for example oauth2-proxy) forwards,
|
|
auto-provisions that user, and exposes `POST /recreate` for a clean restart.
|
|
|
|
The DSH version pin lives in [`versions.env`](../../../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`, serves `/`, gates `/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. 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`. 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. |
|
|
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token, `404` for an unknown user. |
|
|
| `POST` | `/logout` | none | Clears the session cookie and `303`s 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 `303`s 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 the `aaas.example.com/token-sha256` claim annotation.
|
|
* The browser session cookie is
|
|
`dsh_gw_session=<user>:<token>` (HttpOnly, SameSite=Lax, Path=/, optional
|
|
Secure). A session for user A never authorizes user B.
|
|
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
|
|
cookie-authenticated **WebSocket upgrades** require the request `Origin`
|
|
(or `Referer` fallback) to match the `Host` header. Missing or mismatched
|
|
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
|
|
* The gateway's bearer token, the browser `Cookie` header and any `?token=`
|
|
query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
|
|
cookie is injected.
|
|
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are
|
|
absolute paths are rewritten under `/u/<user>/`, and any `token` query
|
|
parameter is stripped.
|
|
|
|
### 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:
|
|
|
|
```bash
|
|
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, mints a rotated session token) 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](../manifests/exposure/gateway-api-sso/) 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:8080` directly (a
|
|
> second Ingress/Gateway/Route, a port-forward on a shared host, a broad
|
|
> NetworkPolicy), it can send `X-Forwarded-User` itself and impersonate any user.
|
|
> The variant's `75-networkpolicies.yaml` restricts pod traffic to the proxy; a
|
|
> custom exposure without an equivalent policy has no network enforcement.
|
|
> Treat `AUTH_PROXY_HEADER` as "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` |
|
|
| Cross-origin cookie mutation | `403` |
|
|
| 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` |
|
|
| Upstream connection/protocol failure | `502` |
|
|
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
|
|
| WS upstream/handshake failure | accept, then close `1011` |
|
|
|
|
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` | `dsh_gw_session` | Browser session cookie name. |
|
|
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie when served over TLS. |
|
|
| `COOKIE_MAX_AGE` | `604800` | Session cookie lifetime in seconds. |
|
|
| `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. |
|
|
| `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](#sso-opt-in)). |
|
|
|
|
### Required RBAC
|
|
|
|
The gateway's ServiceAccount needs, in its namespace:
|
|
|
|
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
|
|
* `sandboxes` (`agents.x-k8s.io`): get, patch
|
|
* `pods`: get
|
|
* `pods/log`: get
|
|
|
|
## Running locally
|
|
|
|
The module has no import-time cluster access and the Kubernetes adapter is
|
|
lazy, so `/healthz` works without a kubeconfig:
|
|
|
|
```bash
|
|
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)
|
|
uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
|
|
```
|
|
|
|
Container image, from the repository root:
|
|
|
|
```bash
|
|
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`](../../../versions.env) — see
|
|
[`docs/versioning.md`](../../../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, idle
|
|
activity clocks and in-flight counters live in one process. Running more
|
|
than one replica would need shared state (or sticky routing), otherwise each
|
|
replica handshakes independently and the sweeper may race another replica.
|
|
* **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; set
|
|
`COOKIE_SECURE=true` there.
|
|
* **Origin-based CSRF protection only.** Cookie-auth mutations require a
|
|
same-origin `Origin`/`Referer`; there is no per-form CSRF token, rate
|
|
limiting or login throttling.
|
|
* **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 upstream `401` the gateway
|
|
re-runs the handshake once and replays the request.
|
|
* **Only DSH on `:3080` is proxied.** There is no dashboard/API-port split and
|
|
no platform API key injection.
|
|
* **SSO trusts a header.** With `AUTH_PROXY_HEADER` set, 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.
|