Skip to content

Latest commit

 

History

History
373 lines (288 loc) · 14.2 KB

File metadata and controls

373 lines (288 loc) · 14.2 KB

Admin gate (P3)

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.

Access model

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).

Session lifecycle

After a successful gate match, the handler runs four checks in order:

  1. 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.
  2. Session resolution: the gw_adm cookie 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 a Set-Cookie: gw_adm=...; Path=/<slug>/<token1>/<token2>; HttpOnly; Secure; SameSite=Strict; Max-Age=900 header is emitted.
  3. 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.
  4. CSRF check: on mutating methods (POST, PUT, PATCH, DELETE), the X-CSRF-Token header must equal the session's stored CSRF token under a constant-time compare. Mismatches return 403 with a fixed body and write a csrf.reject event 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.

Lockout tiers

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.

CSRF flow

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) sets X-CSRF-Token: <token> so the UI can cache it.
  • Client-to-server: every mutating-method request must echo the same token in X-CSRF-Token or 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.

API route reference

Every API path lives under /<slug>/<token1>/<token2>/api/. The list below uses the conventional <admin> shorthand for that prefix.

Common

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)

Hub-only routes

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

Per-node routes

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.

curl examples

The slug, tokens, and admin URL in these examples are placeholders. Replace them with the values printed by the installer.

First-time login (URL is the credential)

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.

Read the CSRF token

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>

List tenants (hub)

curl -sS -b cookies.txt \
  "$HOST/$SLUG/$T1/$T2/api/tenants" | jq .

Upsert a tenant (hub, mutating → CSRF required)

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 audit

Delete a tenant (hub)

curl -sS -b cookies.txt \
  -X DELETE \
  -H "X-CSRF-Token: $CSRF" \
  "$HOST/$SLUG/$T1/$T2/api/tenants/shop.example"

Toggle a feature (any node)

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"

Logout

curl -sS -b cookies.txt \
  -X POST \
  -H "X-CSRF-Token: $CSRF" \
  "$HOST/$SLUG/$T1/$T2/logout"
# → 204; subsequent requests get a fresh session

Configuration reference

The 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: 1h

The 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.

Troubleshooting

Lost slug or tokens

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-hub

The 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.

Locked out from your own admin

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.

Cookie not sticking

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.

CSRF rejects on every mutation

Two common causes:

  1. The client did not echo X-CSRF-Token. Read the latest token from any safe-method response and resend.
  2. 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.

Empty audit log

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.