arizuko

arizuko › components › proxyd

proxyd

proxyd is the one trust boundary at the public edge: it checks who the caller is and forwards the request with a signed identity header.

proxyd is the front door that checks every request

Every request from outside reaches proxyd first. proxyd checks who you are, and only then passes the request to the correct service inside, with a signed note that says who you are.

proxyd is the reverse proxy with auth at the public edge. It receives every inbound HTTP request, decides whether the caller is authenticated, and forwards the request to a backend daemon with a signed identity header.

proxyd matches routes by the longest path prefix over proxyd_routes, the only source of routes. arizuko generate writes the routes of the release into it, and operators change it at runtime through /v1/proxyd_routes. proxyd reads a new snapshot for each request. No environment variable holds routes, so a shipped route never stops applying without a sign.

proxyd is the one trust boundary

Backends such as dashd, webd and onbod listen on :8080 inside their containers and trust what they receive. Without proxyd, nothing checks a JWT, nothing proves that the caller is who the headers say, and nothing limits the rate of route-token posts.

proxyd removes every X-User-* header on entry, runs the auth check, and then sets X-User-Sub, X-User-Name and X-User-Groups again. Anyone can type those headers, so a backend does not trust the headers alone. It trusts the bearer token that comes with them: an ES256 token from authd whose subject is service:proxyd. A backend checks that token with auth.ProxydTransit in auth/middleware.go and trusts the identity only when the check passes. No other part of the deployment can get that token, so no other part can claim to be you.

A request passes four stages in proxyd

The figure shows every request from top to bottom, in the order that server.route in proxyd/main.go runs it. The paths that proxyd answers itself come first. Every other request loses the identity headers that the client typed. It then meets the fixed paths, then the proxyd_routes rows, and then a redirect into the public tree.

request
  │
  ▼
┌──────────────────┐   /auth/forward    forward-auth verdict
│ proxyd's own mux ├──►/auth/*          proxied to authd
│                  │   /v1/proxyd_routes, /openapi.json
│                  │   /dash/proxyd/    requireAuth, operator gate
└────────┬─────────┘
         │ every other path: strip the client's X-User-*,
         │ X-Chat-*, X-Folder; keep X-Forwarded-For only
         ▼ from a TRUSTED_PROXIES peer
