Admin Listener
The Temps control plane serves two very different kinds of traffic on the same port: public ingest (browser SDKs posting analytics events, deployed apps calling the AI gateway, worker nodes registering themselves) and admin/management (login, dashboard, project CRUD, settings). The admin listener lets you split those onto separate bind addresses so the admin surface can live on a private interface — loopback, a VPN, or a Tailscale interface — while the public surface stays reachable from the open internet.
When to use this
Enable the admin listener if any of these apply:
- You expose the Temps control plane on a public IP and don't want random scanners hitting
/api/auth/login. - You front Temps with a reverse proxy and want admin access gated to a separate hostname or IP range.
- Your compliance posture requires admin interfaces to be unreachable from the public internet.
If you're running Temps on a private network with no public exposure, the default single-listener mode is fine — leave TEMPS_CONSOLE_ADMIN_ADDRESS unset.
How it works
Each plugin in the Temps control plane declares two route sets:
configure_routes— the admin surface. Auth, dashboard queries, CRUD, settings, the SPA, Swagger UI, and the AI gateway (API-key authed).configure_public_routes— the public surface. SDK ingest endpoints, webhook delivery targets, worker-facing APIs. These handlers authenticate themselves (API key, DSN token, Host-header lookup) — they don't rely on session auth.
When TEMPS_CONSOLE_ADMIN_ADDRESS is set, the server binds both routers on separate axum::serve listeners. When it's unset, both routers are merged onto TEMPS_CONSOLE_ADDRESS for backwards compatibility (the default). Swagger UI and the embedded SPA mount on the admin listener only.
The admin gate (CIDR + Host allowlists) is layered on top of the admin listener as defense in depth. Denials return 404 Not Found, not 403 Forbidden, so a probing client cannot fingerprint the admin surface.
Swagger UI and OpenAPI JSON always require auth
Independent of the listener split and independent of the admin gate, the Swagger UI and the OpenAPI schema now sit behind their own authentication guard:
- Served paths:
/api/swagger-uiand/api/api-docs/openapi.json(the swagger router is mounted inside the admin router, which is nested under/api). - A
require_auth_for_docsmiddleware checks for an authenticated session/token (atemps_auth::AuthContextin the request extensions, injected by the plugin auth middleware that runs first). Authenticated callers pass through; anonymous callers get401 Unauthorizedwith aWWW-Authenticate: Bearer realm="temps"header and anapplication/problem+jsonbody ("Authentication required to access the API documentation.").
This closes a gap where, in the default no-allowlist (noop) admin-gate mode, those two endpoints were reachable by any unauthenticated caller and leaked the full API schema as a reconnaissance map. Because the guard is attached to the docs router itself — not the admin gate — authentication is required regardless of admin-gate configuration.
Route classification
Public surface
| Plugin | Endpoints | Caller |
|---|---|---|
temps-analytics-events | POST /api/_temps/event | Browser SDK on customer site |
temps-analytics-session-replay | POST /api/_temps/session-replay/init, POST /api/_temps/session-replay/events | Browser SDK |
temps-analytics-performance | POST /api/_temps/speed, POST /api/_temps/speed/update | Browser SDK |
temps-error-tracking | Sentry/OTLP ingest, sentry-cli release/source-map upload | App SDKs + CI/CD |
temps-email | GET /api/emails/{email_id}/track/open, GET /api/emails/{email_id}/track/click/{link_index} | Recipient email clients |
temps-revenue | POST /api/webhooks/revenue/{provider}/{path_token} (e.g. Stripe; signature-verified) | Payment providers |
| Multi-node | Worker node-sync + route-sync endpoints | Worker nodes anywhere |
Admin surface
Everything else — /api/auth/*, /api/projects/..., settings, deployments, alert rules, error-group queries, source-map management, AI gateway usage analytics, the dashboard SPA, and Swagger UI at /api/swagger-ui.
The AI gateway is not public ingest. The OpenAI-compatible endpoints
(/api/ai/v1/chat/completions, /api/ai/v1/embeddings, /api/ai/v1/models)
register only via configure_routes, so they live on the admin surface
and require an authenticated API key. If you split listeners, deployed apps
calling the AI gateway must reach the admin address. Don't treat the gateway
as something you can leave on the open public listener.
Payment-provider webhooks are public on purpose. The revenue webhook
endpoint (/api/webhooks/revenue/{provider}/{path_token}, e.g. Stripe) must
reach the server from arbitrary IPs. The handler verifies the provider
signature, so the public listener doesn't weaken the security model. (Git
provider webhooks — GitHub/GitLab — register via configure_routes, so they
live on the admin surface, not the public one.)
Configuration
| Variable | Type | Default | Effect |
|---|---|---|---|
TEMPS_CONSOLE_ADDRESS | host:port | random | Public listener (and admin listener when admin is not split out) |
TEMPS_CONSOLE_ADMIN_ADDRESS | host:port | unset | Bind admin/management routes to this address. Enables two-listener mode. |
TEMPS_ADMIN_ALLOWED_IPS | CSV of IP/CIDR | empty | Allowlist source addresses for the admin listener. Empty = no IP gate. |
TEMPS_ADMIN_ALLOWED_HOSTS | CSV of hostnames | empty | Allowlist Host header values on the admin listener. Empty = no Host gate. Port is stripped. |
TEMPS_ADMIN_TRUST_FORWARDED_FOR | bool | false | Honor X-Forwarded-For for the IP gate, but only from loopback peers (anti-spoof). |
CIDR entries are standard (10.0.0.0/8, 2001:db8::/32). Bare IPs become host routes (/32 for v4, /128 for v6). Invalid entries fail the server at startup with a typed error rather than silently allowing traffic. Host matching strips the port and is case-insensitive. Empty allowlists mean no restriction — when both lists are empty the gate is a noop and enforcement is skipped entirely.
Admin gate
The admin gate (crate temps-core::admin_gate) is the allowlist that decides which clients can reach the management surface. It is the same TEMPS_ADMIN_ALLOWED_IPS / TEMPS_ADMIN_ALLOWED_HOSTS / TEMPS_ADMIN_TRUST_FORWARDED_FOR allowlist described above, but it has two configuration sources and two enforcement points worth understanding.
Two configuration sources (env wins)
| Source | When it's used | Editable at runtime? |
|---|---|---|
| Environment variables | Any of TEMPS_ADMIN_ALLOWED_IPS / TEMPS_ADMIN_ALLOWED_HOSTS / TEMPS_ADMIN_TRUST_FORWARDED_FOR is set | No — the DB-backed UI becomes read-only |
| DB-backed settings | No TEMPS_ADMIN_* env var is set | Yes — edit in the web console at /settings/security |
When any TEMPS_ADMIN_* env var is set, the env values are authoritative and the database configuration is frozen: a PATCH through the API returns 409 Conflict with an "Admin Gate Read-Only" problem. When no env var is set, the gate reads the singleton settings row under the JSON key admin_gate, editable from the Admin Access Gate card on the Security settings page (/settings/security).
Management API
| Method | Route | Permission | Notes |
|---|---|---|---|
GET | /api/admin/gate-settings | SettingsRead | Read the active gate config and where it came from |
PATCH | /api/admin/gate-settings | SettingsWrite | Update the DB-backed config (rejected 409 when env-active) |
A lockout pre-flight runs on every PATCH: if the new allowlist would deny the saving caller's own (IP, Host), the update is rejected with 409 Conflict rather than locking you out of the console.
Runtime updates, no restart
Both enforcement points share a single atomically-swappable AdminGateHandle. A UI-triggered update takes effect across the proxy and the admin listener at runtime — no restart, and no database read on the request path.
Two enforcement points
- Pingora proxy (primary). When a non-noop gate is active and a request would fall back to the console for a host that has no deployed app (and isn't a workspace/sandbox preview and isn't a public-ingest
/api/_temps/*path), the proxy requires the(IP, Host)to pass the allowlist; otherwise it serves a branded404page. - Axum middleware on the admin listener (defense-in-depth). Every request reaching the admin listener directly is checked; denials return a bare
404.
Enforcement is not literal /admin/* URL-prefix matching. The proxy
enforces by host/IP on the console-fallback path, and the admin-listener
middleware enforces on every request reaching that listener regardless of
path. The only routes literally under /admin/* are the gate-settings
endpoints themselves (/api/admin/gate-settings).
Fail-closed boot
On startup, a database error while loading the gate config — a corrupt settings row, a DB outage, or a JSON parse failure — fails closed: the error propagates as a boot failure with an explicit error! log telling the operator how to recover (repair the row, or set TEMPS_ADMIN_* env vars). An earlier version silently installed an open (noop) config on load error; that was a fail-open weakness and is fixed. The distinct "no settings row / key absent" case is intentional and still yields the default open (noop) gate.
Recipes
SSH tunnel for solo operators
Admin only accessible via an SSH tunnel — simplest setup, no reverse proxy required.
# On the server
export TEMPS_CONSOLE_ADDRESS=0.0.0.0:8080
export TEMPS_CONSOLE_ADMIN_ADDRESS=127.0.0.1:8081
export TEMPS_ADMIN_ALLOWED_IPS=127.0.0.1/32
temps serve --database-url postgres://...
# On your laptop
ssh -L 8081:127.0.0.1:8081 user@temps.example.com
# Now https://localhost:8081 reaches the admin UI through the tunnel
Tailscale / WireGuard private network
Admin reachable from anyone on your tailnet, public ingest stays on the internet.
export TEMPS_CONSOLE_ADDRESS=0.0.0.0:8080
export TEMPS_CONSOLE_ADMIN_ADDRESS=100.64.0.1:8081 # Tailscale interface
export TEMPS_ADMIN_ALLOWED_IPS=100.64.0.0/10 # Tailscale CGNAT range
Reverse proxy with admin Host header
You front Temps with Caddy/Nginx; admin is served on a separate hostname.
export TEMPS_CONSOLE_ADDRESS=127.0.0.1:8080
export TEMPS_CONSOLE_ADMIN_ADDRESS=127.0.0.1:8081
export TEMPS_ADMIN_ALLOWED_IPS=127.0.0.1/32 # only proxy can hit it
export TEMPS_ADMIN_ALLOWED_HOSTS=admin.temps.example.com
export TEMPS_ADMIN_TRUST_FORWARDED_FOR=true # honor proxy's XFF
# Caddyfile
admin.temps.example.com {
reverse_proxy 127.0.0.1:8081
}
temps.example.com {
reverse_proxy 127.0.0.1:8080
}
Office IP allowlist
Admin only accepts requests from your office static IP range.
export TEMPS_CONSOLE_ADDRESS=0.0.0.0:8080
export TEMPS_CONSOLE_ADMIN_ADDRESS=0.0.0.0:8081
export TEMPS_ADMIN_ALLOWED_IPS=203.0.113.0/24,127.0.0.1/32
Testing your setup
After restart, check the logs:
Console PUBLIC API server listening on 0.0.0.0:8080
Console ADMIN API server listening on 127.0.0.1:8081
Admin gate enabled allowed_ips=["127.0.0.1/32"] allowed_hosts=[]
Then verify the split:
# Public listener: ingest endpoints work
curl -i http://127.0.0.1:8080/api/_temps/event \
-H 'content-type: application/json' -d '{}'
# → 400 (validation) or 204. Important: NOT 404.
# Public listener: admin endpoints 404
curl -i http://127.0.0.1:8080/api/auth/login
# → 404 (admin router isn't mounted here)
# Admin listener from loopback: works
curl -i http://127.0.0.1:8081/api/auth/login
# → 400/401 (route exists, auth fails as expected)
# Admin listener from a non-allowlisted IP: blocked
# (bind admin to 0.0.0.0:8081 and curl from another host)
curl -i http://your-server-ip:8081/api/auth/login
# → 404 (admin gate denied, masked as not-found)
# Admin listener with wrong Host header (when allowed_hosts is set)
curl -i -H 'Host: evil.example' http://127.0.0.1:8081/api/auth/login
# → 404
# Swagger UI / OpenAPI JSON require auth even when no allowlist is set
curl -i http://127.0.0.1:8081/api/api-docs/openapi.json
# → 401 (WWW-Authenticate: Bearer realm="temps") — anonymous callers are rejected
Denied requests are logged at WARN level on the server:
admin gate denied: source IP not in allowlist client_ip=203.0.113.5 path=/api/auth/login
admin gate denied: Host header not in allowlist host=evil.example path=/api/auth/login
Threat model
What the admin listener defends against:
- Drive-by scanning of
/api/auth/loginand other admin endpoints by internet-wide scanners. - Credential-stuffing attacks that don't know the admin surface exists.
- Operator mistakes like a misconfigured CORS allowlist that would otherwise expose admin APIs.
- API-schema reconnaissance. Swagger UI and the OpenAPI JSON require authentication regardless of the listener split or admin-gate config, so anonymous callers can't pull the full schema as a map of the attack surface.
- DB-tampering downgrade. If the DB-backed gate config can't load (corrupt row, DB outage), the process fails closed and refuses to boot rather than silently opening the gate — an attacker with DB write access can't disable the gate by corrupting one row.
- Self-lockout. A
PATCHthat would deny the saving caller's own(IP, Host)is rejected with409before it's persisted.
What it does not defend against:
- A user with valid session credentials accessing admin endpoints from an allowlisted network. Use the regular permission system for authorization.
- Stolen API keys used against the public AI gateway / event ingest endpoints — those handlers do their own auth.
- Source IP spoofing when the gate is configured to trust
X-Forwarded-For. The gate only honors XFF when the immediate peer is loopback, so setTEMPS_ADMIN_TRUST_FORWARDED_FOR=trueonly when you sit behind a trusted reverse proxy.
The admin listener is defense in depth, not a replacement for authentication. Protect the auto-generated auth secret on disk (the auth_secret file in the data dir — ~/.temps by default, or wherever TEMPS_DATA_DIR points; it's created with 0o600 permissions), keep the user database backed up, and treat the admin URL as a secret regardless of where it binds.