arizuko › components › 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 over proxyd_routes, which is the only source: arizuko generate writes the release's routes into it, operators mutate it at runtime through /v1/proxyd_routes, and proxyd takes a fresh snapshot per request. There is no env var, so there is no window in which a shipped route silently stops applying.
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, nothing to prove the caller is who the headers say, 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, and X-User-Groups. What makes a backend believe those three headers is not the headers themselves — anyone can type them — but the bearer token riding alongside: an authd-minted ES256 token whose subject is service:proxyd. A backend checks that token with auth.ProxydTransit (auth/middleware.go) and trusts the stamped identity only when it holds. Nothing else in the deployment can mint that token, so nothing else can claim to be you.
How it fits
browser / curl / MCP client
|
v any HTTP request
|
proxyd verify JWT or refresh-token cookie
| strip inbound X-User-*, re-stamp them
| attach the service:proxyd bearer
| longest-prefix match on the route table
v
dashd / webd / onbod / davd / vited / channel adapter
| auth.ProxydTransit — bearer good? trust the stamp
v
handler
The route table comes from two places: a static core list in compose/compose.go (dashd, webd, davd, onbod) and the proxyd_routes block of each package's template/services/<name>.yaml manifest, sitting beside the <name>.yml compose fragment. Compose generation collects the survivors after env-gating and arizuko generate writes them into the proxyd_routes table as arizuko-owned rows. That manifest is the same file arizuko packages install applies — one declaration, two readers. Adding a channel adapter means shipping a fragment plus its manifest; 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 store to reach (the route table, sessions, and route-token lookup), an authd to log users in and sign tokens, and at least one row in proxyd_routes — written by arizuko generate or posted to /v1/proxyd_routes.
export DATA_DIR=/srv/data/arizuko_demo
export PROXYD_LISTEN=:8080
export AUTH_SECRET=$(openssl rand -hex 32)
export AUTHD_URL=http://authd:8080
export AUTHD_SERVICE_KEY=$(openssl rand -hex 32)
./proxyd
Leave AUTHD_URL unset and proxyd still proxies, but it stamps X-User-* with no bearer behind it — fine on a laptop, never on a public port, because a backend has nothing to check. There is no shared secret to copy around: backends fetch authd’s public keys and verify offline.
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/proxyd_routes lists them; POST /v1/proxyd_routes creates one; PATCH and DELETE on /v1/proxyd_routes/{path} update or drop a route. The path segment is URL-encoded, so /slack/ becomes %2Fslack%2F.
proxyd_routes, not routes. routes is routd’s table — the one that decides which folder an inbound message lands in. This one decides which backend an inbound HTTP request lands in. Two daemons, two tables, two names; a resource name is its address on the wire, so no two may claim the same one.The same five operations appear on webd’s /mcp bridge as the tools proxyd_routes.list, proxyd_routes.get, proxyd_routes.create, proxyd_routes.update, and proxyd_routes.delete — for an operator pointing an MCP client at the instance. They are not on the agent socket: the ant in the container never sees them. The tool names are not written down anywhere either; they are the resource name plus the action, derived from the same declaration that mounts the REST endpoints, so the two faces cannot drift apart.
Operators get the third face: dashd serves /dash/proxyd/, a page that lists the live table and takes an add or delete form. It calls the same REST endpoints over HTTP rather than writing the table itself, so proxyd stays the only writer and writes the one audit row, naming the operator dashd forwarded.
Authorisation is the operator gate: an ACL row wide enough to cover proxyd_routes.<action> at an empty scope — in practice the ** operator row. The route table is global, with no per-folder axis, so there is no narrower grant to hand out. Mutations persist to the proxyd_routes table and take effect on the next request — no restart, durable across reboots.
Both forwarders — webd for the agent, dashd for the operator — authenticate the caller themselves and then present a service:webd or service:dashd bearer. proxyd honours the stamped X-User-* only for those two subjects. Any other valid token arriving at /v1/proxyd_routes gets a 401, because a token holder who could stamp their own groups could stamp themselves an operator.
What proxyd does not do
proxyd does not run business logic. It does not query routd.db beyond the route table, sessions, and route-token lookup. It does not enforce per-group scope (backends do, once the transit bearer checks out). 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
- reference/env — proxyd — full env var table.
- components/route tokens — the public chat + webhook surfaces gated by proxyd.
- components/dashd — an auth-required backend behind proxyd.
SECURITY.md— trust model, the transit bearer, key rotation.specs/5/7-proxyd-standalone.md— route field semantics.specs/5/17-openapi-mcp.md— how one handler grows a REST face and an MCP face.