1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 09:23:25 +00:00

fix(gateway)!: replace token cookies with revocable sessions and harden abuse controls (#40)

- 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>
This commit is contained in:
SREDevOps.org Agent
2026-10-31 00:57:35 -03:00
committed by GitHub
parent 2f959e6afb
commit 1a929e3e24
33 changed files with 5450 additions and 319 deletions

View File

@@ -23,6 +23,9 @@ CEREBRAS_API_KEY=
AI_GATEWAY_API_KEY= AI_GATEWAY_API_KEY=
HF_TOKEN= HF_TOKEN=
DSH_VERSION=dsh-v0.2.1-rc.5 DSH_VERSION=dsh-v0.2.1-rc.5
# Full upstream commit SHA that DSH_VERSION resolves to; the main image build
# fails if the clone's HEAD differs (guards against a moved tag).
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
RELEASE_REVISION= RELEASE_REVISION=
PNPM_VERSION=11.28.5 PNPM_VERSION=11.28.5
# Container port, and the host port published by "make run". # Container port, and the host port published by "make run".

View File

@@ -29,6 +29,7 @@ jobs:
outputs: outputs:
identity: ${{ steps.meta.outputs.identity }} identity: ${{ steps.meta.outputs.identity }}
dsh_version: ${{ steps.meta.outputs.dsh_version }} dsh_version: ${{ steps.meta.outputs.dsh_version }}
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
pnpm_version: ${{ steps.meta.outputs.pnpm_version }} pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
release_revision: ${{ steps.meta.outputs.release_revision }} release_revision: ${{ steps.meta.outputs.release_revision }}
ghcr_image: ${{ steps.meta.outputs.ghcr_image }} ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
@@ -53,6 +54,7 @@ jobs:
{ {
echo "identity=$identity" echo "identity=$identity"
echo "dsh_version=$DSH_VERSION" echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION" echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=$RELEASE_REVISION" echo "release_revision=$RELEASE_REVISION"
echo "ghcr_image=$GHCR_IMAGE" echo "ghcr_image=$GHCR_IMAGE"
@@ -75,6 +77,7 @@ jobs:
dockerfile: Dockerfile dockerfile: Dockerfile
build-args: | build-args: |
DSH_VERSION=${{ needs.meta.outputs.dsh_version }} DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }} RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }} PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}

View File

@@ -48,6 +48,7 @@ jobs:
{ {
echo "identity=$identity" echo "identity=$identity"
echo "dsh_version=$DSH_VERSION" echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION" echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=${RELEASE_REVISION:-}" echo "release_revision=${RELEASE_REVISION:-}"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
@@ -66,6 +67,7 @@ jobs:
tags: dsh-web:ci tags: dsh-web:ci
build-args: | build-args: |
DSH_VERSION=${{ steps.meta.outputs.dsh_version }} DSH_VERSION=${{ steps.meta.outputs.dsh_version }}
DSH_COMMIT=${{ steps.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ steps.meta.outputs.release_revision }} RELEASE_REVISION=${{ steps.meta.outputs.release_revision }}
PNPM_VERSION=${{ steps.meta.outputs.pnpm_version }} PNPM_VERSION=${{ steps.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}

View File

@@ -43,7 +43,9 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt # Both files are fully hash-pinned (uv pip compile --generate-hashes);
# pip refuses to install anything not listed with a matching hash.
pip install --require-hashes -r requirements.txt -r requirements-dev.txt
- name: Lint - name: Lint
run: | run: |
set -euo pipefail set -euo pipefail

View File

@@ -34,6 +34,7 @@ jobs:
outputs: outputs:
identity: ${{ steps.meta.outputs.identity }} identity: ${{ steps.meta.outputs.identity }}
dsh_version: ${{ steps.meta.outputs.dsh_version }} dsh_version: ${{ steps.meta.outputs.dsh_version }}
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
pnpm_version: ${{ steps.meta.outputs.pnpm_version }} pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
release_revision: ${{ steps.meta.outputs.release_revision }} release_revision: ${{ steps.meta.outputs.release_revision }}
ghcr_image: ${{ steps.meta.outputs.ghcr_image }} ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
@@ -91,13 +92,28 @@ jobs:
echo "tag '$tag' does not exist in this repository" >&2 echo "tag '$tag' does not exist in this repository" >&2
exit 1 exit 1
fi fi
if ! git ls-remote --tags --exit-code "$DSH_REPO" "$DSH_VERSION" >/dev/null; then if ! upstream="$(git ls-remote --tags --exit-code "$DSH_REPO" \
"refs/tags/$DSH_VERSION^{}" "refs/tags/$DSH_VERSION")"; then
echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2 echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2
exit 1 exit 1
fi fi
# Never release a ref that no longer resolves to the pinned commit: an
# annotated tag has a peeled ^{} line, a lightweight tag only the ref.
resolved="$(printf '%s\n' "$upstream" \
| awk -v t="refs/tags/$DSH_VERSION^{}" '$2 == t {print $1}' | head -n1)"
if [ -z "$resolved" ]; then
resolved="$(printf '%s\n' "$upstream" \
| awk -v t="refs/tags/$DSH_VERSION" '$2 == t {print $1}' | head -n1)"
fi
if [ "$resolved" != "$DSH_COMMIT" ]; then
echo "upstream $DSH_REPO@$DSH_VERSION resolved to '$resolved'," \
"versions.env pins DSH_COMMIT=$DSH_COMMIT" >&2
exit 1
fi
{ {
echo "identity=$identity" echo "identity=$identity"
echo "dsh_version=$DSH_VERSION" echo "dsh_version=$DSH_VERSION"
echo "dsh_commit=$DSH_COMMIT"
echo "pnpm_version=$PNPM_VERSION" echo "pnpm_version=$PNPM_VERSION"
echo "release_revision=$RELEASE_REVISION" echo "release_revision=$RELEASE_REVISION"
echo "ghcr_image=$GHCR_IMAGE" echo "ghcr_image=$GHCR_IMAGE"
@@ -139,7 +155,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt pip install --require-hashes -r requirements.txt -r requirements-dev.txt
- name: Lint - name: Lint
if: needs.meta.outputs.run_tests == 'true' if: needs.meta.outputs.run_tests == 'true'
run: | run: |
@@ -166,6 +182,7 @@ jobs:
dockerfile: Dockerfile dockerfile: Dockerfile
build-args: | build-args: |
DSH_VERSION=${{ needs.meta.outputs.dsh_version }} DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }} RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }} PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
VCS_REF=${{ github.sha }} VCS_REF=${{ github.sha }}

View File

@@ -117,17 +117,33 @@ needs kind plus the agent-sandbox CRDs (see the example README).
- AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is - AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is
reachable exclusively through the authenticating proxy; otherwise any client reachable exclusively through the authenticating proxy; otherwise any client
can spoof X-Forwarded-User. can spoof X-Forwarded-User. The SSO variant's NetworkPolicy is required for
production, and `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
verifies the setup (Secrets, gateway env, TLS reference, placeholders, second
paths).
- The example gateway reads the DSH launch token from pod logs. Treat pod logs - The example gateway reads the DSH launch token from pod logs. Treat pod logs
as sensitive and keep its RBAC namespace-scoped. as sensitive and keep its RBAC namespace-scoped.
- Browser sessions are opaque server-side ids held in gateway memory: they are
revocable (logout/delete/recreate/expiry) but a gateway restart logs everyone
out and horizontal scaling needs a shared store. Session cookies are never
accepted as bearer tokens.
- AUTH_RATE_LIMIT, SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and
WS_MAX_MESSAGE_BYTES are in-process abuse guards, not ingress rate limits,
quotas or an audit log. Add those for an internet-facing deployment.
- The example shares provider keys (dsh-provider-keys plus the optional - The example shares provider keys (dsh-provider-keys plus the optional
dsh-provider-keys-extra) across sandboxes; do not describe it as dsh-provider-keys-extra) across sandboxes; do not describe it as
tenant-isolated. tenant-isolated.
- gVisor, nodeSelector, the NetworkPolicies, extra provider keys and failed-Pod - gVisor, nodeSelector, the shipped sandbox/exposure NetworkPolicies and the
recovery are opt-in and are not active in the base manifests; do not describe documented gateway egress policy, extra provider keys and failed-Pod recovery
the base example as hardened. are opt-in and are not active in the base manifests; do not describe the base
- TLS terminates outside the gateway; set COOKIE_SECURE=true for HTTPS and keep example as hardened.
the base manifest safe for local HTTP. The sandbox and gateway-egress policies are required production steps; an
exposure without its NetworkPolicy is demo-only. See the example's
production checklist.
- TLS terminates outside the gateway. Set COOKIE_SECURE=true for HTTPS and drop
ALLOW_INSECURE_COOKIES; the gateway refuses COOKIE_SECURE=false unless
ALLOW_INSECURE_COOKIES=1, which is only for the plain-HTTP local demo. Never
expose that combination.
## Ask before ## Ask before

View File

@@ -6,13 +6,14 @@
# Build args (all pinned in versions.env, verified by scripts/check-versions.sh): # Build args (all pinned in versions.env, verified by scripts/check-versions.sh):
# DSH_REPO upstream git remote to package # DSH_REPO upstream git remote to package
# DSH_VERSION upstream git ref to build (tag or branch) # DSH_VERSION upstream git ref to build (tag or branch)
# DSH_COMMIT full commit SHA DSH_VERSION must resolve to
# RELEASE_REVISION optional repository revision label (r2, r3, ...) # RELEASE_REVISION optional repository revision label (r2, r3, ...)
# PNPM_VERSION pnpm used to build DSH # PNPM_VERSION pnpm used to build DSH
# VCS_REF this repository's commit, recorded in OCI labels # VCS_REF this repository's commit, recorded in OCI labels
# NODE_BUILD_IMAGE node image used to build DSH # NODE_BUILD_IMAGE node image used to build DSH (pinned by digest)
# NODE_RUNTIME_IMAGE node image used at runtime # NODE_RUNTIME_IMAGE node image used at runtime (pinned by digest)
ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build stage: clone DSH at the pinned ref, build, and package the CLI. # Build stage: clone DSH at the pinned ref, build, and package the CLI.
@@ -21,6 +22,7 @@ FROM $NODE_BUILD_IMAGE AS build
ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
ARG DSH_VERSION=dsh-v0.2.1-rc.5 ARG DSH_VERSION=dsh-v0.2.1-rc.5
ARG DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
ARG PNPM_VERSION=11.28.5 ARG PNPM_VERSION=11.28.5
ENV CI=true \ ENV CI=true \
@@ -31,7 +33,15 @@ RUN corepack enable \
WORKDIR /src WORKDIR /src
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" . # Clone the pinned ref and fail closed unless it resolves to DSH_COMMIT. A moved
# tag would otherwise silently change what the image packages.
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" . \
&& actual="$(git -C /src rev-parse HEAD)" \
&& if [ "$actual" != "$DSH_COMMIT" ]; then \
echo "error: $DSH_REPO $DSH_VERSION resolved to $actual, expected DSH_COMMIT $DSH_COMMIT" >&2; \
echo "error: update DSH_VERSION/DSH_COMMIT in versions.env with scripts/set-version.sh" >&2; \
exit 1; \
fi
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile

View File

@@ -27,6 +27,7 @@ help: ## Show available targets
image: ## Build the main DSH Web image (no push) image: ## Build the main DSH Web image (no push)
docker build \ docker build \
--build-arg DSH_VERSION=$(DSH_VERSION) \ --build-arg DSH_VERSION=$(DSH_VERSION) \
--build-arg DSH_COMMIT=$(DSH_COMMIT) \
--build-arg RELEASE_REVISION=$(RELEASE_REVISION) \ --build-arg RELEASE_REVISION=$(RELEASE_REVISION) \
--build-arg PNPM_VERSION=$(PNPM_VERSION) \ --build-arg PNPM_VERSION=$(PNPM_VERSION) \
--build-arg VCS_REF=$(VCS_REF) \ --build-arg VCS_REF=$(VCS_REF) \

View File

@@ -22,22 +22,40 @@ disclosure once a fix is available.
tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as
sensitive. Replacing log scraping with an upstream token API is tracked in sensitive. Replacing log scraping with an upstream token API is tracked in
the [example architecture document](examples/dsh-agents-as-a-service/docs/architecture.md#upstream-feature-request-remove-log-scraping). the [example architecture document](examples/dsh-agents-as-a-service/docs/architecture.md#upstream-feature-request-remove-log-scraping).
- **Browser sessions are server-side.** The gateway session cookie holds an
opaque session id, not a user name or token; it is not accepted as a bearer
token. Sessions are revoked on logout, user delete, `/recreate` and expiry,
but they live in gateway memory, so a gateway restart logs every browser out
and scaling beyond one replica requires a shared store. Do not treat the
cookie as durable or cross-replica state.
- **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an - **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an
identity header (for example X-Forwarded-User). Enable it only when the identity header (for example X-Forwarded-User). Enable it only when the
gateway Service is reachable exclusively through the authenticating proxy; gateway Service is reachable exclusively through the authenticating proxy
otherwise any client can spoof the header. The SSO exposure variant ships a and its NetworkPolicy; otherwise any client can spoof the header. The SSO
NetworkPolicy that restricts gateway access to oauth2-proxy pods; a custom exposure variant's policy is required, not optional: an exposure without it
exposure needs an equivalent. is demo-only. Run `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
after applying the variant; it checks the Secrets, gateway env, TLS
reference, leftover placeholders and accidental second paths.
- **Insecure-cookie guard.** TLS terminates outside the gateway. Set
COOKIE_SECURE=true for HTTPS and remove ALLOW_INSECURE_COOKIES; the gateway
refuses to start with COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1 is
explicitly set, which exists only for the plain-HTTP local demo. Never expose
that combination outside localhost.
- **Abuse controls are in-process.** AUTH_RATE_LIMIT/AUTH_RATE_WINDOW,
SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and WS_MAX_MESSAGE_BYTES bound
casual abuse and resource exhaustion on the single gateway replica. They
reset on restart and are not an ingress rate limiter, WAF, quota system or
audit log; add those for an internet-facing deployment.
- **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in - **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in
the optional dsh-provider-keys-extra Secret into every tenant sandbox (a the optional dsh-provider-keys-extra Secret into every tenant sandbox (a
warm-pool constraint). Treat tenants as mutually trusting until per-tenant warm-pool constraint). Treat tenants as mutually trusting until per-tenant
credentials or an egress proxy exist. credentials or an egress proxy exist.
- **Optional isolation is opt-in.** gVisor, nodeSelector, the sandbox/exposure - **The base example is demo-only, not hardened.** gVisor, nodeSelector, the
NetworkPolicies and extra provider keys are opt-in. Without a sandbox/exposure NetworkPolicies, the gateway egress policy and extra provider
NetworkPolicy-capable CNI or a gVisor runtime, the base example is not keys are opt-in. Without gVisor and the network policies there is no
tenant-isolated; see examples/dsh-agents-as-a-service/docs/optional-hardening.md. tenant-to-tenant isolation; the sandboxes share the host kernel and can reach
- **TLS is external.** Terminate TLS at an Ingress or Gateway and set cluster-internal endpoints. Production requirements are enumerated in the
COOKIE_SECURE=true. Plain HTTP outside localhost exposes session cookies. [production checklist](examples/dsh-agents-as-a-service/docs/optional-hardening.md#6-production-checklist).
- **No secrets in the repository.** The repository only ships empty Secret - **No secrets in the repository.** The repository only ships empty Secret
manifests and *.yaml.example files. manifests and *.yaml.example files.

View File

@@ -11,12 +11,16 @@ The image is image-first: it is the product, and the examples under
## What the build does ## What the build does
The Dockerfile is a two-stage build: The Dockerfile is a two-stage build. Both stages use the `node` images pinned by
digest in [versions.env](../versions.env): a mutable tag alone is not trusted, and
`./scripts/check-versions.sh` rejects an image reference without a `@sha256:` digest.
1. **Build stage** (`node:24-trixie`) clones DSH at the pinned git ref, installs 1. **Build stage** (`NODE_BUILD_IMAGE`) clones DSH at the pinned git ref, verifies
that the clone's `HEAD` is exactly `DSH_COMMIT` (the build fails otherwise, so a
moved upstream tag cannot silently change what the image packages), installs
dependencies with pnpm, runs the production build, packages the `@deepseek-ai/dsh` dependencies with pnpm, runs the production build, packages the `@deepseek-ai/dsh`
CLI with `pnpm pack`, and installs its production dependencies with npm. CLI with `pnpm pack`, and installs its production dependencies with npm.
2. **Runtime stage** (`node:24-trixie-slim`) copies `/opt/dsh` to `/app`, adds the 2. **Runtime stage** (`NODE_RUNTIME_IMAGE`) copies `/opt/dsh` to `/app`, adds the
CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`, CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`,
`gh`, `tini`, `uv`), installs `bubblewrap` as DSH's preferred command-sandbox `gh`, `tini`, `uv`), installs `bubblewrap` as DSH's preferred command-sandbox
runner, and starts `dsh web` through a small entrypoint. runner, and starts `dsh web` through a small entrypoint.
@@ -36,6 +40,7 @@ To reproduce the equivalent by hand:
```bash ```bash
docker build \ docker build \
--build-arg DSH_VERSION=dsh-v0.2.1-rc.5 \ --build-arg DSH_VERSION=dsh-v0.2.1-rc.5 \
--build-arg DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1 \
--build-arg RELEASE_REVISION= \ --build-arg RELEASE_REVISION= \
--build-arg PNPM_VERSION=11.28.5 \ --build-arg PNPM_VERSION=11.28.5 \
--build-arg VCS_REF="$(git rev-parse --short HEAD)" \ --build-arg VCS_REF="$(git rev-parse --short HEAD)" \
@@ -48,11 +53,17 @@ docker build \
| --- | --- | --- | | --- | --- | --- |
| `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. | | `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. |
| `DSH_VERSION` | `DSH_VERSION` in [versions.env](../versions.env) | Upstream DSH git tag/ref to package. The Dockerfile records it as `io.sredevopsorg.dsh.version` and (for local builds) `org.opencontainers.image.version`; published builds set the version label to the full release identity. | | `DSH_VERSION` | `DSH_VERSION` in [versions.env](../versions.env) | Upstream DSH git tag/ref to package. The Dockerfile records it as `io.sredevopsorg.dsh.version` and (for local builds) `org.opencontainers.image.version`; published builds set the version label to the full release identity. |
| `DSH_COMMIT` | `DSH_COMMIT` in [versions.env](../versions.env) | Full commit SHA `DSH_VERSION` must resolve to upstream. The build stage fails unless `git rev-parse HEAD` in the clone equals this value, which pins the source independently of a movable tag. |
| `RELEASE_REVISION` | `RELEASE_REVISION` in [versions.env](../versions.env) | Repository revision suffix (`rN`), recorded in `io.sredevopsorg.release.revision`. The release identity is `DSH_VERSION` plus `-RELEASE_REVISION` when set. See [versioning](versioning.md). | | `RELEASE_REVISION` | `RELEASE_REVISION` in [versions.env](../versions.env) | Repository revision suffix (`rN`), recorded in `io.sredevopsorg.release.revision`. The release identity is `DSH_VERSION` plus `-RELEASE_REVISION` when set. See [versioning](versioning.md). |
| `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../versions.env) | pnpm version activated with corepack in both stages. | | `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../versions.env) | pnpm version activated with corepack in both stages. |
| `VCS_REF` | `git rev-parse --short HEAD` (via `make image`) | Source revision recorded for traceability. | | `VCS_REF` | `git rev-parse --short HEAD` (via `make image`) | Source revision recorded for traceability. |
| `NODE_BUILD_IMAGE` | `docker.io/node:24-trixie` | Build-stage base image. | | `NODE_BUILD_IMAGE` | `docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19` | Build-stage base image, pinned to the multi-arch index digest. |
| `NODE_RUNTIME_IMAGE` | `docker.io/node:24-trixie-slim` | Runtime-stage base image. | | `NODE_RUNTIME_IMAGE` | `docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446` | Runtime-stage base image, pinned to the multi-arch index digest. |
Base images are pinned by digest because tags are mutable: `node:24-trixie` can be
re-pointed at a new build at any time. Bumping the tag and its digest is a
deliberate, reviewable change in [versions.env](../versions.env); environments that
mirror the image should also mirror the `node` digest they build from.
Never hard-code a version outside [versions.env](../versions.env): Never hard-code a version outside [versions.env](../versions.env):
`./scripts/check-versions.sh` (and `make check`) fails when a pin drifts. `./scripts/check-versions.sh` (and `make check`) fails when a pin drifts.

View File

@@ -61,14 +61,27 @@ Values from [versions.env](../versions.env) at the current identity:
| --- | --- | --- | | --- | --- | --- |
| Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) | | Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) |
| DSH ref | `dsh-v0.2.1-rc.5` | `DSH_VERSION` | | DSH ref | `dsh-v0.2.1-rc.5` | `DSH_VERSION` |
| DSH commit | `b830ec950faba0a46dda564be3e2d854afed94f1` | `DSH_COMMIT`; the main image build fails unless the clone's `HEAD` matches |
| Repository revision | `(empty)` | `RELEASE_REVISION` | | Repository revision | `(empty)` | `RELEASE_REVISION` |
| pnpm | `11.28.5` | `PNPM_VERSION` | | pnpm | `11.28.5` | `PNPM_VERSION` |
| Node build image | `docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19` | `NODE_BUILD_IMAGE`, digest-pinned |
| Node runtime image | `docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446` | `NODE_RUNTIME_IMAGE`, digest-pinned |
| Gateway Python image | `docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159` | `GATEWAY_PYTHON_IMAGE`, digest-pinned |
| agent-sandbox (CRDs) | `v1.0.9` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run | | agent-sandbox (CRDs) | `v1.0.9` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run |
| Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant | | Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant |
| oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5` | `OAUTH2_PROXY_IMAGE` | | oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916` | `OAUTH2_PROXY_IMAGE`, digest-pinned and used by the SSO exposure variant |
| Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` | | Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` |
| Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` | | Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` |
Every container base image is referenced as `name:tag@sha256:<index digest>`, not
by tag alone: `check-versions.sh` rejects a pin without a digest, so a re-pushed
tag cannot change what we build from. The example gateway's Python dependencies
are fully hash-pinned in
[requirements.txt](../examples/dsh-agents-as-a-service/gateway/requirements.txt)
and
[requirements-dev.txt](../examples/dsh-agents-as-a-service/gateway/requirements-dev.txt)
and installed with `pip install --require-hashes`.
Example component images are always nested below the main package: Example component images are always nested below the main package:
`<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated `<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated
top-level package. top-level package.
@@ -86,8 +99,15 @@ file to derive from it. Never edit the Dockerfile, manifests, Makefile or
workflows with a literal version. workflows with a literal version.
- New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`. - New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`.
- Repository-only change at the same DSH ref: keep `DSH_VERSION`, set `set-version` resolves the new ref's commit with `git ls-remote` (peeled `^{}`
`RELEASE_REVISION=r(N+1)`. commit first, tag ref as fallback) and rewrites `DSH_COMMIT` in `versions.env`,
the Dockerfile ARG default and `.env.sample`; the build then verifies it.
- Repository-only change at the same DSH ref: keep `DSH_VERSION` and `DSH_COMMIT`,
set `RELEASE_REVISION=r(N+1)`.
- Base images (Node and Python) are bumped by hand: change the tag *and* the
`@sha256:` digest in `versions.env` together, then run `make check` and a build.
There is deliberately no automated digest refresh, because a silent base-image
change is exactly what digest pinning exists to prevent.
## Release checklist ## Release checklist
@@ -98,7 +118,8 @@ workflows with a literal version.
the tag does not already exist, and previews the generated notes. the tag does not already exist, and previews the generated notes.
5. Create and push the annotated `dsh-*` release tag (or dispatch 5. Create and push the annotated `dsh-*` release tag (or dispatch
`release.yaml` with an existing tag). The workflow validates that the tag `release.yaml` with an existing tag). The workflow validates that the tag
matches [versions.env](../versions.env). matches [versions.env](../versions.env) and that the upstream ref still
resolves to `DSH_COMMIT` before building anything.
6. The workflow builds and pushes multi-arch images for the main image and the 6. The workflow builds and pushes multi-arch images for the main image and the
example components (the identity tag plus `latest`), creates the GitHub example components (the identity tag plus `latest`), creates the GitHub
Release from the git-cliff notes, and opens a `chore(changelog)` PR that Release from the git-cliff notes, and opens a `chore(changelog)` PR that
@@ -142,8 +163,17 @@ make changelog # regenerate CHANGELOG.md locally
## Digest pinning ## Digest pinning
Release identities are mutable only in theory; deployments should still pin the Supply-chain inputs are pinned to immutable content, not to mutable tags:
resolved digest for reproducibility and supply-chain safety:
- **Build inputs**: every container base image is referenced by multi-arch index
digest in [versions.env](../versions.env), and the example gateway's Python
dependencies are hash-pinned (`--require-hashes`).
- **Upstream source**: the Dockerfile clones `DSH_VERSION` and fails unless the
clone's `HEAD` equals `DSH_COMMIT`, so a moved upstream tag cannot change what
a rebuild packages.
Release identities are immutable in theory; deployments should still pin the
_resolved_ digest for reproducibility and supply-chain safety:
```bash ```bash
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5 docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5

