ADR-0019 — anvil-serving owns the tailnet edge¶
- Status: Accepted
- Date: 2026-07-13
- Relates to: ADR-0014,
ADR-0004,
the single tailnet DNS endpoint runbook (T014/F008),
anvil_serving/edge.py,anvil_serving/router/front_door.py
Context¶
Implementation correction, 2026-09-05: Tailscale strips a matched mount
before proxying. The original path-preservation explanation below was incorrect;
the /v1 target requires an explicit /v1 suffix. The decision to preserve the
router API remains unchanged. See the
correction ticket.
T014 (the single tailnet DNS endpoint runbook, shipped in PR #244) established the goal of one front door, one name, one token: every serving surface — chat, embeddings, rerank, the routed OCR/vision presets — is published from the router's single stdlib front door, reached through the host's one MagicDNS name. That runbook was explicitly document-only for two pieces it identified but did not build:
- TLS / a single HTTPS listener via
tailscale serve, so a caller that requireshttps://reaches the same name. - ComfyUI under the same name — its web UI serves on
127.0.0.1:8188and its live queue needs WebSockets. A stdlib reverse proxy in Python would have to re-implement the WS upgrade/pump; Tailscale Serve proxies WebSockets natively.
Several hard constraints bound the option space:
- The
/v1OpenAI/Anthropic contract is the product. The router (anvil_serving/router/front_door.py) parses Anthropic Messages and OpenAI Chat Completions, streams SSE, and enforces the bearer token. Nothing may rewrite or reshape a/v1request or response — preserving OpenAI/Anthropic compatibility is non-negotiable. - The router is stdlib-only by design (
http.server+urllib; no FastAPI, no aiohttp — see the gotchas inCLAUDE.mdand ADR-0004). Adding a WS-capable reverse proxy in Python would either pull in a dependency or hand-roll a WS pump in the inference gateway. - The operator may already own tailnet edge state. A node can already have its own
tailscale servemappings (e.g. a dashboard parked at/). Anything anvil-serving does to the edge must be additive and must never clobber an operator-set mapping.
We need something to own the tailnet edge: bind the one MagicDNS name and path-route it to the right local service, without touching the router's request path or its dialects.
Considered options¶
-
Add a reverse proxy inside anvil-router. Rejected. It fights the stdlib/no-aiohttp constraint, mixes an L7 edge concern into a pure inference gateway, and puts hand-rolled WebSocket proxying (for ComfyUI) directly in the process that must never risk the
/v1contract. Every line of proxy code added next to the front door is a line that could regress OpenAI/Anthropic compatibility. -
Leave it document-only (status quo after T014). Rejected. The runbook already proved the shape; the operator wants a managed, idempotent, dry-runnable verb rather than copy-pasted
tailscale servecommands that are easy to get subtly wrong (and easy to clobber an existing mapping with). -
A new anvil-serving verb group that renders/applies a
tailscale serveconfig. Chosen. anvil-serving owns the edge as a thin, stdlib-only manager over the Tailscale CLI; Tailscale does the WS-capable proxying. The router is untouched.
Decision¶
anvil-serving owns the Tailscale tailnet edge through a new edge verb group
(anvil-serving edge {render,status,up,down}) implemented in anvil_serving/edge.py. It
manages a tailscale serve configuration that binds the host's single MagicDNS name and
path-routes it:
| Mount | Target | Surface |
|---|---|---|
/v1 |
http://127.0.0.1:8000/v1 |
The existing router's OpenAI/Anthropic inference surface. |
/comfyui |
127.0.0.1:8188 |
ComfyUI, whose live queue needs WebSockets (handled natively by Tailscale Serve). |
Additional path -> local port mappings (future dashboards, the anvil dashboard on :8766,
…) are config-driven, not hardcoded — via [edge.routes] in a TOML file or repeatable
--map MOUNT=TARGET overrides.
Design commitments:
- The edge is a pure L7 path-router in front of the unchanged router. Tailscale
removes the public mount and joins the remainder to the target path. The
/v1target retains/v1, so/v1/modelsreaches the router as/v1/models. The direct router binding (e.g.100.64.0.10:8000) is left as-is. - Stdlib-only, no new runtime deps.
edge.pyrenders and appliestailscale serveinvocations viasubprocessand parsestailscale serve status --json; it adds no proxy server in Python. This keeps the router a pure inference gateway and puts the WS-capable proxying in Tailscale — the whole reason Serve is used instead of a stdlib WS proxy. - The MagicDNS name is read from Tailscale, never hardcoded (
.Self.DNSName, trailing dot stripped). --dry-run/renderprint the exacttailscale servecommands without applying them.upis additive and idempotent; it sets only the mounts this tool manages.downremoves ONLY the mappings this tool manages — and only when a managed mount is currently present and points at the configured target. It issues per-pathtailscale serve … offinvocations and nevertailscale serve reset, so an operator-set mapping (e.g. a dashboard at/) is never clobbered.
Consequences¶
- A new
edgeverb group ships under "Control plane & integrations", backed byanvil_serving/edge.py, with render/apply/status/config-parse tests. The T014 runbook is updated to point at the verb, superseding its document-only note for thetailscale serve+ ComfyUI-path sections. - The router keeps its single responsibility.
front_door.pyis unchanged; the/v1OpenAI/Anthropic contract is preserved by the explicit upstream target path. - ComfyUI's base-path caveat from the runbook still stands: ComfyUI serves absolute asset
paths, so the
/comfyuiprefix may need a matching ComfyUI base-path before its UI loads end-to-end. A clean502/connection-refused when ComfyUI is down is expected passthrough, not an edge failure. - Tailscale ACLs remain the outer boundary and the router's bearer token the inner one
(ADR-0004); the edge changes neither. Tailscale terminates TLS but forwards the
Authorizationheader untouched. - Because the edge and any operator-owned
tailscale servemappings share one node-level serve config,up/downare deliberately scoped to managed mounts only. Applying the edge on a node with an active operator session is safe only as a purely additive change; when that cannot be guaranteed, stop atrender/--dry-run.