arizuko › components › proxyd
proxyd is the auth-gated reverse proxy at the public edge. It terminates every inbound HTTP request, decides whether the caller is authenticated, and forwards the request to a backend daemon with a signed identity header attached.
Routes are matched by longest path prefix from a table loaded out of PROXYD_ROUTES_JSON on first boot and persisted into the proxyd_routes SQLite table. After the first boot the table is authoritative; operators can mutate it at runtime via /v1/routes.
Backends like dashd, webd, and onbod listen on :8080 inside their containers and trust whatever they get over the wire. Without proxyd there is no place to check a JWT, no shared HMAC to prove who the caller is, and no rate shield in front of route-token POSTs.
proxyd is the single trust boundary. It strips every X-User-* header on entry, runs the auth check, then re-stamps X-User-Sub, X-User-Name, X-User-Groups, and an HMAC signature over those fields. Backends verify the signature via auth.RequireSigned (redirect to login on failure) or auth.StripUnsigned (drop the header and continue anonymous). Both helpers live in auth/middleware.go.
browser / curl / MCP client
|
v any HTTP request
|
proxyd verify JWT or refresh-token cookie
| strip inbound X-User-*, re-stamp + HMAC-sign
| longest-prefix match on the route table
v
dashd / webd / onbod / davd / vited / channel adapter
| auth.RequireSigned or auth.StripUnsigned
v
handler
The route table comes from two places: a static core list in compose/compose.go (dashd, webd, davd, onbod) and per-service [[proxyd_route]] blocks in template/services/*.toml. Compose generation collects the survivors after env-gating and writes PROXYD_ROUTES_JSON. Adding a channel adapter means shipping a TOML; no edit to main.go.
Two paths get bespoke treatment in dispatchRoute. /chat/* and /hook/* route by token-segment presence; webd hashes the token against route_tokens, anonymous callers run through a per-token in-memory bucket sized by JID prefix. /dav/* requires auth, scopes the path to a group the caller belongs to, and blocks writes to .env, *.pem, .git, and anything under <group>/logs/.
proxyd needs three things to run on its own: a SQLite store (for the route table, sessions, and route-token lookup), an HMAC secret shared with every backend, and at least one route in PROXYD_ROUTES_JSON.
export DATA_DIR=/srv/data/arizuko_demo
export PROXYD_LISTEN=:8080
export AUTH_SECRET=$(openssl rand -hex 32)
export PROXYD_HMAC_SECRET=$(openssl rand -hex 32)
export PROXYD_ROUTES_JSON='[{"path":"/api/","backend":"http://api:8080","auth":"user"}]'
./proxyd
Without PROXYD_HMAC_SECRET, proxyd generates an ephemeral one at boot and logs a warning — backends sharing the same env value will reject every signature. Set it explicitly and copy it to every backend.
TRUSTED_PROXIES is a comma-separated CIDR list. Only peers inside one of those ranges get their X-Forwarded-For honoured; everyone else has it replaced with the TCP peer. Empty means trust nothing.
Health: GET /health returns {"ok":true}. Auth failures show up as 401 with a Set-Cookie: auth_return=<path> and a 303 to /auth/login; route-token rate trips return 429.
Routes are mutable at runtime through a small REST surface. GET /v1/routes lists them; POST /v1/routes creates one; PATCH and DELETE on /v1/routes/{path} update or drop a route. The path segment is URL-encoded, so /slack/ becomes %2Fslack%2F.
The same operations are exposed as MCP tools (routes.list, routes.get, routes.create, routes.update, routes.delete) on webd’s /mcp bridge. One handler in proxyd/resource.go serves both faces — agent and operator see the same shape.
Authorisation is the operator gate: role:operator in the unified ACL, which surfaces as ** in the JWT groups claim. Mutations persist to the proxyd_routes table and take effect on the next request — no restart, durable across reboots.
Backends pick one of two helpers from auth/middleware.go depending on how strict the surface is:
RequireSigned(secret) — strict. A missing or wrong signature means the identity headers are stripped and the response is a 303 to /auth/login. Use on private surfaces (dashd, davd, the channel adapter inboxes).StripUnsigned(secret) — lenient. A wrong signature is logged and the headers are scrubbed, but the handler still runs with no identity. Use where anonymous access is fine but spoofed identity is not.Both paths log auth: user sig verify failed with attempted_sub and remote so spoofing attempts show up in journalctl.
proxyd does not run business logic. It does not query messages.db beyond the route table, sessions, and route-token lookup. It does not enforce per-group scope (backends do, after verifying the signed header). It does not terminate TLS in production deployments — that belongs to a front like Caddy or nginx, which forwards to proxyd over loopback or a trusted network.
SECURITY.md — trust model, signed headers, secret rotation.specs/5/35-proxyd-standalone.md — TOML route field semantics.specs/5/5-uniform-mcp-rest.md — the /v1/routes runtime mutation surface.