No description
  • Go 59.2%
  • PHP 15.2%
  • JavaScript 14.7%
  • CSS 4.2%
  • HTML 3.7%
  • Other 3%
Find a file
2026-06-14 12:30:13 +00:00
build proof of concept 2026-06-14 08:22:50 +02:00
cmd Prequeue 2026-06-14 14:28:56 +02:00
deploy proof of concept 2026-06-14 08:22:50 +02:00
internal Prequeue 2026-06-14 14:28:56 +02:00
php-client Add snork/client Symfony bundle for waiting-room integration 2026-06-14 13:45:45 +02:00
scripts proof of concept 2026-06-14 08:22:50 +02:00
test proof of concept 2026-06-14 08:22:50 +02:00
web Prequeue 2026-06-14 14:28:56 +02:00
.gitignore proof of concept 2026-06-14 08:22:50 +02:00
go.mod proof of concept 2026-06-14 08:22:50 +02:00
go.sum proof of concept 2026-06-14 08:22:50 +02:00
Makefile proof of concept 2026-06-14 08:22:50 +02:00
README.md Add rolling JTI anti-cheat and resume-secret tab-close resilience 2026-06-14 12:49:12 +02:00

snork — virtual waiting room

A containerized virtual waiting room that throttles traffic into a protected "target" site during high-demand events. Users join a fair FIFO queue, see their position and estimated wait, and are admitted at a rate operators control via an authenticated management API. On admission each user receives a signed token the target site validates to confirm they queued legitimately.