View File

@@ -16,8 +16,9 @@ It ships in three layers:
1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every 1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every
mechanism is visible. mechanism is visible.
2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that 2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that
makes the platform browser-usable — a signup page/API, `HttpOnly` cookie makes the platform browser-usable — a signup page/API, revocable server-side
sessions, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently **opaque-cookie sessions**, auth rate limiting and an optional signup invite
code, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently
**wakes suspended instances on connect**, and an idle sweeper that suspends **wakes suspended instances on connect**, and an idle sweeper that suspends
them again. The gateway performs the DSH launch-token handshake server-side, them again. The gateway performs the DSH launch-token handshake server-side,
so the browser never sees the token. so the browser never sees the token.
@@ -65,12 +66,13 @@ What it demonstrates, feature by feature:
upstream instead of splitting `/v1/*` (API) from the dashboard. upstream instead of splitting `/v1/*` (API) from the dashboard.
- **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional - **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional
`dsh-provider-keys-extra` Secret are injected via `envFrom` into every `dsh-provider-keys-extra` Secret are injected via `envFrom` into every
sandbox. There is no per-tenant key, dashboard basic-auth, session-cookie sandbox. There is no per-tenant key, dashboard basic-auth, signed-session
secret, or API-server key. secret (sessions are opaque server-side ids), or API-server key.
- **Launch-token handshake**: DSH gates `/` behind a token it prints only in its - **Launch-token handshake**: DSH gates `/` behind a token it prints only in its
logs. The gateway reads it, performs the handshake, and keeps the resulting logs. The gateway reads it, performs the handshake, and keeps the resulting
auth cookie server-side; the browser authenticates to the gateway with its own auth cookie server-side; the browser authenticates to the gateway with its own
cookie. See [How the DSH handshake works](#how-the-dsh-handshake-works). opaque session cookie, never the token. See
[How the DSH handshake works](#how-the-dsh-handshake-works).
- **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards - **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards
the browser's `Host`, so the public hostname must be added to the browser's `Host`, so the public hostname must be added to
`DSH_TRUSTED_HOSTS` in the template. `DSH_TRUSTED_HOSTS` in the template.
@@ -285,6 +287,15 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
> The gateway binds `8080`; the local port-forward uses `18080` and never `3080`. > The gateway binds `8080`; the local port-forward uses `18080` and never `3080`.
> >
> **Cookie/TLS guard.** The base manifest serves plain HTTP through port-forward,
> so it sets `COOKIE_SECURE=false` **and** `ALLOW_INSECURE_COOKIES=1`. The gateway
> refuses to start when `COOKIE_SECURE=false` unless
> `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges that the session cookie
> travels in clear text; that prevents an HTTPS deployment from silently serving
> an insecure cookie. Before exposing the gateway over TLS, set
> `COOKIE_SECURE=true` and remove the acknowledgement:
> `kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
>
> **Image pull policy.** `manifests/50-gateway.yaml` uses > **Image pull policy.** `manifests/50-gateway.yaml` uses
> `imagePullPolicy: IfNotPresent` with the release identity, so a locally built > `imagePullPolicy: IfNotPresent` with the release identity, so a locally built
> image must be loaded into the cluster under exactly that name (kind: > image must be loaded into the cluster under exactly that name (kind:
@@ -317,25 +328,40 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
### Browser flow ### Browser flow
Open <http://localhost:18080>. The landing page has a one-field signup form. Open <http://localhost:18080>. The landing page has a one-field signup form (plus
Enter `bob`, and the gateway: an invite-code field when `SIGNUP_INVITE_CODE` is configured). Enter `bob`, and
the gateway:
1. creates the `SandboxClaim` over the warm pool, 1. enforces the auth rate limit and, if configured, the signup invite code,
2. waits for it to become `Ready`, 2. creates the `SandboxClaim` over the warm pool,
3. sets an `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<user>:<token>` on 3. waits for it to become `Ready`,
the browser, 4. mints a random, server-side session (the cookie value is an opaque `sid`, not
4. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway. a user name and not a token), stores `sid -> (user, expiry)` in memory, and
sets the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<sid>` on the
browser (`__Host-dsh_gw_session` when `COOKIE_SECURE=true`),
5. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway.
From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is
proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in
the gateway; the browser only holds the gateway cookie. the gateway; the browser only holds the opaque gateway session id. The cookie is
useless as a bearer token.
**Sessions are revocable and mostly short-lived in practice.** `POST /logout`,
deleting the user, `POST /recreate` and cookie expiry (default 7 days,
`COOKIE_MAX_AGE`) invalidate the server-side session. The session table lives in
gateway memory, so **restarting the gateway logs every browser out** — expected
for this single-replica demo; a production deployment needs a shared session
store and sticky routing or per-request re-authentication. With SSO enabled, the
gateway mints a session for the identity oauth2-proxy asserts instead of asking
for a token.
The same flow with `curl`, no JavaScript (the session cookie is the only The same flow with `curl`, no JavaScript (the session cookie is the only
credential): credential):
```bash ```bash
JAR=$(mktemp) JAR=$(mktemp)
# signup (form POST, exactly what the landing page sends) # signup (form POST, exactly what the landing page sends); add -d 'invite=...'
# when SIGNUP_INVITE_CODE is configured, or send X-Signup-Invite
curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \ curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \
-d 'user=bob' http://localhost:18080/users -d 'user=bob' http://localhost:18080/users
# proxy: bob's DSH Web through the gateway # proxy: bob's DSH Web through the gateway
@@ -344,13 +370,38 @@ curl -s -b "$JAR" http://localhost:18080/u/bob/ | head -5
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401 curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401
# derived state: Ready / Waking / Suspended / ... # derived state: Ready / Waking / Suspended / ...
curl -s -b "$JAR" http://localhost:18080/users/bob curl -s -b "$JAR" http://localhost:18080/users/bob
# delete bob (cascades sandbox + PVC) # sign out (revokes the server-side session)
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X POST http://localhost:18080/logout
# delete bob (cascades sandbox + PVC and revokes every session for bob)
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob
``` ```
API clients that prefer bearer tokens can use `POST /users` with a JSON body API clients that prefer bearer tokens can use `POST /users` with a JSON body
(`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a (`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a
token for a cookie, and `Authorization: Bearer <token>` on subsequent calls. token for a cookie, and `Authorization: Bearer <token>` on subsequent calls. A
cookie/session value is never accepted as a bearer token, and a token for one
user cannot read another user's path.
### Auth abuse controls and proxy limits
The gateway is a single process, so these limits are per replica. They stop
casual abuse and one tenant monopolizing the service; an internet-facing
deployment still needs ingress-level rate limiting and an audit trail.
| Setting | Default | Behavior |
| --- | --- | --- |
| `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | `30` per `60` s | Throttles `/users` and `/login` per client (rightmost `X-Forwarded-For` when behind a proxy, else the TCP peer) and answers `429` + `Retry-After` before reading the body or touching the cluster. `0` disables it. The base manifest sets it explicitly. |
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the code as form field `invite` or header `X-Signup-Invite` (constant-time compare, `403` before user lookup). Put it in a Secret and restart the gateway to rotate. |
| `MAX_IN_FLIGHT_PER_USER` | `32` | Caps concurrent proxied requests + WebSockets per user; excess HTTP gets `503` + `Retry-After`, excess WebSocket upgrades close with `1011`. |
| `WS_MAX_MESSAGE_BYTES` | `16 MiB` | Largest WebSocket message received from DSH; oversized frames close the connection. Browser-to-DSH frames are bounded by uvicorn's own limit. HTTP/SSE streaming is not capped by this. |
| `MAX_BODY_MB` | `32` | Largest proxied HTTP request body (`413`). |
The gateway also strips client-supplied `Forwarded`, `X-Forwarded-User` and
`X-Auth-Request-*` headers (and `x-forwarded-*` / `x-auth-request-*` prefixes)
before proxying, so a tenant cannot inject identity or spoof hop headers into
DSH. `UPSTREAM_HEADER_ALLOWLIST` can replace that denylist with an explicit
comma-separated allowlist. See the [gateway README](gateway/README.md) for the
full configuration.
### Gateway routes ### Gateway routes
@@ -395,8 +446,11 @@ not cut off.
The upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`, The upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`,
and performs the launch-token handshake under the same `pod-IP:3080` authority. and performs the launch-token handshake under the same `pod-IP:3080` authority.
Missing or cross-origin auth closes with `4401`; upstream bridging failures close Missing or cross-origin auth closes with `4401`; upstream bridging failures close
with `1011`. See the [gateway README](gateway/README.md#proxy-failure-semantics) with `1011`, as does exceeding `MAX_IN_FLIGHT_PER_USER` (per-user socket cap).
for the full matrix. Messages received from DSH larger than `WS_MAX_MESSAGE_BYTES` (16 MiB by
default) close the connection instead of being buffered; browser-to-DSH frames
are bounded by uvicorn's own limit. See the
[gateway README](gateway/README.md#proxy-failure-semantics) for the full matrix.
### Path routing ### Path routing
@@ -420,17 +474,20 @@ not a stack. Details, commands and the switch procedure are in the
Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits
gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml`
limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS
and the gateway. They are part of the variant; see the and the gateway. They are part of the variant **and required for production**:
[exposure README](manifests/exposure/README.md) and an exposure applied without its policy is demo-only. The sandbox side needs a
[optional hardening](docs/optional-hardening.md). separate policy, and the gateway needs an egress policy; both are covered in
[optional hardening](docs/optional-hardening.md) (see the production checklist)
and summarized in the [exposure README](manifests/exposure/README.md).
### Option A — plain Ingress (gateway's own session auth) ### Option A — plain Ingress (gateway's own session auth)
1. Set the Ingress host/TLS (or drop the `tls:` block), and set 1. Set the Ingress host/TLS (or drop the `tls:` block), and set
`DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same public `DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same public
host; re-apply the template. host; re-apply the template.
2. Set `COOKIE_SECURE=true` once the Ingress serves HTTPS: 2. Set `COOKIE_SECURE=true` and drop the plain-HTTP acknowledgement once the
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`. Ingress serves HTTPS:
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
3. `kubectl apply -f manifests/exposure/ingress/`; verify 3. `kubectl apply -f manifests/exposure/ingress/`; verify
`curl -sI https://<host>/healthz` returns `200`. `curl -sI https://<host>/healthz` returns `200`.
@@ -453,21 +510,37 @@ front of **oauth2-proxy**, which authenticates the browser against GitHub
2. Create a GitHub OAuth App whose homepage/callback match the public FQDN, and 2. Create a GitHub OAuth App whose homepage/callback match the public FQDN, and
create the oauth2-proxy and TLS Secrets from create the oauth2-proxy and TLS Secrets from
`73-oauth2-proxy-secret.yaml.example` and `74-tls-secret.yaml.example`. `73-oauth2-proxy-secret.yaml.example` and `74-tls-secret.yaml.example`.
3. Replace `dsh-demo.example.com` and `your-github-org`, set 3. Replace `dsh-demo.example.com` and `your-github-org` in the variant
`DSH_TRUSTED_HOSTS` in the template, and enable SSO: manifests, set `DSH_TRUSTED_HOSTS` in the template, and enable SSO:
`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true`. `kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
4. `kubectl apply -f manifests/exposure/gateway-api-sso/`; verify the Gateway, 4. `kubectl apply -f manifests/exposure/gateway-api-sso/`; then run
HTTPRoute, oauth2-proxy Deployment and endpoints as shown in the [`scripts/preflight-sso.sh`](scripts/preflight-sso.sh) and fix everything it
reports **before** pointing real traffic at the Gateway. It verifies the
Secrets (including a valid oauth2-proxy cookie secret length), the gateway's
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, that the Gateway's TLS certificate
reference resolves, and that no `your-github-org`/`dsh-demo.example.com`
placeholder was left applied. Full verification steps are in the
[exposure README](manifests/exposure/README.md). [exposure README](manifests/exposure/README.md).
> **Exposure without the variant NetworkPolicy is demo-only.** The SSO
> variant's `75-networkpolicies.yaml` is not optional in practice: it is the
> only thing that makes oauth2-proxy the sole path to the gateway. Applying
> only the Gateway/HTTPRoute/oauth2-proxy objects leaves the trust boundary
> unenforced, so anyone who can reach `dsh-gateway:8080` can send
> `X-Forwarded-User` and impersonate the user they claim to be.
>
> **SSO trust boundary — read this before enabling it.** After SSO is on, the > **SSO trust boundary — read this before enabling it.** After SSO is on, the
> `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is > `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is
> ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic > ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic
> accordingly: the gateway accepts traffic only from oauth2-proxy pods, > accordingly: the gateway accepts traffic only from oauth2-proxy pods,
> oauth2-proxy only from the Gateway implementation, and its egress only to DNS, > oauth2-proxy only from the Gateway implementation, and its egress only to DNS,
> public HTTPS and the gateway. Anyone who can reach `dsh-gateway:8080` directly > public HTTPS and the gateway. The gateway's own egress (Kubernetes API,
> can send `X-Forwarded-User: <victim>`, so never add a second path. To turn SSO > sandbox pods) is not restricted by that file because the API-server endpoint is
> off, remove the variable > cluster-specific; add the
> [gateway egress policy](docs/optional-hardening.md#gateway-egress-required-for-production)
> for production. Anyone who can reach `dsh-gateway:8080` directly can send
> `X-Forwarded-User: <victim>`, so never add a second path. To turn SSO off,
> remove the variable
> (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`) and > (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`) and
> delete the SSO variant. > delete the SSO variant.
@@ -481,25 +554,36 @@ gateway remains. Full commands and verification are in the
## Optional hardening ## Optional hardening
The base walkthrough is deliberately portable. Five opt-in settings are The base walkthrough is deliberately portable, which means it is **not
documented in [optional hardening](docs/optional-hardening.md): hardened**: without the policies and gVisor it is demo-only, not a safe
multi-tenant platform. [Optional hardening](docs/optional-hardening.md) has the
settings and a production checklist. In short:
- **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an - **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an
existing install. Requires a `gvisor` RuntimeClass and capable nodes. existing install. Requires a `gvisor` RuntimeClass and capable nodes.
`kubectl port-forward` does not work under gVisor; the gateway path does. `kubectl port-forward` does not work under gVisor; the gateway path does.
Required for untrusted agents in production.
- **nodeSelector** — uncomment and replace the placeholder node label to pin - **nodeSelector** — uncomment and replace the placeholder node label to pin
sandboxes and the gateway to a dedicated pool; add a toleration for tainted sandboxes and the gateway to a dedicated pool; add a toleration for tainted
pools. pools.
- **Sandbox NetworkPolicy** — - **Sandbox NetworkPolicy** —
`kubectl apply -f manifests/optional/network-policy/` allows only `kubectl apply -f manifests/optional/network-policy/` allows only
gateway→sandbox on `3080` and sandbox→DNS/public `443`. gateway→sandbox on `3080` and sandbox→DNS/public `443`. Required for
production; exposure without it is demo-only.
- **Gateway egress policy** — the gateway needs DNS, the Kubernetes API and
`3080` to sandbox pods. It is not shipped because the API-server endpoint is
cluster-specific; use the
[recipe](docs/optional-hardening.md#gateway-egress-required-for-production).
Required for production.
- **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1. - **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1.
- **Failed-Pod recovery** — uncomment `failedPodPolicy: Recreate` so the - **Failed-Pod recovery** — uncomment `failedPodPolicy: Recreate` so the
controller replaces a `Failed` sandbox Pod (node eviction) instead of parking controller replaces a `Failed` sandbox Pod (node eviction) instead of parking
the sandbox at `Finished=True`. Requires agent-sandbox v1.0.8+. the sandbox at `Finished=True`. Requires agent-sandbox v1.0.8+.
Apply each independently and re-run the walkthrough. `kubectl apply -f manifests/` The **exposure variants' NetworkPolicies** are not optional in production
is non-recursive, so the optional manifests are not applied by the base step. either; see step 9. Apply each setting independently and re-run the walkthrough.
`kubectl apply -f manifests/` is non-recursive, so the optional manifests are not
applied by the base step.
## Troubleshooting ## Troubleshooting
@@ -508,9 +592,16 @@ is non-recursive, so the optional manifests are not applied by the base step.
| Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` | | Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` |
| Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` | | Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` |
| `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` | | `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` |
| `503` with `Retry-After` | The launch token was not found in the pod logs yet; the gateway retries after a short delay. Check the pod logs and `DSH_LOG_TAIL_LINES` | | `503` with `Retry-After` | Either the launch token was not found in the pod logs yet (the gateway retries after a short delay; check the pod logs and `DSH_LOG_TAIL_LINES`) or the user hit `MAX_IN_FLIGHT_PER_USER` (default 32 concurrent requests/WebSockets); reduce client concurrency or raise the limit deliberately |
| `429` with `Retry-After` on signup/login | The per-client auth rate limit (`AUTH_RATE_LIMIT`, default explicitly `30` per `AUTH_RATE_WINDOW=60` s) rejected the attempt before reading the body. Wait for the window, or raise the limit; behind a proxy make sure the real client IP is the rightmost `X-Forwarded-For` entry, not a shared NAT address |
| `403` on signup: invite required/invalid | `SIGNUP_INVITE_CODE` is configured. Send the code as form field `invite` or header `X-Signup-Invite`; the landing page shows the field when enabled. A wrong code is rejected before the user lookup |
| Gateway pod `CrashLoopBackOff` with `refusing to start with an insecure session cookie` | Intentional guard: plain HTTP is only allowed when explicitly acknowledged. For the local demo set `ALLOW_INSECURE_COOKIES=1` (the base manifest does); for HTTPS set `COOKIE_SECURE=true` and remove `ALLOW_INSECURE_COOKIES` |
| Every browser is logged out after a gateway rollout/restart | Expected: sessions are opaque ids in gateway memory and a restart drops them. Sign in again; a production deployment needs shared session state or per-request re-authentication. The DSH sandboxes and their data are unaffected |
| WebSocket closes with `1011` for a busy client | `MAX_IN_FLIGHT_PER_USER` (per-user connection cap) or an upstream/handshake failure closed the socket; check the gateway logs to tell them apart |
| WebSocket closes after a very large message from DSH | A message received from DSH exceeded `WS_MAX_MESSAGE_BYTES` (16 MiB), so the relay closed it instead of buffering; raise the limit only if DSH legitimately sends larger frames. Browser-to-DSH frames are bounded by uvicorn's own limit |
| `scripts/preflight-sso.sh` exits non-zero | It names the failing item: a missing Secret/key, a bad oauth2-proxy cookie-secret length, missing `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, a dangling Gateway TLS reference, a leftover `your-github-org`/`dsh-demo.example.com` placeholder, or a second path to the gateway (an Ingress). Fix all items before exposing the SSO variant |
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first | | `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` for local demos | | Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` **and** `ALLOW_INSECURE_COOKIES=1` for local demos |
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` | | WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
| Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop | | Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop |
| `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost | | `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost |
@@ -539,9 +630,19 @@ is non-recursive, so the optional manifests are not applied by the base step.
Deliberately **not** production code: Deliberately **not** production code:
- **Single replica.** Idle clocks, in-flight tracking, and the handshake cache - **Single replica.** Idle clocks, in-flight tracking, the handshake cache and
are in memory. Running two gateway replicas would double-suspend or lose wake the browser session table are in memory. Running two gateway replicas would
tracking; scale up only after moving that state to a shared store. double-suspend or lose wake tracking, and sessions would land on the wrong
replica; scale up only after moving that state to a shared store.
- **Sessions die with the process.** Browser sessions are opaque ids held in
gateway memory (revocable on logout/delete/recreate, but not durable). A
gateway restart logs every browser out. There is no SSO refresh-token or
password store; identity is re-established by oauth2-proxy on the next
request.
- **Abuse controls are per-process and coarse.** `AUTH_RATE_LIMIT`, the signup
invite code, `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` limit casual
abuse, but they are not a substitute for ingress rate limiting, WAF/quota, or
an audit log, and the in-memory rate buckets reset on restart.
- **Log scraping.** The launch token is recovered from pod logs. Log rotation, a - **Log scraping.** The launch token is recovered from pod logs. Log rotation, a
restarted pod, or a changed DSH log format can break the handshake until the restarted pod, or a changed DSH log format can break the handshake until the
next `401` refresh. next `401` refresh.
@@ -551,12 +652,13 @@ Deliberately **not** production code:
but the platform does not manage that. Real multi-tenancy needs per-tenant but the platform does not manage that. Real multi-tenancy needs per-tenant
credentials or an egress proxy. credentials or an egress proxy.
- **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set - **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set
`COOKIE_SECURE=true`. `COOKIE_SECURE=true`, then remove `ALLOW_INSECURE_COOKIES`. The base manifest
runs plain HTTP for the port-forward demo and is demo-only.
- **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header. - **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header.
The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway
to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a
custom exposure without that policy has no signature, mTLS or network custom exposure without that policy has no signature, mTLS or network
enforcement. enforcement. Exposure without the policy is demo-only.
- **DSH's inner file sandbox is off here.** The template sets - **DSH's inner file sandbox is off here.** The template sets
`DSH_PERMISSION_MODE=danger-full-access` because neither backend of DSH's own `DSH_PERMISSION_MODE=danger-full-access` because neither backend of DSH's own
command sandbox is usable in this pod: `bubblewrap` is installed in the image command sandbox is usable in this pod: `bubblewrap` is installed in the image
@@ -567,21 +669,24 @@ Deliberately **not** production code:
pod remains the boundary — non-root, pod remains the boundary — non-root,
capabilities dropped, `seccompProfile: RuntimeDefault`, its own PVC and Service capabilities dropped, `seccompProfile: RuntimeDefault`, its own PVC and Service
— but an agent command can now read its own `DSH_HOME` (including — but an agent command can now read its own `DSH_HOME` (including
`.credentials.yaml`) and write anywhere in the container. On hosts where a `.credentials.yaml`) and write anywhere in the container. Run sandboxes under
backend works, set `workspace-write` (or `read-only`) and verify before relying gVisor in production. On hosts where a backend works, set `workspace-write`
on the inner policy. (or `read-only`) and verify before relying on the inner policy.
- **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy and the exposure - **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy, the gateway egress
policies are opt-in; without them, sandboxes share the host kernel and can policy and the exposure policies are opt-in; without them, sandboxes share the
reach the cluster network. The base example is a demo, not a hardened host kernel and can reach the cluster network. The base example is a demo, not
multi-tenant platform. a hardened multi-tenant platform. See the
[production checklist](docs/optional-hardening.md#6-production-checklist).
- **OAuth org is the only authorization.** Any GitHub user in the configured - **OAuth org is the only authorization.** Any GitHub user in the configured
organization gets an auto-provisioned sandbox; there is no per-user allow-list, organization gets an auto-provisioned sandbox; there is no per-user allow-list,
quota, or admin API. quota, or admin API. `SIGNUP_INVITE_CODE` gates the gateway's own signup form,
not SSO auto-provisioning.
- **In-memory user directory.** Users are claims + annotations; there is no - **In-memory user directory.** Users are claims + annotations; there is no
database, password reset, or admin API. Tokens are stored only as SHA-256 database, password reset, or admin API. Tokens are stored only as SHA-256
hashes in claim annotations. hashes in claim annotations.
- **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the - **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the
browser flow uses the `HttpOnly` cookie instead. browser flow uses the `HttpOnly` cookie instead. The cookie no longer contains
the token, and a cookie value is never accepted as a bearer token.
## Cleanup ## Cleanup
@@ -603,6 +708,8 @@ delete the Secrets with the namespace and revoke the OAuth App in GitHub.
- [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs, - [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs,
security and RBAC, failure modes, evolution path. security and RBAC, failure modes, evolution path.
- [Exposure README](manifests/exposure/README.md) — the two variants in detail. - [Exposure README](manifests/exposure/README.md) — the two variants in detail.
- [SSO preflight](scripts/preflight-sso.sh) — read-only checks for the SSO
variant (Secrets, gateway env, TLS reference, placeholders, second paths).
- [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector, - [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector,
NetworkPolicy, extra provider keys and failed-Pod recovery, with verification NetworkPolicy, extra provider keys and failed-Pod recovery, with verification,
and rollback. the production checklist and rollback.

View File

@@ -72,8 +72,10 @@ Each variant also ships a NetworkPolicy: `ingress/61-networkpolicy.yaml` limits
to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to
oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to
DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic
enforcement; the caveats (Traefik label, `hostNetwork`, kubelet probes) are in enforcement. These policies are **required for production**: exposing the gateway by applying
[optional hardening](optional-hardening.md). only the Gateway/HTTPRoute/Ingress objects is demo-only. The sandbox-side policy and the
gateway's own egress policy are separate (the API-server endpoint is cluster-specific); see
[optional hardening](optional-hardening.md#gateway-egress-required-for-production).
## Flows ## Flows
@@ -87,20 +89,22 @@ sequenceDiagram
participant K as Kubernetes API participant K as Kubernetes API
participant C as agent-sandbox controller participant C as agent-sandbox controller
B->>G: POST /users (form: user=bob, Origin) B->>G: POST /users (form: user=bob, invite, Origin)
G->>G: validate user name and body size G->>G: rate limit, invite check, validate user name and body size
G->>K: create SandboxClaim dsh-bob (warmPoolRef) G->>K: create SandboxClaim dsh-bob (warmPoolRef)
K->>C: reconcile claim K->>C: reconcile claim
C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
G->>K: read claim (status) + sandbox + pod G->>K: read claim (status) + sandbox + pod
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session G->>G: mint opaque sid, store sid -> (user, expiry)
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session=<sid>
Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict) Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict)
``` ```
Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the
first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409` first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409`
means the user already has a claim, in which case `POST /login` (bearer token) or the existing means the user already has a claim, in which case `POST /login` (bearer token) or the existing
cookie is the right path. cookie is the right path. The cookie value is an opaque session id: it is meaningless outside
this gateway process and is never accepted as a bearer token.
### First request / proxy (with handshake) ### First request / proxy (with handshake)
@@ -112,8 +116,8 @@ sequenceDiagram
participant K as Kubernetes API participant K as Kubernetes API
participant D as DSH Web pod participant D as DSH Web pod
B->>G: GET /u/bob/ (Cookie dsh_gw_session) B->>G: GET /u/bob/ (Cookie dsh_gw_session=<sid>)
G->>G: authenticate cookie, user = bob G->>G: look up sid -> session (valid, not expired), user = bob
G->>K: resolve claim -> sandbox -> pod G->>K: resolve claim -> sandbox -> pod
alt sandbox Suspended alt sandbox Suspended
G->>K: patch operatingMode=Running (wake) G->>K: patch operatingMode=Running (wake)
@@ -140,6 +144,32 @@ Inside `proxy_http` the order is auth, in-flight slot, sandbox resolution/wake,
body read, upstream send. An upload aborted mid-body may therefore already have paid for a wake body read, upstream send. An upload aborted mid-body may therefore already have paid for a wake
and a handshake, but it never reaches DSH. and a handshake, but it never reaches DSH.
### Browser sessions
The browser never holds a DSH credential. On signup/login the gateway mints a random session id
(`secrets.token_urlsafe(32)`), stores `sid -> Session(user, expires_at)` in memory, and sets it
as the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session` (`__Host-dsh_gw_session` when
`COOKIE_SECURE=true`). Every browser request is resolved through that table; the cookie value is
not a bearer token and cannot be replayed against another user's path.
Sessions end on explicit logout, user delete, `/recreate` (all sessions for that user) and
expiry (`COOKIE_MAX_AGE`, default 7 days). Because the table is process memory, a gateway
restart logs every browser out; scaling beyond one replica therefore requires a shared session
store (or re-authentication on every request). The sweeper prunes expired entries and sessions
for users whose claim no longer exists.
### Auth abuse controls and proxy limits
Unauthenticated endpoints are throttled before the body is read: `AUTH_RATE_LIMIT` attempts per
client per `AUTH_RATE_WINDOW` (defaults 30/60s; keyed on the rightmost `X-Forwarded-For` entry
when present, else the peer address), answered with `429` + `Retry-After`. When
`SIGNUP_INVITE_CODE` is set, signup additionally requires a constant-time-matched code (form
field or `X-Signup-Invite`) and answers `403` otherwise. Proxied traffic is capped per user
(`MAX_IN_FLIGHT_PER_USER`, default 32 concurrent HTTP requests + WebSockets: `503` for HTTP,
close `1011` for WebSocket) and per WebSocket message received from DSH
(`WS_MAX_MESSAGE_BYTES`, 16 MiB). These are in-process guards for a single replica, not an ingress
rate limiter or quota system.
### Wake-on-connect and idle suspend ### Wake-on-connect and idle suspend
```mermaid ```mermaid
@@ -220,6 +250,14 @@ sequenceDiagram
end end
``` ```
An upgrade with a missing/expired session, or one that would exceed
`MAX_IN_FLIGHT_PER_USER`, is accepted and then closed: `4401` for authentication, `1011` for
the per-user socket cap. Messages received from DSH above `WS_MAX_MESSAGE_BYTES` (16 MiB) close
the connection instead of being buffered; browser-to-DSH frames are bounded by uvicorn's own
limit. Client-supplied `Forwarded`, `X-Forwarded-User`,
`X-Auth-Request-*` and `x-forwarded-*`/`x-auth-request-*` headers are stripped on the HTTP and
WebSocket paths; `UPSTREAM_HEADER_ALLOWLIST` replaces that denylist with an explicit allowlist.
## Decisions and trade-offs ## Decisions and trade-offs
### ADR-1: Starlette ASGI instead of a sync Flask proxy ### ADR-1: Starlette ASGI instead of a sync Flask proxy
@@ -279,14 +317,21 @@ sequenceDiagram
(`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not (`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not
for a product. for a product.
### ADR-6: Gateway cookie session instead of bearer tokens in the browser ### ADR-6: Opaque server-side session cookie instead of bearer tokens in the browser
- **Decision:** browsers get `dsh_gw_session=<user>:<token>`, `HttpOnly`, `SameSite=Lax` - **Decision:** browsers get `dsh_gw_session=<sid>`, `HttpOnly`, `SameSite=Lax` (`Secure` when
(`Secure` when enabled); API clients may still use `Authorization: Bearer`. enabled); the sid maps to `Session(user, expires_at)` in gateway memory. API clients may still
- **Why:** tokens never appear in URLs or JS-readable storage, and `SameSite=Lax` plus a strict use `Authorization: Bearer`. The cookie name is `__Host-dsh_gw_session` when the Secure flag
`Origin`/`Referer` check on cookie-authenticated mutations blocks basic CSRF. is on and no explicit `COOKIE_NAME` is set.
- **Cost:** cookie-based auth needs TLS and correct `COOKIE_SECURE`; there is no signed-cookie - **Why:** tokens never appear in URLs or JS-readable storage, the cookie value is useless if
layer, so the gateway trusts the token hash comparison for validation. leaked outside the gateway session table, sessions can be revoked server-side (logout,
user delete, recreate, expiry), and a cookie cannot be reused as a bearer token for another
user's path. `SameSite=Lax` plus a strict `Origin`/`Referer` check on cookie-authenticated
mutations blocks basic CSRF.
- **Cost:** stateful auth in a single process: a restart logs everyone out and horizontal
scaling needs a shared session store. Cookie-based auth also needs TLS and the correct
`COOKIE_SECURE` setting; the gateway refuses to start with `COOKIE_SECURE=false` unless
`ALLOW_INSECURE_COOKIES=1` is set for a local HTTP demo.
## Security and RBAC ## Security and RBAC
@@ -305,33 +350,52 @@ Notes and limits:
permission is scoped to one namespace, but anyone who can read gateway memory or logs can permission is scoped to one namespace, but anyone who can read gateway memory or logs can
impersonate users. Prefer the upstream token API when available. impersonate users. Prefer the upstream token API when available.
- **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`. - **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`.
- **Browser-facing hardening:** `HttpOnly`/`SameSite=Lax`/optional `Secure` cookies, (It does read the optional signup invite code if it is mounted as an env var.)
timing-safe SHA-256 token comparison, strict same-origin check on cookie-auth mutations, - **Browser-facing hardening:** opaque server-side sessions instead of token-bearing cookies,
request-body limit (`MAX_BODY_MB`), hop-by-hop header stripping, upstream `Set-Cookie` `HttpOnly`/`SameSite=Lax`/optional `Secure` (+ `__Host-` prefix when secure),
dropped, DSH `Location` headers rewritten under `/u/<user>/`. timing-safe SHA-256 bearer-token comparison, timing-safe invite-code comparison, strict
same-origin check on cookie-auth mutations, request-body limit (`MAX_BODY_MB`), hop-by-hop
header stripping, client identity/hop headers (`Forwarded`, `X-Forwarded-User`,
`X-Auth-Request-*`, `x-forwarded-*`/`x-auth-request-*`) stripped on HTTP and WebSocket,
upstream `Set-Cookie` dropped, DSH `Location` headers rewritten under `/u/<user>/`.
- **Session lifecycle:** login/signup mint an opaque sid; logout and user delete revoke it;
`/recreate` revokes every session for that user; expiry (`COOKIE_MAX_AGE`, default 7 days) is
enforced on lookup. The table is in memory, so a restart logs browsers out and a second
replica needs shared state.
- **Abuse controls (per replica):** `AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW` throttle
unauthenticated signup/login per client with `429` + `Retry-After`; `SIGNUP_INVITE_CODE`
gates signup; `MAX_IN_FLIGHT_PER_USER` caps concurrent proxied requests + WebSockets per user;
`WS_MAX_MESSAGE_BYTES` caps messages received from DSH; browser-to-DSH frames are bounded by
uvicorn. These are in-process guards, not an ingress rate limiter or audit log.
- **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's - **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's
`Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are `Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are
always trusted. always trusted.
- **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set - **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set
`COOKIE_SECURE=true`. Without TLS, session cookies are replayable on the network. `COOKIE_SECURE=true`. The base demo sets `COOKIE_SECURE=false` with
`ALLOW_INSECURE_COOKIES=1` for the port-forward walkthrough; without TLS, session cookies are
replayable on the network and that combination must never be exposed.
- **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary, - **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary,
and the shared provider keys (`dsh-provider-keys` plus the optional and the shared provider keys (`dsh-provider-keys` plus the optional
`dsh-provider-keys-extra`). `manifests/optional/network-policy/` adds an opt-in sandbox `dsh-provider-keys-extra`). The base example is demo-only: production requires gVisor for the
NetworkPolicy, and gVisor/`nodeSelector` are opt-in PodSpec fields. Add per-tenant sandboxes, the sandbox NetworkPolicy, the gateway egress policy and the exposure NetworkPolicy,
credentials before calling it multi-tenant-grade. plus per-tenant credentials or an egress proxy. See the
[production checklist](optional-hardening.md#6-production-checklist).
- **DSH's inner file sandbox is disabled by deployment override.** The template sets - **DSH's inner file sandbox is disabled by deployment override.** The template sets
`DSH_PERMISSION_MODE=danger-full-access` because the in-container backends are unusable here: `DSH_PERMISSION_MODE=danger-full-access` because the in-container backends are unusable here:
`bubblewrap` ships in the image but is unsetuid and cannot create an unprivileged user namespace `bubblewrap` ships in the image but is unsetuid and cannot create an unprivileged user namespace
under the pod's seccomp/capability surface, and no Landlock runner is available; the confined under the pod's seccomp/capability surface, and no Landlock runner is available; the confined
executor would otherwise fail closed on every command. The pod remains the boundary, so an executor would otherwise fail closed on every command. The pod remains the boundary, so an
agent can read its own `DSH_HOME`. Hosts with a working backend can keep a confined mode. agent can read its own `DSH_HOME`. Hosts with a working backend can keep a confined mode;
multi-tenant deployments must put the pod in gVisor and a NetworkPolicy.
- **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies, - **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies,
extra provider keys and failed-Pod recovery are documented in extra provider keys and failed-Pod recovery are documented in
[optional hardening](optional-hardening.md). The base manifests stay portable (no gVisor [optional hardening](optional-hardening.md). The base manifests stay portable (no gVisor
RuntimeClass, no active node labels, no NetworkPolicy, no extra Secret required, and the RuntimeClass, no active node labels, no NetworkPolicy, no extra Secret required, and the
controller's default `failedPodPolicy: Retain`). controller's default `failedPodPolicy: Retain`).
- **No gateway rate limiting.** Signup can create claims until the cluster or warm pool is - **SSO is trust-on-hop.** `AUTH_PROXY_HEADER` makes the gateway trust the proxy-asserted
exhausted; put limits at the ingress for real deployments. identity; the SSO variant must remain the only path to the Service, and its NetworkPolicy is
required, not optional. Run [`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) after
applying it.
## Failure modes ## Failure modes
@@ -340,7 +404,14 @@ Notes and limits:
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted | | DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) | | Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool | | Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
| Gateway restart | Idle sandboxes stop suspending; handshake cache lost | Rebuilt lazily on next request; consider a shared store for production | | Gateway restart | Idle sandboxes stop suspending; handshake cache and browser sessions lost | Idle clocks and handshake cache rebuild lazily; sessions do not, so browsers must sign in again after a rollout. Use a shared session store for production |
| Gateway configured `COOKIE_SECURE=false` without `ALLOW_INSECURE_COOKIES=1` | Gateway pod exits at startup / `CrashLoopBackOff` | Intentional guard against an insecure cookie on an HTTPS deployment; set `ALLOW_INSECURE_COOKIES=1` only for the local plain-HTTP demo, or set `COOKIE_SECURE=true` |
| Auth rate limit hit | `429` + `Retry-After` on signup/login | Per-client in-memory bucket (`AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW`); wait for the window or tune it. Behind a proxy, keying uses the rightmost `X-Forwarded-For` entry |
| Signup invite missing/wrong | `403` before the claim is created | `SIGNUP_INVITE_CODE` is set; send the `invite` form field or `X-Signup-Invite` header |
| Per-user concurrency cap hit | HTTP `503` + `Retry-After`, or WebSocket close `1011` | `MAX_IN_FLIGHT_PER_USER` protects the single gateway from one busy tenant; reduce concurrency or raise the cap deliberately. These are per replica |
| Oversized WebSocket message from DSH | Connection closes | `WS_MAX_MESSAGE_BYTES` (16 MiB) is enforced on upstream messages; raise it only if DSH legitimately needs larger frames |
| Session expiry / revocation | `401` (HTTP) or close `4401` (WebSocket) | Expected after logout, user delete, `/recreate`, `COOKIE_MAX_AGE` expiry or a gateway restart; sign in again |
| SSO variant applied without its NetworkPolicy (or with an Ingress) | Gateway accepts spoofed `X-Forwarded-User` | Demo-only configuration; keep `75-networkpolicies.yaml`, remove the second path, and run `scripts/preflight-sso.sh` |
| Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. | | Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. |
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once | | DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes | | Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
@@ -372,10 +443,11 @@ single-retry refresh are the supported workaround.
## Evolution path ## Evolution path
1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission. 1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission.
2. Move idle clocks, in-flight tracking, and handshake cache to a shared store; then scale the 2. Move idle clocks, in-flight tracking, handshake cache **and sessions** to a shared store; then
gateway horizontally (sticky routing not required if state is shared). scale the gateway horizontally (sticky routing not required if state is shared).
3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes. 3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes.
4. Add NetworkPolicies (gateway → sandboxes only), TLS everywhere, rate limits, and audit 4. Add NetworkPolicies (gateway → sandboxes only, plus the documented gateway egress policy),
logging at the ingress. gVisor for sandbox pods, TLS everywhere, ingress-level rate limits/quota and audit logging.
The gateway's in-process limits are not a substitute.
5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to 5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to
per-user subdomains with wildcard DNS/TLS. per-user subdomains with wildcard DNS/TLS.

View File

@@ -2,14 +2,26 @@
The base example is deliberately portable: it runs on kind and GKE without a The base example is deliberately portable: it runs on kind and GKE without a
gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it
boots with only the required `dsh-provider-keys` Secret. This document covers boots with only the required `dsh-provider-keys` Secret. That portability is
four **opt-in** controls plus failed-Pod recovery: exactly why the base manifests are **demo-only**. This document covers the
controls you add for production and the steps to enable or roll them back.
**Production is not the base walkthrough.** At minimum, a production deployment
needs: gVisor for the sandbox pods (or another kernel-isolation boundary), the
sandbox NetworkPolicy, the gateway egress policy, the exposure variant's
NetworkPolicy, TLS with `COOKIE_SECURE=true` (and no
`ALLOW_INSECURE_COOKIES`), SSO (or an equivalent authenticating proxy) as the
only path to the gateway, and per-tenant provider credentials/egress. The
checklist at the end summarizes this; without the network and kernel layers the
example is not tenant-isolated.
This document covers four **opt-in** controls plus failed-Pod recovery:
| Setting | Where it lives | What it changes | | Setting | Where it lives | What it changes |
| --- | --- | --- | | --- | --- | --- |
| gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel | | gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel; required for untrusted agents in production |
| nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool | | nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool |
| NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/` | limits sandbox, gateway and proxy connectivity | | NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/`, plus the gateway egress recipe | limits sandbox, gateway and proxy connectivity; the sandbox and gateway-egress policies are required for production |
| Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox | | Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox |
| Failed-Pod recovery | commented `failedPodPolicy` in the SandboxTemplates | replaces a Failed sandbox Pod instead of parking the sandbox | | Failed-Pod recovery | commented `failedPodPolicy` in the SandboxTemplates | replaces a Failed sandbox Pod instead of parking the sandbox |
@@ -234,10 +246,17 @@ kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
The base example does **not** manage NetworkPolicy: the SandboxTemplate is The base example does **not** manage NetworkPolicy: the SandboxTemplate is
`networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps `networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps
the walkthrough portable and independent of the sandbox-router; the opt-in the walkthrough portable and independent of the sandbox-router. **For
policies below are standard Kubernetes `NetworkPolicy` objects. production this is not optional**: without the sandbox policy every tenant can
reach every other tenant and cluster-internal endpoints, and without a gateway
egress policy the gateway can talk to anything the cluster network allows.
Apply both. The policies below are standard Kubernetes `NetworkPolicy` objects.
### Optional sandbox policy > **Demo vs production.** An installation that skips the sandbox NetworkPolicy
> and the gateway egress policy is a demo. Exposure (Ingress/Gateway API) adds
> ingress protection only; it does not isolate tenants from each other.
### Sandbox policy (required for production)
[`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml) [`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml)
selects every Sandbox pod through the agent-sandbox tracking label selects every Sandbox pod through the agent-sandbox tracking label
@@ -301,10 +320,10 @@ Caveats:
- `kubectl port-forward` still works for debugging (the connection is local to - `kubectl port-forward` still works for debugging (the connection is local to
the pod), except under gVisor. the pod), except under gVisor.
### Exposure-variant policies ### Exposure-variant policies (required, part of the variant)
Each exposure variant ships its own policy. They are part of the variant, not a Each exposure variant ships its own policy. They are part of the variant and
separate opt-in step: **required for production**; an exposure without its policy is demo-only:
- [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml) - [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml)
allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the
@@ -317,7 +336,10 @@ separate opt-in step:
The SSO policy is what turns the trust boundary in the The SSO policy is what turns the trust boundary in the
[exposure README](../manifests/exposure/README.md) into network enforcement. [exposure README](../manifests/exposure/README.md) into network enforcement.
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
variants. variants. After applying the SSO variant, run
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh): it checks the
gateway's SSO env, the Secrets, the TLS reference, leftover placeholders and
accidental second paths (such as an Ingress).
Verify the negative case from a sandbox pod (it is not the proxy): Verify the negative case from a sandbox pod (it is not the proxy):
@@ -332,11 +354,12 @@ different pod label, replace the `podSelector` with the correct selector or an
`ipBlock`. As with the sandbox policy, kubelet probes can be affected on some `ipBlock`. As with the sandbox policy, kubelet probes can be affected on some
CNIs. CNIs.
### Gateway egress (documented, not shipped) ### Gateway egress (required for production)
The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. The The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. An
API server endpoint is cluster-specific (Service CIDR or node IP/port), so an egress policy is **required for production** but is not shipped, because the API
egress policy is **not** shipped. If your cluster has a stable API-server CIDR server endpoint is cluster-specific (Service CIDR or node IP/port) and a wrong
value silently breaks signup/proxy. If your cluster has a stable API-server CIDR
and port, add an egress policy like this (replace the placeholders; verify the and port, add an egress policy like this (replace the placeholders; verify the
port, commonly `443` or `6443`): port, commonly `443` or `6443`):
@@ -597,7 +620,63 @@ done
Warm-pool spares pick the default up as they are claimed and replenished, or Warm-pool spares pick the default up as they are claimed and replenished, or
delete them as in step 2 above. delete them as in step 2 above.
## 6. Verification summary ## 6. Production checklist
The base walkthrough is a demo. A production install must satisfy every item
below; the sections above give the commands and caveats.
**Isolation and network**
- [ ] Sandbox pods run under gVisor (or an equivalent kernel-isolation
boundary) because DSH runs with `DSH_PERMISSION_MODE=danger-full-access`
in this image; the pod is the boundary. (Section 1.)
- [ ] The sandbox NetworkPolicy is applied and verified: no tenant→tenant,
no tenant→cluster-Service/API, DNS + public HTTPS only. (Section 3.)
- [ ] The gateway egress policy is applied with the cluster's real API-server
CIDR/port, scoped to DNS, the API server and sandbox pods on `3080`.
(Section 3, "Gateway egress".)
- [ ] The exposure variant's NetworkPolicy is applied. An exposure without it is
demo-only. For SSO, the gateway is reachable **only** through
oauth2-proxy; there is no second Ingress/Gateway/port-forward path.
- [ ] Nodes/CNI enforce NetworkPolicy (verify the negative cases, not just the
positive path).
- [ ] The gateway and (optionally) oauth2-proxy run with `runAsNonRoot`,
dropped capabilities, `RuntimeDefault` seccomp and a read-only root
filesystem, as shipped.
**Authentication, TLS and abuse controls**
- [ ] TLS terminates in front of the gateway, `COOKIE_SECURE=true`, and
`ALLOW_INSECURE_COOKIES` is **not** set. The gateway refuses to start with
an insecure cookie unless explicitly acknowledged.
- [ ] SSO (or an equivalent authenticating proxy) is enabled with
`AUTH_PROXY_HEADER=X-Forwarded-User`, and
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) exits `0`.
- [ ] No `your-github-org` / `dsh-demo.example.com` placeholder remains.
- [ ] `SIGNUP_INVITE_CODE` is set from a Secret if the gateway's own signup form
is reachable, and `AUTH_RATE_LIMIT` is set deliberately (`30`/60s shipped).
Add ingress-level rate limiting/quota too; the gateway's buckets are
in-process and reset on restart.
- [ ] Session lifetime (`COOKIE_MAX_AGE`) and the logout/delete revocation
behavior are acceptable; account for gateway restarts logging users out.
- [ ] `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` fit the workload.
**Credentials, platform and operations**
- [ ] Provider keys are per-tenant (or routed through an egress proxy) instead
of the shared `dsh-provider-keys` Secrets; any shared key is treated as
visible to every tenant.
- [ ] RBAC stays namespace-scoped; `pods/log` is treated as sensitive because
it contains launch tokens and model output. Plan the upstream token API
migration.
- [ ] Images are digest-pinned and dependency installs are hash-locked.
- [ ] Gateway replicas stay at `1` (in-memory sessions/idle state) or the state
is externalized.
- [ ] `failedPodPolicy: Recreate` is set if node eviction/spot reclaim is
expected.
- [ ] Audit logging, alerting and backups of the PVCs are in place.
## 7. Verification summary
Required locally for any change to this example: Required locally for any change to this example:
@@ -605,6 +684,7 @@ Required locally for any change to this example:
make check # versions, example layout, manifest YAML shape make check # versions, example layout, manifest YAML shape
yamllint . yamllint .
make test # only when the gateway code changed make test # only when the gateway code changed
shellcheck examples/dsh-agents-as-a-service/scripts/*.sh
``` ```
CI additionally parses every manifest and runs `kubectl apply CI additionally parses every manifest and runs `kubectl apply
@@ -618,17 +698,25 @@ Manual checks, on a cluster with the relevant CNI/runtime:
gateway proxy works, port-forward limitation acknowledged. gateway proxy works, port-forward limitation acknowledged.
- nodeSelector: pods land on the intended nodes; no `Pending` events. - nodeSelector: pods land on the intended nodes; no `Pending` events.
- Sandbox policy: the allow/deny matrix above. - Sandbox policy: the allow/deny matrix above.
- Gateway egress policy: signup and proxy still work; the gateway cannot reach
an arbitrary cluster Service or the metadata server.
- Exposure policies: browser path works; a non-proxy pod cannot reach the - Exposure policies: browser path works; a non-proxy pod cannot reach the
gateway; probes stay healthy. gateway; probes stay healthy. `scripts/preflight-sso.sh` exits `0`.
- Cookie/TLS guard: with `COOKIE_SECURE=false` and no
`ALLOW_INSECURE_COOKIES` the gateway refuses to start; with TLS it starts and
the cookie is `Secure` (`__Host-` prefixed).
- Auth abuse controls: `429` + `Retry-After` after the configured limit, `403`
on a wrong invite code, and revocations on logout/delete/recreate.
- Provider keys: set/unset output (never the values) and the refresh procedure. - Provider keys: set/unset output (never the values) and the refresh procedure.
- Failed-Pod recovery: `spec.failedPodPolicy` on the Sandbox, and the drill - Failed-Pod recovery: `spec.failedPodPolicy` on the Sandbox, and the drill
above replaces a `Failed` Pod while keeping the claim and PVC. above replaces a `Failed` Pod while keeping the claim and PVC.
## 7. Rollback summary ## 8. Rollback summary
| Layer | Rollback | | Layer | Rollback |
| --- | --- | | --- | --- |
| Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` | | Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` |
| Gateway egress policy | `kubectl delete networkpolicy dsh-gateway-egress -n dsh-demo` (do not leave it half-configured) |
| Exposure policy | delete the exposure variant directory | | Exposure policy | delete the exposure variant directory |
| gVisor | patch `runtimeClassName: null`, recreate pods | | gVisor | patch `runtimeClassName: null`, recreate pods |
| nodeSelector | patch `nodeSelector: null`, recreate pods | | nodeSelector | patch `nodeSelector: null`, recreate pods |

View File

@@ -1,16 +1,73 @@
FROM python:3.12-slim # syntax=docker/dockerfile:1.7
#
# Example gateway image: the tenant-facing proxy in front of DSH sandboxes.
# The base image is digest-pinned in versions.env (GATEWAY_PYTHON_IMAGE) and
# scripts/check-versions.sh verifies the ARG default below.
#
# Two stages on that base:
# build installs the fully hash-pinned runtime dependencies into /venv as
# a dedicated non-root UID, so the runtime stage copies a venv that
# contains no root-owned files.
# runtime copies /venv and the app, runs as UID 10000 with HOME=/tmp and no
# writable location of its own: usable with readOnlyRootFilesystem.
ARG GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Read-only-rootfs friendly: no .pyc writes, no home directory writes. # ---------------------------------------------------------------------------
# Build stage: hash-pinned virtualenv owned by the runtime identity.
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS build
ARG APP_UID=10000
ARG APP_GID=10000
# No .pyc writes and no user cache dirs: the runtime image stays read-only.
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
HOME=/tmp HOME=/tmp
WORKDIR /app RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app \
&& python -m venv /venv \
&& chown -R "$APP_UID:$APP_GID" /venv
WORKDIR /build
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY gateway.py . # Install as the unprivileged user so every file in /venv is owned by UID 10000.
# --require-hashes makes pip reject any requirement without a matching hash:
# the dependency set in requirements.txt is fully pinned and verified. Use the
# venv's own pip: the system pip would user-install into $HOME instead.
USER $APP_UID:$APP_GID
RUN /venv/bin/pip install --no-cache-dir --require-hashes -r requirements.txt
# ---------------------------------------------------------------------------
# Runtime stage
# ---------------------------------------------------------------------------
FROM $GATEWAY_PYTHON_IMAGE AS runtime
ARG APP_UID=10000
ARG APP_GID=10000
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HOME=/tmp \
PATH="/venv/bin:$PATH"
RUN groupadd --gid "$APP_GID" app \
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
--home-dir /tmp --shell /usr/sbin/nologin app
WORKDIR /app
# Dependencies come from the build stage: same interpreter, no build tools left
# in the runtime image and nothing owned by root inside /venv.
COPY --from=build --chown=$APP_UID:$APP_GID /venv /venv
COPY --chown=$APP_UID:$APP_GID gateway.py .
EXPOSE 8080 EXPOSE 8080
# Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image. # Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image.
USER 10000 USER 10000

View File

@@ -10,11 +10,13 @@ It does four things:
(only its SHA-256 is stored as a claim annotation), reports derived state, (only its SHA-256 is stored as a claim annotation), reports derived state,
and deletes the claim (cascading the sandbox and PVC). and deletes the claim (cascading the sandbox and PVC).
2. **Browser sessions** — exchanges a user token for an HttpOnly session 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 cookie whose value is only an opaque, server-side session id (user and
still use `Authorization: Bearer <token>`. expiry stay in the gateway's memory) so the DSH UI works in a browser at
`/u/<user>/`. Automation can still use
`Authorization: Bearer <token>`, which never comes from the cookie.
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to 3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
the sandbox's DSH Web server on `:3080`, including DSH's own cookie the sandbox's DSH Web server on `:3080`, including DSH's own cookie
handshake. handshake. Client-supplied forwarding/identity headers are stripped.
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity 4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
header an authenticating reverse proxy (for example oauth2-proxy) forwards, header an authenticating reverse proxy (for example oauth2-proxy) forwards,
auto-provisions that user, and exposes `POST /recreate` for a clean restart. auto-provisions that user, and exposes `POST /recreate` for a clean restart.
@@ -63,10 +65,10 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
| Method | Path | Auth | Purpose | | Method | Path | Auth | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. | | `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. | | `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`. 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` | `/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, `404` for an unknown user. | | `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 | Clears the session cookie and `303`s to `/`. | | `POST` | `/logout` | none | Revokes the server-side session, clears the 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. | | `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`. | | `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`. | | `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. |
@@ -78,19 +80,49 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
* `Authorization: Bearer <token>` is checked with a timing-safe SHA-256 * `Authorization: Bearer <token>` is checked with a timing-safe SHA-256
comparison against the `aaas.example.com/token-sha256` claim annotation. comparison against the `aaas.example.com/token-sha256` claim annotation.
* The browser session cookie is * The browser session cookie is **opaque**: its value is a random
`dsh_gw_session=<user>:<token>` (HttpOnly, SameSite=Lax, Path=/, optional server-side session id; the user and expiry live in gateway memory
Secure). A session for user A never authorizes user B. (`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_session` when `COOKIE_SECURE=true` (HTTPS) and to
`dsh_gw_session` for plain HTTP; attributes are HttpOnly, SameSite=Lax,
Path=/ and (with `COOKIE_SECURE`) Secure.
* Sessions are revoked on `POST /logout`, on `DELETE /users/{user}`, on
`POST /recreate`, and when they expire (looked up on every request; the
sweeper also drops expired entries). `from_env()` refuses to start without
`COOKIE_SECURE=true` or the explicit `ALLOW_INSECURE_COOKIES=1` opt-out for
local HTTP.
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and * Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
cookie-authenticated **WebSocket upgrades** require the request `Origin` cookie-authenticated **WebSocket upgrades** require the request `Origin`
(or `Referer` fallback) to match the `Host` header. Missing or mismatched (or `Referer` fallback) to match the `Host` header. Missing or mismatched
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS). cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
Form signup/login additionally reject a cross-origin `Origin`/`Referer`
when the browser sent one, before the body is read.
* `POST /users` and `POST /login` are rate limited per client:
`AUTH_RATE_LIMIT` attempts per `AUTH_RATE_WINDOW` seconds (default 30/60,
`0` disables). The client key is the rightmost `X-Forwarded-For` entry when
present (the address the last trusted proxy saw, not a spoofable leftmost
value), else the socket peer. Over-limit requests get `429` +
`Retry-After` before the body is read.
* Proxy requests strip client-supplied forwarding/identity headers in both
HTTP and WebSocket paths: `Forwarded`, every `X-Forwarded-*`
(including `X-Forwarded-User`), every `X-Auth-Request-*`, and `X-Real-IP`.
The gateway sets its own `X-Forwarded-For/Host/Proto/Port` values from the
connection. `UPSTREAM_HEADER_ALLOWLIST` optionally 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 `Cookie` header and any `?token=` * The gateway's bearer token, the browser `Cookie` header and any `?token=`
query parameter are never forwarded upstream. Only the cached `dsh-auth-*` query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
cookie is injected. cookie is injected.
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are * Upstream `Set-Cookie` headers are dropped. `Location` headers that are
absolute paths are rewritten under `/u/<user>/`, and any `token` query absolute paths are rewritten under `/u/<user>/`, and any `token` query
parameter is stripped. parameter is stripped.
* Concurrency is capped per user: `MAX_IN_FLIGHT_PER_USER` proxied requests
(default 32) before the gateway answers `503` + `Retry-After` (HTTP) or
closes the WebSocket with `1011`. Messages received from DSH are limited to
`WS_MAX_MESSAGE_BYTES` (default 16 MiB); browser-to-DSH frames are bounded by
uvicorn's own WebSocket size limit.
### SSO (opt-in) ### SSO (opt-in)
@@ -106,7 +138,8 @@ kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-Use
When set, the gateway treats a request carrying that header as authenticated as 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 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 trusted when the variable is unset. The landing page then auto-provisions the
user (creates their claim idempotently, mints a rotated session token) and 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 redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean
restart. The base manifests leave it unset on purpose; the restart. The base manifests leave it unset on purpose; the
[SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to [SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to
@@ -133,14 +166,19 @@ oauth2-proxy's egress to the gateway, DNS and public HTTPS.
| --- | --- | | --- | --- |
| Unknown user / claim | `404` | | Unknown user / claim | `404` |
| Missing or invalid credentials | `401` | | Missing or invalid credentials | `401` |
| Cross-origin cookie mutation | `403` | | 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` | | 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-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. | | 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` | | 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` | | Upstream connection/protocol failure | `502` |
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` | | 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 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; 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 Starlette would turn an early close into an HTTP 403, which is why WS auth
@@ -166,17 +204,27 @@ can also be injected into `create_app` for tests.
| `DSH_PORT` | `3080` | Upstream DSH port. | | `DSH_PORT` | `3080` | Upstream DSH port. |
| `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. | | `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. | | `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_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 when served over TLS. | | `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie. Required unless `ALLOW_INSECURE_COOKIES=1` is set for local HTTP. |
| `COOKIE_MAX_AGE` | `604800` | Session cookie lifetime in seconds. | | `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). | | `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. | | `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. | | `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](#sso-opt-in)). | | `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)). |
The shipped `manifests/50-gateway.yaml` sets `IDLE_TIMEOUT=360` and 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 `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. 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 ### Required RBAC
@@ -197,8 +245,10 @@ python -m pip install -r requirements-dev.txt
python -m pytest -q python -m pytest -q
python -m ruff check . python -m ruff check .
# Uvicorn factory (loads in-cluster credentials only on the first claim call) # Uvicorn factory (loads in-cluster credentials only on the first claim call).
uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080 # 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: Container image, from the repository root:
@@ -226,18 +276,27 @@ uvicorn's access log so bearer tokens in URLs are not logged.
Log rotation, a very chatty container, or DSH changing its startup message Log rotation, a very chatty container, or DSH changing its startup message
breaks the handshake. Prefer a first-class token/credential API when DSH breaks the handshake. Prefer a first-class token/credential API when DSH
offers one. offers one.
* **Single replica, in-memory state.** The handshake cookie cache, idle * **Single replica, in-memory state.** The handshake cookie cache, the opaque
activity clocks and in-flight counters live in one process. Running more session table, idle activity clocks, in-flight counters and rate-limit
than one replica would need shared state (or sticky routing), otherwise each buckets live in one process. Running more than one replica would need shared
replica handshakes independently and the sweeper may race another replica. 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 * **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 restarts invalidate it naturally; otherwise entries survive until the pod or
claim is deleted. claim is deleted.
* **No TLS termination.** Run behind an ingress/load balancer; set * **No TLS termination.** Run behind an ingress/load balancer and set
`COOKIE_SECURE=true` there. `COOKIE_SECURE=true`. `Settings.from_env()` refuses to start without it
unless `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges plain-HTTP
sessions.
* **Origin-based CSRF protection only.** Cookie-auth mutations require a * **Origin-based CSRF protection only.** Cookie-auth mutations require a
same-origin `Origin`/`Referer`; there is no per-form CSRF token, rate same-origin `Origin`/`Referer`, and form signup/login reject a contradicting
limiting or login throttling. 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 rightmost `X-Forwarded-For` value.
* **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not * **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not
streamed. Responses (including SSE) are streamed. streamed. Responses (including SSE) are streamed.
* **Upstream cookies are not forwarded.** DSH cannot rotate its own * **Upstream cookies are not forwarded.** DSH cannot rotate its own

View File

@@ -7,9 +7,12 @@ single teaching-friendly module with three jobs:
(only its SHA-256 is stored, as a claim annotation), report derived state, (only its SHA-256 is stored, as a claim annotation), report derived state,
and delete the claim (which cascades the sandbox and its PVC). and delete the claim (which cascades the sandbox and its PVC).
* **Browser sessions** -- exchange a user token for an HttpOnly session cookie * **Browser sessions** -- exchange a user token for an HttpOnly session cookie
so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser requests whose value is an opaque, server-side session id (the user and expiry live in
authenticate with that cookie; automation can keep using memory) so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser
``Authorization: Bearer <token>``. requests authenticate with that cookie; automation can keep using
``Authorization: Bearer <token>``. Sessions are revoked on logout, user
deletion, recreate and expiry, and a cookie is never accepted as a bearer
token (nor the other way around).
* **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the * **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the
sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the
gateway scrapes the launch token from the pod logs, performs the ``?token=`` gateway scrapes the launch token from the pod logs, performs the ``?token=``
@@ -38,6 +41,7 @@ import contextlib
import hashlib import hashlib
import html import html
import json import json
import math
import os import os
import re import re
import secrets import secrets
@@ -121,6 +125,19 @@ OWNED_REQUEST_HEADERS = HOP_BY_HOP | {
"x-real-ip", "x-real-ip",
} }
# Default session cookie names. ``__Host-`` is only legal over HTTPS and pins
# the cookie to Path=/ with no Domain attribute; the insecure default keeps the
# example usable over plain HTTP behind a port-forward.
DEFAULT_COOKIE_NAME = "dsh_gw_session"
SECURE_COOKIE_NAME = "__Host-dsh_gw_session"
# Header prefixes a client must never be able to smuggle upstream. The gateway
# derives its own X-Forwarded-* values from the connection; anything the browser
# sends with these names is attacker-controlled and is dropped in both the HTTP
# and WebSocket proxy paths.
FORWARDING_HEADER_PREFIXES = ("x-forwarded-", "x-auth-request-")
FORWARDING_HEADERS = frozenset({"forwarded"})
_DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)") _DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)")
@@ -249,6 +266,33 @@ def same_origin(headers) -> bool:
return False return False
def strip_forwarding_header(name: str) -> bool:
"""True for client-supplied identity/forwarding headers we never forward.
``X-Forwarded-User`` and ``X-Auth-Request-*`` carry identities an
authenticating proxy or the gateway itself owns; ``Forwarded`` /
``X-Forwarded-*`` describe the hop chain. Forwarding any of them lets a
browser spoof its origin or a trusted SSO user upstream.
"""
lowered = name.lower()
return lowered in FORWARDING_HEADERS or lowered.startswith(FORWARDING_HEADER_PREFIXES)
def client_identity(headers, client) -> str:
"""The rate-limit bucket for a request.
The rightmost ``X-Forwarded-For`` entry is the address the last trusted
proxy saw (each proxy appends), so it cannot be spoofed by prepending
arbitrary values. Without the header fall back to the socket peer.
"""
forwarded = (headers.get("x-forwarded-for") or "").strip()
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[-1]
return getattr(client, "host", None) or "unknown"
def rewrite_location(location: str, user: str) -> str: def rewrite_location(location: str, user: str) -> str:
"""Prefix absolute-path redirects so the browser stays under ``/u/<user>/``.""" """Prefix absolute-path redirects so the browser stays under ``/u/<user>/``."""
if not location.startswith("/") or location.startswith("//"): if not location.startswith("/") or location.startswith("//"):
@@ -306,6 +350,14 @@ def _env_float(name: str, default: float) -> float:
return default return default
def _env_allowlist(name: str) -> frozenset[str] | None:
"""Parse a comma-separated header allowlist; unset means "denylist mode"."""
raw = os.environ.get(name)
if raw is None:
return None
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
# --- Settings -------------------------------------------------------------- # --- Settings --------------------------------------------------------------
@@ -325,7 +377,7 @@ class Settings:
dsh_port: int = 3080 dsh_port: int = 3080
dsh_container: str = "dsh-web" dsh_container: str = "dsh-web"
dsh_log_tail_lines: int = 2000 dsh_log_tail_lines: int = 2000
cookie_name: str = "dsh_gw_session" cookie_name: str = DEFAULT_COOKIE_NAME
cookie_secure: bool = False cookie_secure: bool = False
cookie_max_age: int = 7 * 24 * 60 * 60 cookie_max_age: int = 7 * 24 * 60 * 60
max_body_bytes: int = 32 * 1024 * 1024 max_body_bytes: int = 32 * 1024 * 1024
@@ -339,9 +391,36 @@ class Settings:
# valid user in this header are treated as authenticated (SSO); unset (None) # valid user in this header are treated as authenticated (SSO); unset (None)
# disables SSO entirely and the header is never trusted. # disables SSO entirely and the header is never trusted.
auth_proxy_header: str | None = None auth_proxy_header: str | None = None
# Abuse controls. AUTH_RATE_LIMIT applies to the unauthenticated auth routes
# (POST /users and POST /login) per client and window; 0 disables it.
auth_rate_limit: int = 30
auth_rate_window: int = 60
# When set, signup requires this invite code (form field ``invite`` or the
# ``X-Signup-Invite`` header). None keeps signup open, as in the demo.
signup_invite_code: str | None = None
# Optional allowlist of client request headers to forward upstream. None
# keeps the default denylist behavior; a set (possibly empty) forwards only
# the named headers (plus the gateway's own X-Forwarded-* values).
upstream_header_allowlist: frozenset[str] | None = None
# Concurrent proxied requests per user before 503/1011 is returned.
max_in_flight_per_user: int = 32
# Largest WebSocket message accepted from DSH, in bytes. Browser-to-DSH
# frames are bounded by the ASGI server's own WebSocket limit.
ws_max_message_bytes: int = 16 * 1024 * 1024
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:
cookie_secure = _env_bool("COOKIE_SECURE", False)
allow_insecure = _env_bool("ALLOW_INSECURE_COOKIES", False)
if not cookie_secure and not allow_insecure:
raise RuntimeError(
"refusing to start with an insecure session cookie: set "
"COOKIE_SECURE=true behind TLS, or ALLOW_INSECURE_COOKIES=1 "
"for a local plain-HTTP demo"
)
cookie_name = os.environ.get("COOKIE_NAME") or (
SECURE_COOKIE_NAME if cookie_secure else DEFAULT_COOKIE_NAME
)
return cls( return cls(
namespace=os.environ.get("NAMESPACE", "dsh-demo"), namespace=os.environ.get("NAMESPACE", "dsh-demo"),
pool=os.environ.get("POOL", "dsh-pool"), pool=os.environ.get("POOL", "dsh-pool"),
@@ -350,13 +429,19 @@ class Settings:
dsh_port=_env_int("DSH_PORT", 3080), dsh_port=_env_int("DSH_PORT", 3080),
dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"), dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"),
dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000), dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000),
cookie_name=os.environ.get("COOKIE_NAME", "dsh_gw_session"), cookie_name=cookie_name,
cookie_secure=_env_bool("COOKIE_SECURE", False), cookie_secure=cookie_secure,
cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60), cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60),
max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024, max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024,
http_timeout=_env_float("HTTP_TIMEOUT", 300.0), http_timeout=_env_float("HTTP_TIMEOUT", 300.0),
sweep_interval=_env_float("SWEEP_INTERVAL", 15.0), sweep_interval=_env_float("SWEEP_INTERVAL", 15.0),
auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None, auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None,
auth_rate_limit=_env_int("AUTH_RATE_LIMIT", 30),
auth_rate_window=_env_int("AUTH_RATE_WINDOW", 60),
signup_invite_code=os.environ.get("SIGNUP_INVITE_CODE") or None,
upstream_header_allowlist=_env_allowlist("UPSTREAM_HEADER_ALLOWLIST"),
max_in_flight_per_user=_env_int("MAX_IN_FLIGHT_PER_USER", 32),
ws_max_message_bytes=_env_int("WS_MAX_MESSAGE_BYTES", 16 * 1024 * 1024),
) )
@@ -546,6 +631,19 @@ class Kubernetes:
# --- Gateway --------------------------------------------------------------- # --- Gateway ---------------------------------------------------------------
@dataclass(frozen=True)
class Session:
"""A browser session stored server-side, keyed by an opaque random id.
The cookie only carries the id; the user and the expiry stay in memory, so
a cookie can never be edited into another user's credentials and a stolen
token is not recoverable from the browser.
"""
user: str
expires_at: float
@dataclass @dataclass
class GatewayState: class GatewayState:
"""In-memory, per-process state (single replica by design).""" """In-memory, per-process state (single replica by design)."""
@@ -555,6 +653,10 @@ class GatewayState:
maintenance: dict[str, bool] = field(default_factory=dict) maintenance: dict[str, bool] = field(default_factory=dict)
# (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..." # (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..."
handshake: dict[tuple[str, str, str], str] = field(default_factory=dict) handshake: dict[tuple[str, str, str], str] = field(default_factory=dict)
# Opaque session id -> server-side session (user + expiry).
sessions: dict[str, Session] = field(default_factory=dict)
# Auth rate limiting: client -> (window start, attempts in window).
auth_rate: dict[str, tuple[float, int]] = field(default_factory=dict)
lock: asyncio.Lock = field(default_factory=asyncio.Lock) lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -576,17 +678,40 @@ class Gateway:
# -- credentials ------------------------------------------------------- # -- credentials -------------------------------------------------------
def session_for(self, sid: str | None) -> Session | None:
"""Look up a live session, dropping it when it has expired."""
if not sid:
return None
session = self.state.sessions.get(sid)
if session is None:
return None
if session.expires_at <= time.time():
self.state.sessions.pop(sid, None)
return None
return session
def revoke_session(self, sid: str | None) -> None:
if sid:
self.state.sessions.pop(sid, None)
def revoke_sessions(self, user: str) -> None:
for sid in [key for key, session in self.state.sessions.items() if session.user == user]:
self.state.sessions.pop(sid, None)
def credentials(self, headers, cookies) -> tuple[str | None, str | None]: def credentials(self, headers, cookies) -> tuple[str | None, str | None]:
"""Return ``(token, cookie_user)``; bearer wins over the session cookie.""" """Return ``(token, session_user)``; bearer wins over the session cookie.
A bearer token is never resolved from a cookie and a session id is never
accepted as a bearer token. An unknown or expired session is treated as
anonymous (and the expired entry is dropped).
"""
auth = headers.get("authorization", "") auth = headers.get("authorization", "")
if auth.lower().startswith("bearer "): if auth.lower().startswith("bearer "):
return auth[7:].strip(), None return auth[7:].strip(), None
raw = cookies.get(self.settings.cookie_name) session = self.session_for(cookies.get(self.settings.cookie_name))
if raw: if session is None:
user, sep, token = raw.partition(":")
if sep and user and token:
return token, user
return None, None return None, None
return None, session.user
def remote_user(self, headers) -> str | None: def remote_user(self, headers) -> str | None:
"""The user asserted by a trusted reverse proxy, when SSO is enabled. """The user asserted by a trusted reverse proxy, when SSO is enabled.
@@ -605,30 +730,36 @@ class Gateway:
self, self,
claim: dict, claim: dict,
token: str | None, token: str | None,
cookie_user: str | None, session_user: str | None,
user: str, user: str,
remote: str | None = None, remote: str | None = None,
) -> bool: ) -> bool:
if remote == user: if remote == user:
return True # identity asserted by the authenticating proxy return True # identity asserted by the authenticating proxy
if cookie_user is not None and cookie_user != user: if session_user is not None:
return False # a session for another user never authorizes this one return session_user == user # server-side session bound to this user
return token_matches(claim, token) return token_matches(claim, token)
async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None: async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None:
"""Provision ``remote`` idempotently and mint/refresh a session token. """Provision ``remote`` idempotently and mint/refresh a session.
The authenticating proxy has already verified ``remote``, so it is The authenticating proxy has already verified ``remote``, so it is
trusted. A brand-new user is provisioned (claim created, token minted); trusted. A brand-new user is provisioned (claim created, token minted);
an existing user whose session cookie is missing or no longer matches an existing user whose session cookie is missing, foreign or no longer
gets a rotated token so a fresh cookie can be issued. A still-valid matches gets a rotated token so a fresh cookie can be issued. A still-valid
session is reused untouched. Provisioning is non-blocking: the sandbox session for the same user is reused untouched. Provisioning is
is awaited by the proxy path (``_resolve_sandbox``), so the landing page non-blocking: the sandbox is awaited by the proxy path
can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``. (``_resolve_sandbox``), so the landing page can report "Provisioning"
instead of stalling for ``WAKE_TIMEOUT``.
""" """
claim = await self.k8s.get_claim(remote) claim = await self.k8s.get_claim(remote)
token, cookie_user = self.credentials(headers, cookies) sid = cookies.get(self.settings.cookie_name)
if claim is not None and cookie_user == remote and token_matches(claim, token): session = self.session_for(sid)
if session is not None:
if session.user != remote:
# A session for another identity never authorizes this user.
self.revoke_session(sid)
elif claim is not None:
return claim return claim
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
@@ -645,7 +776,7 @@ class Gateway:
else: else:
await self.k8s.patch_token(remote, token_hash) await self.k8s.patch_token(remote, token_hash)
await self._touch(remote) await self._touch(remote)
self.set_session(response, remote, token) self.set_session(response, remote)
return await self.k8s.get_claim(remote) if is_new else claim return await self.k8s.get_claim(remote) if is_new else claim
@staticmethod @staticmethod
@@ -656,16 +787,73 @@ class Gateway:
and not same_origin(headers) and not same_origin(headers)
) )
def set_session(self, response: Response, user: str, token: str) -> None: def set_session(self, response: Response, user: str) -> str:
"""Mint an opaque server-side session and set the browser cookie."""
sid = secrets.token_urlsafe(32)
self.state.sessions[sid] = Session(
user=user,
expires_at=time.time() + self.settings.cookie_max_age,
)
response.set_cookie( response.set_cookie(
self.settings.cookie_name, self.settings.cookie_name,
f"{user}:{token}", sid,
max_age=self.settings.cookie_max_age, max_age=self.settings.cookie_max_age,
path="/", path="/",
httponly=True, httponly=True,
samesite="lax", samesite="lax",
secure=self.settings.cookie_secure, secure=self.settings.cookie_secure,
) )
return sid
# -- abuse controls ----------------------------------------------------
def _rate_limit(self, request: Request) -> Response | None:
"""Return a 429 response when this client is over the auth budget."""
limit = self.settings.auth_rate_limit
if limit <= 0:
return None
window = max(1, self.settings.auth_rate_window)
key = client_identity(request.headers, request.client)
now = time.time()
bucket = self.state.auth_rate.get(key)
if bucket is None or now - bucket[0] >= window:
self.state.auth_rate[key] = (now, 1)
return None
start, count = bucket
if count >= limit:
retry_after = max(1, math.ceil(start + window - now))
return self._error(
429,
"too many authentication attempts, retry shortly",
{"Retry-After": str(retry_after)},
)
self.state.auth_rate[key] = (start, count + 1)
return None
def _invite_allowed(self, request: Request, payload: dict) -> bool:
"""Check the signup invite code, when one is configured."""
expected = self.settings.signup_invite_code
if expected is None:
return True
provided = payload.get("invite")
if not isinstance(provided, str) or not provided:
provided = request.headers.get("x-signup-invite", "")
if not isinstance(provided, str) or not provided:
return False
return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
def _form_cross_origin(self, request: Request) -> bool:
"""Reject a form signup/login whose Origin/Referer contradicts Host.
Only checked when the browser actually sent one: non-browser clients
(automation, tests) commonly omit both headers and must keep working.
"""
if not self._wants_form(request):
return False
headers = request.headers
if not (headers.get("origin") or headers.get("referer")):
return False
return not same_origin(headers)
# -- activity / maintenance ------------------------------------------- # -- activity / maintenance -------------------------------------------
@@ -674,12 +862,21 @@ class Gateway:
self.state.last_activity[user] = time.time() self.state.last_activity[user] = time.time()
async def _acquire(self, user: str) -> bool: async def _acquire(self, user: str) -> bool:
"""Reserve an in-flight slot, waiting out a concurrent suspend.""" """Reserve an in-flight slot, waiting out a concurrent suspend.
Returns False when the user already has ``MAX_IN_FLIGHT_PER_USER``
requests in flight (an abuse guard, not a transient error) or when a
suspend does not finish within ``WAKE_TIMEOUT``.
"""
deadline = time.monotonic() + self.settings.wake_timeout deadline = time.monotonic() + self.settings.wake_timeout
cap = self.settings.max_in_flight_per_user
while True: while True:
async with self.state.lock: async with self.state.lock:
if not self.state.maintenance.get(user): if not self.state.maintenance.get(user):
self.state.in_flight[user] = self.state.in_flight.get(user, 0) + 1 current = self.state.in_flight.get(user, 0)
if cap > 0 and current >= cap:
return False
self.state.in_flight[user] = current + 1
self.state.last_activity[user] = time.time() self.state.last_activity[user] = time.time()
return True return True
if time.monotonic() > deadline: if time.monotonic() > deadline:
@@ -759,14 +956,19 @@ class Gateway:
host: str, host: str,
force: bool = False, force: bool = False,
) -> tuple[str, tuple[str, str, str], bool]: ) -> tuple[str, tuple[str, str, str], bool]:
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.""" """Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.
Every failure raises ``HandshakeError`` with a fixed, credential-free
message: the exception reaches the browser in the 503 body, so it must
never carry the launch token, the handshake URL or pod details.
"""
pod_name = self._pod_name(sandbox) pod_name = self._pod_name(sandbox)
try: try:
pod = await self.k8s.get_pod(pod_name) pod = await self.k8s.get_pod(pod_name)
except ApiException as exc: except ApiException as exc:
raise HandshakeError(f"could not read pod {pod_name}: {exc}") from exc raise HandshakeError("could not read the DSH handshake pod") from exc
if pod is None: if pod is None:
raise HandshakeError(f"pod {pod_name} not found") raise HandshakeError("the DSH handshake pod is not available")
uid = (pod.get("metadata") or {}).get("uid") or pod_name uid = (pod.get("metadata") or {}).get("uid") or pod_name
key = (user, uid, host) key = (user, uid, host)
if not force: if not force:
@@ -780,7 +982,7 @@ class Gateway:
self.settings.dsh_log_tail_lines, self.settings.dsh_log_tail_lines,
) )
except ApiException as exc: except ApiException as exc:
raise HandshakeError(f"could not read logs for {pod_name}: {exc}") from exc raise HandshakeError("could not read the pod logs for the handshake") from exc
launch_token = parse_dsh_launch_token(logs) launch_token = parse_dsh_launch_token(logs)
if not launch_token: if not launch_token:
raise HandshakeError("DSH launch token not found in pod logs") raise HandshakeError("DSH launch token not found in pod logs")
@@ -798,12 +1000,12 @@ class Gateway:
timeout=self.settings.http_timeout, timeout=self.settings.http_timeout,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise HandshakeError(f"DSH handshake failed: {exc}") from exc raise HandshakeError("the DSH handshake request failed") from exc
if response.status_code >= 400: if response.status_code >= 400:
raise HandshakeError(f"DSH handshake returned {response.status_code}") raise HandshakeError("the DSH handshake returned an error status")
cookie = extract_dsh_cookies(response.headers.get_list("set-cookie")) cookie = extract_dsh_cookies(response.headers.get_list("set-cookie"))
if not cookie: if not cookie:
raise HandshakeError("DSH handshake set no dsh-auth cookie") raise HandshakeError("the DSH handshake set no auth cookie")
self.state.handshake[key] = cookie self.state.handshake[key] = cookie
return cookie, key, False return cookie, key, False
@@ -879,9 +1081,9 @@ class Gateway:
if "set-cookie" in response.headers: if "set-cookie" in response.headers:
page.headers["set-cookie"] = response.headers["set-cookie"] page.headers["set-cookie"] = response.headers["set-cookie"]
return page return page
token, user = self.credentials(request.headers, request.cookies) _token, user = self.credentials(request.headers, request.cookies)
session = "" session = ""
if user and token: if user:
safe_user = html.escape(user) safe_user = html.escape(user)
session = ( session = (
f'<p class="session">Signed in as <strong>{safe_user}</strong>. ' f'<p class="session">Signed in as <strong>{safe_user}</strong>. '
@@ -889,6 +1091,11 @@ class Gateway:
'<form method="post" action="/logout">' '<form method="post" action="/logout">'
'<button type="submit">Sign out</button></form>' '<button type="submit">Sign out</button></form>'
) )
invite_input = (
'<input name="invite" type="password" placeholder="invite code" required>'
if self.settings.signup_invite_code is not None
else ""
)
body = f"""<!doctype html> body = f"""<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -911,6 +1118,7 @@ class Gateway:
<h2>Create a sandbox</h2> <h2>Create a sandbox</h2>
<form method="post" action="/users"> <form method="post" action="/users">
<input name="user" placeholder="username (dns-1123 label)" required> <input name="user" placeholder="username (dns-1123 label)" required>
{invite_input}
<button type="submit">Create</button> <button type="submit">Create</button>
</form> </form>
</section> </section>
@@ -970,11 +1178,18 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- users ------------------------------------------------------------- # -- users -------------------------------------------------------------
async def signup(self, request: Request) -> Response: async def signup(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request) wants_form = self._wants_form(request)
try: try:
payload = await self._read_payload(request) payload = await self._read_payload(request)
except BodyTooLarge: except BodyTooLarge:
return self._error(413, "request body too large") return self._error(413, "request body too large")
if not self._invite_allowed(request, payload):
return self._error(403, "invalid or missing invite code")
user = payload.get("user", "") user = payload.get("user", "")
if not valid_user(user): if not valid_user(user):
return self._error(400, "body must be {'user': '<dns-1123 label>'}") return self._error(400, "body must be {'user': '<dns-1123 label>'}")
@@ -992,16 +1207,21 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
sandbox_name = (sandbox or {}).get("metadata", {}).get("name") sandbox_name = (sandbox or {}).get("metadata", {}).get("name")
if wants_form: if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303) response = RedirectResponse(f"/u/{user}/", status_code=303)
self.set_session(response, user, token) self.set_session(response, user)
return response return response
response = JSONResponse( response = JSONResponse(
{"user": user, "token": token, "state": state, "sandbox": sandbox_name}, {"user": user, "token": token, "state": state, "sandbox": sandbox_name},
status_code=201 if state == "Ready" else 202, status_code=201 if state == "Ready" else 202,
) )
self.set_session(response, user, token) self.set_session(response, user)
return response return response
async def login(self, request: Request) -> Response: async def login(self, request: Request) -> Response:
limited = self._rate_limit(request)
if limited is not None:
return limited
if self._form_cross_origin(request):
return self._error(403, "cross-origin request rejected")
wants_form = self._wants_form(request) wants_form = self._wants_form(request)
try: try:
payload = await self._read_payload(request) payload = await self._read_payload(request)
@@ -1012,21 +1232,29 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if not isinstance(user, str) or not isinstance(token, str): if not isinstance(user, str) or not isinstance(token, str):
return self._error(400, "user and token are required") return self._error(400, "user and token are required")
claim = await self.k8s.get_claim(user) claim = await self.k8s.get_claim(user)
if claim is None: # An unknown user is indistinguishable from a bad token to the client:
return self._error(404, "not found") # never reveal which usernames exist.
if not token_matches(claim, token): if claim is None or not token_matches(claim, token):
return self._error(401, "unauthorized") return self._error(401, "unauthorized")
await self._touch(user) await self._touch(user)
if wants_form: if wants_form:
response = RedirectResponse(f"/u/{user}/", status_code=303) response = RedirectResponse(f"/u/{user}/", status_code=303)
else: else:
response = JSONResponse({"user": user, "state": "ok"}) response = JSONResponse({"user": user, "state": "ok"})
self.set_session(response, user, token) self.set_session(response, user)
return response return response
async def logout(self, request: Request) -> Response: async def logout(self, request: Request) -> Response:
self.revoke_session(request.cookies.get(self.settings.cookie_name))
response = RedirectResponse("/", status_code=303) response = RedirectResponse("/", status_code=303)
response.delete_cookie(self.settings.cookie_name, path="/") # Match the attributes the cookie was set with: a __Host- cookie
# deletion that lacks Secure is rejected by browsers, leaving a stale
# (though already revoked) cookie behind.
response.delete_cookie(
self.settings.cookie_name,
path="/",
secure=self.settings.cookie_secure,
)
return response return response
async def get_user(self, request: Request) -> Response: async def get_user(self, request: Request) -> Response:
@@ -1061,6 +1289,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "DELETE", cookie_user): if self._mutation_from_cookie(request.headers, "DELETE", cookie_user):
return self._error(403, "cross-origin request rejected") return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(user) await self.k8s.delete_claim(user)
self.revoke_sessions(user)
self._clear_handshake(user) self._clear_handshake(user)
async with self.state.lock: async with self.state.lock:
self.state.last_activity.pop(user, None) self.state.last_activity.pop(user, None)
@@ -1084,6 +1313,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if self._mutation_from_cookie(request.headers, "POST", cookie_user): if self._mutation_from_cookie(request.headers, "POST", cookie_user):
return self._error(403, "cross-origin request rejected") return self._error(403, "cross-origin request rejected")
await self.k8s.delete_claim(remote) await self.k8s.delete_claim(remote)
self.revoke_sessions(remote)
self._clear_handshake(remote) self._clear_handshake(remote)
async with self.state.lock: async with self.state.lock:
self.state.last_activity.pop(remote, None) self.state.last_activity.pop(remote, None)
@@ -1111,7 +1341,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
return self._error(404, "unknown user") return self._error(404, "unknown user")
token, cookie_user = self.credentials(request.headers, request.cookies) token, cookie_user = self.credentials(request.headers, request.cookies)
if remote == user and not (cookie_user == user and token_matches(claim, token)): if remote == user and cookie_user != user:
# SSO user without a valid session cookie: bootstrap, then continue. # SSO user without a valid session cookie: bootstrap, then continue.
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)
if not self._authorized(claim, token, cookie_user, user, remote): if not self._authorized(claim, token, cookie_user, user, remote):
@@ -1171,11 +1401,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
if query: if query:
url = f"{url}?{query}" url = f"{url}?{query}"
headers = { headers = {}
key: value allowlist = self.settings.upstream_header_allowlist
for key, value in request.headers.items() for key, value in request.headers.items():
if key.lower() not in OWNED_REQUEST_HEADERS and key.lower() != "content-length" lowered = key.lower()
} if lowered in OWNED_REQUEST_HEADERS or lowered == "content-length":
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers are spoofable
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
host = request.headers.get("host", "") host = request.headers.get("host", "")
headers["host"] = host headers["host"] = host
headers["cookie"] = cookie # only the cached DSH auth cookie headers["cookie"] = cookie # only the cached DSH auth cookie
@@ -1309,13 +1545,19 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
query = filtered_query(websocket.url.query) query = filtered_query(websocket.url.query)
if query: if query:
url = f"{url}?{query}" url = f"{url}?{query}"
headers = { headers = {}
key: value allowlist = self.settings.upstream_header_allowlist
for key, value in websocket.headers.items() for key, value in websocket.headers.items():
if key.lower() not in OWNED_REQUEST_HEADERS lowered = key.lower()
and key.lower() != "origin" if lowered in OWNED_REQUEST_HEADERS or lowered == "origin":
and not key.lower().startswith("sec-websocket-") continue
} if lowered.startswith("sec-websocket-"):
continue
if strip_forwarding_header(lowered):
continue # client-supplied identity/hop-chain headers
if allowlist is not None and lowered not in allowlist:
continue
headers[key] = value
headers["cookie"] = cookie headers["cookie"] = cookie
headers["x-forwarded-host"] = host headers["x-forwarded-host"] = host
headers["x-forwarded-proto"] = websocket.url.scheme headers["x-forwarded-proto"] = websocket.url.scheme
@@ -1331,7 +1573,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
additional_headers=headers, additional_headers=headers,
subprotocols=subprotocols or None, subprotocols=subprotocols or None,
open_timeout=self.settings.wake_timeout, open_timeout=self.settings.wake_timeout,
max_size=None, max_size=self.settings.ws_max_message_bytes,
proxy=None, proxy=None,
) )
upstream = await upstream_cm.__aenter__() upstream = await upstream_cm.__aenter__()
@@ -1391,8 +1633,41 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
# -- idle sweeper ------------------------------------------------------ # -- idle sweeper ------------------------------------------------------
def _prune_state(self, claims: list[dict]) -> None:
"""Drop state that can no longer be reached.
Sessions and activity clocks for a user whose claim is gone, expired
sessions and expired rate-limit buckets would otherwise accumulate for
the lifetime of the process. In-flight counters are only pruned at
zero, and a user with work in flight is always preserved.
"""
now = time.time()
present = set()
for claim in claims:
name = (claim.get("metadata") or {}).get("name") or ""
if name.startswith("dsh-"):
present.add(name[len("dsh-") :])
for user in [user for user in self.state.last_activity if user not in present]:
self.state.last_activity.pop(user, None)
for user, count in list(self.state.in_flight.items()):
if user not in present and count == 0:
self.state.in_flight.pop(user, None)
for user, flag in list(self.state.maintenance.items()):
if user not in present and not flag:
self.state.maintenance.pop(user, None)
for key in [key for key in self.state.handshake if key[0] not in present]:
self.state.handshake.pop(key, None)
for sid, session in list(self.state.sessions.items()):
if session.user not in present or session.expires_at <= now:
self.state.sessions.pop(sid, None)
window = max(1, self.settings.auth_rate_window)
for key, (start, _count) in list(self.state.auth_rate.items()):
if now - start >= window:
self.state.auth_rate.pop(key, None)
async def sweep_once(self) -> None: async def sweep_once(self) -> None:
claims = await self.k8s.list_claims() claims = await self.k8s.list_claims()
self._prune_state(claims)
for claim in claims: for claim in claims:
# Only suspend claims this gateway created. Everything else in # Only suspend claims this gateway created. Everything else in
# the namespace (resource-layer claims from the README walkthrough, # the namespace (resource-layer claims from the README walkthrough,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,10 +18,12 @@ from types import SimpleNamespace
import httpx import httpx
import pytest import pytest
from kubernetes.client.exceptions import ApiException from kubernetes.client.exceptions import ApiException
from starlette.responses import Response
from starlette.testclient import TestClient from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from gateway import ( from gateway import (
Session,
Settings, Settings,
UserExists, UserExists,
claim_name, claim_name,
@@ -219,6 +221,39 @@ def add_user(k8s, user="alice", token=TOKEN, ready=True, pod_name=None, suffix="
return sandbox_name, pod_name return sandbox_name, pod_name
# --- browser sessions ------------------------------------------------------
#
# A browser session is an opaque server-side id. Tests must never forge a
# cookie (or a bearer token); they mint a real session through the gateway so
# the production lookup path is exercised.
def mint_session(env, user="alice") -> tuple[str, str]:
"""Mint a server-side session for ``user``; returns ``(cookie_name, sid)``."""
response = Response()
sid = env.gateway.set_session(response, user)
return env.gateway.settings.cookie_name, sid
def session_dict(env, user="alice") -> dict:
name, sid = mint_session(env, user)
return {name: sid}
def session_cookie(env, user="alice") -> str:
name, sid = mint_session(env, user)
return f"{name}={sid}"
def response_session_user(env, response) -> str:
"""The user of the server-side session a response just set."""
cookie = response.headers["set-cookie"]
value = cookie.split(";", 1)[0].split("=", 1)[1]
session = env.gateway.state.sessions.get(value)
assert session is not None, "response did not set a server-side session"
return session.user
@pytest.fixture @pytest.fixture
def env(): def env():
settings = Settings( settings = Settings(
@@ -463,6 +498,12 @@ def test_settings_from_env(monkeypatch):
monkeypatch.setenv("MAX_BODY_MB", "2") monkeypatch.setenv("MAX_BODY_MB", "2")
monkeypatch.setenv("HTTP_TIMEOUT", "12.5") monkeypatch.setenv("HTTP_TIMEOUT", "12.5")
monkeypatch.setenv("AUTH_PROXY_HEADER", "X-Forwarded-User") monkeypatch.setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
monkeypatch.setenv("AUTH_RATE_LIMIT", "5")
monkeypatch.setenv("AUTH_RATE_WINDOW", "30")
monkeypatch.setenv("SIGNUP_INVITE_CODE", "invite-x")
monkeypatch.setenv("UPSTREAM_HEADER_ALLOWLIST", "X-Custom, Accept")
monkeypatch.setenv("MAX_IN_FLIGHT_PER_USER", "4")
monkeypatch.setenv("WS_MAX_MESSAGE_BYTES", "1024")
settings = Settings.from_env() settings = Settings.from_env()
assert settings.namespace == "ns-x" assert settings.namespace == "ns-x"
assert settings.pool == "pool-x" assert settings.pool == "pool-x"
@@ -476,13 +517,56 @@ def test_settings_from_env(monkeypatch):
assert settings.max_body_bytes == 2 * 1024 * 1024 assert settings.max_body_bytes == 2 * 1024 * 1024
assert settings.http_timeout == 12.5 assert settings.http_timeout == 12.5
assert settings.auth_proxy_header == "X-Forwarded-User" assert settings.auth_proxy_header == "X-Forwarded-User"
assert settings.auth_rate_limit == 5
assert settings.auth_rate_window == 30
assert settings.signup_invite_code == "invite-x"
assert settings.upstream_header_allowlist == frozenset({"x-custom", "accept"})
assert settings.max_in_flight_per_user == 4
assert settings.ws_max_message_bytes == 1024
def test_settings_auth_proxy_header_defaults_to_none(monkeypatch): def test_settings_auth_proxy_header_defaults_to_none(monkeypatch):
monkeypatch.delenv("AUTH_PROXY_HEADER", raising=False) monkeypatch.delenv("AUTH_PROXY_HEADER", raising=False)
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
assert Settings.from_env().auth_proxy_header is None assert Settings.from_env().auth_proxy_header is None
def test_settings_from_env_rejects_insecure_cookies_by_default(monkeypatch):
monkeypatch.delenv("COOKIE_SECURE", raising=False)
monkeypatch.delenv("ALLOW_INSECURE_COOKIES", raising=False)
with pytest.raises(RuntimeError, match="insecure session cookie"):
Settings.from_env()
def test_settings_from_env_allows_insecure_cookies_with_the_explicit_flag(monkeypatch):
monkeypatch.delenv("COOKIE_SECURE", raising=False)
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
monkeypatch.delenv("COOKIE_NAME", raising=False)
settings = Settings.from_env()
assert settings.cookie_secure is False
assert settings.cookie_name == "dsh_gw_session"
def test_settings_default_cookie_name_is_host_prefixed_when_secure(monkeypatch):
monkeypatch.setenv("COOKIE_SECURE", "true")
monkeypatch.delenv("COOKIE_NAME", raising=False)
assert Settings.from_env().cookie_name == "__Host-dsh_gw_session"
def test_settings_explicit_cookie_name_wins(monkeypatch):
monkeypatch.setenv("COOKIE_SECURE", "true")
monkeypatch.setenv("COOKIE_NAME", "sid")
assert Settings.from_env().cookie_name == "sid"
def test_settings_upstream_header_allowlist_unset_means_denylist_mode(monkeypatch):
monkeypatch.setenv("ALLOW_INSECURE_COOKIES", "1")
monkeypatch.delenv("UPSTREAM_HEADER_ALLOWLIST", raising=False)
assert Settings.from_env().upstream_header_allowlist is None
monkeypatch.setenv("UPSTREAM_HEADER_ALLOWLIST", "")
assert Settings.from_env().upstream_header_allowlist == frozenset()
# --- Kubernetes adapter ---------------------------------------------------- # --- Kubernetes adapter ----------------------------------------------------
@@ -635,11 +719,22 @@ async def test_landing_page_has_signup_and_login_forms(env):
assert "text/html" in response.headers["content-type"] assert "text/html" in response.headers["content-type"]
assert 'action="/users"' in response.text assert 'action="/users"' in response.text
assert 'action="/login"' in response.text assert 'action="/login"' in response.text
# No invite code configured => no invite input on the landing page.
assert 'name="invite"' not in response.text
@pytest.mark.asyncio
async def test_landing_page_shows_invite_input_only_when_configured(env):
configure(env, signup_invite_code="let-me-in")
async with asgi_client(env) as client:
response = await client.get("/")
assert response.status_code == 200
assert 'name="invite"' in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_landing_page_shows_session_when_cookie_present(env): async def test_landing_page_shows_session_when_cookie_present(env):
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/") response = await client.get("/")
assert "Signed in as" in response.text assert "Signed in as" in response.text
assert "/u/alice/" in response.text assert "/u/alice/" in response.text
@@ -708,11 +803,14 @@ async def test_signup_json_stores_only_hash_and_returns_token(env, monkeypatch):
assert pool == "test-pool" assert pool == "test-pool"
assert token_hash == hashlib.sha256(body["token"].encode()).hexdigest() assert token_hash == hashlib.sha256(body["token"].encode()).hexdigest()
assert token_hash != body["token"] assert token_hash != body["token"]
# The JSON API also gets a browser session cookie (HttpOnly, SameSite=Lax). # The JSON API also gets a browser session cookie (HttpOnly, SameSite=Lax)
# carrying only an opaque server-side session id, never the user/token.
cookie = response.headers["set-cookie"] cookie = response.headers["set-cookie"]
assert "dsh_gw_session=alice:" in cookie assert "dsh_gw_session=" in cookie
assert f"dsh_gw_session={TOKEN}" not in cookie
assert "HttpOnly" in cookie assert "HttpOnly" in cookie
assert "SameSite=lax" in cookie assert "SameSite=lax" in cookie
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -741,13 +839,7 @@ async def test_signup_form_redirects_and_sets_session_cookie(env, monkeypatch):
) )
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/u/bob/" assert response.headers["location"] == "/u/bob/"
cookie = response.headers["set-cookie"] assert response_session_user(env, response) == "bob"
assert re_cookie_user(cookie) == "bob"
def re_cookie_user(set_cookie: str) -> str:
value = set_cookie.split(";", 1)[0].split("=", 1)[1]
return value.split(":", 1)[0]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -774,7 +866,29 @@ async def test_login_form_sets_cookie_and_redirects(env):
) )
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/u/alice/" assert response.headers["location"] == "/u/alice/"
assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
async def test_secure_session_cookie_uses_the_host_prefix_and_flags(env):
configure(env, cookie_secure=True, cookie_name="__Host-dsh_gw_session")
add_user(env.k8s)
async with asgi_client(env) as client:
response = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={"content-type": "application/x-www-form-urlencoded"},
)
cookie = response.headers["set-cookie"]
assert cookie.startswith("__Host-dsh_gw_session=")
assert "Secure" in cookie
assert "HttpOnly" in cookie
assert "Path=/" in cookie
assert "SameSite=lax" in cookie
assert "Domain=" not in cookie
# The value is an opaque session id, not <user>:<token>.
assert "alice" not in cookie.split(";", 1)[0]
assert TOKEN not in cookie
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -786,17 +900,197 @@ async def test_login_json_and_failures(env):
missing = await client.post("/login", json={"user": "ghost", "token": TOKEN}) missing = await client.post("/login", json={"user": "ghost", "token": TOKEN})
assert ok.status_code == 200 assert ok.status_code == 200
assert bad.status_code == 401 assert bad.status_code == 401
assert missing.status_code == 404 # An unknown user is a 401 too: login must not reveal which users exist.
assert missing.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_logout_clears_cookie(env): async def test_logout_clears_cookie_and_revokes_the_session(env):
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: add_user(env.k8s)
name, sid = mint_session(env)
assert env.gateway.session_for(sid) is not None
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post("/logout") response = await client.post("/logout")
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/" assert response.headers["location"] == "/"
assert "dsh_gw_session=" in response.headers["set-cookie"] assert "dsh_gw_session=" in response.headers["set-cookie"]
assert "Max-Age=0" in response.headers["set-cookie"] assert "Max-Age=0" in response.headers["set-cookie"]
# The old sid is dead server-side, not just deleted from the browser.
assert env.gateway.session_for(sid) is None
async with asgi_client(env, cookies={name: sid}) as client:
replay = await client.get("/users/alice")
assert replay.status_code == 401
@pytest.mark.asyncio
async def test_logout_deletion_cookie_is_secure_for_the_host_prefix(env):
"""A __Host- cookie can only be cleared by a Secure deletion Set-Cookie."""
configure(env, cookie_secure=True, cookie_name="__Host-dsh_gw_session")
add_user(env.k8s)
name, sid = mint_session(env)
assert name == "__Host-dsh_gw_session"
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post("/logout")
assert response.status_code == 303
cookie = response.headers["set-cookie"]
assert cookie.startswith("__Host-dsh_gw_session=")
assert "Max-Age=0" in cookie
assert "Path=/" in cookie
assert "Secure" in cookie
assert "Domain=" not in cookie
# --- signup invite / auth abuse controls -----------------------------------
def configure(env, **overrides):
"""Return a copy of the fixture settings with overrides applied."""
env.settings = Settings(**{**env.settings.__dict__, **overrides})
env.gateway.settings = env.settings
return env
@pytest.mark.asyncio
async def test_signup_requires_invite_when_configured(env, monkeypatch):
configure(env, signup_invite_code="invite-code")
async def not_ready(user, timeout):
return None
monkeypatch.setattr(env.gateway, "wait_ready", not_ready)
async with asgi_client(env) as client:
missing = await client.post("/users", json={"user": "alice"})
wrong = await client.post("/users", json={"user": "alice", "invite": "nope"})
good = await client.post("/users", json={"user": "alice", "invite": "invite-code"})
assert missing.status_code == 403
assert wrong.status_code == 403
assert good.status_code == 202
assert [created[0] for created in env.k8s.created] == ["alice"]
@pytest.mark.asyncio
async def test_invite_is_checked_before_the_user_lookup(env):
configure(env, signup_invite_code="invite-code")
add_user(env.k8s)
async with asgi_client(env) as client:
response = await client.post("/users", json={"user": "alice"})
# 403, not 409: an unauthenticated caller cannot probe for existing users.
assert response.status_code == 403
@pytest.mark.asyncio
async def test_invite_accepts_the_header_and_the_form_field(env, monkeypatch):
configure(env, signup_invite_code="invite-code")
async def not_ready(user, timeout):
return None
monkeypatch.setattr(env.gateway, "wait_ready", not_ready)
async with asgi_client(env) as client:
via_header = await client.post(
"/users",
json={"user": "alice"},
headers={"X-Signup-Invite": "invite-code"},
)
via_form = await client.post(
"/users",
data={"user": "bob", "invite": "invite-code"},
headers={"content-type": "application/x-www-form-urlencoded"},
)
assert via_header.status_code == 202
assert via_form.status_code == 303
@pytest.mark.asyncio
async def test_form_auth_rejects_cross_origin_requests(env):
add_user(env.k8s)
form = {"content-type": "application/x-www-form-urlencoded"}
async with asgi_client(env) as client:
signup = await client.post(
"/users", data={"user": "bob"}, headers={**form, "Origin": "http://evil.test"}
)
login = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Origin": "http://evil.test"},
)
referer = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Referer": "http://evil.test/login"},
)
same_origin = await client.post(
"/login",
data={"user": "alice", "token": TOKEN},
headers={**form, "Origin": "http://gateway.test"},
)
assert signup.status_code == 403
assert login.status_code == 403
assert referer.status_code == 403
assert same_origin.status_code == 303
@pytest.mark.asyncio
async def test_auth_rate_limit_returns_429_with_retry_after(env):
configure(env, auth_rate_limit=2, auth_rate_window=60)
add_user(env.k8s)
async with asgi_client(env) as client:
first = await client.post("/login", json={"user": "alice", "token": "nope"})
second = await client.post("/login", json={"user": "alice", "token": "nope"})
third = await client.post("/login", json={"user": "alice", "token": "nope"})
assert first.status_code == 401
assert second.status_code == 401
assert third.status_code == 429
assert int(third.headers["retry-after"]) >= 1
@pytest.mark.asyncio
async def test_auth_rate_limit_is_per_client_via_rightmost_forwarded_for(env):
configure(env, auth_rate_limit=1, auth_rate_window=60)
add_user(env.k8s)
async with asgi_client(env) as client:
first = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "1.1.1.1, 10.0.0.1"},
)
# A spoofed leftmost entry cannot escape the same rightmost-client bucket.
same_client = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "9.9.9.9, 10.0.0.1"},
)
other_client = await client.post(
"/login",
json={"user": "alice", "token": "nope"},
headers={"X-Forwarded-For": "10.0.0.2"},
)
assert first.status_code == 401
assert same_client.status_code == 429
assert other_client.status_code == 401
@pytest.mark.asyncio
async def test_auth_rate_limit_zero_disables_it(env):
configure(env, auth_rate_limit=0)
add_user(env.k8s)
async with asgi_client(env) as client:
responses = [
await client.post("/login", json={"user": "alice", "token": "nope"}) for _ in range(40)
]
assert {response.status_code for response in responses} == {401}
@pytest.mark.asyncio
async def test_signup_is_rate_limited_before_the_body_is_read(env):
"""429 wins over parsing: an abusive client cannot even submit a body."""
configure(env, auth_rate_limit=1, auth_rate_window=60)
async with asgi_client(env) as client:
# Spend the single allowed auth attempt on a login, then hit signup.
await client.post("/login", json={"user": "alice", "token": "nope"})
limited = await client.post("/users", content=b"not json at all")
assert limited.status_code == 429
assert env.k8s.created == []
# --- auth ------------------------------------------------------------------ # --- auth ------------------------------------------------------------------
@@ -810,11 +1104,11 @@ async def test_get_user_requires_valid_bearer_or_cookie(env):
bearer = await client.get("/users/alice", headers={"Authorization": f"Bearer {TOKEN}"}) bearer = await client.get("/users/alice", headers={"Authorization": f"Bearer {TOKEN}"})
cookie = await client.get( cookie = await client.get(
"/users/alice", "/users/alice",
headers={"Cookie": f"dsh_gw_session=alice:{TOKEN}"}, headers={"Cookie": session_cookie(env)},
) )
wrong_user_cookie = await client.get( wrong_user_cookie = await client.get(
"/users/alice", "/users/alice",
headers={"Cookie": f"dsh_gw_session=bob:{TOKEN}"}, headers={"Cookie": session_cookie(env, "bob")},
) )
assert forbidden.status_code == 401 assert forbidden.status_code == 401
assert bearer.status_code == 200 assert bearer.status_code == 200
@@ -852,7 +1146,7 @@ async def test_delete_user_requires_auth(env):
async def test_delete_with_cookie_requires_same_origin(env): async def test_delete_with_cookie_requires_same_origin(env):
add_user(env.k8s) add_user(env.k8s)
env.gateway.state.handshake[("alice", POD_UID, "gateway.test")] = "dsh-auth-a=x" env.gateway.state.handshake[("alice", POD_UID, "gateway.test")] = "dsh-auth-a=x"
cookies = {"dsh_gw_session": f"alice:{TOKEN}"} cookies = session_dict(env)
async with asgi_client(env, cookies=cookies) as client: async with asgi_client(env, cookies=cookies) as client:
missing = await client.delete("/users/alice") missing = await client.delete("/users/alice")
cross = await client.delete("/users/alice", headers={"Origin": "http://evil.test"}) cross = await client.delete("/users/alice", headers={"Origin": "http://evil.test"})
@@ -890,13 +1184,78 @@ async def test_unexpected_api_error_propagates(env):
await client.get("/users/alice") await client.get("/users/alice")
@pytest.mark.asyncio
async def test_bearer_token_never_authorizes_another_user(env):
add_user(env.k8s, "alice", token=TOKEN)
add_user(env.k8s, "bob", token="bob-token")
async with asgi_client(env) as client:
alice_on_bob = await client.get("/users/bob", headers={"Authorization": f"Bearer {TOKEN}"})
bob_on_alice = await client.get(
"/users/alice", headers={"Authorization": "Bearer bob-token"}
)
alice_on_alice = await client.get(
"/users/alice", headers={"Authorization": f"Bearer {TOKEN}"}
)
assert alice_on_bob.status_code == 401
assert bob_on_alice.status_code == 401
assert alice_on_alice.status_code == 200
@pytest.mark.asyncio
async def test_session_cookie_never_authorizes_another_user(env):
add_user(env.k8s, "alice", token=TOKEN)
add_user(env.k8s, "bob", token="bob-token")
bob_cookie = session_cookie(env, "bob")
async with asgi_client(env) as client:
user = await client.get("/users/alice", headers={"Cookie": bob_cookie})
proxy = await client.get("/u/alice/api/state", headers={"Cookie": bob_cookie})
assert user.status_code == 401
assert proxy.status_code == 401
@pytest.mark.asyncio
async def test_opaque_session_cookie_is_not_a_bearer_token(env):
add_user(env.k8s)
_name, sid = mint_session(env)
async with asgi_client(env) as client:
response = await client.get("/users/alice", headers={"Authorization": f"Bearer {sid}"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_expired_session_is_revoked_and_treated_as_anonymous(env):
add_user(env.k8s)
name, sid = mint_session(env)
env.gateway.state.sessions[sid] = Session(user="alice", expires_at=time.time() - 1)
async with asgi_client(env, cookies={name: sid}) as client:
user = await client.get("/users/alice")
landing = await client.get("/")
assert user.status_code == 401
assert "Signed in as" not in landing.text
assert sid not in env.gateway.state.sessions
@pytest.mark.asyncio
async def test_delete_user_revokes_all_its_sessions(env):
add_user(env.k8s)
cookie = session_dict(env)
other = session_dict(env) # a second browser session for the same user
async with asgi_client(env, cookies=cookie) as client:
response = await client.delete("/users/alice", headers={"Origin": "http://gateway.test"})
assert response.status_code == 200
assert env.gateway.state.sessions == {}
async with asgi_client(env, cookies=other) as client:
landing = await client.get("/")
assert "Signed in as" not in landing.text
# --- DSH handshake --------------------------------------------------------- # --- DSH handshake ---------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proxy_handshakes_and_uses_browser_host(env): async def test_proxy_handshakes_and_uses_browser_host(env):
add_user(env.k8s) add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 200 assert response.status_code == 200
assert response.text == "hello from DSH" assert response.text == "hello from DSH"
@@ -912,7 +1271,7 @@ async def test_proxy_handshakes_and_uses_browser_host(env):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dsh_cookie_is_cached_per_pod_uid_and_host(env): async def test_dsh_cookie_is_cached_per_pod_uid_and_host(env):
add_user(env.k8s) add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
first = await client.get("/u/alice/api/one") first = await client.get("/u/alice/api/one")
second = await client.get("/u/alice/api/two") second = await client.get("/u/alice/api/two")
assert first.status_code == second.status_code == 200 assert first.status_code == second.status_code == 200
@@ -924,7 +1283,7 @@ async def test_dsh_cookie_is_cached_per_pod_uid_and_host(env):
async def test_missing_launch_token_returns_503_with_retry_after(env): async def test_missing_launch_token_returns_503_with_retry_after(env):
_sandbox_name, pod_name = add_user(env.k8s) _sandbox_name, pod_name = add_user(env.k8s)
env.k8s.logs[pod_name] = "no token printed yet\n" env.k8s.logs[pod_name] = "no token printed yet\n"
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 503 assert response.status_code == 503
assert response.headers["retry-after"] == "10" assert response.headers["retry-after"] == "10"
@@ -935,7 +1294,7 @@ async def test_missing_launch_token_returns_503_with_retry_after(env):
async def test_handshake_4xx_is_503(env): async def test_handshake_4xx_is_503(env):
add_user(env.k8s) add_user(env.k8s)
env.upstream.handshake_status = 401 env.upstream.handshake_status = 401
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 503 assert response.status_code == 503
@@ -944,11 +1303,27 @@ async def test_handshake_4xx_is_503(env):
async def test_missing_cookie_in_handshake_is_503(env): async def test_missing_cookie_in_handshake_is_503(env):
add_user(env.k8s) add_user(env.k8s)
env.upstream.handshake_cookies = ["other=1; Path=/"] env.upstream.handshake_cookies = ["other=1; Path=/"]
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 503 assert response.status_code == 503
@pytest.mark.asyncio
async def test_handshake_error_never_leaks_the_launch_token_or_url(env):
_sandbox_name, pod_name = add_user(env.k8s)
secret = "super-secret-launch-token"
env.k8s.logs[pod_name] = f"dsh web: http://10.0.0.7:3080/?token={secret}\n"
env.upstream.handshake_status = 500
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
body = response.text
assert secret not in body
assert FQDN not in body
assert "10.0.0.7" not in body
assert "dsh-auth" not in body
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pod_annotation_overrides_sandbox_name(env): async def test_pod_annotation_overrides_sandbox_name(env):
sandbox_name, pod_name = add_user(env.k8s) sandbox_name, pod_name = add_user(env.k8s)
@@ -957,7 +1332,7 @@ async def test_pod_annotation_overrides_sandbox_name(env):
) )
env.k8s.pods["custom-pod"] = {"metadata": {"uid": "other-uid"}} env.k8s.pods["custom-pod"] = {"metadata": {"uid": "other-uid"}}
env.k8s.logs["custom-pod"] = env.k8s.logs[pod_name] env.k8s.logs["custom-pod"] = env.k8s.logs[pod_name]
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 200 assert response.status_code == 200
assert ("alice", "other-uid", "gateway.test") in env.gateway.state.handshake assert ("alice", "other-uid", "gateway.test") in env.gateway.state.handshake
@@ -984,7 +1359,7 @@ async def test_proxy_strips_gateway_auth_cookie_and_hop_headers(env):
env.upstream.proxy_handler = handler env.upstream.proxy_handler = handler
headers = { headers = {
"Authorization": f"Bearer {TOKEN}", "Authorization": f"Bearer {TOKEN}",
"Cookie": "dsh_gw_session=alice:x; browser=1", "Cookie": session_cookie(env) + "; browser=1",
"X-Forwarded-For": "1.2.3.4", "X-Forwarded-For": "1.2.3.4",
"X-Forwarded-Proto": "https", "X-Forwarded-Proto": "https",
"Host": "gateway.test", "Host": "gateway.test",
@@ -1009,6 +1384,83 @@ async def test_proxy_strips_gateway_auth_cookie_and_hop_headers(env):
assert "connection" not in response.headers assert "connection" not in response.headers
@pytest.mark.asyncio
async def test_proxy_strips_client_supplied_forwarding_and_sso_headers(env):
add_user(env.k8s)
headers = {
"Authorization": f"Bearer {TOKEN}",
"Forwarded": "for=evil.test;proto=https",
"X-Forwarded-User": "bob",
"X-Forwarded-Host": "evil.test",
"X-Auth-Request-User": "bob",
"X-Auth-Request-Email": "bob@evil.test",
"X-Real-IP": "6.6.6.6",
"Origin": "http://gateway.test",
}
async with asgi_client(env) as client:
response = await client.get("/u/alice/api/state", headers=headers)
assert response.status_code == 200
proxy = env.upstream.proxy_requests[0]
for name in (
"forwarded",
"x-forwarded-user",
"x-auth-request-user",
"x-auth-request-email",
"x-real-ip",
):
assert name not in proxy.headers
# The gateway's own hop metadata comes from the real connection instead.
assert proxy.headers["x-forwarded-for"] == "127.0.0.1"
assert proxy.headers["x-forwarded-host"] == "gateway.test"
@pytest.mark.asyncio
async def test_proxy_upstream_header_allowlist_drops_unlisted_headers(env):
configure(env, upstream_header_allowlist=frozenset({"x-custom-ok"}))
add_user(env.k8s)
headers = {
"Authorization": f"Bearer {TOKEN}",
"X-Custom-Ok": "keep",
"X-Custom-Drop": "drop",
"X-Forwarded-User": "bob",
"Origin": "http://gateway.test",
}
async with asgi_client(env) as client:
response = await client.get("/u/alice/api/state", headers=headers)
assert response.status_code == 200
proxy = env.upstream.proxy_requests[0]
assert proxy.headers["x-custom-ok"] == "keep"
assert "x-custom-drop" not in proxy.headers
# Allowlisting never re-enables gateway-owned or spoofable headers.
assert "authorization" not in proxy.headers
assert "x-forwarded-user" not in proxy.headers
assert "cookie" not in proxy.headers or proxy.headers["cookie"] == "dsh-auth-a=one"
@pytest.mark.asyncio
async def test_proxy_rejects_requests_over_the_per_user_in_flight_cap(env):
configure(env, max_in_flight_per_user=1)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 503
assert "retry-after" in response.headers
assert env.gateway.state.in_flight["alice"] == 1
assert env.upstream.proxy_requests == []
@pytest.mark.asyncio
async def test_proxy_accepts_requests_below_the_per_user_in_flight_cap(env):
configure(env, max_in_flight_per_user=2)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state")
assert response.status_code == 200
assert env.gateway.state.in_flight["alice"] == 1 # the slot was released again
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proxy_rewrites_absolute_path_location(env): async def test_proxy_rewrites_absolute_path_location(env):
add_user(env.k8s) add_user(env.k8s)
@@ -1019,7 +1471,7 @@ async def test_proxy_rewrites_absolute_path_location(env):
("set-cookie", "upstream=leak; Path=/"), ("set-cookie", "upstream=leak; Path=/"),
], ],
) )
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/redirect", follow_redirects=False) response = await client.get("/u/alice/api/redirect", follow_redirects=False)
assert response.status_code == 303 assert response.status_code == 303
assert response.headers["location"] == "/u/alice/?next=1" assert response.headers["location"] == "/u/alice/?next=1"
@@ -1040,7 +1492,7 @@ async def test_proxy_refreshes_cookie_once_on_upstream_401(env):
return dsh_response(200, b"fresh") return dsh_response(200, b"fresh")
env.upstream.proxy_handler = handler env.upstream.proxy_handler = handler
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 200 assert response.status_code == 200
assert response.text == "fresh" assert response.text == "fresh"
@@ -1053,7 +1505,7 @@ async def test_proxy_refreshes_cookie_once_on_upstream_401(env):
async def test_proxy_does_not_refresh_401_without_cached_cookie(env): async def test_proxy_does_not_refresh_401_without_cached_cookie(env):
add_user(env.k8s) add_user(env.k8s)
env.upstream.proxy_handler = lambda request: dsh_response(401, b"no") env.upstream.proxy_handler = lambda request: dsh_response(401, b"no")
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 401 assert response.status_code == 401
assert len(env.upstream.proxy_requests) == 1 assert len(env.upstream.proxy_requests) == 1
@@ -1067,7 +1519,7 @@ async def test_proxy_upstream_connection_error_is_502(env):
raise httpx.ConnectError("boom", request=request) raise httpx.ConnectError("boom", request=request)
env.upstream.proxy_handler = handler env.upstream.proxy_handler = handler
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 502 assert response.status_code == 502
assert env.gateway.state.in_flight["alice"] == 0 assert env.gateway.state.in_flight["alice"] == 0
@@ -1106,7 +1558,7 @@ async def test_proxy_client_disconnect_mid_body_returns_499(env):
"/u/alice/api/echo", "/u/alice/api/echo",
headers=[ headers=[
(b"origin", b"http://gateway.test"), (b"origin", b"http://gateway.test"),
(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode()), (b"cookie", session_cookie(env).encode()),
(b"content-type", b"application/json"), (b"content-type", b"application/json"),
], ],
), ),
@@ -1167,7 +1619,7 @@ async def test_proxy_client_disconnect_mid_stream_frees_slot(env):
raw_scope( raw_scope(
"GET", "GET",
"/u/alice/api/stream", "/u/alice/api/stream",
headers=[(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode())], headers=[(b"cookie", session_cookie(env).encode())],
), ),
receive, receive,
) )
@@ -1234,7 +1686,7 @@ async def test_proxy_client_disconnect_survives_cancelled_upstream_close(env):
raw_scope( raw_scope(
"GET", "GET",
"/u/alice/api/stream", "/u/alice/api/stream",
headers=[(b"cookie", f"dsh_gw_session=alice:{TOKEN}".encode())], headers=[(b"cookie", session_cookie(env).encode())],
), ),
receive, receive,
) )
@@ -1258,7 +1710,7 @@ async def test_proxy_wakes_suspended_sandbox(env, monkeypatch):
return env.k8s.sandboxes[sandbox_name] return env.k8s.sandboxes[sandbox_name]
monkeypatch.setattr(env.gateway, "wait_ready", ready) monkeypatch.setattr(env.gateway, "wait_ready", ready)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 200 assert response.status_code == 200
assert ("dsh-alice-sbx", "Running") in env.k8s.patches assert ("dsh-alice-sbx", "Running") in env.k8s.patches
@@ -1273,7 +1725,7 @@ async def test_proxy_wake_timeout_returns_503(env, monkeypatch):
return None return None
monkeypatch.setattr(env.gateway, "wait_ready", never) monkeypatch.setattr(env.gateway, "wait_ready", never)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/u/alice/api/state") response = await client.get("/u/alice/api/state")
assert response.status_code == 503 assert response.status_code == 503
assert response.headers["retry-after"] == "10" assert response.headers["retry-after"] == "10"
@@ -1297,7 +1749,7 @@ async def test_proxy_requires_auth(env):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_proxy_cookie_mutation_requires_same_origin(env): async def test_proxy_cookie_mutation_requires_same_origin(env):
add_user(env.k8s) add_user(env.k8s)
cookies = {"dsh_gw_session": f"alice:{TOKEN}"} cookies = session_dict(env)
async with asgi_client(env, cookies=cookies) as client: async with asgi_client(env, cookies=cookies) as client:
cross = await client.post( cross = await client.post(
"/u/alice/api/mutate", headers={"Origin": "http://evil.test"}, content=b"{}" "/u/alice/api/mutate", headers={"Origin": "http://evil.test"}, content=b"{}"
@@ -1385,7 +1837,7 @@ def test_ws_cookie_cross_origin_closes_4401(env):
with client.websocket_connect( with client.websocket_connect(
"/u/alice/api/remote.mux", "/u/alice/api/remote.mux",
headers={ headers={
"cookie": f"dsh_gw_session=alice:{TOKEN}", "cookie": session_cookie(env),
"origin": "http://evil.test", "origin": "http://evil.test",
}, },
) as ws: ) as ws:
@@ -1425,7 +1877,7 @@ def test_ws_cookie_same_origin_bridges_frames(env):
with client.websocket_connect( with client.websocket_connect(
"/u/alice/api/remote.mux", "/u/alice/api/remote.mux",
headers={ headers={
"cookie": f"dsh_gw_session=alice:{TOKEN}", "cookie": session_cookie(env),
"origin": "http://testserver", "origin": "http://testserver",
}, },
) as ws: ) as ws:
@@ -1464,6 +1916,70 @@ def test_ws_upstream_connect_failure_closes_1011(env):
assert exc.value.code == 1011 assert exc.value.code == 1011
def test_ws_strips_client_supplied_forwarding_and_sso_headers(env):
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={
"Authorization": f"Bearer {TOKEN}",
"Forwarded": "for=evil.test",
"X-Forwarded-User": "bob",
"X-Auth-Request-User": "bob",
},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
headers = env.ws_connect.calls[0][1]["additional_headers"]
assert "forwarded" not in headers
assert "x-forwarded-user" not in headers
assert "x-auth-request-user" not in headers
assert headers["cookie"] == "dsh-auth-a=one"
def test_ws_per_user_in_flight_cap_closes_1011(env):
configure(env, max_in_flight_per_user=1)
add_user(env.k8s)
env.gateway.state.in_flight["alice"] = 1
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
with pytest.raises(WebSocketDisconnect) as exc:
ws.receive_text()
assert exc.value.code == 1011
assert env.ws_connect.calls == []
def test_ws_passes_the_default_max_message_size(env):
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
assert env.ws_connect.calls[0][1]["max_size"] == 16 * 1024 * 1024
def test_ws_max_message_size_is_configurable(env):
configure(env, ws_max_message_bytes=2048)
add_user(env.k8s)
with TestClient(env.app) as client:
with client.websocket_connect(
"/u/alice/api/remote.mux",
headers={"Authorization": f"Bearer {TOKEN}"},
) as ws:
ws.send_text("hello")
assert ws.receive_text() == "welcome"
assert ws.receive_text() == "pong"
assert env.ws_connect.calls[0][1]["max_size"] == 2048
# --- idle sweeper ---------------------------------------------------------- # --- idle sweeper ----------------------------------------------------------
@@ -1576,14 +2092,46 @@ async def test_sweeper_survives_transient_api_error(env):
assert env.gateway.state.maintenance["alice"] is False assert env.gateway.state.maintenance["alice"] is False
@pytest.mark.asyncio
async def test_sweeper_prunes_orphan_and_expired_state(env):
now = time.time()
# No claim for "ghost": every per-user entry is orphaned.
env.gateway.state.last_activity["ghost"] = now
env.gateway.state.in_flight["ghost"] = 0
env.gateway.state.maintenance["ghost"] = False
env.gateway.state.handshake[("ghost", POD_UID, "gateway.test")] = "dsh-auth-a=x"
_ghost_name, ghost_sid = mint_session(env, "ghost")
env.gateway.state.auth_rate["1.2.3.4"] = (now - 3600, 5)
# A user with requests still in flight is preserved even without a claim.
env.gateway.state.in_flight["busy"] = 2
# A live claim keeps its user's current session...
env.k8s.claims["alice"] = ready_claim()
env.k8s.sandboxes["dsh-alice-sbx"] = ready_sandbox()
env.gateway.state.last_activity["alice"] = now
_alice_name, alice_sid = mint_session(env, "alice")
# ...but an expired session is pruned even for a live user.
_old_name, expired_sid = mint_session(env, "alice")
env.gateway.state.sessions[expired_sid] = Session(user="alice", expires_at=now - 1)
await env.gateway.sweep_once()
assert "ghost" not in env.gateway.state.last_activity
assert "ghost" not in env.gateway.state.in_flight
assert "ghost" not in env.gateway.state.maintenance
assert ("ghost", POD_UID, "gateway.test") not in env.gateway.state.handshake
assert ghost_sid not in env.gateway.state.sessions
assert env.gateway.state.auth_rate == {}
assert env.gateway.state.in_flight["busy"] == 2
assert alice_sid in env.gateway.state.sessions
assert expired_sid not in env.gateway.state.sessions
# --- SSO (reverse-proxy identity) ------------------------------------------ # --- SSO (reverse-proxy identity) ------------------------------------------
def enable_sso(env, header="X-Forwarded-User"): def enable_sso(env, header="X-Forwarded-User"):
"""Flip an env into SSO mode: the gateway trusts the named identity header.""" """Flip an env into SSO mode: the gateway trusts the named identity header."""
env.settings = Settings(**{**env.settings.__dict__, "auth_proxy_header": header}) return configure(env, auth_proxy_header=header)
env.gateway.settings = env.settings
return env
def test_remote_user_none_when_sso_disabled(env): def test_remote_user_none_when_sso_disabled(env):
@@ -1611,7 +2159,7 @@ async def test_sso_landing_new_user_shows_provisioning(env):
assert "Signed in as <strong>alice</strong>" in response.text assert "Signed in as <strong>alice</strong>" in response.text
assert "Provisioning" in response.text assert "Provisioning" in response.text
assert "Recreate sandbox" not in response.text assert "Recreate sandbox" not in response.text
assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert response_session_user(env, response) == "alice"
assert [c[0] for c in env.k8s.created] == ["alice"] assert [c[0] for c in env.k8s.created] == ["alice"]
assert env.k8s.token_patches == [] assert env.k8s.token_patches == []
@@ -1620,7 +2168,7 @@ async def test_sso_landing_new_user_shows_provisioning(env):
async def test_sso_landing_ready_connects_and_offers_recreate_only(env): async def test_sso_landing_ready_connects_and_offers_recreate_only(env):
enable_sso(env) enable_sso(env)
add_user(env.k8s) add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"}) response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200 assert response.status_code == 200
assert "Signed in as <strong>alice</strong>" in response.text assert "Signed in as <strong>alice</strong>" in response.text
@@ -1642,11 +2190,55 @@ async def test_sso_landing_rotates_token_when_session_missing(env):
response = await client.get("/", headers={"X-Forwarded-User": "alice"}) response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200 assert response.status_code == 200
assert "Recreate sandbox" in response.text assert "Recreate sandbox" in response.text
assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert response_session_user(env, response) == "alice"
assert [p[0] for p in env.k8s.token_patches] == ["alice"] assert [p[0] for p in env.k8s.token_patches] == ["alice"]
assert env.k8s.created == [] assert env.k8s.created == []
@pytest.mark.asyncio
async def test_sso_landing_reuses_a_valid_session_for_the_remote_user(env):
enable_sso(env)
add_user(env.k8s)
name, sid = mint_session(env)
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
assert "Recreate sandbox" in response.text
# Untouched: no cookie is re-issued and no token is rotated.
assert "set-cookie" not in response.headers
assert [p[0] for p in env.k8s.token_patches] == []
assert env.gateway.state.sessions[sid].user == "alice"
@pytest.mark.asyncio
async def test_sso_landing_revokes_a_foreign_session(env):
enable_sso(env)
add_user(env.k8s)
bob_name, bob_sid = mint_session(env, "bob")
async with asgi_client(env, cookies={bob_name: bob_sid}) as client:
response = await client.get("/", headers={"X-Forwarded-User": "alice"})
assert response.status_code == 200
# A session for another identity is never reused and is revoked server-side.
assert bob_sid not in env.gateway.state.sessions
assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio
async def test_recreate_revokes_the_old_session_and_mints_a_new_one(env):
enable_sso(env)
add_user(env.k8s)
name, sid = mint_session(env)
async with asgi_client(env, cookies={name: sid}) as client:
response = await client.post(
"/recreate",
headers={"X-Forwarded-User": "alice", "Origin": "http://gateway.test"},
)
assert response.status_code == 303
assert sid not in env.gateway.state.sessions
new_sid = response.headers["set-cookie"].split(";", 1)[0].split("=", 1)[1]
assert env.gateway.state.sessions[new_sid].user == "alice"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recreate_deletes_and_reprovisions(env): async def test_recreate_deletes_and_reprovisions(env):
enable_sso(env) enable_sso(env)
@@ -1657,7 +2249,7 @@ async def test_recreate_deletes_and_reprovisions(env):
assert response.headers["location"] == "/u/alice/" assert response.headers["location"] == "/u/alice/"
assert env.k8s.deleted == ["alice"] assert env.k8s.deleted == ["alice"]
assert [c[0] for c in env.k8s.created] == ["alice"] assert [c[0] for c in env.k8s.created] == ["alice"]
assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert response_session_user(env, response) == "alice"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1673,7 +2265,7 @@ async def test_recreate_requires_sso(env):
async def test_recreate_rejects_cross_origin(env): async def test_recreate_rejects_cross_origin(env):
enable_sso(env) enable_sso(env)
add_user(env.k8s) add_user(env.k8s)
async with asgi_client(env, cookies={"dsh_gw_session": f"alice:{TOKEN}"}) as client: async with asgi_client(env, cookies=session_dict(env)) as client:
response = await client.post( response = await client.post(
"/recreate", "/recreate",
headers={"X-Forwarded-User": "alice", "Origin": "http://evil.test"}, headers={"X-Forwarded-User": "alice", "Origin": "http://evil.test"},

View File

@@ -131,6 +131,24 @@ spec:
# a private PVC), so trade DSH's inner file policy for a working # a private PVC), so trade DSH's inner file policy for a working
# agent. Set this to workspace-write (or read-only) on hosts where a # agent. Set this to workspace-write (or read-only) on hosts where a
# backend is available; see the README troubleshooting row. # backend is available; see the README troubleshooting row.
# SECURITY: the pod is the isolation boundary in this example.
# danger-full-access is set only because DSH's own command sandbox
# has no usable backend in this pod (see above), which means an
# agent command can read its own DSH_HOME (including
# .credentials.yaml) and write anywhere in the container. What
# still contains it is the pod itself: one pod per user, non-root,
# all capabilities dropped, RuntimeDefault seccomp, its own PVC and
# Service. That is NOT a hardened multi-tenant boundary on a shared
# kernel. For production/multi-tenant use, additionally run the
# sandbox under gVisor (`runtimeClassName: gvisor` above) so a
# container escape is trapped in a userspace kernel, and apply the
# sandbox NetworkPolicy (manifests/optional/network-policy/) so
# tenants cannot reach each other or cluster-internal endpoints.
# The base manifests without those two controls are a demo. Treat
# danger-full-access as a fallback, not a target: on hosts where
# DSH's command-sandbox backend works, prefer workspace-write (or
# read-only), and recreate the pod because the value is stamped at
# creation.
- name: DSH_PERMISSION_MODE - name: DSH_PERMISSION_MODE
value: danger-full-access value: danger-full-access
# Probes target /favicon.svg: DSH gates / behind its launch-token # Probes target /favicon.svg: DSH gates / behind its launch-token

View File

@@ -134,14 +134,30 @@ spec:
# the DSH launch token. # the DSH launch token.
- name: DSH_CONTAINER - name: DSH_CONTAINER
value: dsh-web value: dsh-web
# Cookie dsh_gw_session is HttpOnly + SameSite=Lax. "false" keeps # Browser session cookie: HttpOnly + SameSite=Lax, holds only an
# the plain-HTTP demo usable through port-forward: a Secure cookie # opaque, server-side session id (sid) - never the user's DSH
# is never sent over HTTP, so login would silently fail. # token. Sessions can be revoked server-side: POST /logout, user
# HTTPS deployments (the ingress/ variant with TLS, or # delete, POST /recreate and expiry (COOKIE_MAX_AGE, default
# gateway-api-sso/, which terminates TLS at the Gateway) MUST set # 7 days) all invalidate them. The session table is in process
# this to "true". # memory, so restarting this Deployment logs every browser out.
# With COOKIE_SECURE=true the cookie is named
# __Host-dsh_gw_session; COOKIE_NAME overrides the name.
#
# "false" keeps the plain-HTTP demo usable through port-forward: a
# Secure cookie is never sent over HTTP, so login would silently
# fail. The gateway REFUSES TO START with COOKIE_SECURE=false
# unless ALLOW_INSECURE_COOKIES=1 acknowledges that the session
# travels in clear text, so the base demo sets both. HTTPS
# deployments (the ingress/ variant with TLS, or gateway-api-sso/,
# which terminates TLS at the Gateway) MUST set this to "true".
- name: COOKIE_SECURE - name: COOKIE_SECURE
value: "false" value: "false"
# Explicit acknowledgement that this base demo serves plain HTTP.
# Required when COOKIE_SECURE=false (the gateway exits at startup
# otherwise); ignored once COOKIE_SECURE=true, so HTTPS installs
# should drop it. Never set this on an internet-facing deployment.
- name: ALLOW_INSECURE_COOKIES
value: "1"
# SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the # SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the
# gateway trusts only its own bearer/session auth and ignores every # gateway trusts only its own bearer/session auth and ignores every
# identity header. Enable it ONLY behind # identity header. Enable it ONLY behind
@@ -152,9 +168,49 @@ spec:
# reach :8080 can impersonate any user by sending that header. # reach :8080 can impersonate any user by sending that header.
# - name: AUTH_PROXY_HEADER # - name: AUTH_PROXY_HEADER
# value: X-Forwarded-User # value: X-Forwarded-User
# Brute-force / abuse throttle for the unauthenticated auth
# endpoints (/users and /login): at most N attempts per client per
# AUTH_RATE_WINDOW seconds (default 60). A client is the rightmost
# X-Forwarded-For entry (the one added by the trusted proxy) when
# present, otherwise the TCP peer. Over the limit the gateway
# answers 429 + Retry-After BEFORE reading the body or touching the
# cluster; 0 disables the limiter. This is a per-process bucket, so
# it is not a substitute for an ingress rate limit.
- name: AUTH_RATE_LIMIT
value: "30"
# Window in seconds for AUTH_RATE_LIMIT (default 60).
# - name: AUTH_RATE_WINDOW
# value: "60"
# Optional signup gate. When set, POST /users requires the invite
# code - form field "invite" or header X-Signup-Invite - compared in
# constant time. A missing/wrong code is rejected with 403 before
# the user is looked up, so it also stops unauthenticated claim
# creation. The landing page shows the invite input only when this
# is set. Keep the value in a Secret, create it before rollout and
# restart the gateway to pick up a rotation:
# kubectl -n dsh-demo create secret generic dsh-signup-invite \
# --from-literal=code="$(openssl rand -hex 16)"
# - name: SIGNUP_INVITE_CODE
# valueFrom:
# secretKeyRef:
# name: dsh-signup-invite
# key: code
# Reject proxied request bodies larger than this (413). # Reject proxied request bodies larger than this (413).
- name: MAX_BODY_MB - name: MAX_BODY_MB
value: "32" value: "32"
# Per-user concurrency guard (default 32): at most this many
# proxied HTTP requests and WebSocket connections per user.
# Excess HTTP requests get 503 + Retry-After and excess WebSocket
# upgrades close with 1011, so one tenant cannot exhaust the
# single-replica gateway for everyone else.
# - name: MAX_IN_FLIGHT_PER_USER
# value: "32"
# Largest WebSocket message received from DSH, in bytes (default
# 16 MiB); oversized frames close the connection. Browser-to-DSH
# frames are bounded by uvicorn's own limit. HTTP/SSE responses are
# streamed without this cap.
# - name: WS_MAX_MESSAGE_BYTES
# value: "16777216"
# /healthz is unauthenticated and returns 200 without touching the # /healthz is unauthenticated and returns 200 without touching the
# cluster, so it is safe for both readiness and liveness. # cluster, so it is safe for both readiness and liveness.
readinessProbe: readinessProbe:

View File

@@ -16,6 +16,16 @@ Applying both gives the gateway two entry points, and the SSO variant is only
safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not
mix them. mix them.
> **Production requirement.** Each variant's NetworkPolicy is part of the
> variant, and applying the variant without it is demo-only. The sandbox
> NetworkPolicy and the gateway egress policy (the API server is cluster-specific)
> are separate and equally required; see the
> [production checklist](../../docs/optional-hardening.md#6-production-checklist)
> and the
> [gateway egress recipe](../../docs/optional-hardening.md#gateway-egress-required-for-production).
> For SSO, run [`scripts/preflight-sso.sh`](../../scripts/preflight-sso.sh)
> after applying the variant and before sending real traffic.
Each variant also ships a NetworkPolicy, applied by the same directory apply: Each variant also ships a NetworkPolicy, applied by the same directory apply:
- `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway - `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway
@@ -27,7 +37,8 @@ Each variant also ships a NetworkPolicy, applied by the same directory apply:
The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label. The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label.
Adjust the selector for another implementation or a `hostNetwork` install; see Adjust the selector for another implementation or a `hostNetwork` install; see
[optional hardening](../../docs/optional-hardening.md) for the probe caveat and [optional hardening](../../docs/optional-hardening.md) for the probe caveat and
verification commands. verification commands. Keep the policies: an exposure without its NetworkPolicy
is demo-only.
## Applying a variant ## Applying a variant
@@ -69,10 +80,13 @@ Secrets imperatively (commands below) before applying the directory.
2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host: 2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host:
the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on
`/api` (see `../10-sandboxtemplate.yaml`). `/api` (see `../10-sandboxtemplate.yaml`).
3. Set `COOKIE_SECURE=true` on the gateway once the Ingress serves HTTPS: 3. Set `COOKIE_SECURE=true` and remove the plain-HTTP acknowledgement once the
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`. The Ingress serves HTTPS:
base manifest keeps `false` so the plain-HTTP port-forward demo works; a `kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
`Secure` cookie is never sent over HTTP, which silently breaks login. The base manifest keeps `false` + `ALLOW_INSECURE_COOKIES=1` so the
plain-HTTP port-forward demo works; a `Secure` cookie is never sent over HTTP,
which silently breaks login, while the acknowledgement must not survive into
production.
4. Do not attach buffering or compression middlewares to this router: they 4. Do not attach buffering or compression middlewares to this router: they
break WebSocket upgrades and streaming. break WebSocket upgrades and streaming.
5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods 5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods
@@ -116,14 +130,23 @@ kubectl -n dsh-demo create secret tls dsh-demo-tls --cert=tls.crt --key=tls.key
# 3. Enable SSO on the gateway and trust the public host in the template. # 3. Enable SSO on the gateway and trust the public host in the template.
kubectl -n dsh-demo set env deploy/dsh-gateway \ kubectl -n dsh-demo set env deploy/dsh-gateway \
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
# then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate # then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate
# (see ../10-sandboxtemplate.yaml) and re-apply it. # (see ../10-sandboxtemplate.yaml) and re-apply it.
# 4. Apply the variant. # 4. Apply the variant.
kubectl apply -f manifests/exposure/gateway-api-sso/ kubectl apply -f manifests/exposure/gateway-api-sso/
# 5. Preflight: fails loudly on missing Secrets, a bad oauth2-proxy cookie
# secret, missing gateway SSO env, a dangling TLS reference, leftover
# placeholders, or a second path to the gateway.
./scripts/preflight-sso.sh
``` ```
The preflight is read-only and exits non-zero on any failure, naming the exact
fix. Run it again after rotating a Secret or changing the public host; it also
verifies that the oauth2-proxy cookie secret decodes to 16, 24 or 32 bytes.
### SSO trust boundary ### SSO trust boundary
`AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway `AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway
@@ -141,8 +164,14 @@ takes all of these:
impersonate any user. impersonate any user.
3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in 3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in
the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy
egress). A custom exposure without an equivalent policy has no network egress). This is required, not optional: an exposure without the policy is
demo-only, and a custom exposure without an equivalent policy has no network
enforcement. enforcement.
4. Apply the gateway egress policy too: the gateway talks to the Kubernetes API
and to sandbox pods, and `75-networkpolicies.yaml` intentionally leaves that
direction unrestricted because the API-server endpoint is cluster-specific.
See
[gateway egress](../../docs/optional-hardening.md#gateway-egress-required-for-production).
Also terminate TLS in front of the gateway (the Gateway listener does) and Also terminate TLS in front of the gateway (the Gateway listener does) and
leave `COOKIE_SECURE=true` so the session cookie never travels in clear text. leave `COOKIE_SECURE=true` so the session cookie never travels in clear text.

View File

@@ -25,8 +25,9 @@
# TLS terminates at the Gateway listener (70-gateway.yaml); keep # TLS terminates at the Gateway listener (70-gateway.yaml); keep
# --cookie-secure=true and COOKIE_SECURE=true on the gateway. # --cookie-secure=true and COOKIE_SECURE=true on the gateway.
# #
# The image is pinned to quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 # The image is digest-pinned: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:...
# (versions.env: OAUTH2_PROXY_IMAGE). Credentials come from the # (versions.env: OAUTH2_PROXY_IMAGE). The digest is the immutable multi-arch
# index, so the tag can never be moved under us. Credentials come from the
# "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this # "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this
# manifest never carries secrets. # manifest never carries secrets.
apiVersion: apps/v1 apiVersion: apps/v1
@@ -61,7 +62,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: oauth2-proxy - name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false

View File

@@ -1,5 +1,5 @@
# OPTIONAL with the SSO exposure variant: enforce the trust boundary at the # REQUIRED with the SSO exposure variant: enforce the trust boundary at the
# network layer. # network layer. Applying the SSO variant without this file is demo-only.
# #
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These # The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
# policies make it the ONLY chain: # policies make it the ONLY chain:
@@ -8,8 +8,9 @@
# - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the # - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the
# gateway. # gateway.
# The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not # The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not
# restricted here: the API-server endpoint is cluster-specific. See # restricted here: the API-server endpoint is cluster-specific. Add it for
# ../../../docs/optional-hardening.md for that recipe. # production. See
# ../../../docs/optional-hardening.md#gateway-egress-required-for-production.
# #
# This manifest assumes Traefik, matching gatewayClassName: traefik in # This manifest assumes Traefik, matching gatewayClassName: traefik in
# 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another # 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another

View File

@@ -14,8 +14,13 @@
# DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin # DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin
# on /api. # on /api.
# 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to # 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to
# "true" once this Ingress serves HTTPS, so the session cookie is Secure. # "true" and remove ALLOW_INSECURE_COOKIES once this Ingress serves HTTPS,
# The base manifest sets "false" for the plain-HTTP port-forward demo. # so the session cookie is Secure:
# kubectl -n dsh-demo set env deploy/dsh-gateway \
# COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
# The base manifest sets "false" + ALLOW_INSECURE_COOKIES=1 for the
# plain-HTTP port-forward demo; without the acknowledgement the gateway
# refuses to start.
# #
# WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH # WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH
# uses WebSockets for live session traffic. Traefik upgrades WS/SSE # uses WebSockets for live session traffic. Traefik upgrades WS/SSE
@@ -24,9 +29,11 @@
# and upgrades. `path: /` Prefix covers the landing page and every per-user # and upgrades. `path: /` Prefix covers the landing page and every per-user
# proxy path, so no per-sandbox Ingress is needed. # proxy path, so no per-sandbox Ingress is needed.
# #
# 61-networkpolicy.yaml is part of this variant: it restricts the gateway's # 61-networkpolicy.yaml is part of this variant and required for production:
# ingress to Traefik pods. Adjust its selector if your ingress controller uses # it restricts the gateway's ingress to Traefik pods. Adjust its selector if
# a different label or hostNetwork. # your ingress controller uses a different label or hostNetwork. The sandbox
# and gateway-egress NetworkPolicies are separate requirements; see
# ../../../docs/optional-hardening.md#6-production-checklist.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:

View File

@@ -1,11 +1,16 @@
# OPTIONAL with the Ingress exposure variant: restrict the gateway's ingress # REQUIRED with the Ingress exposure variant for production: restrict the
# to the ingress controller. # gateway's ingress to the ingress controller. Applying the variant without
# this policy is demo-only.
# #
# This manifest assumes Traefik, matching this variant's # This manifest assumes Traefik, matching this variant's
# `ingressClassName: traefik`. Traefik's Helm chart labels its pods # `ingressClassName: traefik`. Traefik's Helm chart labels its pods
# app.kubernetes.io/name=traefik; adjust the selector for another controller or # app.kubernetes.io/name=traefik; adjust the selector for another controller or
# for a hostNetwork install (where the source is the node IP, not a pod IP). # for a hostNetwork install (where the source is the node IP, not a pod IP).
# #
# The sandbox NetworkPolicy and the gateway egress policy are separate and also
# required for production; see
# ../../../docs/optional-hardening.md#6-production-checklist.
#
# NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the # NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the
# gateway starts failing its /healthz probes, allow the node CIDR on port 8080 # gateway starts failing its /healthz probes, allow the node CIDR on port 8080
# or remove this policy. See ../../../docs/optional-hardening.md. # or remove this policy. See ../../../docs/optional-hardening.md.

View File

@@ -23,6 +23,12 @@ It requires a CNI that enforces `NetworkPolicy`. The selector uses the
agent-sandbox controller's pod tracking label, so the policy applies to agent-sandbox controller's pod tracking label, so the policy applies to
existing warm-pool and claimed sandboxes without recreation. existing warm-pool and claimed sandboxes without recreation.
**This is opt-in for the demo but required for production.** Without it every
tenant can reach every other tenant and cluster-internal endpoints. Production
also needs the gateway egress policy (cluster-specific API-server endpoint) and
the exposure variant's policy; see the
[production checklist](../../docs/optional-hardening.md#6-production-checklist).
## gVisor, nodeSelector and provider keys ## gVisor, nodeSelector and provider keys
These are configured in the main manifests or with an extra Secret: These are configured in the main manifests or with an extra Secret:

View File

@@ -1,9 +1,15 @@
# OPTIONAL, opt-in tenant network policy for the DSH sandboxes. # OPTIONAL for the base walkthrough, REQUIRED for production. Opt-in tenant
# network policy for the DSH sandboxes: without it every tenant can reach every
# other tenant and cluster-internal endpoints.
# #
# It is NOT part of the top-level walkthrough. Apply and delete it explicitly: # It is NOT part of the top-level walkthrough. Apply and delete it explicitly:
# kubectl apply -f manifests/optional/network-policy/ # kubectl apply -f manifests/optional/network-policy/
# kubectl delete -f manifests/optional/network-policy/ # kubectl delete -f manifests/optional/network-policy/
# #
# Production also needs the gateway egress policy (cluster-specific API-server
# endpoint) and the exposure variant's policy, plus gVisor
# (docs/optional-hardening.md#6-production-checklist).
#
# The selector uses the agent-sandbox controller's pod tracking label, which is # The selector uses the agent-sandbox controller's pod tracking label, which is
# present on every Sandbox pod (including warm-pool spares), so the policy # present on every Sandbox pod (including warm-pool spares), so the policy
# applies to existing sandboxes without recreating them. The SandboxTemplate # applies to existing sandboxes without recreating them. The SandboxTemplate

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env bash
# Preflight for the gateway-api-sso exposure variant.
#
# Run this AFTER creating the oauth2-proxy/TLS Secrets, setting the gateway
# env and applying manifests/exposure/gateway-api-sso/, and BEFORE sending real
# traffic. It checks the configuration that is easy to get wrong and whose
# failure is either silent (login appears to work but the gateway still asks
# for its own token) or dangerous (a spoofable identity header, a placeholder
# host, a TLS Secret that does not exist).
#
# Checks:
# 1. kubectl reaches the cluster and the SSO objects exist;
# 2. the oauth2-proxy Secret has client-id, client-secret and a cookie-secret
# that decodes to 16, 24 or 32 bytes; the TLS Secret exists;
# 3. deploy/dsh-gateway has AUTH_PROXY_HEADER=X-Forwarded-User and
# COOKIE_SECURE=true (both are opt-in and both are required for SSO over
# TLS);
# 4. the Gateway's TLS certificate reference resolves to an existing Secret;
# 5. no your-github-org / dsh-demo.example.com placeholder is left in the
# applied oauth2-proxy args, Gateway listeners or HTTPRoute hostnames;
# 6. (warning) no Ingress in the namespace creates a second path to
# dsh-gateway. With SSO enabled the Service must be reachable only through
# oauth2-proxy.
#
# Exit status: 0 when every check passes (warnings allowed), 1 on any failure.
# Every failure prints the fix. This script only reads cluster state.
#
# Usage:
# examples/dsh-agents-as-a-service/scripts/preflight-sso.sh
#
# Environment overrides:
# NAMESPACE namespace (default: dsh-demo)
# GATEWAY_DEPLOY gateway Deployment name (default: dsh-gateway)
# OAUTH_DEPLOY oauth2-proxy Deployment name (default: oauth2-proxy)
# OAUTH_SECRET oauth2-proxy Secret name (default: oauth2-proxy)
# TLS_SECRET TLS Secret name the Gateway should reference (default:
# dsh-demo-tls)
set -euo pipefail
cd "$(dirname "$0")/.."
NS="${NAMESPACE:-dsh-demo}"
GATEWAY_DEPLOY="${GATEWAY_DEPLOY:-dsh-gateway}"
OAUTH_DEPLOY="${OAUTH_DEPLOY:-oauth2-proxy}"
OAUTH_SECRET="${OAUTH_SECRET:-oauth2-proxy}"
TLS_SECRET="${TLS_SECRET:-dsh-demo-tls}"
FAILED=0
pass() { printf 'PASS %s\n' "$*"; }
warn() { printf 'WARN %s\n' "$*" >&2; }
fail() { printf 'FAIL %s\n' "$*" >&2; FAILED=1; }
if ! command -v kubectl >/dev/null 2>&1; then
echo "FAIL kubectl is not installed; install it and select the target context" >&2
exit 1
fi
if ! kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" >/dev/null 2>&1; then
echo "FAIL deploy/$GATEWAY_DEPLOY not found in namespace $NS." >&2
echo " Apply the base manifests first: kubectl apply -f manifests/" >&2
echo " Then apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/" >&2
exit 1
fi
echo "== Secrets"
# check_secret_key <secret> <key> <hint>
check_secret_key() {
local secret=$1 key=$2 hint=$3 value escaped
# Kubernetes JSONPath treats "." as a path separator; escape it in keys such
# as tls.crt.
escaped=${key//./\\.}
value=$(kubectl -n "$NS" get secret "$secret" -o jsonpath="{.data.${escaped}}" 2>/dev/null || true)
if [ -n "$value" ]; then
pass "Secret $secret has non-empty key '$key'"
else
fail "Secret $secret is missing a non-empty '$key' key. $hint"
fi
}
check_secret_key "$OAUTH_SECRET" client-id \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
check_secret_key "$OAUTH_SECRET" client-secret \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
check_secret_key "$OAUTH_SECRET" cookie-secret \
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
# The cookie secret must be URL-safe unpadded base64 of 16, 24 or 32 bytes;
# oauth2-proxy refuses anything else (the classic error is "44 bytes").
if COOKIE_B64=$(kubectl -n "$NS" get secret "$OAUTH_SECRET" \
-o jsonpath='{.data.cookie-secret}' 2>/dev/null); then
if [ -n "$COOKIE_B64" ]; then
if ! COOKIE_LEN=$(printf '%s' "$COOKIE_B64" | base64 -d 2>/dev/null | wc -c); then
fail "Secret $OAUTH_SECRET key 'cookie-secret' is not valid base64. Generate it with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
else
case "$COOKIE_LEN" in
16 | 24 | 32) pass "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes" ;;
*)
fail "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes; it must be 16, 24 or 32. Generate a URL-safe unpadded value with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
;;
esac
fi
fi
fi
check_secret_key "$TLS_SECRET" tls.crt \
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
check_secret_key "$TLS_SECRET" tls.key \
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
echo
echo "== Gateway SSO configuration (deploy/$GATEWAY_DEPLOY)"
GATEWAY_ENV=$(kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" \
-o jsonpath='{range .spec.template.spec.containers[*].env[*]}{.name}={.value}{"\n"}{end}')
# env_value <NAME>: print the literal env value, or nothing when unset.
env_value() {
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print }'
}
env_is_set() {
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { found = 1 } END { exit found ? 0 : 1 }'
}
if ! env_is_set AUTH_PROXY_HEADER; then
fail "AUTH_PROXY_HEADER is not set on deploy/$GATEWAY_DEPLOY, so the gateway ignores oauth2-proxy's identity header and users are still asked for the gateway token. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
elif [ "$(env_value AUTH_PROXY_HEADER)" != "X-Forwarded-User" ]; then
fail "AUTH_PROXY_HEADER is '$(env_value AUTH_PROXY_HEADER)', expected 'X-Forwarded-User'. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
else
pass "AUTH_PROXY_HEADER=X-Forwarded-User"
fi
if ! env_is_set COOKIE_SECURE; then
fail "COOKIE_SECURE is not set on deploy/$GATEWAY_DEPLOY; the session cookie must be Secure behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
elif [ "$(env_value COOKIE_SECURE | tr '[:upper:]' '[:lower:]')" != "true" ]; then
fail "COOKIE_SECURE is '$(env_value COOKIE_SECURE)', expected 'true' behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
else
pass "COOKIE_SECURE=true"
fi
if env_is_set ALLOW_INSECURE_COOKIES; then
warn "ALLOW_INSECURE_COOKIES is still set on deploy/$GATEWAY_DEPLOY. It is ignored while COOKIE_SECURE=true but is a footgun: remove it with 'kubectl -n $NS set env deploy/$GATEWAY_DEPLOY ALLOW_INSECURE_COOKIES-'."
fi
echo
echo "== SSO objects and placeholders"
if kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" >/dev/null 2>&1; then
pass "deploy/$OAUTH_DEPLOY exists"
OAUTH_ARGS=$(kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" \
-o jsonpath='{range .spec.template.spec.containers[*].args[*]}{@}{"\n"}{end}')
else
OAUTH_ARGS=""
fail "deploy/$OAUTH_DEPLOY not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
# GitHub org restriction is the only authorization in the shipped variant.
# --email-domain=* is acceptable only because --github-org narrows membership;
# a missing or empty org value would accept any GitHub account.
if [ -n "$OAUTH_ARGS" ]; then
if printf '%s\n' "$OAUTH_ARGS" | grep -q '^--provider=github$'; then
pass "oauth2-proxy uses --provider=github"
else
fail "oauth2-proxy is not configured with --provider=github. Fix: add --provider=github to deploy/$OAUTH_DEPLOY."
fi
if printf '%s\n' "$OAUTH_ARGS" | grep -Eq '^--github-org=.+$'; then
pass "oauth2-proxy restricts membership with --github-org"
else
fail "oauth2-proxy has no non-empty --github-org. Fix: set --github-org=<your-org> in deploy/$OAUTH_DEPLOY; otherwise --email-domain=* accepts any GitHub account."
fi
fi
if kubectl -n "$NS" get httproute dsh-gateway >/dev/null 2>&1; then
pass "HTTPRoute dsh-gateway exists"
ROUTE_STATE=$(kubectl -n "$NS" get httproute dsh-gateway \
-o jsonpath='{.spec.hostnames[*]}' 2>/dev/null || true)
else
ROUTE_STATE=""
fail "HTTPRoute dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
if kubectl -n "$NS" get gateway dsh-gateway >/dev/null 2>&1; then
pass "Gateway dsh-gateway exists"
GATEWAY_STATE=$(kubectl -n "$NS" get gateway dsh-gateway \
-o jsonpath='{range .spec.listeners[*]}{.hostname}{"\n"}{end}' 2>/dev/null || true)
CERT_REFS=$(kubectl -n "$NS" get gateway dsh-gateway \
-o jsonpath='{range .spec.listeners[*].tls.certificateRefs[*]}{.namespace}{"/"}{.name}{"\n"}{end}' 2>/dev/null || true)
else
GATEWAY_STATE=""
CERT_REFS=""
fail "Gateway dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
fi
# Placeholders are the default values in the committed manifests. They reach
# the cluster if the variant is applied before editing them.
PLACEHOLDER_RE='your-github-org|dsh-demo\.example\.com'
PLACEHOLDER_STATE=$(printf '%s\n%s\n%s\n' "$OAUTH_ARGS" "$ROUTE_STATE" "$GATEWAY_STATE")
if printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" >/dev/null 2>&1; then
fail "the applied SSO objects still contain a placeholder (your-github-org or dsh-demo.example.com):"
printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" | sed 's/^/ /' >&2
echo " Replace --github-org, --redirect-url, the Gateway listener host and the HTTPRoute hostname with the real public FQDN and organization, then re-apply and restart the proxy." >&2
else
pass "no your-github-org / dsh-demo.example.com placeholder in the applied SSO objects"
fi
# The Gateway must reference an existing TLS Secret; the default name is
# dsh-demo-tls, but 70-gateway.yaml can be edited to another name.
if [ -n "$CERT_REFS" ]; then
while IFS= read -r ref; do
[ -n "$ref" ] || continue
ref_ns=${ref%%/*}
ref_name=${ref#*/}
ref_ns=${ref_ns:-$NS}
if kubectl -n "$ref_ns" get secret "$ref_name" >/dev/null 2>&1; then
pass "Gateway TLS certificate reference ${ref_ns}/${ref_name} exists"
else
fail "Gateway references TLS Secret ${ref_ns}/${ref_name} but it does not exist. Create it (see manifests/exposure/gateway-api-sso/74-tls-secret.yaml.example)."
fi
done <<<"$CERT_REFS"
else
warn "the Gateway has no TLS certificateRefs; SSO over plain HTTP would expose the session cookie. Terminate TLS at the Gateway listener."
fi
# A second path to the gateway defeats SSO: anyone who can reach
# dsh-gateway:8080 can spoof X-Forwarded-User. This is a best-effort check.
if kubectl -n "$NS" get ingress >/dev/null 2>&1; then
INGRESS_STATE=$(kubectl -n "$NS" get ingress -o json 2>/dev/null || true)
if printf '%s' "$INGRESS_STATE" | grep -q '"name":"dsh-gateway"'; then
fail "an Ingress in namespace $NS also routes to dsh-gateway. With SSO enabled the gateway must be reachable only through oauth2-proxy: delete the Ingress variant (kubectl delete -f manifests/exposure/ingress/)."
fi
fi
echo
if [ "$FAILED" -ne 0 ]; then
echo "preflight-sso: FAILED - fix the items above before exposing the gateway." >&2
exit 1
fi
echo "preflight-sso: ok - the SSO variant looks correctly configured."

View File

@@ -22,14 +22,48 @@ expect() { # <file> <literal>
# Main Dockerfile build args # Main Dockerfile build args
expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION" expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION"
expect Dockerfile "ARG DSH_COMMIT=$DSH_COMMIT"
expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION" expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION"
expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION" expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION"
expect Dockerfile "ARG NODE_BUILD_IMAGE=$NODE_BUILD_IMAGE"
expect Dockerfile "ARG NODE_RUNTIME_IMAGE=$NODE_RUNTIME_IMAGE"
# The build stage must fail closed when the tag no longer resolves to DSH_COMMIT.
expect Dockerfile "git -C /src rev-parse HEAD"
# Example gateway build base image
expect examples/dsh-agents-as-a-service/gateway/Dockerfile \
"ARG GATEWAY_PYTHON_IMAGE=$GATEWAY_PYTHON_IMAGE"
# Local configuration mirrored for humans # Local configuration mirrored for humans
expect .env.sample "DSH_VERSION=$DSH_VERSION" expect .env.sample "DSH_VERSION=$DSH_VERSION"
expect .env.sample "DSH_COMMIT=$DSH_COMMIT"
expect .env.sample "PNPM_VERSION=$PNPM_VERSION" expect .env.sample "PNPM_VERSION=$PNPM_VERSION"
expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION" expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION"
# Human-facing docs embed the resolved commit in the build example and table.
expect docs/main-image.md "$DSH_COMMIT"
expect docs/versioning.md "$DSH_COMMIT"
# Every container image is pinned to an immutable multi-arch index digest, not
# a mutable tag. This is a supply-chain invariant: don't "fix" a failing check
# by dropping the digest.
for image_var in NODE_BUILD_IMAGE NODE_RUNTIME_IMAGE GATEWAY_PYTHON_IMAGE OAUTH2_PROXY_IMAGE; do
image_value="${!image_var:-}"
if ! printf '%s' "$image_value" | grep -qE '@sha256:[0-9a-f]{64}$'; then
err "versions.env: $image_var must be digest-pinned (name:tag@sha256:<64 hex>), got: ${image_value:-<unset>}"
fi
done
if ! printf '%s' "${DSH_COMMIT:-}" | grep -qE '^[0-9a-f]{40}$'; then
err "versions.env: DSH_COMMIT must be a full 40-character commit SHA, got: ${DSH_COMMIT:-<unset>}"
fi
# Build entrypoints must pass DSH_COMMIT rather than trusting the tag alone.
expect Makefile "--build-arg DSH_COMMIT=\$(DSH_COMMIT)"
for wf in build-main.yaml release.yaml ci-image.yaml; do
expect ".github/workflows/$wf" "DSH_COMMIT="
done
# Image references in the example manifests # Image references in the example manifests
for f in \ for f in \
examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \ examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \
@@ -38,6 +72,10 @@ for f in \
done done
expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY" expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY"
# The oauth2-proxy exposure variant pins the digest, not the mutable tag.
expect examples/dsh-agents-as-a-service/manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml \
"image: $OAUTH2_PROXY_IMAGE"
# The gateway image must live below the repository image (one package per # The gateway image must live below the repository image (one package per
# example component, no unrelated top-level packages). # example component, no unrelated top-level packages).
case "$GATEWAY_IMAGE" in case "$GATEWAY_IMAGE" in

View File

@@ -4,6 +4,11 @@
# #
# ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision # ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision
# ./scripts/set-version.sh dsh-v0.2.1-rc.5 r3 # same ref, new repo revision # ./scripts/set-version.sh dsh-v0.2.1-rc.5 r3 # same ref, new repo revision
#
# For a new DSH ref the script resolves the tag's commit with "git ls-remote"
# (peeled ^{} commit first, tag ref as fallback) and rewrites DSH_COMMIT in
# versions.env, the Dockerfile ARG default and .env.sample. A revision-only bump
# at the same ref keeps the existing commit and needs no network access.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -23,9 +28,11 @@ if [ -n "$new_revision" ]; then
fi fi
# shellcheck source=/dev/null # shellcheck source=/dev/null
old_version="$(. ./versions.env; printf '%s' "$DSH_VERSION")" . ./versions.env
# shellcheck source=/dev/null
old_revision="$(. ./versions.env; printf '%s' "${RELEASE_REVISION:-}")" old_version="$DSH_VERSION"
old_revision="${RELEASE_REVISION:-}"
old_commit="${DSH_COMMIT:-}"
old_identity="$old_version" old_identity="$old_version"
[ -z "$old_revision" ] || old_identity="$old_version-$old_revision" [ -z "$old_revision" ] || old_identity="$old_version-$old_revision"
@@ -37,6 +44,29 @@ if [ "$old_identity" = "$new_identity" ]; then
exit 0 exit 0
fi fi
# Resolve the commit the new upstream tag points at. Annotated tags expose a
# peeled ^{} line (the commit) and a tag-object line; prefer the peeled commit
# and fall back to the tag ref for lightweight tags. A repository-only revision
# bump keeps the already pinned commit, so it needs no network.
new_commit="$old_commit"
if [ "$new_version" != "$old_version" ] || [ -z "$new_commit" ]; then
if ! refs="$(git ls-remote --tags --exit-code "$DSH_REPO" \
"refs/tags/$new_version^{}" "refs/tags/$new_version")"; then
echo "set-version: upstream tag '$new_version' not found in $DSH_REPO" >&2
exit 1
fi
new_commit="$(printf '%s\n' "$refs" \
| awk -v t="refs/tags/$new_version^{}" '$2 == t {print $1}' | head -n1)"
if [ -z "$new_commit" ]; then
new_commit="$(printf '%s\n' "$refs" \
| awk -v t="refs/tags/$new_version" '$2 == t {print $1}' | head -n1)"
fi
if ! printf '%s' "$new_commit" | grep -qE '^[0-9a-f]{40}$'; then
echo "set-version: could not resolve a commit for '$new_version' in $DSH_REPO" >&2
exit 1
fi
fi
# Replace the identity first (it contains the base version), then the base # Replace the identity first (it contains the base version), then the base
# version itself. CHANGELOG.md is generated from tags, so it is left alone. # version itself. CHANGELOG.md is generated from tags, so it is left alone.
while IFS= read -r file; do while IFS= read -r file; do
@@ -48,17 +78,30 @@ while IFS= read -r file; do
sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file" sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file"
done < <(git ls-files) done < <(git ls-files)
# versions.env is authoritative: write both keys explicitly so an empty # versions.env is authoritative: write these keys explicitly so an empty
# revision is stored as an empty value. # revision is stored as an empty value.
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" versions.env
# Values stored separately from the full identity: Dockerfile ARG defaults and # Values stored separately from the full identity: Dockerfile ARG defaults and
# the human-facing sample cannot be reached by the identity replacement above. # the human-facing sample cannot be reached by the identity replacement above.
sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile
sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile
sed -i "s/^ARG DSH_COMMIT=.*/ARG DSH_COMMIT=$new_commit/" Dockerfile
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" .env.sample
# The human-facing build examples embed the full SHA, which the version
# replacement above cannot see. Keep them in step; check-versions.sh verifies.
if [ -n "$old_commit" ] && [ "$old_commit" != "$new_commit" ]; then
for doc in docs/main-image.md docs/versioning.md; do
[ -f "$doc" ] || continue
sed -i "s/$old_commit/$new_commit/g" "$doc"
done
fi
echo "set-version: $old_identity -> $new_identity" echo "set-version: $old_identity -> $new_identity"
echo "set-version: DSH_COMMIT=$new_commit"
echo "set-version: run 'make check' and review the diff" echo "set-version: run 'make check' and review the diff"

View File

@@ -2,10 +2,17 @@
# repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub # repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub
# Actions workflows. Never hard-code these values elsewhere: run "make check" # Actions workflows. Never hard-code these values elsewhere: run "make check"
# (scripts/check-versions.sh) to verify every pin. # (scripts/check-versions.sh) to verify every pin.
#
# Every container image below is pinned to an immutable multi-arch index digest
# (name:tag@sha256:...), not just a mutable tag. Bump the tag and the digest
# together; scripts/check-versions.sh requires the digest form.
# Upstream DeepSeek Harness source, and the ref this repository packages. # Upstream DeepSeek Harness source, and the ref this repository packages.
# DSH_COMMIT is the full commit SHA that DSH_VERSION resolves to upstream; the
# Dockerfile verifies the clone's HEAD against it and fails the build on drift.
DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
DSH_VERSION=dsh-v0.2.1-rc.5 DSH_VERSION=dsh-v0.2.1-rc.5
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
PNPM_VERSION=11.28.5 PNPM_VERSION=11.28.5
# Release identity = DSH_VERSION plus an optional repository revision. # Release identity = DSH_VERSION plus an optional repository revision.
@@ -15,6 +22,13 @@ PNPM_VERSION=11.28.5
# repository-only change at the same ref becomes -r2. # repository-only change at the same ref becomes -r2.
RELEASE_REVISION= RELEASE_REVISION=
# Base images for the main DSH Web image (root Dockerfile), pinned by digest.
NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
# Base image for the example gateway (examples/dsh-agents-as-a-service/gateway).
GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
# Control-plane versions used by the examples and their manifests. # Control-plane versions used by the examples and their manifests.
# The matching SHA-256s pin the release manifests that CI downloads and # The matching SHA-256s pin the release manifests that CI downloads and
# applies; update both the version and the digest when bumping either pin. # applies; update both the version and the digest when bumping either pin.
@@ -22,7 +36,7 @@ AGENT_SANDBOX_VERSION=v1.0.9
AGENT_SANDBOX_MANIFEST_SHA256=4bf9ee5558e9a663f91cfa5963e96f2591bfaa1dcf79f03bfab65bc5d65cc699 AGENT_SANDBOX_MANIFEST_SHA256=4bf9ee5558e9a663f91cfa5963e96f2591bfaa1dcf79f03bfab65bc5d65cc699
GATEWAY_API_VERSION=v1.2.1 GATEWAY_API_VERSION=v1.2.1
GATEWAY_API_MANIFEST_SHA256=97598bf6ab3b33b9b5c5432bdd24de091e4e9c3aa0575ebb0710a2a19cd64d64 GATEWAY_API_MANIFEST_SHA256=97598bf6ab3b33b9b5c5432bdd24de091e4e9c3aa0575ebb0710a2a19cd64d64
OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
# Registry layout: the main image is the repository itself, and every example # Registry layout: the main image is the repository itself, and every example
# component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>). # component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>).