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-ui and /api/api-docs/openapi.json (the swagger router is mounted inside the admin router, which is nested under /api).
  • A require_auth_for_docs middleware checks for an authenticated session/token (a temps_auth::AuthContext in the request extensions, injected by the plugin auth middleware that runs first). Authenticated callers pass through; anonymous callers get 401 Unauthorized with a WWW-Authenticate: Bearer realm="temps" header and an application/problem+json body ("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

PluginEndpointsCaller
temps-analytics-eventsPOST /api/_temps/eventBrowser SDK on customer site
temps-analytics-session-replayPOST /api/_temps/session-replay/init, POST /api/_temps/session-replay/eventsBrowser SDK
temps-analytics-performancePOST /api/_temps/speed, POST /api/_temps/speed/updateBrowser SDK
temps-error-trackingSentry/OTLP ingest, sentry-cli release/source-map uploadApp SDKs + CI/CD
temps-emailGET /api/emails/{email_id}/track/open, GET /api/emails/{email_id}/track/click/{link_index}Recipient email clients
temps-revenuePOST /api/webhooks/revenue/{provider}/{path_token} (e.g. Stripe; signature-verified)Payment providers
Multi-nodeWorker node-sync + route-sync endpointsWorker 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.


Configuration

VariableTypeDefaultEffect
TEMPS_CONSOLE_ADDRESShost:portrandomPublic listener (and admin listener when admin is not split out)
TEMPS_CONSOLE_ADMIN_ADDRESShost:portunsetBind admin/management routes to this address. Enables two-listener mode.
TEMPS_ADMIN_ALLOWED_IPSCSV of IP/CIDRemptyAllowlist source addresses for the admin listener. Empty = no IP gate.
TEMPS_ADMIN_ALLOWED_HOSTSCSV of hostnamesemptyAllowlist Host header values on the admin listener. Empty = no Host gate. Port is stripped.
TEMPS_ADMIN_TRUST_FORWARDED_FORboolfalseHonor 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)

SourceWhen it's usedEditable at runtime?
Environment variablesAny of TEMPS_ADMIN_ALLOWED_IPS / TEMPS_ADMIN_ALLOWED_HOSTS / TEMPS_ADMIN_TRUST_FORWARDED_FOR is setNo — the DB-backed UI becomes read-only
DB-backed settingsNo TEMPS_ADMIN_* env var is setYes — 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

MethodRoutePermissionNotes
GET/api/admin/gate-settingsSettingsReadRead the active gate config and where it came from
PATCH/api/admin/gate-settingsSettingsWriteUpdate 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

  1. 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 branded 404 page.
  2. Axum middleware on the admin listener (defense-in-depth). Every request reaching the admin listener directly is checked; denials return a bare 404.

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/login and 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 PATCH that would deny the saving caller's own (IP, Host) is rejected with 409 before 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 set TEMPS_ADMIN_TRUST_FORWARDED_FOR=true only 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.


See also

Was this page helpful?