Skip to content

feat(serve): split public and admin console listeners#89

Open
dviejokfs wants to merge 10 commits into
mainfrom
feat/admin-public-listener-split
Open

feat(serve): split public and admin console listeners#89
dviejokfs wants to merge 10 commits into
mainfrom
feat/admin-public-listener-split

Conversation

@dviejokfs
Copy link
Copy Markdown
Contributor

Summary

Lets operators bind admin/management routes to a private interface while public ingest endpoints stay reachable from the internet. Single-listener mode remains the default — set TEMPS_CONSOLE_ADMIN_ADDRESS to opt in.

  • Public surface (browser SDKs, app SDKs with API keys, worker nodes, external webhooks): analytics events, session replay, performance metrics, Sentry/OTLP error ingest + sentry-cli source map upload, email tracking pixel/click/SES webhook, AI gateway (/ai/v1/*), multi-node register/heartbeat/route-sync.
  • Admin surface (UI, dashboard, CRUD, settings, SwaggerUI, the SPA): everything else.
  • Defense-in-depth admin gate: optional TEMPS_ADMIN_ALLOWED_IPS (CIDR allowlist), TEMPS_ADMIN_ALLOWED_HOSTS (Host-header allowlist), and TEMPS_ADMIN_TRUST_FORWARDED_FOR (honor XFF only from loopback peers). Denials return 404, not 403, so probes can't fingerprint the surface.
  • Per-plugin route split: each plugin classifies its own endpoints via the existing configure_routes (admin) / configure_public_routes (public) hooks. Six plugins updated.

What's in this PR

Commit Scope
64149c1 feat(serve): split public/admin listeners — the core change
a02b18d docs(serve): admin-listener guide + env vars + security cross-link
7e9adf4 fix(git): contextual logging on GitHub App scoped token mint failures
7541ee7 docs(changelog): unreleased entries for both above
a458eac, c0a9dc8 Unrelated visitor-facets + env-subdomain work that landed on this branch between sessions

Configuration

# Default: single listener, backwards-compatible
TEMPS_CONSOLE_ADDRESS=0.0.0.0:8080

# Two listeners with admin on loopback + IP allowlist
TEMPS_CONSOLE_ADDRESS=0.0.0.0:8080
TEMPS_CONSOLE_ADMIN_ADDRESS=127.0.0.1:8081
TEMPS_ADMIN_ALLOWED_IPS=127.0.0.1/32,10.0.0.0/8
TEMPS_ADMIN_ALLOWED_HOSTS=admin.temps.example.com

Full reference: docs/howto/admin-listener — covers SSH tunnel, Tailscale, reverse proxy, and office IP allowlist recipes plus the threat model.

Test plan

  • cargo check --bin temps — clean across 56 crates
  • cargo test --bin temps admin_gate — 10 admin_gate unit tests pass (CIDR/host matchers, XFF anti-spoof)
  • cargo test --lib -p temps-core split_application — 2 split-router tests pass (admin-only routes 404 on public, public-only routes 404 on admin)
  • Manual smoke: launch with TEMPS_CONSOLE_ADMIN_ADDRESS=127.0.0.1:8081; verify curl http://127.0.0.1:8080/api/auth/login returns 404 and curl http://127.0.0.1:8081/api/auth/login returns 401
  • Manual smoke: with TEMPS_ADMIN_ALLOWED_IPS=127.0.0.1/32 and admin bound to 0.0.0.0:8081, verify a request from a non-loopback IP returns 404
  • Manual smoke: with TEMPS_ADMIN_ALLOWED_HOSTS=admin.local, verify curl -H 'Host: evil.example' returns 404

Threat model

Defends against drive-by scanning of /api/auth/login, credential-stuffing attacks that don't know the admin surface exists, and CORS-misconfiguration mistakes that would otherwise expose admin APIs. Does not replace authentication — keep TEMPS_AUTH_SECRET strong and treat the admin URL as a secret regardless.

Webhooks (GitHub, Stripe, SES) stay on the public listener — they verify their own signatures and must be reachable from arbitrary IPs.

dviejokfs added 10 commits May 13, 2026 08:43
Adds a two-listener model for the console API so admin/management routes
can bind to a private interface (loopback, VPN, etc.) while public ingest
endpoints stay reachable from the open internet.

Each plugin now reports its public-facing routes through the existing
configure_public_routes hook; configure_routes is reserved for the admin
surface. PluginManager::build_split_application returns the two routers
separately and the serve command spawns one axum::serve per listener
when TEMPS_CONSOLE_ADMIN_ADDRESS is set, falling back to a single
merged listener for backwards compatibility.

Plugin route splits in this commit:
- analytics-events: /_temps/event ingest is public; dashboard stays admin
- analytics-session-replay: /_temps/session-replay/* public, queries admin
- analytics-performance: /_temps/speed{,/update} public, metrics admin
- error-tracking: Sentry/OTLP ingest + sentry-cli compat public; alerts,
  DSN mgmt, source-map mgmt, error-group queries stay admin
- email-tracking: tracking pixel/click/SES webhook public; event queries admin
- ai-gateway: /ai/v1/{chat/completions,embeddings,models} public (API-key
  authed at the handler); usage/pricing/provider keys stay admin

Agent-facing routes wired in serve/console.rs (node register/heartbeat,
edge route sync) move to the public listener since workers connect from
arbitrary networks with bearer tokens.

Defense-in-depth admin gate (serve/admin_gate.rs) adds optional
CIDR + Host-header allowlists on top of the network-layer binding:
- TEMPS_ADMIN_ALLOWED_IPS  - comma-separated IPs/CIDRs
- TEMPS_ADMIN_ALLOWED_HOSTS - comma-separated Host values
- TEMPS_ADMIN_TRUST_FORWARDED_FOR - honor XFF only from loopback peers
Denials return 404 (not 403) so probes can't fingerprint the surface.

SwaggerUi and the embedded SPA now mount on the admin listener only.

Tests:
- 10 admin_gate unit tests (CIDR/host matchers, XFF anti-spoof)
- 2 PluginManager::build_split_application tests verifying admin-only
  and public-only routes land on the correct router
- Add visitor facet aggregation endpoints (country, region, city, channel,
  referrer, event, browser, OS, device, language, UTM*) with ClickHouse +
  Postgres backends and matching SDK.
- Add FacetCombobox UI and wire VisitorsList to the facet API.
- Add environment subdomain rename handler/service with audit logging
  and slug normalization.
- Regenerate web SDK against /api/api-docs/openapi.json (correct admin-
  listener path after the listener split).
Adds a dedicated /docs/admin-listener guide covering when to enable
the split, the per-plugin route classification (public ingest vs admin),
the four configuration env vars, deployment recipes (SSH tunnel,
Tailscale/WireGuard, reverse proxy, IP allowlist), a testing checklist,
and the threat model.

Updates the environment-variables reference table with the four new
vars (TEMPS_CONSOLE_ADMIN_ADDRESS, TEMPS_ADMIN_ALLOWED_IPS,
TEMPS_ADMIN_ALLOWED_HOSTS, TEMPS_ADMIN_TRUST_FORWARDED_FOR) and adds
a cross-link from the security architecture page.
Visitor facets:
- Drop event-row dimensions (event/browser/os/device/language/utm_*)
  from VisitorSegmentFilters, VisitorFacets, OpenAPI, and the
  VisitorsList UI. Only country/region/city/channel/referrer remain.
- Fan the 5 visitor-row queries out with tokio::try_join! so wall-
  clock is bounded by the slowest (~15 ms) instead of summed.
- Drop facet_event_dimension and the EXISTS-against-events subquery
  in get_visitors. No more events hypertable touches in this code
  path — facets endpoint goes from ~1 s to ~15-20 ms at 150 k events.

Environment subdomain:
- Build env URLs from environments.subdomain (the canonical source)
  instead of reconstructing from project_slug + env_slug. Renames
  via update_environment_subdomain now propagate to listing/detail.
- Expose subdomain on EnvironmentResponse and the settings UI.
Adds error-level structured logging at each fallible step of the GitHub
App installation token mint flow (private key parse, JWT creation,
client build, installation fetch, access_tokens URL parse, GitHub
access_tokens POST). Each log includes installation_id and app_id so a
failure can be traced back to the specific installation without
re-deriving it from the request.

Most useful for diagnosing the "GitHub rejected access_tokens" path,
which silently fails today when the requested repo isn't selected on
the installation or the App lacks a requested permission.
…ogging

Unreleased entries:
- Added: public/admin console listener split with optional CIDR/Host
  admin gate (TEMPS_CONSOLE_ADMIN_ADDRESS + TEMPS_ADMIN_ALLOWED_*).
- Fixed: contextual error logging on GitHub App scoped token mint
  failures so installation/permission misconfigs are traceable.
GitHub's POST /app/installations/{id}/access_tokens rejects `owner/repo`
in the `repositories` array — the owner is fixed by the installation, so
the field expects bare repo names. We were sending `kfsoftware/foo` and
getting 422 even when the App had access.

Pass `repo` alone to for_repo_read/for_repo_write, update their docs, and
flip the regression test from "uses full name" to "uses bare repo name"
so this can't regress.
Resolves the Dependabot alerts that can be cleared without source-level
refactors:

- openssl 0.10.78 → 0.10.79 (GHSA-xp3w-r5p5-63rr high, GHSA-xv59-967r-8726 medium)
- astral-tokio-tar 0.6.0 → 0.6.1 (GHSA-xx64-wwv2-hcqq low, GHSA-fp55-jw48-c537 medium)
- examples/nextjs/basic next 16.2.3 → 16.2.6 (13 Next.js advisories)
- examples/node/vercel-ai-tracing @opentelemetry/sdk-node ^0.57.0 → ^0.217.0
  (GHSA-q7rr-3cgh-j5r3 high)

Out of scope (require source migrations, tracked separately):
- rmcp 0.6 → 1.4 (major API change in temps-mcp)
- hickory-proto 0.25 → 0.26 (major bump in temps-dns-resolver)
- protobuf 2.28 / remove_dir_all 0.5 (locked by pingora and nixpacks transitives)
GitHub's POST /app/installations/{id}/access_tokens silently strips the
entire `permissions` block if any requested key isn't in the App's
*declared* permission set. `metadata` is granted implicitly to every
installation token but is not a declarable permission, so listing it
explicitly triggers the strip — the minted token comes back with empty
permissions and surfaces as `push:false, pull:false` on every repo, even
when the App has `contents:read+write` granted.

Drop `metadata` from for_repo_read/for_repo_write. Tests now assert it
is absent so this can't regress. Symptom this fixes: `git push` from
workspace sandboxes 403'ing with "Write access to repository not granted"
even on private repos where the App has Read & write on Contents.
Public ingest endpoints (e.g. /api/_temps/session-replay/init) failed at
runtime with "Missing request extension RequestMetadata" because
build_split_application applied middleware only to the admin router. The
auth middleware doubled as the RequestMetadata builder, so the public
router got neither.

Split the responsibilities:
- temps-core::RequestMetadataMiddleware now owns metadata injection
  (Observability priority, apply_to_public=true).
- PluginMiddleware gained apply_to_public; build_split_application
  partitions middleware and runs shared middleware on both routers.
- AuthPlugin registers both middlewares; AuthMiddleware drops its
  inline metadata block.

Regression tests in temps-core lock in the wiring contract:
shared middleware reaches the public router, admin-only middleware
does not.

Also bundles unrelated CI hardening: bounded tokio::time::timeout
wrappers around the MongoDB + Redis docker-tests that hung GitHub run
25806816492 (PR #89) for 90 min.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant