This document describes the hidden admin surface delivered in P3. The gate exists on every node type — hub, proxy, door — and replaces the P1 501 stub with a full session-managed JSON API plus a static UI mount point that P4 populates.
See admin-ui.md for the web UI delivered in P4 — theme,
language, page reference, and extension guide. This document stays focused
on the gate mechanics (sessions, CSRF, lockout, audit) that the UI and the
JSON API share.
The URL itself is the credential. There is no login form, no password, no account database. An operator who knows the slug and two tokens can administer the node; everyone else gets a 404 indistinguishable from a missing route.
The path /<slug>/<token1>/<token2>/ is the only entry point. The slug
and tokens are 32-character random strings generated by the installer
and printed exactly once at install time. They are stored in
config.yaml under the admin: block and never written to a log file.
The match is constant-time across all three segments: regardless of which
byte of which segment first differs from the configured value, the gate
performs the same number of comparisons. A disabled gate (no tokens
configured, or admin.enabled: false) follows the same code path with
dummy buffers, so an outside observer cannot distinguish "admin not
configured" from "admin configured with different secrets" by timing.
When the gate matches, the gate strips the prefix from r.URL.Path and
delegates to the registered handler. When it does not match, the gate
returns a 404 with an empty body, no Content-Type header, and no Server
header — byte-identical to any other unrouted path.
Anyone serving a hub, proxy, or door node MUST treat the slug and tokens as secrets equivalent to a TLS private key. The installer prints them once; an operator who loses them must re-run the installer to generate new ones (see "Troubleshooting" below).
After a successful gate match, the handler runs four checks in order:
- Lockout: the hashed source IP is consulted against the in-memory lockout tracker. If the IP is in soft-backoff or hard-banned state, the handler returns 404 without any further work.
- Session resolution: the
gw_admcookie is read from the request. If present and not expired, the corresponding session is loaded and refreshed. If absent or expired, a fresh session is created and aSet-Cookie: gw_adm=...; Path=/<slug>/<token1>/<token2>; HttpOnly; Secure; SameSite=Strict; Max-Age=900header is emitted. - First-load redirect: when a fresh session was just minted, the
handler 302s to
/<slug>/<token1>/<token2>/. The browser navigates to the trailing-slash URL with the cookie already set, and the address bar displays the clean prefix without the secrets being re-quoted on every subsequent load. - CSRF check: on mutating methods (POST, PUT, PATCH, DELETE), the
X-CSRF-Tokenheader must equal the session's stored CSRF token under a constant-time compare. Mismatches return 403 with a fixed body and write acsrf.rejectevent to the audit log.
A session has two TTLs. The idle TTL (default 15 minutes) is reset on every request that hits the handler. The absolute TTL (default 8 hours) caps the session's lifetime regardless of activity. When either expires, the cookie's session ID no longer resolves and the next request gets a fresh session.
POST /<admin-prefix>/logout invalidates the server-side session and
sends a Max-Age=-1 cookie to evict the browser-side copy. The same
behaviour is exposed under /api/logout for clients that prefer the
JSON surface.
A session GC goroutine runs in the background and sweeps expired entries once a minute. It exits when the run-context is cancelled.
The gate tracks per-IP-hash failure counts in a sliding window. "Failure" means a request whose source IP has not minted a session recently and is currently being processed by the gate. The two tiers are configurable; the defaults match the spec:
| Tier | Threshold | Window | Penalty |
|---|---|---|---|
| Soft | 3 | 60 seconds | 30s backoff |
| Hard | 10 | 10 minutes | 1h ban |
While an IP is in soft-backoff or hard-banned state, every admin request from that IP returns 404 immediately, with no gate evaluation. The state expires automatically on its own timer.
Any successful entry from the same IP resets both counters to zero — a legitimate operator who fat-fingered the URL once does not accumulate ban credit. Hard-ban shadows soft-backoff: an IP that crossed both thresholds reports the longer state until that timer expires.
The lockout tracker holds an in-memory map keyed on the SHA-256 hash of the client IP (the same hash the metrics labeler uses). A second GC goroutine prunes entries with no recent failures and no live ban once a minute, so the keyspace stays bounded under steady-state probe traffic.
Every session record carries a 32-byte random CSRF token, encoded as base64url without padding. The token is generated together with the session ID; both have 256 bits of entropy.
On the wire, the same header carries the token in both directions:
- Server-to-client: every safe-method response (
GET,HEAD,OPTIONS) setsX-CSRF-Token: <token>so the UI can cache it. - Client-to-server: every mutating-method request must echo the same
token in
X-CSRF-Tokenor the handler returns 403.
Mismatched tokens are recorded in the audit log as csrf.reject events.
Missing tokens follow the same path. The constant-time compare uses
length-padded buffers so a wrong-length token does not short-circuit
out of the comparison and leak its length.
Every API path lives under /<slug>/<token1>/<token2>/api/. The list
below uses the conventional <admin> shorthand for that prefix.
| Method | Path | Description |
|---|---|---|
| GET | <admin>/ |
Static UI index page |
| GET | <admin>/static/* |
Static UI assets (P4) |
| GET | <admin>/api/me |
Session + node identity snapshot |
| POST | <admin>/logout |
Invalidate session (form or JSON) |
| POST | <admin>/api/logout |
Invalidate session (JSON only) |
These are only useful on a hub node. Proxy and door binaries pass
Hub: nil to the router; calls return a 403 with the body
{"error":"route requires hub node"}.
| Method | Path | Description |
|---|---|---|
| GET | <admin>/api/tenants |
List all tenants |
| GET | <admin>/api/tenants/{host} |
Fetch a single tenant |
| PUT | <admin>/api/tenants/{host} |
Create or update a tenant |
| DELETE | <admin>/api/tenants/{host} |
Remove a tenant |
| GET | <admin>/api/globals |
Read globals.yaml |
| PUT | <admin>/api/globals |
Replace globals.yaml |
| GET | <admin>/api/mirrors |
List all mirror health rows |
Available on every node type. Implementations differ: proxy and door expose only the local feature view, while hub exposes the global registry's view.
| Method | Path | Description |
|---|---|---|
| GET | <admin>/api/features |
List features + status |
| POST | <admin>/api/features/{name}/toggle |
Enable/disable a feature |
| GET | <admin>/api/metrics |
Prometheus text snapshot |
| GET | <admin>/api/metrics/history?limit=N |
Recent in-memory buckets |
| GET | <admin>/api/audit?since=...&limit=... |
Query the audit log |
When a per-node access adapter is not wired (e.g. metrics not yet populated on a fresh proxy), the route responds with 503 and a JSON body explaining the missing dependency.
The slug, tokens, and admin URL in these examples are placeholders. Replace them with the values printed by the installer.
SLUG="EXAMPLE-SLUG"
T1="EXAMPLE-TOKEN-A"
T2="EXAMPLE-TOKEN-B"
HOST="https://hub.example:9080"
curl -sS -L -c cookies.txt -b cookies.txt \
-D - "$HOST/$SLUG/$T1/$T2/" -o /dev/null
# → first request: 302 + Set-Cookie gw_adm=...
# → second request (curl -L): 200 + UI index-c cookies.txt -b cookies.txt makes curl persist the session cookie
across the redirect so subsequent calls reuse the issued session.
Every safe-method response echoes X-CSRF-Token:
curl -sS -b cookies.txt -D - \
"$HOST/$SLUG/$T1/$T2/api/me" -o /dev/null \
| grep -i '^x-csrf-token:'
# → x-csrf-token: <43-char base64url string>curl -sS -b cookies.txt \
"$HOST/$SLUG/$T1/$T2/api/tenants" | jq .CSRF=$(curl -sS -b cookies.txt -D - \
"$HOST/$SLUG/$T1/$T2/api/me" -o /dev/null \
| awk -F': *' 'tolower($1)=="x-csrf-token"{print $2}' | tr -d '\r\n')
curl -sS -b cookies.txt \
-X PUT \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
--data-binary @tenant.json \
"$HOST/$SLUG/$T1/$T2/api/tenants/shop.example"
# → 204 on success; mutation appears as a tenant.upsert event in auditcurl -sS -b cookies.txt \
-X DELETE \
-H "X-CSRF-Token: $CSRF" \
"$HOST/$SLUG/$T1/$T2/api/tenants/shop.example"curl -sS -b cookies.txt \
-X POST \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: $CSRF" \
-d '{"enabled":false}' \
"$HOST/$SLUG/$T1/$T2/api/features/blocklist_regex/toggle"curl -sS -b cookies.txt \
-X POST \
-H "X-CSRF-Token: $CSRF" \
"$HOST/$SLUG/$T1/$T2/logout"
# → 204; subsequent requests get a fresh sessionThe full admin: block lives in config.yaml. Defaults are filled in
by config.fillDefaults so a minimal block (slug + two tokens) keeps
working.
admin:
enabled: true
slug: "EXAMPLE-SLUG" # 32+ random chars, generated by installer
token1: "EXAMPLE-TOKEN-A" # 32+ random chars, generated by installer
token2: "EXAMPLE-TOKEN-B" # 32+ random chars, generated by installer
# Idle window before the cookie is treated as stale and the operator
# is bounced back through the URL gate. Default 15m.
session_idle_ttl: 15m
# Maximum lifetime of a single session regardless of activity.
# Default 8h. Must be greater than session_idle_ttl.
session_absolute_ttl: 8h
# Per-day JSONL files plus a BoltDB index live here. Defaults to a
# per-node-type path under /var/lib/gateway/<role>/audit.
audit_data_dir: /var/lib/gateway/hub/audit
lockout:
soft_threshold: 3
soft_window: 60s
soft_backoff: 30s
hard_threshold: 10
hard_window: 10m
hard_ban: 1hThe validator refuses to boot when admin.enabled: true and any of
the slug or tokens is shorter than 32 characters, when
session_idle_ttl >= session_absolute_ttl, or when any lockout value
is zero or negative.
The slug and tokens are printed exactly once at install time and are
not stored anywhere outside config.yaml. If they are lost — operator
mishap, lost backup, compromised disk — the only recourse is to
regenerate them.
On a hub, proxy, or door host:
# Stop the binary first so it does not log a half-rotation.
sudo systemctl stop gateway-hub # or gateway-proxy / gateway-door
# Generate three fresh 32-character random strings.
SLUG=$(openssl rand -hex 16)
T1=$(openssl rand -hex 16)
T2=$(openssl rand -hex 16)
echo "slug: $SLUG"
echo "token1: $T1"
echo "token2: $T2"
# Edit /etc/gateway/config.yaml and replace the admin.slug, admin.token1,
# admin.token2 fields with the values above. Save and restart.
sudo systemctl start gateway-hubThe next time an operator visits /<new-slug>/<new-token1>/<new-token2>/
the fresh values take effect. Old session cookies are invalidated
automatically because the Path attribute no longer matches.
If your IP is in soft-backoff or hard-banned state, every admin request returns 404 — including the legitimate URL. Wait for the timer to expire (30 seconds for soft, one hour for hard by default), or restart the binary to clear the in-memory lockout tracker.
The lockout state is intentionally not persisted; a binary restart is the operator-side reset.
Browsers refuse Secure cookies on plain-HTTP origins. The admin gate
sets Secure: true unconditionally, so an admin URL served over HTTP
(no TLS terminator in front) will issue a cookie the browser ignores
and re-issue on every request, looping in the 302.
Either install the binary behind TLS (the production path), or run curl which does not enforce the Secure attribute.
Two common causes:
- The client did not echo
X-CSRF-Token. Read the latest token from any safe-method response and resend. - The client cached an old token after a session refresh. The token is
stable for the life of a session; if the session expired and a new
one was issued (the client was 302'd to
/<prefix>/), the cached token is no longer valid. Re-read it from/api/me.
Every CSRF rejection appears in the audit log with action
csrf.reject and the rejected request's path as the target, so the
operator can correlate failures with client IPs.
The audit log file is created lazily on first write. If no admin
mutation has happened yet, the file does not exist and a Query call
returns an empty list. Trigger a logout (which writes a
session.logout event) to seed the file.
If the audit data directory is on a filesystem the gateway user cannot
write to, OpenLog fails at startup and the binary refuses to boot.
Check audit_data_dir ownership and mode (0700 is fine) before
filing a bug.