- Go 59.2%
- PHP 15.2%
- JavaScript 14.7%
- CSS 4.2%
- HTML 3.7%
- Other 3%
|
|
||
|---|---|---|
| build | ||
| cmd | ||
| deploy | ||
| internal | ||
| php-client | ||
| scripts | ||
| test | ||
| web | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
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; thedispatcheradvances anadmittedpointer at the configured inflow rate. You're admitted whenadmitted >= 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.