┌──────────────────┐   /health          {"ok":true}
│ hard-wired paths ├──►/                mapped host? 302 /pub/W/
│                  │                    else 302 /pub/
│                  │   /pub/*           web_routes longest prefix:
│                  │                    redirect, deny, auth or vited
│                  │   /priv/*          requireAuth, MatchSlot, vited
└────────┬─────────┘
         ▼ one snapshot of proxyd_routes per request
┌──────────────────┐   /dav/*           requireAuth, davAllow, davd
│ MatchRoute       ├──►"/x/" prefix, "/x" exact, longest wins
│                  │   redirect_to      302
│                  │   auth=public      forward as sent
│                  │   auth=optional    stamp if a bearer verifies,
│                  │                    transit bearer either way
│                  │   auth=user        requireAuth, then forward
│                  │   auth=operator    as user; proxyd adds no check
│                  │                    of its own
└────────┬─────────┘
         ▼ no row matched
┌──────────────────┐   mapped host      302 /pub/W/<path>
│ final catch-all  ├──►any other host   302 /pub<path>
└──────────────────┘

requireAuth
  AUTH_SECRET unset ──► 404
  Bearer JWT, ES256 against authd's JWKS ──┐
  refresh_token cookie, authd /v1/refresh ─┤
                                           ▼
                  Authorization = service:proxyd bearer
                  X-User-Sub, X-User-Name
                  X-User-Groups = acl scopes, read per request
  neither ──► auth_return cookie, 303 /auth/login

The fixed paths stay in Go because a row cannot express them. /pub/* checks for an external redirect and passes websocket upgrades. /priv/* checks your scopes against the folder that owns the web folder. / sends the host of a world to its web folder. Everything else is a row. proxyd reads the table once per request and uses that one read for the match and for the backend. So a concurrent edit cannot pair the auth of one row with the backend of another row.

The final catch-all handles the host of a world: krons.example.com/guide answers with a 302 to /pub/krons/guide. Every reserved prefix is handled above it, so the redirect cannot hide /dash or /chat on any host. It cannot loop either, because /pub/ never redirects again.

Rows come from the core list in compose/compose.go and each service package's proxyd_routes declaration. For a symlinked service fragment, generate reads template/services/<name>/arizuko.yml from the catalog. Otherwise, it reads packages/<name>/arizuko.yml in the instance. generate skips a route write when its gated_by variable is off, but does not delete an existing row. sync applies package routes without evaluating the field. proxyd does not evaluate it at request time.

/chat/* and /hook/* are plain rows with auth: optional. proxyd stamps your identity if your bearer is valid and forwards you anonymously if not. In both cases it attaches its own service:proxyd bearer. webd resolves the token and applies the rate limits. /dav/* requires auth, limits the path to a group you belong to, and blocks writes to .env, *.pem, .git and everything under <group>/logs/.

Three web daemons follow one rule

The web side is three unrelated programs. proxyd is the Go part. davd is dufs plus wget for its healthcheck, and vited serves files. Neither carries arizuko Go code, and you could replace either. You cannot replace the rule that all three follow. Every web URL is scoped by the same folder and the same acl rows as the agent. proxyd exists to enforce that rule.

proxyd runs alone with a store, an authd and one route

proxyd needs three things to run alone. It needs a store with the route table, web_routes and acl. It needs an authd to log users in and sign tokens. It needs at least one row in proxyd_routes, which arizuko generate writes or a post to /v1/proxyd_routes adds.

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

Without AUTHD_URL, proxyd authenticates nobody. Public routes can still proxy; authenticated surfaces are unavailable. No shared secret exists to copy: backends fetch the public keys of authd and verify offline.

TRUSTED_PROXIES is a comma-separated list of CIDRs. proxyd accepts X-Forwarded-For only from peers in one of those ranges, and replaces it with the TCP peer for every other caller. An empty list trusts nothing.

GET /health returns {"ok":true}. A request with no valid credential gets a 303 to /auth/login and a Set-Cookie: auth_return=<path>, so the login returns you to the same page. webd applies the route-token rate limit and answers 429 when a caller exceeds it.

Operators change routes at runtime

A small set of REST endpoints changes the routes at runtime. GET /v1/proxyd_routes lists them, and POST /v1/proxyd_routes creates one. PATCH and DELETE on /v1/proxyd_routes/{path} change or remove a route. The path segment is URL-encoded, so /slack/ becomes %2Fslack%2F.

The table is proxyd_routes, not routes. routes is the table of routd that decides which folder an inbound message goes to. proxyd_routes decides which backend an inbound HTTP request goes to. Two daemons have two tables with two names. A resource name is its address on the wire, so no two resources can use the same name.

The same five operations exist as tools on the /mcp bridge of webd, for an operator with an MCP client. The tools are proxyd_routes.list, proxyd_routes.get, proxyd_routes.create, proxyd_routes.update and proxyd_routes.delete. They are not on the agent socket, so the ant in the guest never sees them. No file lists the tool names. Each name is 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 a third face. proxyd serves /dash/proxyd/ itself: a page that lists the live table, longest prefix first, with forms to add or delete a row. The forms call the same store writers as the REST handler, so the table still has one writer. A path on the own mux of proxyd never reaches the routing code that authenticates you, so the page carries its own requireAuth.

Authorization is the operator gate: an ACL row that covers proxyd_routes.<action> at an empty scope, which in practice is the ** operator row. The route table is global, with no folder axis, so no narrower grant exists. Changes persist to the proxyd_routes table and apply on the next request, with no restart and no reload button. The table has no enabled column: to turn a route off, delete it.

Two forwarders exist: webd for MCP clients and dashd for the operator. Each authenticates the caller itself and then presents a service:webd or service:dashd bearer. proxyd accepts the stamped X-User-* only for those two subjects. Any other valid token at /v1/proxyd_routes gets a 401, because a token holder who could stamp their own groups could make themselves an operator.

proxyd runs no business logic

proxyd runs no business logic. It reads three tables of routd per request, proxyd_routes, web_routes and acl, and nothing else. Route tokens belong to webd. proxyd enforces group scope only on /priv and /dav, and backends enforce it elsewhere after the transit bearer passes. proxyd does not terminate TLS in production. A front server such as Caddy or nginx does that and forwards to proxyd over loopback or a trusted network.

Six pages cover the details