arizuko

arizukocomponents › proxyd

proxyd

What it is

In plain terms, proxyd is the front door. Every request from the outside hits it first, it checks who you are, and only then passes you through to the right service inside — with a tamper-proof note stapled on that says who you are.

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.

Why it exists

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.

How it fits

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 skip the plain prefix match and get their own handling in dispatchRoute. /chat/* and /hook/* route on the token segment: webd hashes the token against route_tokens, and anonymous callers run through a per-token in-memory rate bucket keyed 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/.

Standalone usage

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.

Runtime route mutation

Routes are mutable at runtime through a small set of REST endpoints. 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.

The two verify modes

Backends pick one of two helpers from auth/middleware.go depending on how strict the surface is:

Both paths log auth: user sig verify failed with attempted_sub and remote so spoofing attempts show up in journalctl.

What proxyd does not do

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.

Go deeper