How it works

              ┌──────────────┐    join / status / token
  browser ──▶ │ edge (nginx)  │ ──────────────┐
              └──────────────┘                 ▼
                  static UI            ┌──────────────┐     ┌────────┐
                                       │  queue-api   │────▶│        │
              admin ──(authn)─────────▶│  admin-api   │────▶│ redis  │
                                       │  dispatcher  │────▶│        │
                                       └──────────────┘     └────────┘
                                              │ JWKS
   target-demo ◀── redirect w/ access token ──┘  (validates offline)
  • Algorithm — a monotonic "deli counter": each joiner gets an INCR-allocated ticket number; the dispatcher advances an admitted pointer at the configured inflow rate. You're admitted when admitted >= your ticket. Position = ticket admitted; ETA = ceil(ahead / rate) × interval. Every hot-path operation is one Redis counter op or a tiny atomic Lua script.
  • Tokens — EdDSA (Ed25519) JWTs. A short-lived queue token carries the ticket number (signed, so position can't be forged). On exchange it becomes an access token bound to the target audience, which the target validates offline via the published JWKS — with optional one-time-use enforcement.
  • Automatic enforcement — a visitor who reaches the protected site without a valid token or session cookie is redirected to the waiting room (carrying the URL they wanted), queued, and sent back to that exact URL once admitted. The target sets a session cookie on entry, so already-admitted users — and anyone holding a valid token — pass straight through; only those who need to wait do.
  • Statistics — total users passed, peak concurrent queue length, peak pass-rate, and a passed-per-minute series, all exposed on the admin API and as Prometheus metrics (see internal/queue/stats.go).

Services

Service Role
edge nginx: serves the waiting-room UI, proxies the public queue API, per-IP rate limiting
queue-api join, status, token exchange, validate, JWKS
admin-api authenticated room config, inflow control, live stats, audit log
dispatcher advances admission at the inflow rate (single active leader via Redis lock)
target-demo example protected site validating tokens via JWKS
redis queue + room state (AUTH, AOF, noeviction)

Quick start (Docker Compose)

make keys           # generate signing key, admin token, redis password
make up             # build + start the whole stack
make demo           # end-to-end walkthrough (join → wait → admit → token → stats)

Then open http://localhost:8088/?room=demo to watch the waiting-room UI. The admin API is bound to 127.0.0.1:8081; the demo target to 127.0.0.1:8089.

Automatic waiting-room enforcement

The protected site decides, per request, whether a visitor may proceed:

GET /products/42
      │
      ├─ valid session cookie?  ── yes ─▶ serve the page
      ├─ ?token= from waiting room? ─ yes ─▶ set cookie, 303 to clean URL
      └─ otherwise ─▶ 302 to  waiting-room?room=demo&return=/products/42
                          (queue → admit → back to /products/42 with token)

target-demo implements this; enable it with:

Env Meaning
WAITING_ROOM_URL Public edge URL to redirect tokenless visitors to
GATE_ROOM Which room id gates this site
TOKEN_AUDIENCE Audience the access token must carry
JWKS_URL Where to fetch signing keys for offline validation
VALIDATE_URL (optional) queue-api /validate for one-time-use at entry

Gating is off when WAITING_ROOM_URL/GATE_ROOM are unset (the site then only validates tokens presented to it). The return path is honored only on the target's own origin, so a crafted waiting-room link can't redirect a victim (with a live token) to a foreign site. In your real application, replicate this check in middleware or a reverse proxy in front of it.

Administration dashboard

admin-api serves a built-in web dashboard (embedded in the binary via go:embed, so it lives behind the same restricted boundary as the API — no separate public surface, no CORS). Open it at the admin-api address and sign in with the admin token:

open http://localhost:8081/          # token: cat deploy/compose/secrets/admin_token

It provides:

  • Overview — every room with live queue length, admitted/issued, passed totals and status (open/paused/closed), plus aggregate stats in the header.
  • Room detail — live counters, peak queue length and peak pass-rate (with timestamps), an observed admit-rate readout, and a passed-per-minute chart.
  • Controls — edit inflow rate / interval / burst, and one-click Pause / Open floodgates / Reset / Delete.
  • Waiting-room messages — add/edit/enable/delete per-room notices (info or warning) that the waiting page shows and rotates through (e.g. "Did you know…", "Sold out in S, M and XXXL").
  • Audit log — who changed what, when.
  • Create room — full configuration form.

Auth is the static admin bearer token for now (entered at runtime, kept in sessionStorage); the roadmap is OIDC/SSO + RBAC. In Kubernetes the dashboard is not exposed publicly — reach it with kubectl -n snork port-forward svc/admin-api 8081:8081.

Development

make test           # unit tests (queue logic + tokens, via embedded Redis)
make vet
make build

Kubernetes

See deploy/k8s/README.md for the kustomize base (Deployments, HPA, Ingress, NetworkPolicies, Redis StatefulSet).

API summary

Public (queue-api, via edge):

Method Path Purpose
POST /api/v1/queue/join Join a room → {queue_token, ticket, poll_after_sec, resume_secret}
GET /api/v1/queue/status Position, ETA, rotated queue_token (Bearer queue token)
POST /api/v1/queue/resume Re-mint a queue token after tab close → {queue_token, ticket, poll_after_sec}
POST /api/v1/queue/token Exchange admitted ticket → access token
GET /api/v1/queue/messages?room=ID Enabled operator messages for waiters (cacheable)
POST /api/v1/validate Validate an access token (optional one-time-use)
GET /.well-known/jwks.json Public keys for offline validation

Queue-token security: each /status call atomically burns the current JTI and returns a fresh one. Two people sharing the same token will both get 401 within 10 seconds of the other polling. The client stores the queue token in localStorage; resume_secret (also in localStorage) allows re-minting a token after closing the tab and returning hours later. Set queue_ttl_sec to the expected max wait when creating a room.

Admin (admin-api, authenticated, internal only):

Method Path Purpose
POST /api/v1/admin/rooms Create a room
GET /api/v1/admin/rooms[/{id}] List / get room + live counters
PATCH /api/v1/admin/rooms/{id} Update inflow, interval, burst, enabled
DELETE /api/v1/admin/rooms/{id} Delete a room (config, counters, audit)
POST /api/v1/admin/rooms/{id}/actions pause / open / reset
GET·POST·PATCH·DELETE /api/v1/admin/rooms/{id}/messages[/{mid}] Manage waiter messages
GET /api/v1/admin/rooms/{id}/stats Passed total, peaks, passed/min series
GET /api/v1/admin/rooms/{id}/audit Recent config-change audit entries
GET /api/v1/admin/stats Aggregate totals/peaks across rooms

The administration dashboard (see above) consumes this API.