Documentation
Note
wayhouse is a work in progress. These documents describe the design the code implements, but details can still change. See the notice in the top-level README.
Design documents (00–12)
The numbered chapters are the source of truth for the design: when the code and a chapter disagree, the chapter wins and the code (or the chapter) gets fixed.
Start here: 00 Overview for the problem and the vocabulary, then 02 Architecture for how the pieces fit together.
| Read this | When you want to know |
|---|---|
| 00 Overview | What problem this solves, goals, non-goals, glossary |
| 01 Requirements | Functional and non-functional requirements (the NFR latency budgets live here) |
| 02 Architecture | Data plane vs. control plane, components, data flows |
| 03 Routing | Route rules, matchers, balancers, sniffers, external resolvers |
| 04 Transport and client IP | TCP/UDP handling, PROXY protocol, TPROXY transparent mode |
| 05 Configuration | The full config schema with examples |
| 06 Operations and observability | Metrics, logs, health checks, draining, admin and fleet endpoints, HA operations |
| 07 Security and DDoS | Filters, rate limits, amplification guard, sniffer sandbox |
| 08 Roadmap | Phases 0–14 with their status, and what is left |
| 09 Technology choices | Libraries, alternatives and the ADR table of locked decisions |
| 10 Distributed control plane | wayhouse-controller, wayhouse-aggregator, wayhouse-ui, Raft HA, canary rollout, regional health |
| 11 Backend transport | WireGuard transport and wayhouse-agent for origins on other networks |
| 12 Deployment | Container images, many-port proxies under Docker and Kubernetes |
| ACME certificates | Controller-issued certificates (DNS-01), sites, renewal, metrics |
Feature specs and plans
superpowers/ holds the design spec and the step-by-step
implementation plan written before each larger change since the roadmap phases were
finished. They are kept as a record of why things were built the way they were; see
superpowers/README.md.
Elsewhere in the repository
../deploy/README.md: reference images, Compose demo and Kubernetes manifests.../crates/wayhouse-sniffer-abi/README.md: the WASM sniffer ABI (guest side); the official sniffers are inwayhouse-proxy/sniffers.upgrading.md: upgrading a fleet one component at a time: compatibility window, version table, order of operations, rollback.web-proxy.md: publishing HTTP(S) sites through the packaged Caddy (wayhouse-web-sync, thewayhouse-caddyimage, compose and Kubernetes recipes).sniffers.md: sniffer manifest, registry index and verification formats.../crates/wayhouse-bench/README.md: the latency / load harness.../crates/wayhouse-config/fuzz/README.md: fuzz targets.../HANDOVER.md: current state, known follow-ups and gotchas.
00 – Overview
Problem
Exposing game servers directly to the internet creates recurring problems:
- The real server IP is public and therefore directly exposed to DDoS.
- Many server instances (matches, lobbies, regions) require many ports/IPs and some external component that steers clients to the right instance.
- Restarts/deployments disconnect active players.
- Operations (metrics, connection logs, block lists) have to be rebuilt per game.
Existing solutions like BungeeCord/Velocity (Minecraft) or game-specific master servers each solve this for one game only, and often for one transport protocol only.
Goal
A reverse proxy for game servers that works protocol-agnostically at layer 4 (TCP + UDP) and optionally derives routing decisions from the first handshake packet via pluggable L7 sniffers. The core knows no game; game knowledge lives exclusively in optional sniffers or in external routing logic.
Use cases
- IP protection / edge termination – clients connect only to the proxy; backends sit in a private network. The proxy is the single DDoS-exposed point.
- Fan-out to many instances – one public endpoint
play.example.com:7777distributes to hundreds of match containers. - Region/hostname routing –
eu.example.comandus.example.comon the same port, selected by SNI (TLS) or by the first packet. - Matchmaker integration – on connect, the proxy asks an external service (gRPC/HTTP): “Where does this player / this token belong?”
- Zero-downtime deployments – a new backend version is registered, the old one is set to “draining”; existing sessions drain, new ones go to the new version.
- Blue/green & canary for game server builds.
- Central operational view – uniform metrics (connections, throughput, added RTT, errors) and connection logs across all games.
- Access control – IP allow/deny lists, geo filters, rate limiting, per-backend connection caps.
- Fleet management (v2) – one control plane + web UI to view and configure many proxy instances across regions from a single authoritative place, with revision history and persisted operator intent (see 10-distributed-control-plane.md).
Non-goals
- No game logic, no matchmaking, no anti-cheat, no lobby system.
- No parsing/rewriting of game packets in the core (only optional sniffers may read the first N bytes — never modify the running stream).
- Not a NAT replacement for arbitrary client-to-client traffic (no TURN/relay mesh).
- Does not force transport encryption where the game does not use it (optional TLS/DTLS wrapping is an add-on feature, not a requirement).
- Not a replacement for an L3 scrubbing provider against volumetric attacks — the proxy complements it, it does not replace it.
- No cross-instance session state. Instances are independent on the data path; HA is an anycast / L4-LB concern. The v2 control plane (10) shares config and health, never sessions — a client rehashed to another instance reconnects.
Guiding principles
- Latency first. Every extra hop costs RTT. The data path must be allocation-light, copy-light, and free of unnecessary serialization.
- Agnostic by default, sniffer as the exception. Pure L4 forwarding must work without any sniffer.
- Separate control plane from data plane. Config/backend changes must not block the data path.
- Operations is a feature. Health checks, draining, metrics, reload belong in the first version, not in “later”.
Glossary
| Term | Meaning |
|---|---|
| Listener | Public socket (IP:port + protocol) where clients arrive. |
| Route | Rule that maps incoming connections to a backend pool. |
| Backend / target | A concrete game server instance (IP:port). |
| Pool / upstream | A set of equivalent backends with a selection strategy. |
| Session | For UDP: a logical connection, identified by the 4-tuple. |
| Sniffer (L7 module) | Optional parser that extracts a routing hint from the first bytes (e.g. SNI, Minecraft handshake hostname). |
| Plugin | An integration with another system (e.g. the Pelican panel, #213). Not the same as a sniffer; plugins will live in wayhouse-proxy/plugins. Design pending. |
| Draining | Backend accepts no new sessions; existing ones drain. |
| PROXY protocol | Header (v1/v2) prepended to the connection carrying the real client address to the backend. |
| TPROXY | Linux kernel feature for transparent proxying that preserves the source IP. |
01 – Requirements
Functional requirements
F1 – Listeners
- F1.1 Multiple listeners at the same time (different IP:port/protocol).
- F1.2 Transport: TCP and UDP; QUIC/DTLS as “opaque UDP” with no special handling.
- F1.3 Dual stack (IPv4 + IPv6).
- F1.4 Optional port-range listener (one listener for
30000–30999) for games with dynamic ports and for subdomain-per-port routing (scheme B). - F1.5 Optional prefix listener: bind to a routed IP prefix with one socket,
destination address per packet/connection via
IP_PKTINFO/getsockname. Basis fordstrouting for games without a protocol hint (scheme A).
F2 – Routing
- F2.1 Static listener → pool mapping.
- F2.2 Routing by SNI (on a TLS handshake) without terminating TLS.
- F2.3 Routing by first-packet match (byte prefix, regex over the first N bytes).
- F2.4 Routing via an external resolver (gRPC/HTTP callback) with cache & timeout fallback.
- F2.5 Routing by client IP / geo (CIDR lists).
- F2.6 Routing by destination IP/prefix (
dst) and destination port — for games that send raw data to a fixedIP:portwith no hostname/SNI/token. The subdomain is mapped by DNS to its own destination IP (scheme A) or its own port (scheme B). - F2.7 Push resolver: short-lived
src_ip → poolmapping set in advance by a launcher/API (scheme C). - F2.8 Fallback/default route when no rule matches.
- F2.9 Deterministic rule priority (first matching rule wins).
F3 – Upstream / pool
- F3.1 Selection strategies: round-robin, least-connections, consistent hashing (on client IP or routing key), weighted, “first available”.
- F3.2 Session affinity: same 4-tuple / same routing key → same backend, as long as that backend is healthy.
- F3.3 Health checks: active (TCP connect, UDP probe, HTTP GET on a sidecar port) and passive (error rate/timeouts observed in the data path).
- F3.4 Backend states:
healthy,unhealthy,draining,disabled. - F3.5 Max sessions / max new sessions per second per backend.
F4 – Session management
- F4.1 UDP session table keyed by 4-tuple with a configurable idle timeout.
- F4.2 TCP: normal connection lifetime, half-close propagation.
- F4.3 Graceful draining: redirect new sessions, end old ones after a grace period.
- F4.4 Caps: global and per-listener connection limits.
F5 – Client-IP preservation (details in 04)
- F5.1 PROXY protocol v1/v2 for TCP, optional per pool.
- F5.2 PROXY protocol v2 for UDP (header in the first datagram) as an option.
- F5.3 Transparent mode (TPROXY,
IP_TRANSPARENT) on Linux. - F5.4 Without any of that: only the proxy IP is visible (documented behavior).
F6 – Configuration & control plane
- F6.1 Declarative file (YAML) as the source of truth.
- F6.2 Hot reload without dropping connections (SIGHUP / file watch / API).
- F6.3 Admin API (HTTP/gRPC) for: register/deregister backend, set state, read config snapshot, trigger draining.
- F6.4 Optional dynamic backend discovery adapter (DNS SRV, Consul, Kubernetes EndpointSlices) behind a stable internal interface.
- F6.5 Validation on load; an invalid config is rejected and the old one stays active.
F7 – Observability (details in 06)
- F7.1 Prometheus metrics.
- F7.2 Structured connection logs (JSON), sampleable.
- F7.3 Optional OpenTelemetry tracing for the connection-setup phase.
- F7.4 Health/readiness endpoints for the proxy itself.
F8 – Security (details in 07)
- F8.1 IP allow/deny lists (CIDR), per listener.
- F8.2 Rate limiting: new connections/datagrams per source IP and per subnet.
- F8.3 SYN-flood protection (SYN cookies via the kernel), UDP amplification protection (respond-only-after-first-valid-packet heuristic, optional sniffer).
- F8.4 Resource caps against memory/FD exhaustion.
Non-functional requirements
| # | Requirement | Target (first version) |
|---|---|---|
| N1 | Added latency (p50) from the proxy within the same DC | < 0.5 ms |
| N2 | Added latency (p99) | < 2 ms |
| N3 | Throughput per instance (commodity 16-core) | ≥ 20 Gbit/s or ≥ 2 Mpps forwarded |
| N4 | Concurrent TCP connections per instance | ≥ 500,000 |
| N5 | Concurrent UDP sessions per instance | ≥ 1,000,000 |
| N6 | Config reload without packet loss for existing sessions | yes |
| N7 | Startup to “ready” | < 2 s |
| N8 | Memory per idle session | < 1 KB (UDP), < 4 KB (TCP incl. buffers) |
| N9 | Availability in an HA cluster | loss of one instance without a global drop |
Also: horizontally scalable (stateless w.r.t. persistent storage, session state local only), Linux as the primary platform, container-friendly, runs without root after setup (capabilities instead of root).
Assumptions
- Backends are reachable over a trusted internal network. Superseded by phase 14 (backend transport, built): see 11-backend-transport.md for how a proxy reaches a backend that isn’t on its local network (a WireGuard tunnel per origin). A same-network deployment needs none of that and this assumption still holds for it.
- The client reaches the proxy via DNS; the proxy does not need to cryptographically verify client identity (the game does that if needed).
- Volumetric L3/4 attacks are absorbed upstream (scrubbing/anycast).
Open questions
- Must the proxy serve multiple backends at once per client (e.g. TCP control + UDP gameplay on separate instances)? → affects the session model.
- Do we need cross-instance session sharing (failover of a live UDP session to a different proxy instance)? First version: no.
- Is QUIC-aware routing (connection ID) required, or is opaque UDP enough?
02 – Architecture
Overview
One proxy instance in context. Traffic enters on the left and leaves on the right; sniffers run inside the proxy on the data path, while plugins run in the controller, off it.
Blue boxes are the data path (ingress left, egress right); amber boxes are everything off it. Dotted lines are control, config or state flows.
The immutable snapshot is not tied to one component: it carries the listener definitions with their filters and routes, the pools with their backends and health, and the resolver and discovery settings, so the diagram points it at each of them.
Data plane / control plane separation
- Data plane: accepts connections, makes the routing decision against an
immutable config snapshot, pumps bytes. No lock on the hot path; snapshot swap
via an atomic pointer swap (
arc-swappattern). - Control plane: builds new snapshots (from file + discovery + admin API), runs health checks, exports metrics/logs. Runs in its own tasks/threads, may block.
A snapshot contains: listener definitions, the compiled routing table (including precompiled matchers), pools with their current backend list and health state. Health updates produce a new snapshot (cheap copy-on-write of the affected pool struct, the rest is shared).
Components
1. Listener
- Binds socket(s). TCP:
acceptloop orSO_REUSEPORTsharding across workers. - UDP: one/more
recvmmsgloops per socket,SO_REUSEPORTfor scaling. - Applies early filters: IP allow/deny, connection rate limit (cheap, before any allocation).
- Hands off to the router: for TCP after an optional peek of the first bytes; for UDP with the first datagram.
2. Router
- Selects the first matching route (deterministic priority).
- Matcher types:
always,sni,first-bytes(prefix/regex/length),client-cidr,dst(destination IP/prefix),port(destination port),external. - Determine destination address: with a prefix bind (one socket for a whole
prefix), via
IP_PKTINFO/IPV6_RECVPKTINFO(UDP, per datagram) orgetsockname()(TCP). This is the lever for games with no protocol hint: subdomain → its own destination IP. - First-packet peek (TCP):
MSG_PEEKup topeek_max_bytesorpeek_timeout. If that is not enough (the client sends nothing first), the default route applies. - External resolver: calls gRPC/HTTP with
{listener, src_ip, sni, first_bytes(b64), routing_key}, expects{pool | target, ttl}. The result is cached (key configurable). Timeout → fallback route. - Result: a target pool or a fixed target.
3. Upstream / pool
- Holds the backend list + states from the snapshot.
- The LB strategy picks a healthy backend; a
consistent_hashpool picks by client IP (or IP and port), stateless. - Checks per-backend caps (max sessions, new-session rate). No candidate → defined error handling (TCP: RST/close; UDP: drop datagram + metric).
4. Connection / session pump
- TCP: bidirectional copy. Linux fast path via
splice()between the two sockets (zero-copy), fallback to user-space buffers. Propagate half-close, timeouts (idle, connect), pass throughTCP_NODELAY. - UDP: one connected upstream socket per session (
connect(2)) so replies find their way back without a table lookup. Session table:HashMap<4-tuple, Session>+ an idle-timer timing wheel. The idle timeout closes the session. - PROXY protocol: when the option is enabled, write the header before the first payload to the backend (TCP) or prepend it (UDP, first datagram).
5. Health checker (control plane)
- Active checks per backend by interval/timeout/thresholds (rise/fall).
- Passive signals from the data path (connect errors, early resets, UDP “port unreachable” / ICMP) feed in as events.
- Publishes a new snapshot on a state change.
6. Config loader & discovery adapter
- Loads/validates YAML, builds the snapshot, compiles matchers.
- Adapters (DNS SRV, Consul, K8s EndpointSlices, static) implement a common
BackendSourceinterface and provide backend lists per pool. - Reload: new snapshot becomes active atomically; listeners are reconciled by name (added spawned, removed stopped, changed re-spawned) — an unchanged listener keeps running.
7. Admin API
GET /config(active snapshot, plaintext — implemented, phase 5),GET /pools,GET /sessions[?listener=&pool=&proto=&src=](live connection / session list — implemented)POST /admin/drain/POST /admin/undrain(flipreadyzfor the LB — implemented, phase 5)PATCH /pools/{p}/backends/{addr} {state: enabled|draining|disabled},POST /pools/{p}/backends {addr},DELETE /pools/{p}/backends/{addr}— all implemented (phase 5). Add/remove edits live in a runtime overlay layered on the file config, so they survive a file reload.POST /route-hint– push resolver:{src_ip, pool|target, ttl_sec}for games with no protocol hint, set by launcher/matchmaker (scheme C in 03)POST /reload,GET /healthz,GET /readyz,GET /metrics- Auth: mTLS or bearer token, bound to an internal interface only.
Threading / runtime model
- One worker per CPU core,
SO_REUSEPORTsockets per worker → no accept contention. - Each session is pinned to its accepting worker (thread-local session table → no locks in the UDP path).
- Control-plane tasks (health checks, reload, discovery refresh, admin API) run
as ordinary tasks on the same shared multi-thread
tokioruntime as the data plane, not a separate pool — simpler than the originally-planned split, and safe because control-plane work never blocks (rule: it may be slow, but never blocking-syscall slow) and never touches the hot path’s locks. - One dedicated OS thread (not a tokio task) for the sniffer engine’s
epoch ticker — a
wasmtime::Engine::increment_epochheartbeat that arms the per-call timeout for WASM sniffers. Spawned once with theSnifferLoader, runs for the process lifetime, does no I/O. Absent when nosettings.sniffersis configured, and absent entirely from a build without thewasm-snifferscargo feature (which refuses asettings.sniffersblock at startup). - Memory: per-worker pre-reserved buffer pools; session structs from a slab allocator to avoid fragmentation.
Failure & error behavior
- Backend fails during a session: TCP → close both sides, metric; UDP → the session stays briefly (configurable), then dropped. Optionally “rehome” new datagrams to another backend if no affinity is required.
- Proxy instance fails: clients reconnect; an L4 LB / anycast in front of the proxy spreads them onto the remaining instances. No shared session state (v1, and no shared session state in v2 either — see below).
- Overload: early rate limits +
acceptthrottling + load shedding with a metric, before the latency of existing sessions suffers.
Extension points
- Sniffer API:
fn sniff(&[u8]) -> Option<RouteHint>(SNI, Minecraft, Steam A2S, FiveM …). SNI is built in; the others are sandboxed WASM modules loaded fromsettings.sniffers.dir(see sniffers.md). They run on the data path and only see the bytes they are handed. - Plugins are a separate mechanism: WASM modules hosted by the controller, off the data path, reacting to timers, webhooks and events through capabilities the operator approved (see plugins.md). They can publish routes, which proxies merge after their own.
- BackendSource adapters (see above).
- Filter chain before routing (ACL, rate limit, geo) as an ordered, configurable list.
Multi-instance & the distributed control plane (v2)
The diagram above is one instance. Production runs many identical, independent instances across hosts / AZs / regions, fronted by anycast or an L4 LB; they share nothing on the data path (ADR 4).
10-distributed-control-plane.md plans the v2 additions that give a fleet operator (and a management GUI) one authoritative config, persisted operator intent, and multi-vantage health — without touching the hot path. In brief:
- Tier 1 — a global, ordered, durable store for structural config and
operator intent (the phase-5 overlay, admin state, route hints). Instances
pull revisions and feed them through the same
validate() → Snapshot::build → ArcSwap::storepath a file reload uses. Written by a new optionalwayhouse-controller, which also aggregates fleet reads and hosts the GUI + auth. - Tier 2 — a per-failure-domain gossip fabric for observed backend reachability. Advisory and rebuildable: each instance’s own checks still decide, with the domain quorum as weighted input.
- Unchanged: the data plane, the immutable-snapshot invariant, and “no shared session state”. Rate-limit buckets and UDP session tables stay purely instance-local.
Maps onto the predicate the data plane already computes:
takes_new_sessions() == is_healthy() && admin_state() == Enabled — Tier 2 feeds
the left half, Tier 1 the right.
03 – Routing
Goal: map an incoming connection/session to a pool (and optionally an affinity key) — ideally without game-protocol knowledge, with optional sniffers where needed.
Implementation status (phase 3, partial).
listeners[].routesis a priority-ordered[{ match, action }]list, first match wins, withaction: { pool: <name> }. Implemented matchers:always,client_cidr(source IP),dst(destination IP the client connected to, viagetsockname/ the bind address, against acidrslist — see the caveat below),port(destination port from the accepting socket),first-bytes(aprefix—"hex:..." | "ascii:...", ≤ 512 B — and/or alength: { min, max }byte-count window; on TCPlengthsees only what one peek returned, on UDP the exact datagram length), andsni(hostpatterns: exact,*.suffix,.suffix— matched againstserver_namefrom the peeked, non-terminated TLS ClientHello; TCP listeners only). Thesniffermatcher exists ({ type: sniffer, sniffer: <name>, host: [...] }, several per listener) but no sniffers are built in — asniffer:route never matches until a sniffer is loaded (Phase 9). The TCP pathMSG_PEEKs up to 4096 B (250 ms budget) before routing, only when a route needs bytes; UDP inspects the first datagram it already holds. A ClientHello split across TCP segments is reassembled: the path re-peeks every 5 ms until the whole first TLS record is buffered or the 250 ms budget expires, sosnistill matches a multi-segment ClientHello. (first-byteslengthon TCP is still “what has been peeked so far” — aprefixfits in the first segment.) Theconsistent_hashbalancer is implemented (balancer: consistent_hash, pool-levelhash_on: src_ip | src_ip_port; rendezvous/HRW hash over the healthy backends); so isweighted(balancer: weighted+ aweights:map).dst— two forms: a normally-bound TCP/UDP listener sees only its own bind IP (useful across addresses the host serves separately; TCP can bind a non-local address withfreebind: true). A UDP listener withprefix: <cidr>runs in prefix mode — one wildcard socket withIP_PKTINFO/IPV6_RECVPKTINFOserves the whole routed prefix (scheme A below): the real per-datagram destination feedsdst, replies go out from that address, and datagrams outside the prefix are dropped (wayhouse_datagrams_dropped_total{reason="outside_prefix"}). The sniffer seam iswayhouse_core::sniff::Sniffer→RouteHint { host, key, reject }, fed into routing before matchers run — arejecthint drops the connection / datagram outright (result="sniffer_reject"/reason="sniffer_reject"); loading real sniffers (sandboxed, from a separate repo) is Phase 9. The push resolver (POST /route-hint) is implemented: a listener withroute_hint: truechecks a short-livedsrc_ip → pooltable before its route list (wayhouse_route_hints_applied_total{listener}counts hits). The external resolver (action: { resolver: <name> },resolvers:section) is implemented — phase 4 slices 1–4: HTTP and gRPC transports (type: http | grpc),poolandtargetresults (target= connect straight to a resolver-named instance, no pool / health / cap),on_error: reject | fallback_route | stale_ok, and a TTL’d LRU result cache (cache: { key, positive_ttl_sec, negative_ttl_sec, max_entries }; key partssrc_ip/src_ip_port/sni/routing_key/first_bytes:a:b). Only The resolver has no affinity key (sticky_keywas removed, #54): affinity comes fromconsistent_hashpools and the request-keyed cache. Still pending elsewhere: the TCP side of prefix binding beyondfreebindand thefirst_availablebalancer. Theweightedbalancer is implemented (balancer: weighted+ aweights:map of"ip:port"→ share, default 1) — weighted round-robin over the healthy set, one atomic tick per selection. Regex-over-first-bytes is folded into the Phase 9 sniffer layer, not afirst-bytessub-form. A listener with a barepool:is normalised to onealwaysroute.
Evaluation order
- Early filters (ACL, rate limit, geo) – before routing, may reject immediately.
- Listener binding – the listener may already map 1:1 to a pool (simplest case, no further logic).
- Sniffer (if a
snifferroute is configured) – runs once on the peeked first bytes. A listener may route on several sniffers (e.g.quic,wireguardanda2son one UDP port): they are tried in order of first appearance in the route list and the first that recognises the bytes wins — a later sniffer is not consulted, even if none of the winner’s routes match (routing then falls through to non-sniffer routes such asalways) — and asnifferroute only matches the sniffer it names. ARouteHint { reject: true }drops the connection / datagram immediately (before the push-resolver hint, so a spoofablesrc_iphint cannot override it); TCPwayhouse_listener_connections_total{result="sniffer_reject"}, UDPwayhouse_datagrams_dropped_total{reason="sniffer_reject"}and no reply. A non-rejecthint is carried into route matching. - Push-resolver hint – if the listener has
route_hint: trueand a livesrc_ip → poolentry exists (fromPOST /route-hint) whose pool still exists, it wins and steps 5–7 are skipped. - Route matching – the listener’s ordered rule list; the first matching rule
wins. Each rule:
match+action(poolorresolver). - Resolver (if the rule requires it) – external lookup with cache/fallback.
- Default route – when nothing matches.
Matcher types
always
Always matches. For default/catch-all rules.
port
The destination port (of the accepting socket) selects the pool; ports:
accepts a "lo-hi" range too. Pairs naturally with a port-range listener
(bind: "host:lo-hi", F1.4) — one config entry spawning a real socket per
port across the range, so 30000–30099 → pool-a needs one listener, not one
per port. See 05.
dst (destination IP / prefix of the incoming packet)
The address the client sent to — not from the payload but from the socket.
Compared against a CIDR/prefix list (LPM trie). This is the lever for games with no
protocol hint at all: the subdomain is mapped by DNS to its own destination IP and the
proxy distinguishes on dst. See the section
“Routing without a protocol hint”.
Data-path requirement: receive on a whole prefix with one socket (IP_PKTINFO /
IPV6_RECVPKTINFO), see 04.
client_cidr
Source IP in a CIDR list. Uses: internal testers to a staging pool, region roughly by
IP block, partner ranges. Also for the launcher/push resolver (section below):
short-lived src_ip → pool mapping.
sni (TCP, TLS handshake – without termination)
- Reads the
ClientHello, extractsserver_name. - Comparison exact / suffix (
*.eu.example.com) / regex. - TLS is not terminated; the byte stream (including the ClientHello) is forwarded unchanged. Peek only.
- Cost: one
MSG_PEEK, a minimal parser (no OpenSSL required).
first_bytes (TCP or UDP first datagram)
prefix: exact byte/string prefix (hex:orascii:notation).regex: regex over the firstNbytes (precompiled,Nbounded).length: datagram length within a range (coarse heuristic, e.g. query vs. gameplay).sniffer: <name>: a named sniffer returns structured hints, e.g.:sni(generic, see above)minecraft→ handshake hostname + protocol versiona2s/source-query→ Valve query recognized (route to a query pool)quic→ QUIC Initial recognized (v1, v2, IETF drafts; keyquic). The sniffer also decrypts the Initial (its keys derive from the packet’s own destination connection ID and a public salt, RFC 9001 §5.2) and returns the TLS SNI as the hint’shost(v1, v2 and drafts 29 to 34). When the SNI cannot be read (an older draft, or a ClientHello split across Initial packets with the SNI in a later one) only the key is set, so ahost:route does not match and an empty-host:route still doeswireguard→ handshake initiation recognized (keywireguard)openvpn→ client hard reset recognized, UDP or TCP-framed (keyopenvpn; a weak one-byte signal: about 3 in 256 random datagrams match, so it makes the first-packet gate leaky and belongs after stronger sniffers in the sniffer list)raknet→ RakNet offline handshake recognized by its magic (Minecraft Bedrock and other RakNet games; keyraknet)teamspeak3→ TeamSpeak 3TS3INIT1client init recognized (keyteamspeak3)
- Sniffer contract: read-only, receives up to
peek_max_bytes, returnsOption<RouteHint { key?: String, pool_hint?: String, reject?: bool }>. No access to later bytes, no writing.
external (resolver)
- A rule action instead of a fixed pool.
- Request to a gRPC/HTTP endpoint:
{ "listener": "public-udp", "src": "203.0.113.7:51343", "dst": "198.51.100.10:7777", "sni": "eu.example.com", "first_bytes_b64": "AAECaGVsbG8=", "routing_key": "eu.example.com" } - Response:
either{ "pool": "match-eu-1", "target": null, "ttl_sec": 30 }pool(then normal LB) ortarget(a fixed instance, e.g. assigned by the matchmaker). - Cache: key configurable (
src_ip,sni,routing_key, hash of the first bytes). Positive TTL from the response, negative TTL separately. - Timeout/error:
on_error: fallback_route | reject | stale_ok. - Use: the matchmaker assigns a player token to an instance; the proxy does not need to understand the token, only forward it and cache the decision.
Load balancing within a pool
| Strategy | Use |
|---|---|
round_robin | equivalent stateless backends |
least_conn | long-lived sessions of uneven duration |
weighted | heterogeneous hardware / canary; weights: { "ip:port": N }, default 1, weighted round-robin |
consistent_hash | affinity without a sticky table; hash key = client IP or routing key |
first_available | active/passive, a backend fills up to its cap, then the next (not yet implemented) |
Session affinity
- Implemented: a
consistent_hashpool (rendezvous hash ofsrc_iporsrc_ip_port). Stateless, so it holds at any worker count and after any idle eviction; an unhealthy or draining backend is skipped and its clients move to the next-highest score. Adding a backend remaps about 1/N of the clients. - Not implemented: a
key → backend_id (+ TTL)sticky table keyed by the snifferkey. The UDP table that existed was removed in #56: it was per worker, so withSO_REUSEPORT(clients spread by source port) it kept only ~34% of clients on 4 workers. - For UDP, affinity is effectively mandatory (otherwise the gameplay stream
fragments across multiple instances): point UDP listeners at a
consistent_hashpool.
Routing without a protocol hint (raw data to an IP:port)
Many games send raw, often encrypted payload from the first byte to a fixed
IP:port — no hostname, no SNI, nothing stable in the first packet. Then there is
nothing in the stream to tell survival from creative.
Principle: route by what you still know without the payload — the destination
address (dst_ip) and the destination port the client sent to. So the
“subdomain” must be mapped by DNS to a distinguishable (IP, port) combination. Three
schemes, used alone or combined:
Scheme A – one IP per server (recommended, especially with IPv6)
- DNS maps the name to its own address:
survival.example.net AAAA 2001:db8:ace:1::1 creative.example.net AAAA 2001:db8:ace:1::2 hardcore.example.net A 198.51.100.7 # IPv4 only when necessary - The proxy is given a routed prefix (e.g. an IPv6
/64or/48) and accepts traffic on all addresses in it with one single wildcard socket — no socket per IP:- UDP:
IP_PKTINFO/IPV6_RECVPKTINFOyields the actual destination address per datagram; the reply is sent viacmsgwith the same source address. - TCP:
IP_FREEBINDornet.ipv4.ip_nonlocal_bind/ bind-any on the prefix; the destination IP of the accepted connection comes fromgetsockname().
- UDP:
- Routing table: a
dstmatcher against an LPM trieprefix → pool. Millions of destination IPs cost almost nothing (memory ~ number of rules, not number of IPs). - IPv6 is the normal case here: address space is effectively unlimited, each instance can get its own public address. IPv4 only with a small, expensive IP pool — then prefer scheme B.
Scheme B – one port per server
- All names point to the same IP; the port distinguishes them:
survival.example.net → 198.51.100.7:30001 creative.example.net → 198.51.100.7:30002 - Where the game/protocol supports it, an SRV record hands out host+port
(
_game._udp.survival.example.net SRV 0 0 30001 edge.example.net). Otherwise the launcher / server-browser config carries the port. - Proxy: a port-range listener (
bind: "0.0.0.0:30000-30099", F1.4) +portmatcher (30001 → pool-survival). - Limits: some clients hard-code the port; restrictive client firewalls; SRV support is rare outside a few protocols.
Scheme C – pre-registration via launcher/API (push resolver)
- If the game starts through a launcher or server browser, that calls the control
plane API before connecting:
POST /route-hint { src_ip: "203.0.113.7", pool: "survival", ttl_sec: 30 }. - The proxy keeps a short-lived
src_ip → pooltable. The first packet/SYN from that IP with no other hint is resolved via it, then the pool’s balancer takes over. - Weakness: several players behind one NAT IP wanting different subdomains at the
same time cannot be told apart — unless the launcher can additionally set a short
token that does end up in the first packet (then
first-bytes). Otherwise fall back to scheme A/B. - This is the “reverse” of the
externalresolver: push instead of pull, useful when the proxy cannot query the client context itself.
What fundamentally does not work
Same game, same IP, same port, no token in the first packet, multiple logical servers behind it: not distinguishable. Then there is no way around scheme A (own IP) or B (own port) — or a launcher that supplies a token.
DNS and routing table from one source
To keep name → (IP/prefix | port) → pool consistent, one inventory file (per
server: name, address or port, pool) should generate both the DNS zone and the
listeners[].routes with the dst/port match. Otherwise resolution and forwarding
drift apart.
Examples
A) One port, many match instances, matchmaker decides
listener public-udp (0.0.0.0:7777/udp)
route 1: match = always → action = external(resolver = matchmaker)
resolver cache key = first_bytes(0..16) # contains a session token
on_error = reject
B) Region by SNI, TLS stays with the backend
listener public-tcp (0.0.0.0:443/tcp)
route 1: sni suffix ".eu.example.com" → pool match-eu (consistent_hash on sni)
route 2: sni suffix ".us.example.com" → pool match-us
route 3: always → pool lobby-default
C) Minecraft network (sniffer only reads the hostname)
listener public-tcp (0.0.0.0:25565/tcp)
route 1: sniffer minecraft, host == "survival.example.net" → pool mc-survival
route 2: sniffer minecraft, host == "creative.example.net" → pool mc-creative
route 3: always → pool mc-lobby
D) Separate query and gameplay pools by packet length
listener public-udp (0.0.0.0:27015/udp)
route 1: first-bytes prefix hex:FFFFFFFF → pool source-query # A2S
route 2: always → pool gameplay
E) Raw UDP with no hint at all – subdomain by destination IP (scheme A)
DNS: survival.example.net AAAA 2001:db8:ace:1::1
creative.example.net AAAA 2001:db8:ace:1::2
arena.example.net AAAA 2001:db8:ace:1::3
listener raw-udp
bind: "[2001:db8:ace:1::]/64 :7777/udp" # one wildcard socket, IPV6_RECVPKTINFO
routes:
route 1: dst 2001:db8:ace:1::1/128 → pool survival
route 2: dst 2001:db8:ace:1::2/128 → pool creative
route 3: dst 2001:db8:ace:1::3/128 → pool arena
route 4: always → reject # unknown destination IP
The client connects to survival.example.net:7777, immediately sends raw packets; the
proxy reads the destination address from the datagram cmsg and picks the pool.
Replies go back with 2001:db8:ace:1::1 as the source.
F) Like E, but only IPv4 available – subdomain by port (scheme B)
DNS/SRV: _game._udp.survival.example.net SRV 0 0 30001 edge.example.net
_game._udp.creative.example.net SRV 0 0 30002 edge.example.net
listener raw-udp-v4
bind: "0.0.0.0:30000-30099/udp"
routes:
route 1: port 30001 → pool survival
route 2: port 30002 → pool creative
route 3: always → reject
(Pseudo-code — real YAML has no /udp suffix on bind, protocol: udp is
its own key, and routes: entries are { match: {...}, action: {...} }. See
docs/05 for the actual shape; the range-bind idea itself (F1.4) is real and
built.)
G) Web sites behind Caddy (web:)
listener https bind 0.0.0.0:443 route 1: sni *.example.com → pool web (proxy_protocol: v2)
listener http bind 0.0.0.0:80 route 1: always → pool web
pool web targets [127.0.0.1:8443] proxy_protocol v2 ← Caddy's PROXY-aware listener
web.sites[].upstreams: addr 10.0.0.5:8080 Caddy → origin directly
pool panel Caddy resolves the pool's backends (via: direct)
pool wings1, via: wayhouse Caddy → 127.0.0.1:<port> → listener web-up-wings1 → pool wings1
Wayhouse stays on the public ports and does not parse HTTP. The entry route (a port or
sni route to the web pool with proxy_protocol: v2) delivers a PROXY v2 header with
the real client address, then the client’s bytes untouched. Behind Caddy, a plain addr
or a via: direct pool is reached by Caddy itself; a via: wayhouse pool goes back
through a generated loopback listener, so balancing, health checks, drains and tunnel
backends apply to the origin. Caddy puts the client address in X-Forwarded-For; on the
via: wayhouse hop the TCP peer is Caddy’s loopback address. Fields, defaults and the
port allocation are in docs/05 (“Web sites”); GET /web is in docs/06.
04 – Transport & client-IP preservation
TCP
- Accept:
accept4()withSOCK_NONBLOCK;SO_REUSEPORTsharding per worker. - Destination IP of the connection: for
dst-based subdomain routing without a socket per IP, the listener is bound withIP_FREEBIND/ip_nonlocal_bindto a routed prefix; the concrete destination IP of the accepted connection comes fromgetsockname(). (In transparent modegetsockname()already returns the real IP the client addressed.) - Peek for routing:
recv(MSG_PEEK)up topeek_max_bytes/peek_timeout_ms. If the client sends nothing first (server-speaks-first), the default route applies immediately. - Upstream connect: non-blocking,
connect_timeout.TCP_NODELAYset; the client’s options are not “inherited” but set from config. - Data pump: Linux
splice()(socket→pipe→socket, zero-copy — ADR 17). Fallback (non-Linux, or apipe2failure): one 32 KiB buffer per direction,try_read/try_write. Pipe size andSO_RCVBUF/SO_SNDBUFuse the kernel defaults — not exposed as config; the simpler fixed-default path covered every case actually hit so far. - Half-close: propagate
shutdown(SHUT_WR)in one direction, keep the other direction running until it too closes. - Timeouts: connect, idle (no byte in either direction), optional max-lifetime.
- Keepalive: optionally enable
TCP_KEEPALIVEtoward the backend to detect dead sessions in NAT-free internal networks.
UDP
UDP has no connection — the proxy builds the “session” concept itself.
- Session key:
(src_ip, src_port, dst_ip, dst_port)(the 4-tuple). - Receive:
recvmmsg()in batches (fixed at 16 datagrams/syscall,RECV_BATCH— not config-exposed, ADR 18) onSO_REUSEPORTsockets, one loop per worker. - Send:
sendmmsg()toward the backend for consecutive datagrams of one session, and toward the client in the reply pump (up to 16 per syscall, ADR 31). - Receiving on a whole prefix (for
dstrouting for games with no protocol hint, see 03): the listener does not bind a socket per destination IP but one wildcard socket and enablesIP_PKTINFO/IPV6_RECVPKTINFO. Per datagram thecmsgyields the actual destination address (ipi_addr/ipi6_addr); then thedstmatch runs against the LPM trie. Replies must carry the same source address — the destination IP on send is set viacmsg(IP_PKTINFO) or through the session socket bound to the client IP. Requirement: the prefix is routed to the proxy host (no per-address NDP/ARP needed) andnet.ipv6.ip_nonlocal_bindorIP_FREEBINDpermits the bind. - One upstream socket per session, bound to the backend via
connect(2):- Replies arrive directly on the right socket without a table lookup.
- The kernel filters foreign senders.
sendmmsg()toward the backend in batches.
- Session table:
HashMap<4-tuple, SessionRef>thread-local per worker (no locks). Slab allocator forSession. - Idle timeout: timing wheel; any activity pushes the entry forward. Default 30–120 s, configurable per route (turn-based games need more).
- Caps: max sessions global / per worker / per backend; new-session rate per source IP.
- ICMP: treat “port unreachable” from the backend as a passive health signal.
- Fragmentation / MTU: no reassembly; oversized replies use normal IP
fragmentation.
IP_MTU_DISCOVERtoward the backend as needed.
QUIC/DTLS
Treated as opaque UDP. Migration (client port change) breaks the session without
QUIC-CID awareness → an optional quic sniffer that reads the connection ID and uses
it as the session key instead of the 4-tuple. Later stage only.
Passing the client IP to the backend
By default the backend only sees the proxy IP. Options, selectable per pool:
1. PROXY protocol (recommended when the backend supports it)
- TCP: v2 binary header (or v1 text) before the first payload to the backend. Carries the real source/destination address. The backend (or its framework) must parse it.
- UDP: the v2 header is prepended to the first datagram of the session;
subsequent datagrams have no header. Requires backend support (e.g. a library that
interprets the first payload as a PROXY header). Option
proxy_protocol: v2-udp. - Security: backends must accept PROXY headers only from the proxy IP (otherwise clients can spoof IPs).
2. Transparent mode (Linux TPROXY)
- Proxy socket with
IP_TRANSPARENT; the upstream connection usesIP_TRANSPARENT+ binds the client IP as its source. The backend sees the real client IP with no protocol change. - Prerequisite: routing set up so the backend’s return traffic goes back through the
proxy (policy routing /
ip rule+iptables -t mangleor nftables, or the backend’s default gateway = the proxy). More work in the network design, but 100% transparent. - Required capability:
CAP_NET_ADMIN(orCAP_NET_RAW), no full root.
Config
Set transparent: true on a TCP or UDP listener (Linux only). It is
mutually exclusive with prefix (both derive the per-datagram destination).
TCP — wayhouse:
- binds the listen socket with
IP_TRANSPARENT(so it accepts connections a TPROXY rule redirected to a non-local address;getsockname()on the accepted socket still returns the original destination, which feedsdst/portrouting exactly as a normal bind does); - for every upstream connection — pool or resolver
target— opens the backend socket withIP_TRANSPARENT,bind()s the real clientip:portas its source, then connects.
UDP — wayhouse:
- binds the listen socket with
IP_TRANSPARENT+IP_RECVORIGDSTADDR/IPV6_RECVORIGDSTADDRand reads the original destinationip:portfrom therecvmsgcontrol message (this, notIP_PKTINFO, carries the redirected port), feedingdst/portrouting and the session key; - binds the per-session upstream socket to the real client
ip:portwithIP_TRANSPARENT; - sends replies from a per-session
IP_TRANSPARENTsocket bound to the original destinationip:port, so the client sees them coming from the address it addressed.
If the client and backend address families differ the upstream source bind is skipped and a normal bind/connect is used (logged). v4 and v6 are both supported.
Network setup (example)
Redirect inbound game traffic to the proxy’s port 7777 and mark it, then route
marked traffic locally:
# nftables: TPROXY inbound game ports to the local proxy
table inet tproxy {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
ip daddr 198.51.100.0/24 tcp dport 7777 tproxy to :7777 meta mark set 1
ip daddr 198.51.100.0/24 udp dport 7777 tproxy to :7777 meta mark set 1
}
}
# policy routing: locally deliver anything with mark 1
ip rule add fwmark 1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
The backend’s return traffic must come back through the proxy host — either
make the proxy the backend’s default gateway, or add an ip rule on the backend
network that sends the client prefixes back via the proxy. Without that the
client gets replies straight from the backend IP and the connection stalls.
3. No preservation
- The backend sees the proxy IP. Sufficient when anti-cheat/logic does not need the client IP or gets it elsewhere (in the game login token). Documented default behavior.
Socket tuning (as built — kernel defaults, not config knobs)
None of these ended up as config options: every socket uses the OS default buffer/pipe sizes, and the batch size is a compile-time constant. Simpler than the originally-planned per-parameter config surface, and nothing so far has needed to override a kernel default.
| Parameter | TCP | UDP |
|---|---|---|
SO_REUSEPORT | on (sharding) | on (sharding) |
SO_RCVBUF / SO_SNDBUF | kernel default | kernel default |
TCP_NODELAY | on | – |
SO_BUSY_POLL | not used | not used |
recvmmsg ingress batch | – | 16 (RECV_BATCH, fixed) |
sendmmsg egress batch | – | up to 16 (RECV_BATCH forward run / REPLY_BATCH_SIZE reply pump, fixed, ADR 31) |
pipe size for splice | kernel default | – |
SO_REUSEPORT lets any same-user socket join a port, so before binding a
listener’s worker sockets the proxy probes each bind address with a socket that
does not set it. A port some other process already holds is refused: startup
fails, and a reload keeps the previous listener running and reports the failure
(wayhouse_listener_bind_failures_total); every reload, even of an unchanged
config, retries a listener whose bind failed. The probe is not atomic with the
bind, so two instances starting at the same instant can both pass it.
OS limits
- Raise the FD limit (
RLIMIT_NOFILE) (≥ 2 × expected connections). - Large ephemeral port range toward the backend
(
net.ipv4.ip_local_port_range) — only matters in non-transparent mode; in transparent mode the socket binds the client IP. net.core.somaxconn,net.core.netdev_max_backlog,nf_conntrack(or disable conntrack for the proxy path viaNOTRACK).
05 – Configuration
Principles
- One declarative YAML file is the source of truth. Discovery adapters and the admin API only supplement the backend lists and states at runtime.
- Validate before activating. A faulty config is rejected; the running one stays active.
- Hot reload via
SIGHUPor file watch. Listeners are reconciled by name: an added listener is spawned, a removed one is stopped, and one whose definition changed (bind, protocol, routes, …) is stopped and re-spawned. A listener whose definition is unchanged keeps running untouched.backend_sourcesdiscovery refresh tasks are reconciled the same way (added / removed / re-parameterised → started / stopped / restarted). - Environment-variable interpolation (
${VAR}) for secrets/tokens.
Schema (reference)
Implemented subset (roadmap phase 2 + phase 3 routing, partial).
wayhouse-configcurrently accepts a reduced, flatter schema:pools[].targetsor apools[].sourcenaming abackend_sources[]entry (static/dns_srv/consul/kubernetes/tunnel, flat fields,refresh_interval_sec);balancer: round_robin | least_conn | consistent_hash | weighted(scalar, not an object) —consistent_hashalso reads a pool-levelhash_on: src_ip | src_ip_port(defaultsrc_ip), andweighteda pool-levelweights: { "ip:port": N }map (weight>= 1, default 1), each rejected on the other balancers;health_check.type: tcp_connect | udp_probe | nonewithsend_hex/expect_hex_prefixforudp_probe(see “Health checks” below);per_backend.max_sessions; andproxy_protocol: none | v1 | v2 | v2-udp(scalar).v1/v2are TCP-only,v2-udpUDP-only; the form must match the transport of the listeners that statically route to the pool (validated).A listener maps to a pool either with the
pool: <name>shorthand or with a priority-orderedroutes:list —[{ match, action }], first match wins,action: { pool: <name> }— but not both. Implemented matchers:match: { type: always },{ type: client_cidr, cidrs: [<prefix>, ...] }(source IP),{ type: dst, cidrs: [<prefix>, ...] }(destination IP the client connected to; a plain listener sees only its bind IP, but a UDP listener withprefix: <cidr>serves a whole routed prefix on oneIP_PKTINFOsocket and replies from the address that was hit),{ type: port, ports: [<int> | "lo-hi", ...] }(destination port from the accepting socket),{ type: first_bytes, prefix: "hex:ff.." | "ascii:..", length: { min, max } }— a ≤ 512-byte prefix and/or a byte-count window on the connection’s first bytes (TCPMSG_PEEK/ first UDP datagram; at least one ofprefix/length), and{ type: sni, host: ["exact", "*.suffix", ".suffix"] }— theserver_namefrom the peeked (not terminated) TLS ClientHello, TCP listeners only. The{ type: sniffer, sniffer: <name>, host: [...] }matcher parses (a listener may use several sniffers, seedocs/03-routing.md) but matches nothing until a sniffer is loaded — no sniffers are built in; the name is checked at listener start, not byvalidate(). The sniffer loader is Phase 9; regex-over-first-bytes is a Phase 9 sniffer concern, not afirst_bytessub-form.External resolver (phase 4, slices 1–4): a top-level
resolvers:list of{ name, type: http|grpc, endpoint, timeout_ms, on_error: reject|fallback_route|stale_ok, proxy_protocol?, target_connect_timeout_ms?, target_idle_timeout_sec?, cache? }and a routeaction: { resolver: <name> }(exactly one ofpool/resolverper action). The proxyPOSTs{listener, src, dst, sni?, first_bytes_b64, routing_key?}and expects{pool?, target?, ttl_sec?}—target(“ip:port”) wins overpooland connects straight to that instance (no pool / health / cap).proxy_protocol: none | v1 | v2 | v2-udp(defaultnone) is the PROXY protocol header to prepend to atargetconnection — there is no pool to read it from — so the backend still sees the real client IP; a resolver-chosen pool uses that pool’s ownproxy_protocol. Same transport rule as pools: v1/v2 need TCP listeners on that resolver’s routes, v2-udp needs UDP.target_connect_timeout_ms(default 300) /target_idle_timeout_sec(default 90) are the connect / idle timeouts for atargetconnection or UDP session — again, no pool to read them from; a resolver-chosen pool uses its own.fallback_routecontinues the route list on failure;rejectdrops;stale_okserves the last (expired) cached answer if there is one, else drops. Optionalcache: { key: [<part>, ...], positive_ttl_sec, negative_ttl_sec, max_entries }— a TTL’d LRU keyed by the joined parts (src_ip/src_ip_port/sni/routing_key/first_bytes:a:b); a responsettl_secoverridespositive_ttl_sec; a request missing a key part bypasses the cache. Fortype: grpctheendpointis anhttp://host:portURI and the contract isproto/resolver.proto(wayhouse.resolver.v1.Resolver/Resolve).Push resolver:
POST /route-hint(admin API) with{ "src_ip": "...", "pool": "...", "ttl_sec": 30 }records a short-livedsrc_ip → poolhint; a listener withroute_hint: trueapplies it before its route list.poolmust exist;ttl_sec1..=3600.Listener options:
prefix: <cidr>(UDP only) → prefix mode as above, with a wildcardbind;freebind: true(TCP only) → bind withIP_FREEBIND/IPV6_FREEBIND;transparent: true(TCP or UDP, Linux) → TPROXY mode:IP_TRANSPARENTon the listen socket, the original destination read per connection / datagram, and the real client address bound as the upstream source (needsCAP_NET_ADMIN; mutually exclusive withprefix);route_hint: true→ consult the push resolver.Filter chain (phase 7):
allow: [<cidr>, …]/deny: [<cidr>, …]on a listener (TCP or UDP) are checked against the client source IP before routing.denyis checked first and wins; a non-emptyallowmakes the listener default-deny for any source it does not cover. A blocked connection / new UDP session is dropped silently (no error reply — no reflection) and counted bywayhouse_filter_blocked_total{listener,filter="acl"}. Established UDP sessions are not re-checked per datagram.allow/denyare compiled to a radix trie, so large block lists (bogons + a threat feed) match in bounded time.
rate_limit: { per_ip: { rate, burst }, per_net: { rate, burst } }(at least one ofper_ip/per_net) is a token bucket on new connections / new UDP sessions, checked right after the ACL.rateis permits/second sustained,burstthe bucket capacity (defaults torate).per_netaggregates by the client’s /24 (IPv4) or /64 (IPv6). A permit is taken only when every configured bucket can afford it; excess is dropped silently and counted bywayhouse_filter_blocked_total{listener,filter="rate_ip"|"rate_net"}. Bucket state is per proxy instance (size it per node behind anycast HA). Each table is capped at 200 000 buckets (one for IPs, one for networks) to bound memory; idle buckets are pruned first, but if a flood of distinct sources (an IPv6 /64 sweep, spoofed UDP) fills a table with live buckets, a source with no bucket yet is refused (fail closed) until some expire, which can also turn away legitimate new clients while the flood lasts. Those refusals are counted asrate_ip/rate_netlike any other over-rate drop, and the proxy logs arate limiter table is fullwarning (at most once a minute per listener and table) so the two can be told apart.Per-source concurrent cap:
per_source: { max_per_ip, max_per_net }(at least one) bounds how many connections / UDP sessions are live at once from one client IP / one /24 (v4) / /64 (v6) — whererate_limitbounds the rate of new ones. Checked afterrate_limit, before allocation; over the cap ⇒ silent drop +wayhouse_filter_blocked_total{listener,filter="src_conn_ip"|"src_conn_net"}. The slot is released when the connection closes / the UDP session idles out.GeoIP filter:
geo: { allow: [CC], deny: [CC] }on a listener (ISO 3166-1 alpha-2 country codes, case-insensitive) is checked on the client source IP right after the CIDR ACL. Same precedence asallow/deny:denywins, a non-emptyallowis default-deny, and an IP the database can’t place is admitted only when there is noallowlist. Requiressettings.geo_db— the path to a MaxMind Country.mmdb(e.g. MaxMind’s free GeoLite2-Country);--checkand startup fail if it can’t be opened, and a listener whose DB somehow isn’t loaded fails closed. Blocked ⇒wayhouse_filter_blocked_total{listener,filter="geo"}. Startup-only, likesettings.workers.Sniffer loader (phase 9; config schema slice 2, the
wasmtimeloader slice 3, livedirrescanning slice 4 — all landed):settings.sniffers: { dir, call_timeout_ms, max_memory_bytes, modules: [{ name, sha256, config? }] }. Absent ⇒ no sniffers load and asniffer:route never matches.diris a directory of*.wasmmodules, scanned at startup and rescanned on every reload (added modules load, removed ones drop, changed ones recompile — swapped in atomically, no listener restart);call_timeout_ms(default 20) bounds a sniffer’s wall-clock time per call viawasmtimeepoch interruption;max_memory_bytes(default 16 MiB) caps a call’s instance memory;modules:optionally pins each module’s SHA-256 for supply-chain verification — when non-empty, adirentry not listed there (or whose hash doesn’t match) is refused (and, on a rescan, keeps the previous sniffer set rather than applying a half-updated one).modules[].configis an optional opaque string handed to that sniffer on everysniffcall (the sniffer parses it itself — e.g. a match pattern forregex-firstbytes); a module needs amodules[]entry (hence itssha256) to carry aconfig.validate()rejects an emptydir, a zerocall_timeout_ms/max_memory_bytes, a non-64-hex-charsha256, and an emptyconfigstring.call_timeout_ms/max_memory_bytesthemselves, and thesettings.sniffersblock appearing/disappearing, are startup-only — thewasmtime::Engineand its epoch-ticker thread are built once (likesettings.workers). Modules within an already-configureddircan also be managed over HTTP:GET/POST /admin/sniffers+DELETE /admin/sniffers/{name}+POST /admin/sniffers/{name}/rollback(swaps in the kept previous version,docs/sniffers.md“Updates and rollback”) on the instance’s own admin API (409ifsettings.sniffersis absent). An upload is checked before it is written and written atomically: anything that is not a loadable module (not wasm, has imports, lacks thememory/alloc/sniffexports, declares no ABI version or another one than the host’s, fails to instantiate, empty) is400, a body over 8 MiB is413, and on an instance withsettings.sniffers.modulespins an unlisted name or a different sha256 is409. A bad.wasmfile already indiris logged and skipped by every rescan whenmodulesis not set. Whenmodulespins are set, a pin violation, a pinned module that fails validation, or a pinned path that is not a readable file (a directory, a dangling symlink) fails the whole scan (a pin whose file has not been uploaded yet is fine): the process refuses to start (naming the module), and a reload keeps the previous sniffer set. Uploads are fanned out fleet-wide viawayhouse-aggregator’sPOST/DELETE /fleet/sniffers[/{name}]and the admin GUI’s Sniffers page — seedocs/sniffers.md“Installing by hand”.Tier-2 regional health fabric (phase 13, docs/10 “Tier 2” — built):
settings.failure_domain(a string identity — this instance’s reachability-equivalence class, e.g. an AZ or region) andsettings.gossip: { bind, seeds?, quorum_fraction?, psk }must be set together, or not at all —validate()rejects either one alone.bind/seeds[]are UDP socket addresses;seedsis optional (any subset of a domain’s live members is enough to join, membership then discovers the rest) and defaults to empty.quorum_fraction(default 0.66) must be> 0.5and<= 1.0— the fraction of the domain that must report a backend down before that verdict can override this instance’s own “up” reading; a same-or-under-half quorum could contradict itself between two overlapping majorities, hence the> 0.5floor.psk(a pre-shared key, must not be empty) authenticates every gossip datagram with an HMAC-SHA256 tag. When the mesh itself lands, it will only ever be able to push a backend’s flag down, never revive it — a backend returns healthy only on this instance’s own localrisestreak, and a Tier-1force-down(AdminState::Disabled) always wins over the domain view. Startup-only, likesettings.workers.Fleet grouping:
settings.group(optional string) is this instance’s self-reported organization path — e.g."eu/frankfurt/cluster-a"— pushed to wayhouse-aggregator alongside itsIngestPayloadso the admin GUI can render a grouped/tree fleet view./-separated, non-empty segments, no leading/trailing/;validate()rejects anything else. Purely a display label — never consulted by routing/forwarding, and independent offailure_domain(which is about health-fabric membership, not fleet organization).Global caps:
settings.limits: { max_connections, max_udp_sessions, max_new_sessions_per_sec }are process-wide ceilings (all optional; omit for no cap).max_connections/max_udp_sessionsbound the live counts across every listener;max_new_sessions_per_secis a token bucket (burst = the rate) over new connections and new UDP sessions combined. A new connection / session that would breach a cap is dropped before it is allocated (existing ones keep running) and counted bywayhouse_filter_blocked_total{filter="max_conn"|"max_udp"|"max_new_rate"}. Startup-only, likesettings.workers.Web sites (schema 3): the optional top-level
web:section describes HTTP(S) sites published through a Caddy that sits behind a Wayhouse pool (design: issue #297, specdocs/superpowers/specs/2026-10-15-web-routing-design.md). It is validated and resolved asConfig.weband needsschema_version: 3. The only routing it adds are the loopback listeners forvia: wayhouseupstreams (below); everything else is read bywayhouse-web-syncthroughGET /web(docs/06) and rendered into Caddy.web: pool: web # pool in front of Caddy (default "web"); must set proxy_protocol: v2 listen: { http: 127.0.0.1:8080, https: 127.0.0.1:8443 } # where Caddy listens (defaults shown) trusted_proxy: [] # addresses / CIDRs Caddy trusts PROXY headers from internal_bind: 127.0.0.1 # loopback only (default shown) internal_ports: "19000-19999" # lo-hi, at most 1000 ports (default shown) sites: - hosts: [panel.example.com, "*.apps.example.com"] upstreams: - addr: 10.0.0.5:8080 # a plain address, or - pool: panel # a pool (via defaults to direct), or - { pool: wings1, via: wayhouse, port: 19010 } # port pins the loopback listener tls: { cert: /certs/panel.pem, key: /certs/panel.key } # served by Caddy (optional) upstream_tls: skip # verify | skip | { ca_file: <path> }; absent = plain HTTP health_path: /healthz # active health check path on each upstream (optional)Rules:
poolmust exist and setproxy_protocol: v2(Caddy’s PROXY listener rejects plain connections). Hosts are plain hostnames or*.suffix(labels ofa-z0-9_-; no port, no IP literal, no leading-dot form), lowercased, and unique across sites; each site needs a host and an upstream. An upstream is exactly one ofaddr/pool;via(direct|wayhouse) andportapply only to a pool, andportonly withvia: wayhouse, insideinternal_ports, one port per pool and one pool per port, and not one oflisten’s ports; a pool usedvia: wayhousemust not setproxy_protocol(the next hop is plain HTTP).health_pathstarts with/and has no whitespace, control characters,{or}.internal_bindmust be a loopback address.
listenmay be a routable address: on a gateway node Caddy cannot listen on loopback only.trusted_proxyis a list of IPs or CIDRs (a bare address is a /32 or /128); with awebpool on another node, put that node’s address in it, because Caddy’sproxy_protocolallowmust contain the peer.tls.certandtls.keyare PEM files thatwayhouse-web-syncreads (paths on its filesystem), both non-empty; withouttlsCaddy has no certificate for the site’s hosts and serves them over plain HTTP.upstream_tlsis per site:skipturns off verification (acceptable on an authenticated tunnel),ca_fileis a path on Caddy’s side.health_pathmust start with/. A non-loopbacklistenwith an emptytrusted_proxylogs a warning, as does aweb.poolthat no TCP listener routes to (--check, startup and reload).Listeners for
via: wayhouseupstreams. For each distinct pool used withvia: wayhousethe validator appends a TCP listenerweb-up-<pool>to the config’s listeners, bound tointernal_bind:<port>with onealwaysroute to that pool. Caddy’s upstream is that loopback address, so the connection takes the normal path: pool balancing, health checks, drains, tunnel backends. Several sites sharing a pool share one listener. The port is the pin if there is one, otherwise the first 8 bytes of the SHA-256 of the pool name (big-endian) modulo the range size, moving on to the next free port when taken. Taken means: another pool’s pin or generated port, an operator TCP listener bound to the same address or to the wildcard address of the same family, or the admin address. Adding or removing another pool does not move an existing pool’s port, except when two names hash to the same port; pools are then handled in name order, so pin a port if you need it fixed whatever else changes. A pin on a port an operator listener or the admin API already holds is an error, as are a listener name that clashes withweb-up-<pool>and a range with no free port left. Reload reconciles these listeners by name like any other.Client address. Caddy sets
X-Forwarded-Forfrom the PROXY v2 header, so an origin sees the client address in HTTP with eitherviamode. On thevia: wayhousehop the TCP peer is Caddy’s loopback address, which is expected: Wayhouse does not parse PROXY headers inbound and none is needed for HTTP.Plugin routes (schema 2):
plugin_routes: { type: sni }(or{ type: sniffer, sniffer: <name> }) on a TCP listener lets enabled controller plugins add routes to it (docs/plugins.md“Routes”). They are matched by the hostname the connection announces, appended after the listener’s own routes (so any operator route, including analwaysfallback, wins), and each backend gets a one-target pool namedplugin-<hash of the address>. Only meaningful with--controller; the field needsschema_version: 2.UDP first-packet gate:
first_packet_gate: trueon a UDP listener makes it create a session only when the first datagram is positively recognised — asnifferhint that is notreject, or afirst_bytesroute on the listener that matches the datagram. Applied before theroute_hintlookup (a spoofablesrc_iphint must not bypass it). Unrecognised datagrams are dropped with no session and no reply, counted bywayhouse_datagrams_dropped_total{reason="first_packet_gate"}. Requires at least onefirst_bytesroute or asniffer(else it would drop everything).A UDP session reads the routed pool’s
idle_timeout_seconce when it is created. UDP affinity (a client that comes back after its session was evicted reaches the same backend) comes from the pool: usebalancer: consistent_hashwithhash_on: src_ip | src_ip_port. There is no per-listeneraffinitykey and no sticky table (removed in #56: it was per worker, soSO_REUSEPORTkept only ~34% of clients on 4 workers). Around_robinpool gives a returning client a new backend. Seeconfig.example.yaml.Handshake-only sniffers (UDP, #131): the
quic,wireguard,openvpn,raknetandteamspeak3sniffers recognise only a flow’s handshake datagram. The route is decided when a session opens, not per connection, so a flow whose session was evicted byidle_timeout_sec(or whose NAT mapping changed, or that reached this proxy after a restart or an ECMP move) reopens on a transport datagram that no sniffer recognises. Without a gate it falls to the next matching route; withfirst_packet_gate: trueit is dropped until the client handshakes again (WireGuard rekeys after about two minutes). Per protocol: keep the pool’sidle_timeout_secwell above the keepalive (the config warns below 60 s, at load, reload and--check); a catch-allalwaysroute after such a sniffer that goes to a different pool is rejected unlessfirst_packet_gate: trueis set; and useroute_hint, or a pool withbalancer: consistent_hash(hash_on: src_ip), so a reopened session still reaches the same backend. The check goes by the sniffer’s module name.Admin API auth (phase 10+11 slice 10):
settings.admin.auth_token(a flat string, not the target schema’sauth: { mode, token }object below) gates every admin API request exceptGET /healthzwith a bearer check.None(the default) leaves the API open — network-boundary-only auth, same as always. A single shared secret, not RBAC/mTLS — appropriate for gating a control-plane API that’s meant to stay behind its own network boundary regardless; needed once something calls in from outside that boundary, which today meanswayhouse-aggregator’s intent-verb fan-out (docs/10“The aggregator”), given the same token to present.Admin API TLS (2026-10-02):
settings.admin.tls: { cert, key }(PEM chain, leaf first; PEM private key — both required) serves the admin API over HTTPS. Startup-only likelisten; the files are re-read every 30 s, so a renewed certificate needs no restart.wayhouse --checkloads the pair; errors namesettings.admin.tls.cert/.key. Optional keys undertlsbound the TLS handshakes:max_pending(512),max_pending_per_source(16),new_per_source_per_sec(20;0= no rate limit),new_per_source_burst(64); see docs/12 “Native TLS”. The admin URL reported to--aggregatorbecomeshttps://<listen>, so the certificate must be valid for thelistenaddress (an IP SAN), and the aggregator needs--ca-filefor a private CA. With--controllerevery instance gets the same YAML, so the paths are the same on every host while each host’s file must hold a certificate for its ownlistenaddress. See docs/12 “TLS for the fleet services”.The full schema below is the target.
# Optional: the minimum config schema this document needs (whole number, 1 or
# higher; absent = 1). See "Schema version" below.
schema_version: 1
# global
settings:
workers: 0 # 0 = number of CPU cores
shutdown_grace_sec: 30 # wait this long for in-flight conns on SIGTERM
admin:
listen: "127.0.0.1:9900"
auth: { mode: "bearer", token: "${ADMIN_TOKEN}" } # or mode: mtls
metrics: { path: "/metrics" }
log:
format: "json"
connection_log: { enabled: true, sample: 1.0 }
limits:
max_connections: 500000
max_udp_sessions: 1000000
max_new_sessions_per_sec: 50000 # new conns + UDP sessions/s (burst = rate)
# reusable filters
filters:
- name: block-bogons
type: deny_cidr
cidrs: ["10.0.0.0/8", "192.168.0.0/16", "100.64.0.0/10"]
- name: conn-rate
type: rate_limit
scope: src_ip # src_ip | src_/24 | global
new_per_sec: 50
burst: 100
# backend sources (phase 8) — a pool takes its backends from `targets:` OR a
# named `source:`, never both. Level-triggered: the adapter returns the current
# address set; the runtime diffs it against the live set. A refresh that errors
# or returns empty keeps the last-known-good set. `static` is folded into the
# pool's targets at load time; the dynamic kinds each get one control-plane
# refresh task (never on the data path).
backend_sources:
- name: static-eu
type: static
targets: ["10.1.0.11:7777", "10.1.0.12:7777"]
- name: k8s-match
type: kubernetes # LIST .../endpointslices for the service, plus a watch for instant updates
service: "match-server"
namespace: "games" # default: "default"
port_name: "game" # optional; else each slice's first port
api: "https://kubernetes.default.svc" # default; SA token + CA read in-pod
refresh_interval_sec: 10 # k8s: resync interval; changes arrive via the watch
- name: consul-eu
type: consul # GET /v1/health/service/<service>?passing=true, plus a blocking query for instant updates
service: "match-server"
consul_addr: "http://127.0.0.1:8500" # default
tag: "prod" # optional
consul_token_file: "/run/secrets/consul-token" # optional ACL token (X-Consul-Token), re-read as it rotates
refresh_interval_sec: 10 # consul: resync interval; changes arrive via the blocking query
- name: srv-us
type: dns_srv # resolves the SRV record; port from the record
record: "_game._udp.us.internal.example.com"
refresh_interval_sec: 10
- name: home-origin
type: tunnel # phase 14 (built) — the `tunnel` BackendSource.
# Resolves backend addresses a `wayhouse-agent`-managed
# origin behind a WireGuard tunnel has registered
# with the controller's backend-peers registry.
pubkey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # origin's WG pubkey
refresh_interval_sec: 10
# pools
pools:
- name: match-eu
source: static-eu
balancer: { strategy: consistent_hash, hash_on: src_ip }
affinity: { table_ttl_sec: 120 }
health_check:
type: udp_probe # tcp_connect | udp_probe | none | http
send_hex: "01"
expect_hex_prefix: "02"
interval_sec: 2
timeout_ms: 500
rise: 2
fall: 3
per_backend:
max_sessions: 200
max_new_per_sec: 20
proxy_protocol: "none" # none | v1 | v2 | v2-udp
connect_timeout_ms: 300
idle_timeout_sec: 90
- name: match-us
source: srv-us
balancer: { strategy: least_conn }
health_check: { type: tcp_connect, interval_sec: 2, timeout_ms: 400, rise: 2, fall: 3 }
- name: source-query
source: static-eu
balancer: { strategy: round_robin }
idle_timeout_sec: 10
# resolvers (external routing logic)
resolvers:
- name: matchmaker
type: grpc # grpc | http
endpoint: "https://matchmaker.internal:8443"
timeout_ms: 40
proxy_protocol: "none" # none | v1 | v2 | v2-udp — header for a `target` result
target_connect_timeout_ms: 300 # connect / idle timeout for a `target` result
target_idle_timeout_sec: 90 # (no pool to read them from)
cache:
key: ["first_bytes:0:16"] # e.g. a session-token prefix
positive_ttl_sec: 30
negative_ttl_sec: 2
max_entries: 200000
on_error: "reject" # reject | fallback | stale_ok
# listeners + routes
listeners:
- name: public-udp
bind: "0.0.0.0:7777"
protocol: udp
filters: ["block-bogons", "conn-rate"]
peek_max_bytes: 64
routes:
- match: { type: always }
action: { resolver: matchmaker }
- match: { type: always } # fallback if resolver on_error=fallback
action: { pool: match-eu }
- name: public-tls
bind: "0.0.0.0:443"
protocol: tcp
peek_max_bytes: 2048
peek_timeout_ms: 200
routes:
- match: { type: sni, suffix: ".eu.example.com" }
action: { pool: match-eu }
- match: { type: sni, suffix: ".us.example.com" }
action: { pool: match-us }
- match: { type: always }
action: { pool: match-eu }
- name: public-mc
bind: "0.0.0.0:25565"
protocol: tcp
peek_max_bytes: 512
routes:
- match: { type: sniffer, name: minecraft, host: "survival.example.net" }
action: { pool: match-eu }
- match: { type: always }
action: { pool: match-us }
- name: source-27015
bind: "0.0.0.0:27015"
protocol: udp
routes:
- match: { type: first_bytes, prefix: "hex:FFFFFFFF" }
action: { pool: source-query }
- match: { type: always }
action: { pool: match-eu }
# Raw UDP with no protocol hint: subdomain by destination IP (see docs/03 scheme A)
- name: raw-udp
bind: "[::]:7777" # wildcard bind, one socket
protocol: udp
prefix: "2001:db8:ace:1::/64" # enables IP_PKTINFO / IPV6_RECVPKTINFO;
# routes by the real per-datagram destination
routes:
- match: { type: dst, cidrs: ["2001:db8:ace:1::1/128"] } # survival.example.net
action: { pool: match-eu }
- match: { type: dst, cidrs: ["2001:db8:ace:1::2/128"] } # creative.example.net
action: { pool: match-us }
# No `always`/catch-all route and no `reject` action — there isn't one;
# an unmatched destination IP simply has no matching route and is
# dropped (no_route).
# IPv4-only variant: subdomain by port (scheme B), SRV hands out the port.
# One listener, one `bind: "host:lo-hi"` port range (F1.4) — one real socket
# per port, all sharing this listener's routes; the `port` route matcher
# picks the pool by the port the datagram actually arrived on. Ports in the
# range with no matching route (anything but 30001/30002 here) are dropped.
- name: raw-udp-v4
bind: "0.0.0.0:30000-30099"
protocol: udp
routes:
- match: { type: port, ports: [30001] }
action: { pool: match-eu }
- match: { type: port, ports: [30002] }
action: { pool: match-us }
A kubernetes source reads the service’s EndpointSlices (discovery.k8s.io/v1,
selected by the kubernetes.io/service-name label) and merges them: ready
endpoints only (an unset ready counts as ready), duplicates across slices
collapsed, FQDN slices skipped, and a slice without the wanted port ignored.
Its service account needs list and watch on endpointslices in the
namespace; deploy/k8s/45-wayhouse-rbac.yaml is a
ready-made Role. Without watch the source logs one warning per outage and keeps
converging on the poll interval, so a missing grant slows updates but never breaks
discovery. The watch resumes from the version of the last list and reopens itself
when the API server ends it; a 410 Gone triggers an immediate list, which renews
the version. A failing watch retries every 5 s, doubling up to 60 s. The
service-account token is read from its file again every 60 s, so the kubelet’s
rotation of the bound token (about hourly) is followed without a restart.
A consul source lists the passing instances of the service and then holds a
blocking query (?index=<X-Consul-Index>&wait=300s) on the index of its last
list; a new index triggers a list at once, and the poll interval stays as the
resync net. An index that goes backwards, or a response without one, is treated as
a reset and gets a fresh list. A failing query retries like the Kubernetes watch
(5 s doubling to 60 s). With ACLs enabled, put the token in a file and point
consul_token_file at it; the file (not the token) is what the YAML and
GET /config carry, and it is read again every 60 s. The token needs
service:read on the service and node:read on the nodes running it. The service
name and the tag are URL-escaped.
Both pushed signals are coalesced: after the first change the source is fetched once it has been quiet for 500 ms (at most 5 s after the first change), so a rolling update of many pods costs one fetch, not one per event.
Validation rules (excerpt)
- A pool needs exactly one of
targets:/source:;sourcemust point to abackend_sources[].name.refresh_interval_sec >= 1.dns_srvneedsrecord;consul/kubernetesneedservice(consul_token_file, if given, must not be empty); a Kubernetesapi(which receives the pod’s service-account token) and aconsul_addrused together withconsul_token_filemust behttps://orhttp://to a loopback address, with no user info;tunnelneedspubkey(a base64-encoded 32-byte WireGuard key). - Every
action.pool/action.resolvermust exist. - Every listener needs at least one route. There is currently no warning for a
route list with no trailing
always— an unmatched connection/datagram is silently dropped (no_route); a “did you forget a catch-all route?” lint is a plausible small future addition, not built today. proxy_protocol: v2-udponly together withprotocol: udp(for a pool, and for a resolver whose routes are on UDP listeners); v1/v2 only with TCP.- A resolver’s
timeout_ms,target_connect_timeout_msandtarget_idle_timeout_secmust all be> 0. transparent: true(TCP or UDP) may not be combined withprefix, and may not be set on a listener whosepool:or a route’s pool action uses atunnelbackend source (the client and tunnel address families can differ). A resolver route can still return such a pool at runtime; there the proxy connects without the client’s source address.- Every entry in a listener’s
allow/denymust be a valid CIDR. rate_limit, if present, needs at least one ofper_ip/per_net, each withrate >= 1.per_source, if present, needs at least one ofmax_per_ip/max_per_net, each>= 1.- Every
settings.limits.*value, if present, must be>= 1(0 would block all traffic — omit the key for no cap). first_packet_gate: trueis UDP-only and needs at least onefirst_bytesroute or asnifferon the listener.- A UDP listener with a handshake-only sniffer (
quic,wireguard,openvpn,raknet,teamspeak3) must not have a lateralwaysroute to a different action unlessfirst_packet_gate: trueis set (#131). - A listener
geofilter requiressettings.geo_db, a non-emptyallowordeny, and 2-letter country codes; the DB file must open at startup /--check. consistent_hashrequireshash_on.weightsis accepted only withbalancer: weighted; every key must parse asip:portand every value must be>= 1.settings.sniffers.dirmust not be empty;call_timeout_ms/max_memory_bytesmust be>= 1; eachmodules[].sha256must be a 64-char hex digest andnamemust not be empty; amodules[].config, if given, must be non-empty.settings.failure_domainandsettings.gossipmust both be set, or neither;gossip.bind/ eachgossip.seeds[]entry must be a valid socket address;quorum_fractionmust be> 0.5and<= 1.0;pskmust not be empty.match.type: dstneeds the destination address to be known: on TCP it comes fromgetsockname()for free; on UDP it needs eithertransparent: true(any dest) orprefix: <cidr>(dest within that prefix) on the listener — there is no separaterecv_dst_addrflag,prefix/transparentthemselves turn on the recv path.freebindis TCP-only and is rejected together withprefix(a UDP prefix listener needs the routed prefix reachable to the host, notIP_FREEBIND).match.type: portmatches the destination port of the accepting socket; its ownports:list supports a"lo-hi"range.- A listener’s
bindis either onehost:portsocket address, or ahost:lo-hiport range (F1.4) — one real socket per port (TCP: one per worker per port; UDP: same,SO_REUSEPORT-shared), all sharing that listener’s routes/filters/pool selection; a route’sportmatcher still sees the real port a connection/datagram arrived on. Capped at 1024 ports per range (a typo like0-65535would otherwise try to open tens of thousands of sockets). Mutually exclusive withprefix(which needs exactly one wildcard socket). - Bind addresses must not overlap between listeners (same IP:port:proto, checked per port of a range too); a prefix bind must not cover a single bind address of another listener.
- Numeric ranges: timeouts > 0,
rise/fall≥ 1, TTLs ≥ 0.
Schema version
schema_version (top level, optional) is the minimum config schema the document
needs; absent means 1. A build supports up to the schema in
wayhouse_config::version::CONFIG_SCHEMA_VERSION (currently 3). A document that
asks for more is refused with config schema_version N is newer than this build supports (max M), on startup, on a file reload (the running config stays active)
and on a controller-delivered revision. A string, 0 or a fractional value is
an error that says so. deny_unknown_fields stays on, so an older node also rejects
a newer field loudly instead of ignoring it.
Bump rule for contributors: a PR that adds a config field, even an optional
one, raises CONFIG_SCHEMA_VERSION and adds the field’s dotted path to
FIELD_SINCE (wayhouse-config/src/version.rs) with the new number. The parser then
refuses a document that uses that field without a schema_version that covers it,
so nobody adopts a new field by accident while older proxies still run.
Mixed fleets. Proxies that register with the controller (the tunnel source)
report the newest schema they understand (max_config_schema) and how often they
re-register. POST /config (and POST /config/promote/{revision}, for a staged
revision) answers 422 when the document’s schema_version is above the lowest value
reported by any live proxy: one that re-registered within three of its own
intervals, so a stopped or deleted proxy stops counting. A live proxy that reports
nothing predates the field and cannot parse a document that carries the
schema_version key, so it holds such documents back until it upgrades or expires;
a document without the key still goes through. If the controller cannot read its
proxy registry the check fails closed (503). Upgrade every proxy before submitting
a document that declares schema_version or uses a field introduced by the new
version. Proxies that do not register (no tunnel) are not tracked: they still refuse a
document they cannot parse and keep their previous config.
Reload semantics
| Change | Behavior |
|---|---|
Backend added/removed (file or POST/DELETE /pools/{p}/backends) | immediately in the new snapshot, existing sessions untouched. Admin add/remove edits are kept in a runtime overlay and re-applied on every file reload (the file can’t silently undo them). |
Backend → draining / disabled (PATCH .../{addr}) | no new sessions, existing ones drain; state carried across a reload by address |
| Pool balancer changed | applies to new routing decisions |
| Route changed/added | applies to new connections/sessions |
resolvers: changed | live — the reload task rebuilds the resolver clients and swaps the whole set in atomically when (and only when) resolvers: actually differs; an in-flight resolver call finishes against the old client, new calls use the new one. A rebuild resets each resolver’s LRU result cache, so expect a brief cache-cold window. A bad endpoint keeps the previous set (logged). |
backend_sources: changed | live — the reload task reconciles the discovery refresh tasks (SourceManager): a backend_sources[] entry added / removed / re-parameterised (or a pools[].source re-pointed) starts / stops / restarts its task. A restarted task does an immediate first fetch, so the stale-set window is one round-trip. A pool that loses its source drops its cached discovered set (it now serves its file targets). A source that fails to rebuild is logged and skipped — the pool keeps its last-known-good set. |
| Listener added / removed / changed | reconciled by name at runtime — added spawned, removed stopped, changed (bind / protocol / routes / …) stopped and re-spawned. SO_REUSEPORT means a same-bind rebind has no gap; new sockets bind before the old ones are torn down. |
settings.shutdown_grace_sec changed | live (read per shutdown) |
settings.workers changed | requires a restart (documented) |
settings.limits.* changed | requires a restart — the live counters / token bucket are built once at startup (like workers) |
settings.admin.* changed (listen, auth_token, tls) | requires a restart — the admin API is bound once at startup; a reload silently keeps the old values. With tls set, the certificate files are still re-read every 30 s, so renewal needs no restart |
settings.geo_db changed | requires a restart — the MaxMind DB is opened once at startup (a listener’s geo codes are reloadable, the DB path is not) |
settings.sniffers.dir contents changed (module added / removed / recompiled), or a modules[].config string changed | live — rescanned on every reload (phase 9 slice 4) and swapped in like the snapshot |
settings.sniffers block added / removed, or call_timeout_ms / max_memory_bytes changed | requires a restart — the wasmtime::Engine and its epoch-ticker thread are built once at startup, like settings.workers |
Invalid file (including a schema_version newer than this build supports) | reload rejected, metric config_reload_failed_total++, old config stays active |
Health checks
health_check.typedefaults totcp_connect, which can never succeed against a UDP-only backend. A pool that only UDP listeners route to and that leavestypeout is therefore rejected at load: pickudp_probe(withsend_hex, andexpect_hex_prefixif the reply is recognisable) ornone. A pool shared with a TCP listener keeps the default; an explicittypeis always accepted.noneruns no active probe, for a game with no probe payload. Health then comes from passive observations alone: a UDP ICMP port-unreachable, a failed upstream send or a TCP connect failure counts against a backend, and a backend marked unhealthy is given another chance after oneinterval_sec(re-admitted afterrisesuch intervals), since nothing else could ever bring it back. A truly dead backend therefore flaps: down on passive failures, back up afterriseintervals, down again on the next failure.- For UDP, a successful
send()is not a passive success (it succeeds whether or not anything listens). The first reply datagram of a session is. - A proxy-side shortage (
EMFILE,ENFILE,ENOMEM,ENOBUFS,EADDRNOTAVAIL) while opening a socket, for a probe or for a session, is not counted for or against the backend. - Probes run in the background, at most 256 at once; the first check of each
backend is spread over one
interval_sec. A slow probe does not delay the others, and a backend is never probed twice at once. idle_timeout_secmust be greater than 0 (as for the other timeouts).
06 – Operations & observability
Metrics (Prometheus)
Listener / connections
wayhouse_listener_connections_total{listener,result}–result=accepted|no_route|sniffer_reject(sniffer_reject: asniffersniffer returned arejecthint for the first bytes; the connection is dropped before routing). ACL / rate-limit / geo / cap rejections do not get aresultvalue here — they incrementwayhouse_filter_blocked_totalinstead (see “Security / filter chain” below), and a resolver miss/error is folded intono_routerather than its own value. Noprotocollabel — split bylistenername instead (a listener has one fixed protocol).wayhouse_active_connections{listener}(gauge) — noprotocollabel.wayhouse_active_udp_sessions{listener}(gauge)wayhouse_connection_duration_seconds{listener}(histogram) — nopoollabel (a connection’s pool can change across a reload; the listener is stable).wayhouse_session_setup_seconds{listener,phase}— planned, not implemented; see “Planned / not yet built” below.
Throughput
wayhouse_bytes_total{listener,dir}–dir=c2s|s2c. Nopoollabel.wayhouse_packets_total{listener,dir}(UDP). Nopoollabel.wayhouse_datagrams_dropped_total{listener,reason}— v0reason=no_route|no_backend|upstream_bind|upstream_send|outside_prefix|draining|reply_bind|first_packet_gate|sniffer_reject|pending_full(outside_prefix: prefix-mode listener, datagram destination not inprefix;first_packet_gate:first_packet_gatelistener, first datagram not recognised;sniffer_reject: asniffersniffer returned arejecthint — no session, no reply;pending_full: a new session’s external resolver call is still in flight and the per-worker pending cap (1024 sessions, 1 MiB buffered) or the per-session buffer (4 datagrams) is full)
Upstream / pool
wayhouse_pool_backends{pool,state}(gauge;state= healthy|unhealthy|draining|disabled)wayhouse_backend_active_sessions{pool,backend}(gauge)wayhouse_backend_connect_errors_total{pool,backend,kind}–kind=timeout|refused|unreachablewayhouse_healthcheck_total{pool,backend,result}wayhouse_lb_selections_total{pool,strategy,result}–strategy=round_robin|least_conn|consistent_hash|weighted;result=ok|no_backend|at_capacitywayhouse_route_hints_applied_total{listener}– aPOST /route-hintentry decided routing for a connection / UDP sessionwayhouse_proxy_protocol_headers_total{pool,version}–version=v1|v2|v2-udp; a PROXY protocol header was prepended to an upstream connection (TCP) or the first datagram of a session (UDPv2-udp).pool="(resolver target)"for a pool-less resolvertargetconnection (the header form comes from the resolver’sproxy_protocol:)
Backend discovery (phase 8)
wayhouse_discovery_refresh_total{pool,kind,result}–kind=dns_srv|consul|kubernetes|tunnel;result=ok|empty|error|withdrawn. One per refresh attempt.empty/errorkeep the last-known-good backend set (the pool is never cleared by a failed refresh).withdrawn(tunnelonly) means the controller deleted or expired an origin this proxy had seen: the pool’s set is cleared.wayhouse_discovery_backends{pool}(gauge) – addresses returned by the pool’s source at its last successful refresh.
Security / filter chain
wayhouse_accept_errors_total{listener}(counter, #174) – a TCPacceptfailed, typicallyEMFILE(out of file descriptors). The worker pauses 50 ms, doubling to 1 s, until an accept succeeds, and logs at most oneaccept failedwarning per second with asuppressedcount; this counter carries the full rate.wayhouse_filter_blocked_total{listener,filter}–filter=acl|geo|rate_ip|rate_net|src_conn_ip|src_conn_net|max_conn|max_udp|max_new_rate; a connection / new UDP session was dropped by the pre-routing filter chain —acl= source-IP allow/deny,geo= GeoIP country allow/deny (or the DB failed to load and the listener fails closed),rate_ip/rate_net= the per-listener per-IP / per-/24 (per-/64) token bucket was empty,src_conn_ip/src_conn_net= the per-listener concurrent per-source connection / session cap was reached,max_conn/max_udp/max_new_rate= a process-widesettings.limitscap was hit
Sniffers (phase 9)
wayhouse_sniffer_calls_total{name,result}–result=ok|unrecognised|timeout|trap|bad_output; one increment persniff()call through the WASM loader.timeout= the epoch-interruption deadline (settings.sniffers.call_timeout_ms) fired;trap= any other WASM trap or an instantiation failure;bad_output= the sniffer returned a result the host couldn’t decode as aRouteHint.wayhouse_sniffer_call_seconds{name}(histogram) – wall-clock time of onesniff()call, instantiation included.
Resolver
wayhouse_resolver_requests_total{resolver,result}–ok|empty|timeout|errorwayhouse_resolver_cache_total{resolver,result}–hit|hit_negative|miss|stale|uncacheable
Proxy internals
wayhouse_config_reload_total{result}/wayhouse_config_version(gauge, timestamp)wayhouse_listener_bind_failures_total{listener}– a reload could not (re)bind the listener (port taken by another process, no permission, bad address). The previous listener, if any, keeps running; every later reload retries (even of an unchanged file). Startup refuses to run instead.wayhouse_build_info{component,version,commit}(gauge, always1) — set once at startup; the same label set onwayhouse(component="wayhouse") and every fleet binary.commitis a 12-char git SHA baked in at build time (crates/wayhouse-http/build.rs): theWAYHOUSE_GIT_SHAenvironment variable if set (the Docker build arg, since the build has no.git), elsegit rev-parse, else"unknown"(e.g. a source tarball).wayhouse_fd_open(gauge, no labels) — this process’s open file descriptor count (/proc/self/fdon Linux; absent elsewhere), sampled every 5 s by a small background task (crates/wayhouse/src/procinfo.rs), independent of the health-check sweep.wayhouse_fd_limit(gauge, no labels) — this process’sRLIMIT_NOFILEsoft limit (getrlimit, vianix), sampled once at startup (it doesn’t change at runtime).wayhouse_tls_handshakes_refused_total{reason="per_source"|"rate"}(counter) — TLS connections to the admin API closed at the door: the source already had its cap of handshakes in flight, or was opening connections faster than its rate. Counted inwayhouse-http(its name lives inwayhouse_http::tls, notmetrics_defs.rs, whichwayhouse-httpcannot depend on). Exposed onwayhouse‘s/metricsand on the fleet binaries’ (below). Present only when that listener serves TLS.wayhouse_tls_handshakes_evicted_total(counter, no labels) — pending handshakes dropped to make room at the global cap (same notes).wayhouse_gossip_members(gauge, no labels) — current SWIM member count in this instance’s Tier-2 gossip mesh (phase 13,docs/10“Tier 2”,wayhouse-core:: gossip). Present only whensettings.gossipis set.wayhouse_gossip_messages_total{direction="sent"|"received"}(counter, phase 13) — gossip datagrams that passed HMAC verification.wayhouse_gossip_auth_rejected_total(counter, no labels, phase 13) — gossip datagrams dropped for a missing/invalid HMAC tag; never trusted, never forwarded to the SWIM state machine.wayhouse_gossip_stale_rejected_total(counter, no labels) — authentic gossip datagrams dropped because their sender timestamp is more than 30 s from this node’s clock (a replay, or an instance with a skewed clock; keep NTP running).wayhouse_gossip_version_rejected_total(counter, no labels, #185) — authentic, fresh gossip datagrams dropped because their magic or version byte is not this build’s gossip version: a peer on an incompatible release. Dropped before decoding; the first one per process is logged as a warning.wayhouse_gossip_registers_dropped_total{reason="future"|"non_member"}(counter) — authentic gossip health registers dropped because their timestamp is more than 60 s ahead of this node’s clock (the origin’s clock runs fast; keep NTP running) or their origin is not a current member. Each origin also gets awarnlog line at most once a minute.wayhouse_protocol_mismatch_total{route_group="controller"|"aggregator"|"raft"|"proxy"}(counter, #185) — component requests refused with426because the caller’sX-Wayhouse-Protocolmajor differs, is not<major>.<minor>, or (on component routes) is missing. Emitted by the controller, aggregator and proxy; see 10 “Versioning”.wayhouse_backend_domain_down{pool,backend}(gauge, 0/1, phase 13) — whether the Tier-2 domain quorum is currently overriding this backend to down. Independent of, and unable to clear, the backend’s own localhealthyflag (wayhouse_pool_backends’shealthy/unhealthycounts already reflect the combined result).
Added RTT: no built-in RTT SLO metric exists yet — make bench’s added
p50/p99 (vs. NFR N1/N2) is the closest thing today, measured out-of-band, not
exported as a /metrics series. See “Planned / not yet built” below.
wayhouse-web-sync (web proxy, web-proxy.md)
Served on the sync’s own GET /metrics (--metrics-listen, default 127.0.0.1:9904).
wayhouse_web_sync_last_ok(gauge) — unix time of the last cycle that left Caddy on the intended configuration (loaded, or already identical). Alert ontime() - this.wayhouse_web_sync_loads_total(counter) — documents Caddy accepted.wayhouse_web_sync_rejects_total(counter) — documents Caddy refused (4xx); it keeps its previous configuration, and the sync does not resend the same document.wayhouse_web_sync_errors_total{stage="read"|"render"|"send"}(counter) — cycles that failed before Caddy had a new document:readWayhouse (/web,/pools),render(including unreadable certificate files),send(Caddy unreachable, timeout, 5xx).wayhouse_web_sync_last_render_seconds(gauge) — how long the last render took.
Controller ACME (--acme), on the current leader’s /metrics (a follower emits none of these), not on wayhouse’s (these are
registered by wayhouse-controller, not in metrics_defs.rs): wayhouse_acme_cert_expiry_seconds{site},
wayhouse_acme_issue_total{result} and wayhouse_acme_held; see acme.md.
Planned / not yet built
Documented here as real future work, not implemented — none of these exist in
metrics_defs.rs today, so don’t expect them on /metrics yet:
wayhouse_resolver_latency_seconds{resolver},wayhouse_session_setup_seconds{listener,phase},wayhouse_worker_busy_ratio{worker}— plausible finer-grained latency / saturation instrumentation, deferred until a real debugging need shows the existing aggregate metrics (wayhouse_resolver_requests_total,wayhouse-bench’s added-latency numbers) aren’t enough to explain a slowdown. Not worth the hot-path cost speculatively./wayhouse_resolver_cache{resolver,state}— dropped from the plan: the former was a duplicate ofwayhouse_buffer_pool_exhausted_totalwayhouse_resolver_cache_totalabove (typo’d as a second metric), the latter described a buffer-pool architecture that was never built (per-worker fixed buffers replaced it early on — seedocs/09“Key libraries”).
Connection logs (structured, JSON)
One event on session close (plus optionally on start), sampleable:
{
"ts": "2026-09-03T10:48:12Z",
"event": "session_close",
"listener": "public-udp",
"protocol": "udp",
"src": "203.0.113.7:51343",
"route": "route#1",
"resolver": "matchmaker",
"pool": "match-eu",
"backend": "10.1.0.12:7777",
"bytes_c2s": 184320,
"bytes_s2c": 942080,
"pkts_c2s": 1440,
"pkts_s2c": 1460,
"duration_ms": 812345,
"setup_ms": 6,
"close_reason": "idle_timeout",
"proxy_protocol": "none"
}
close_reason: client_close|backend_close|idle_timeout|drain|limit|backend_down|error.
Tracing (optional, OpenTelemetry)
Trace only the setup phase (not the byte stream): span session.setup with child
spans peek, route.match, resolver.call, backend.connect. The trace ID can be
carried in the PROXY v2 TLV if the backend should correlate it.
The proxy’s own health endpoints
GET /healthz– the process is alive.GET /readyz– config loaded & valid, at least one listener bound, and not drained. Returnsdraining(503) afterPOST /admin/drain.GET /metrics– Prometheus.GET /config– plaintext view of the active snapshot (listeners + pools +draining/active_conns). Implemented (phase 5).GET /pools– per-pool backend list with health /state=/ active count.GET /web– the resolvedweb:section as JSON forwayhouse-web-sync:pool,listen.{http,https},trusted_proxy,internal_bind, and per site itshostsandupstreamsin final form ({addr},{pool, via: "direct"}, or{pool, via: "wayhouse", local: "127.0.0.1:<port>"}for the generated loopback listener).via: directpools are resolved by the reader fromGET /pools. Same bearer token and TLS as the other routes;404when the config has noweb:section. Follows reloads. Per site it also carries the optionaltls(cert,key),upstream_tls("verify","skip"or{"ca_file"}) andhealth_path; read bywayhouse-web-sync.GET /sessions[?listener=&pool=&proto=tcp|udp&src=<ip>]– plaintext list of every live proxied connection / UDP session: id, transport, listener, client (peer=) and local (local=) address, chosenpool=/backend=, andage=. Backed by a smallid → metadataregistry on the connection tracker (one brief lock per connection at open / close, nothing on the per-byte path). Point-in-time; the optional query params filter by exactlistener/pool, transport, or source IP. Implemented.POST /admin/drain/POST /admin/undrain– take this instance out of / back into LB rotation by flippingreadyz, without stopping the data path. Implemented (phase 5).POST /route-hint{src_ip, pool, ttl_sec}– push-resolver hint (scheme C); applied by listeners withroute_hint: true. Implemented.PATCH /pools/{pool}/backends/{addr}{state: enabled|draining|disabled}– set an operator backend state. Implemented (phase 5).draining/disabledremove the backend from new-session selection (including UDP affinity) immediately; existing sessions keep running. Carried across a config reload by address.GET /poolsshows the currentstate=per backend.
Backend health checks
- Active:
tcp_connect/udp_probe(send/expect bytes). Parameters:interval,timeout,rise,fall. Checks run in the control plane, the result → a new snapshot. Anhttpkind (ping a sidecar health port) is planned, not implemented —HealthCheckKindonly hasTcpConnect/UdpProbetoday; deferred until a real backend that exposes an HTTP health endpoint needs it. - Passive: the data path reports
connect refused/timeout, early RST, ICMP unreachable. Afternerrors withintseconds → backendunhealthy(faster than the active check). - Recovery: back to
healthyonly afterrisesuccessful active checks.
Graceful draining & deployments
Flow for swapping a backend without dropping players:
- Register the new backend (
POST /pools/{p}/backends) →healthyafterrisechecks. - Set the old backend
PATCH state=draining→ the LB assigns no new sessions there; a client whose resolver answer pointed at it is re-resolved once the cached answer expires. - Wait until
wayhouse_backend_active_sessions= 0 ordrain_deadlineis reached. - After the deadline: end remaining sessions with
close_reason=drain. - Remove the old backend.
For a proxy instance restart:
- Set
readyzto “false” (POST /admin/drain;POST /admin/undrainreverts) → the upstream LB/anycast takes the instance out of rotation. The data path keeps running; watchactive_conns(inGET /config) fall. - Grace period: no new
accepts, existing sessions keep running. - After
settings.shutdown_grace_sec(default 30) close remaining sessions, exit the process. SIGINT/SIGTERMtriggers exactly this flow (implemented, phase 5): the TCP accept loops and the UDP recv loops stop taking new work immediately; in-flight TCP connections and established UDP sessions keep running and are waited on (UDP sessions until they idle out); once all have finished — or the grace period expires — the process exits. New datagrams to a draining UDP listener are dropped (wayhouse_datagrams_dropped_total{reason="draining"}). A config reload that replaces a UDP listener behaves differently: the old workers close their sockets at once and their sessions end, so the kernel hashes every flow to the replacement group, which opens a fresh session (under the new config) on the client’s next datagram. Nothing is dropped asdraining, and a chatty client cannot hold a drain open. A listener that is removed, or whose bind address changed, still drains as described above.
Multi-instance operations (HA)
Production is many identical, independent wayhouse instances across hosts / AZs /
regions, fronted by anycast or an L4 load balancer. No shared data-plane
state (ADR 4): lose an instance and the LB / anycast spreads its clients onto the
rest; affected sessions reconnect.
- Config distribution: either the file-based approach below, or the phase
10+11 controller (see “Fleet control plane” below) — bake into the image
(change = rolling redeploy), render the file from a store (ConfigMap /
consul-template/ Ansible) and let file-watch pick it up in seconds, or point every instance at awayhouse-controllerwith--controller <url>. Either way the structural config is hot-reloaded; validate-before-swap keeps a bad config from taking an instance down (the controller rejects it at submission time instead, one hop earlier — see below). - Backend membership comes from phase-8 discovery (
backend_sources: DNS SRV / Consul / k8s Endpoints), not per-instance file edits. Every instance runs the same source config and converges independently; a source outage freezes the set at last-known-good rather than draining a pool fleet-wide. - Operator intent (drain / add / remove a backend, route hints) is still
applied per instance, either directly against that instance’s admin API or
fanned out through
wayhouse-aggregator(see below), and is not persisted or replayed after a restart — moving intent into the controller’s revision log is phase 12, not this release. transparent: trueneeds the backend’s return path through the same instance that owns the connection (per-flow-consistent ECMP / anycast). PROXY protocol has no such constraint — prefer it when the fronting layer can rehash.- Aggregate
/metricsand/poolsyourself in Prometheus for per-instance detail; for a single fleet-wide read/operate surface, seewayhouse-aggregatorandwayhouse-uibelow — there is still no fleet-wide metrics aggregation.
Fleet control plane (phase 10+11)
Three additional, independent binaries. Originally a single-tier PoC
(phase 10+11); the docs/10 standalone/slave hierarchy, intra-tier HA,
adoption, staged/canary rollout, RBAC, and moving operator intent into the
controller’s revision log are phase 12 — all built, documented per-flag in
the subsections below. All three serve unauthenticated
GET /healthz for liveness regardless of their auth settings below.
GET /metrics on the fleet binaries. Each serves a Prometheus endpoint
(wayhouse_http::metrics) with wayhouse_build_info{component,version,commit} and the TLS handshake
counters above. It is gated like the rest of the binary’s API: on wayhouse-controller and
wayhouse-aggregator by --auth-token (Authorization: Bearer, open when no token is set),
unless a dedicated --metrics-token (at least 16 bytes) is given: then /metrics
accepts only that token (not the admin token) and the token opens nothing else, so
Prometheus never holds the admin secret. wayhouse-ui has only --metrics-token, because a
scraper cannot hold the UI’s browser session. A wayhouse-ui with a login configured and no
--metrics-token does not serve /metrics at all; an open UI (no login) serves it
open. Per-request or per-store metrics for these binaries are not built; the
per-instance proxy surface stays on each wayhouse.
wayhouse-controller — structural config distribution
One --auth-token-gated (optional) surface, backed by an embedded sled
store that never forgets a revision (ADR 20):
POST /config— submit a raw YAML config (the same document awayhouse --configfile would hold). Validated with the samewayhouse_config::parse_stra file reload runs; a rejected submission (422, JSON error body) leaves the current revision untouched — the revision only advances on success. Response body is{"revision": N}(the new revision only appears here, not as a header).GET /config— the current revision’s raw text, withX-Config-Revisionheader.404before any submission.GET /config/subscribe?since=<revision>(SSE) — catch-up range then a live tail;wayhouse --controller <url>consumes this to hot-reload. A lagging subscriber just re-runs the catch-up query against the store — no delivery state kept on the writer side.GET /config/revisions— history (revision, size, whether it’scurrent).GET /config/revisions/{revision}— a past revision’s raw text.GET /config/revisions/{revision}/diff[?against=<revision>]— a line diff againstcurrentor another revision.POST /config/rollback/{revision}— re-submits that revision’s exact bytes through the same validate-then-store pathPOST /configuses; rollback never rewrites history, it creates a new revision with old content.
A wayhouse instance opts in with --controller <url> (+ --controller-token if
the controller requires one) instead of --config <file> — the two are
mutually exclusive. On a controller outage wayhouse keeps running its
last-applied config and retries the subscribe connection with capped
exponential backoff (500ms → 30s); nothing about the data path pauses.
Role (phase 12 slice 1): --role standalone (default) is everything
above unchanged. --role slave --parent-url <url> [--parent-token <token>]
makes this controller a relay: it never accepts a write of its own (POST /config and POST /config/rollback/* both 403, "this controller is a slave tier...") and instead seeds from the parent’s GET /config at
startup, then holds a GET /config/subscribe connection to the parent and
re-lands every revision it receives in its own store (its own local revision
numbers — not required to match the parent’s, only the config text is
shared). A wayhouse instance, or a further slave tier, points at this
controller exactly as it would at a standalone one — the relay is
transparent below this tier. Losing the parent freezes this tier on
last-known-good with the same reconnect-with-backoff behavior wayhouse’s own
--controller client has; it never promotes itself to standalone.
Operator intent log (phase 12 slice 3): alongside /config*, every
wayhouse-controller also serves POST /intent and GET /intent/subscribe, a
second sled-backed revision log (its own <data_dir>/intent database)
covering the pool-scoped phase-5 admin verbs — backend add
({"op":"backend_add","pool":...,"addr":...}), backend remove
(backend_remove), backend admin state (backend_patch, state one of
enabled/draining/disabled), and route-hint (route_hint,
{"src_ip":...,"pool":...,"ttl_sec":...}, default ttl_sec 30). POST /intent validates the op’s shape (addr/IP parse, known state) before
accepting it — there’s no wayhouse_config::validate() equivalent for a bare op,
so this is that check, one hop earlier than broadcasting it. A wayhouse
instance running with --controller automatically also subscribes to
/intent/subscribe and applies every op it receives through the same
RuntimeHandle calls the admin API’s own handlers use — an intent op is a
new source for an existing mutation, never a new code path. Not part of
this log: whole-instance drain/undrain (targets one instance, not “every
instance with pool X” — use the aggregator’s targeted fan-out or the
instance’s own /admin/drain) and resolver pins (still open, docs/01).
The slave-role write gate applies to /intent too, and (phase 12 slice 4)
a slave tier also relays the intent log from its parent, the same way it
relays config — a slave controller now holds a full copy of both of its
parent’s logs while still never originating either one itself.
Intra-tier HA (phase 12 slice 6): --ha-node-id <id> + --ha-peers id=host:port,... (identical on every replica; an entry may instead be
id=https://host[:port], see docs/12 “HA replicas over TLS”) turn a standalone tier into
an N-node Raft group (embedded openraft) replicating both the config and
intent logs together. POST /config and POST /intent propose a Raft entry
and only return once it’s committed; a replica that isn’t the current leader
transparently forwards the request to whichever one is (never a redirect —
every existing client stays unaware HA exists). Reads (GET /config//config/subscribe//intent/subscribe/etc.) are served by
whichever replica gets the request, straight from its own local copy — not
a linearizable read, a deliberate relaxation justified in docs/10
“Intra-tier HA (design)”. --ha-token gates the new peer-only /raft/*
routes (append, vote, snapshot) — a separate secret from
--auth-token. HA combines with --role slave (2026-10-05): only the
Raft leader subscribes to the parent and proposes each relayed revision into
the group; the relay cursor (the highest parent revision absorbed, one per
log) is replicated with the log, so a newly elected leader resumes where the
group left off and a duplicate proposal is skipped. Start every replica with
the same --role slave --parent-url .... POST /admin/adopt is refused on an
HA controller (the role is a per-node setting).
Staged / canary rollout (phase 12 slice 7): POST /config?stage=canary &group=<name> submits a revision visible only to a subscriber reporting
that group; a plain POST /config (no stage) is unaffected — immediately
visible to everyone, exactly as before this feature existed. GET /config?group=<name> and GET /config/subscribe?since=<revision>&group= <name> show what’s visible to that group: the highest revision that’s
either promoted or staged to it. POST /config/promote/{revision} makes an
existing revision visible to everyone (404 if it doesn’t exist) — this is
the one operation that changes an existing revision rather than creating a
new one. GET /config/revisions gained promoted/canary_groups fields
per entry. One group per submission in this release. Not available for
/intent (config only).
Adoption (phase 12 slice 5): POST /admin/adopt {"parent_url":"...","parent_token":"..."} flips a running standalone
tier to slave without a restart. Refused with 409 unless this tier’s
config and intent stores are both still empty (a tier with existing
history must be replaced by a fresh one to join a hierarchy, per docs/10
“Adoption”) or it’s already a slave. On success, seeds from the new
parent’s current config and starts relaying both logs, exactly like a
--role slave boot — the response body’s seeded_config_revision is the
parent’s revision number seeded from (null if the parent’s config log was
empty at that moment). Same --auth-token gate as /config*//intent*.
wayhouse-aggregator — fleet reads and operational fan-out
One --auth-token-gated (optional) surface fed by every instance’s own push,
plus a --instance-token this aggregator presents going back out to each
instance’s admin API (a separate secret from --auth-token — one gates calls
in, the other authenticates calls out):
Hierarchy (phase 12 slice 2): --parent-url (+ --tier-name, required;
--parent-token; --parent-push-interval-sec, default 10) makes this tier
also push its own merged view — every instance it currently knows,
individually, with its name rewritten "{tier_name}/{instance}" — up to a
parent aggregator’s POST /ingest, on the same interval shape wayhouse’s own
--aggregator push uses. A parent needs no configuration to accept this: a
child aggregator’s push looks exactly like a proxy’s own push, just under a
namespaced instance name, so it shows up in the parent’s /fleet/* views
with no special-casing. Namespacing only affects what’s pushed upward — this
tier’s own local /fleet/* view and admin_url-based fan-out still use the
un-namespaced names.
POST /ingest— an instance’s periodic self-reported summary (pool/backend health + admin state, session counts, its ownadmin_url). Latest-write-wins, never persisted — every fact here is a proxy’s own state, re-pushed on the next tick, so restarting the aggregator loses nothing durable.GET /fleet/pools//fleet/sessions— every known instance’s ingested summary, each entry carryinglast_seen_ms_ago.GET /fleet/healthz— per-instance staleness (stalepast 30s, ~3xwayhouse’s default 10s push interval) and the productversioneach instance runs (empty from a build that predates the field). The UI backend checks a registry sniffer’smin_proxyagainst these versions on install and update.GET /fleet/subscribe(SSE) — the same summaries, pushed on change (debounced 150ms so a burst of near-simultaneous instance pushes collapses into one resend, not one per push).POST /fleet/instances/{instance}/drain//undrain— targeted fan-out to that instance’s ownPOST /admin/drain//admin/undrain;404if the instance isn’t known.POST /fleet/pools/{pool}/backends— broadcast to every known instance’s ownPOST /pools/{pool}/backends.PATCH/DELETE /fleet/pools/{pool}/backends/{addr}— broadcast to every instance’s ownPATCH/DELETE /pools/{pool}/backends/{addr}.POST /fleet/route-hint— broadcast to every instance’s ownPOST /route-hint.
Every broadcast response is {"results": [{"instance", "status", "error", "detail"?}, ...]}
— one entry per known instance, status: null (not a failed request) for one
that couldn’t be reached; an instance that refuses (non-2xx) also carries
detail, the first 200 bytes of its reply; a broadcast never fails or blocks on one bad
instance. A wayhouse instance opts in with --aggregator <url> (+
--aggregator-token, --aggregator-instance, --aggregator-interval-sec,
default 10s, and --aggregator-admin-url, the base URL the aggregator should
fan out to when http(s)://<settings.admin.listen> is not reachable from it,
e.g. in a container, behind NAT or a TLS terminator) — independent of --controller, pushing state and pulling
config are unrelated axes.
wayhouse-ui — the operator dashboard’s BFF
A dedicated process holding both the controller’s and the aggregator’s own
bearer tokens on the operator’s behalf; the browser only ever holds a session
cookie (POST /ui/login//ui/logout, GET /ui/session), never a bearer
token. Everything else is a thin, header-preserving proxy:
/api/fleet/* → the wayhouse-aggregator routes above (--aggregator-url/
--aggregator-token), /api/config* → the wayhouse-controller routes above
(--controller-url/--controller-token), and GET /ws/fleet — a browser
WebSocket fed by one shared subscription to the aggregator’s
/fleet/subscribe (one aggregator connection total, fanned out to every
connected browser, not one per tab). An open socket does not outlive its
session: it re-checks the session every 30 s and before each outgoing message,
and closes with code 1008 after logout, idle timeout, max age or eviction (the
check doesn’t count as activity, so a tab left open still idles out). --static-dir (default
crates/wayhouse-ui/web/dist, built by make ui) serves the React/Vite/TS
frontend as a fallback under whatever the API routes above don’t claim — it’s
the one process, one port an operator’s browser ever talks to. Either proxy
target is optional; fleet reads/config actions 503 cleanly if the
corresponding --*-url was never given.
RBAC and audit (phase 12 slice 8, docs/10 “RBAC and audit (design)”):
two login modes, mutually exclusive.
--ui-password <secret>(legacy, unchanged from phase 10+11) — one shared secret,POST /ui/login {"password":"..."}, every session implicitlyadmin.--users-file <path>— multi-operator accounts, a YAML list of{username, password_hash, role}(roleone ofviewer/operator/admin); login isPOST /ui/login {"username":"...","password":"..."}.password_hashis an argon2 PHC string —wayhouse-ui --hash-passwordreads a password from stdin and prints one, the intended way to populate an entry (never put a plaintext password in the file).
With neither flag, the UI is fully open and every session is implicitly
admin — same posture every other optional-auth surface in this fleet has. By default
open mode is loopback-only; --insecure-no-auth permits a non-loopback --listen with no
login (and logs a warning). The UI guards the operator’s own browser against other sites:
a state-changing request or WebSocket upgrade is refused (403) when Sec-Fetch-Site
is cross-site or same-site, or, when the browser sends no Sec-Fetch-Site, when
Origin is null or names another host; in open mode every request’s Host must also
be a loopback name or address (against DNS rebinding; this Host check applies only when the
UI listens on loopback). Requests without those headers
(curl, scripts) pass. Names the UI forwards to the aggregator or controller (backend,
pool, sniffer) are percent-encoded to stay one path segment, and . / .. are refused.
Sessions expire: --session-idle-timeout-secs (default 1800) and
--session-max-age-secs (default 43200, also the cookie’s Max-Age), with at
most --max-sessions (default 1000) held at once. POST /ui/login is
rate-limited per client address and per username (429 + Retry-After).
Three roles gate three route groups: viewer (every GET — fleet reads,
config/revision reads/diffs, GET /ws/fleet), operator (+ the phase-5
intent verbs — drain/undrain, backend add/patch/delete, route-hint),
admin (+ config submit/rollback/promote). A session below a route’s
minimum role gets 403 (distinct from 401, which means no valid session
at all).
Audit: every write wayhouse-ui proxies carries an X-Actor: <username>
header (--ui-password mode never sets it — there’s no per-session
identity there). wayhouse-controller records it per revision — GET /config/revisions’ new actor field is the durable half of this audit
trail. wayhouse-aggregator logs (instance/pool, actor, verb) via tracing
for the fan-out verbs and forwards the header on to each instance — a
convenience, not a durable record (this aggregator holds no durable state
by design).
Fronting layer: anycast vs. L4 load balancer
| BGP anycast (same VIP announced from every site) | L4 LB / NLB (per-region VIP, flow hashing) | |
|---|---|---|
| Client → nearest site | routing decides; no extra hop | DNS / GeoDNS picks the region; one LB hop |
| Instance loss | BGP withdraw (hold-timer seconds); flows rehash to another instance | LB health check fails (1–3 intervals); flows rehash |
| Flow stability | ECMP must be per-flow consistent (resilient hashing) or TCP breaks on any member change | LB provides this; UDP needs “sticky” / per-flow tables |
transparent: true | works only if replies return via the same instance — usually means anycast for the return path too, or SNAT | works if the LB is DSR or the backend routes replies back through the LB/instance |
| Best for | UDP-heavy, latency-critical, multi-region | single region, TCP, teams already running an NLB |
Rules of thumb: prefer PROXY protocol over transparent whenever the fronting
layer may rehash a live flow (anycast reconvergence, LB scaling) — a rehashed TCP
flow that lands on a new instance is a reconnect either way, but PROXY protocol
keeps client-IP preservation working without a symmetric return path. Keep UDP
idle timeouts short (tens of seconds) so a moved client re-establishes quickly.
Capacity planning per instance
Size an instance by the scarcest of:
- New-session rate. Each new TCP connection / UDP session does one
Pool::acquire, one upstreamconnect(2), one task spawn (see the latency ledger inHANDOVER.md). Load-test withwayhouse-bench --connectionsandtcpkali; setsettings.limits.max_new_sessions_per_secto ~70 % of the measured knee and alert before it. - Concurrent sessions / FDs. ~1 FD per client + 1 per upstream, plus the
per-session buffers (2×64 KB UDP, 2×32 KB TCP). Set
max_connections/max_udp_sessionsunder the file-descriptorulimitwith headroom; watchwayhouse_fd_openagainstwayhouse_fd_limit. - Bandwidth. Single-stream throughput is near line rate (the pump is a
buffered copy); the limit is NIC / softirq. Spread interrupts (RSS) and run
workers= cores. - Headroom for failover. With N instances behind the fronting layer, plan so any single instance loss leaves the rest below ~75 % on every axis above.
Dashboards & alerts
Per-instance panels: wayhouse_active_connections / wayhouse_active_udp_sessions,
wayhouse_listener_connections_total rate by result, wayhouse_bytes_total rate,
wayhouse_connection_duration_seconds p50/p99, wayhouse_fd_open vs. wayhouse_fd_limit.
(No worker-level busy-ratio panel yet — that metric is planned, not
implemented; see above.)
Fleet roll-ups: sum by (pool) (wayhouse_pool_backends{state="healthy"}),
sum(rate(wayhouse_filter_blocked_total[5m])) by (filter),
sum(rate(wayhouse_discovery_refresh_total{result!="ok"}[15m])) by (pool,kind).
Alerts (in addition to the list below):
wayhouse_discovery_refresh_total{result!="ok"}continuously for > 3 refresh intervals on a pool → the source is down and the pool is frozen at last-known-good.wayhouse_discovery_backendsdrops > 50 % between scrapes → a bad discovery result; cross-check againstwayhouse_pool_backends{state="healthy"}.count(up{job="wayhouse"}) < N_expected→ an instance is gone; confirm the fronting layer took its VIP / member out.wayhouse_config_versionstale (not advancing) across a rollout → file-watch or SIGHUP not reaching that instance.
The v2 distributed control plane (10-distributed-control-plane.md) removes the “fan it out yourself” / “re-apply after restart” caveats and adds a single fleet view + web UI.
Capacity planning / alerts
- Latency budget (NFR N1/N2):
make bench(crates/wayhouse-bench,latencymode) measures the proxy’s added p50/p99 request→response latency on a single host and reportsPASS/MISSvs.< 0.5 ms/< 2 ms. Run it before/after a change to catch per-connection overhead regressions. - Concurrency ramp (partial N1/N2-under-load, informational for N4/N5):
cargo run --release -p wayhouse-bench -- --mode concurrencyspawns the realwayhousebinary as a separate process and ramps a real held-open connection count through it (--steps), reporting the proxy child’s own RSS/fd count and added-latency percentiles at each step — seecrates/wayhouse-bench/README.md. Reaches tens of thousands of connections on one host (capped by the client’s ephemeral-port range), not the full 500k/1M N4/N5 targets, and says nothing about N3 (loopback bandwidth exceeds the 20 Gbit/s target, so it can’t be validated locally) or N9 (real HA). Aggregate throughput (N3), the full 500k conns / 1M sessions (N4/N5) and HA (N9) still need dedicated hardware, multiple hosts, and a real load generator (tcpkali,wrk2,iperf3). - Alert:
wayhouse_pool_backends{state="healthy"} < N_minper pool. - Alert:
wayhouse_datagrams_dropped_totalrate > 0 (buffers too small / overload). - Alert:
wayhouse_resolver_requests_total{result!="ok"}share > 1%. - Alert:
wayhouse_fd_open / wayhouse_fd_limit > 0.8. - Once built (see “Planned / not yet built” above): worker-busy-ratio and session-setup-latency alerts.
07 – Security & DDoS mitigation
Threat model
| Attacker | Goal | Countermeasure (layer) |
|---|---|---|
| Volumetric (L3/4, Gbit/s) | saturate the uplink | upstream: anycast/scrubbing, BGP flowspec. The proxy cannot do this alone. |
| SYN flood (TCP) | connection table / accept | kernel SYN cookies, accept backpressure, per-IP conn rate limit |
| UDP flood / spoofed | worker CPU, session table | rate limit before allocation, “validate first packet”, session cap, no reflect |
| Amplification via the proxy | abuse the proxy as a reflector | never reply unsolicited; reply only to the sender of an established session |
| Slowloris / idle-holding | tie up resources | peek timeout, idle timeout, min-throughput watchdog |
| Backend IP leak | direct attack bypassing the proxy | backends in the private network only, firewall: only proxy IPs may reach backends |
| PROXY-header spoofing | forged client IP at the backend | the backend accepts PROXY headers only from the proxy IP (allowlist) |
| Resolver abuse | flood the matchmaker with lookups | resolver cache, negative TTL, per-IP rate limit before the resolver call |
| Admin API access | reconfiguration | mTLS/token, internal interface only, no default bind on 0.0.0.0 |
Filter chain (before routing, cheapest first)
- CIDR allow/deny – static lists + an optional dynamic block list (feed/file, hot
reload). O(1) via an LPM trie. An IPv4-mapped IPv6 address (
::ffff:a.b.c.d) is matched as the IPv4 address it carries, so it cannot slip past an IPv4 deny list. - Geo filter (optional) – MaxMind Country DB (
settings.geo_db), per-listenergeo: { allow, deny }by ISO country code;denywins, a non-emptyallowis default-deny, an unplaceable IP is admitted only with noallowlist. Fails closed if the DB is not loaded. - Connection/datagram rate limit – token bucket per
src_ipand per/24(against distributed single IPs). Scope configurable. Excess → drop + metric, no error reply (no reflect). - Global caps –
max_connections,max_udp_sessions,max_new_sessions_per_sec. When reached: reject new ones, protect existing ones.
All steps run before buffer/session allocation.
TCP hardening
- Kernel:
net.ipv4.tcp_syncookies=1, a sensiblesomaxconn, a shorttcp_synack_retries. acceptbackpressure: when a worker is overloaded, throttle the accept rate instead of letting the queue explode.- Peek timeout (
peek_timeout_ms): anyone who sends nothing after connecting is dropped before a backend connect happens. - Min-progress watchdog: a connection with no byte progress beyond
idle_timeout→ close. - Optional per-source concurrent cap:
per_source: { max_per_ip, max_per_net }(implemented — a live counter per client IP / /24 / /64, refused before allocation).
UDP hardening
- No blind replies. The proxy sends toward the client only after the backend has replied within an established session.
- First-packet gate (optional, per route): the first datagram must pass a
first_bytesmatch (a known handshake prefix / a sniffer saying “valid”), otherwise no session is created. Keeps generic spoof floods off the session table. - Session cap per
src_ip//24(per_source, implemented — a hard concurrent counter, new sessions refused when full) and global (settings.limits.max_udp_sessions). LRU eviction of the oldest idle sessions under pressure is not done — the idle sweep reaps and new sessions are refused until room frees. - Keep the idle timeout short where the game allows it (query pools: a few seconds).
- Receive buffers large enough that legitimate bursts do not compete with flood drops.
- Disable
conntrackfor the proxy path viaNOTRACK(otherwise it is its own DoS surface).
Network & deployment hardening
- Backends: their own security zone; the ingress firewall allows only proxy source IPs to the game server ports. Backend egress minimal.
- The proxy runs without root: only
CAP_NET_BIND_SERVICE(ports < 1024) and — if transparent mode —CAP_NET_ADMIN. Read-only rootfs, seccomp profile. - Secrets (admin token, mTLS keys) via env/file with
0600, not in the YAML. - Bind the admin API and metrics to a separate internal interface; never on the public listener IPs.
- Rate-limit and ACL state is per instance; behind anycast HA, size it per node (an attack is spread across all nodes).
Abuse as an amplifier – checklist
Guarded by crates/wayhouse-core/tests/amplification.rs:
- No reply to datagrams without an established session
(
no_unsolicited_or_duplicated_replies). - No error / unsolicited reply from the app path to a dropped datagram —
routing, ACL, rate-limit, first-packet-gate drops are all silent
(
dropped_datagrams_get_no_error_reply). ICMP is the kernel’s; the proxy emits none itself. - Reply size never larger than the backend payload — the proxy forwards
backend bytes verbatim and prepends nothing toward the client
(
reply_is_exactly_the_backend_payload). - The rate limit applies before any state change — rejected datagrams create
no session and are never forwarded
(
rate_limit_is_enforced_before_any_state_change).
Sniffer sandbox guarantees (phase 9)
A loaded sniffer (settings.sniffers, crates/wayhouse/src/sniffer_loader.rs)
runs under wasmtime as a core WASM module with no WASI and no host
imports — nothing is wired in beyond the alloc/sniff exports the sniffer
itself provides. Concretely, a sniffer cannot:
- read or write the filesystem — no
wasi_snapshot_preview1, no ambient authority of any kind is linked in; - open a socket or make any network call — same reason;
- read the wall clock, environment variables, or process/thread state — nothing exposes them;
- see any byte the proxy hasn’t explicitly copied into its linear memory
(the first-bytes prefix the route already peeked, capped at
peek_len()≤PEEK_MAX) — it never sees later application bytes, and never sees another connection’s data (a freshStore+Instance, and therefore fresh linear memory, per call); - send anything toward the client — a
Snifferonly returns aRouteHint(host / key / reject); it has no reply path, so the amplifier checklist above is untouched by sniffers entirely.
Two independent bounds cap what a misbehaving (buggy or malicious) sniffer can cost the process, checked every call, not just at load time:
- Time:
wasmtimeepoch interruption. A dedicated ticker thread bumps the engine’s epoch everycall_timeout_ms / 2; each call sets a two-tick deadline before running, so a call still executing at the second tick traps (wayhouse_sniffer_calls_total{result="timeout"}). This is a ceiling, not an exact limit: the first tick lands anywhere after the call starts, so a call gets betweencall_timeout_ms / 2andcall_timeout_ms. A call well under half the timeout never traps (sniffer_loader::tests::short_calls_never_time_out_under_the_shipped_ticker). - Memory: a
StoreLimitscap (max_memory_bytes) on theStore— a sniffer that tries to grow its linear memory past the cap gets a failedmemory.grow(WASM-spec-1), not more memory.
Supply chain: settings.sniffers.modules: [{ name, sha256 }] optionally
pins each module’s SHA-256; when set, a dir entry that isn’t listed (or
whose hash doesn’t match) fails the whole load/rescan rather than loading
unpinned code.
Latency vs. NFR N1 (< 0.5 ms added; docs/01-requirements.md) — the
“latency is a gate, not an assumption” decision from docs/08 Phase 9,
measured end to end (real alloc/memory.write/sniff/decode round trip,
a fresh Store + Instance per call, release build) against the three
official sniffers (released from wayhouse-proxy/sniffers):
| sniffer | p50 | p90 | p99 | max |
|---|---|---|---|---|
a2s | 10.0 µs | 11.4 µs | 25.8 µs | 399 µs |
minecraft | 8.9 µs | 10.2 µs | 16.9 µs | 217 µs |
regex_firstbytes | 8.3 µs | 9.1 µs | 12.0 µs | 361 µs |
(Loopback numbers on the same box as crates/wayhouse-bench’s N1/N2 measurements;
2000 calls/sniffer after warmup. Reproduce with make sniffers-fetch then
cargo test -p wayhouse --release wasm_boundary -- --ignored --nocapture in
crates/wayhouse/src/sniffer_loader.rs.) All three pass N1 with wide margin — a
fresh per-call Store/Instance (the “no state survives between
connections” design from slice 3) turned out cheap enough on this hardware
that the InstancePre / pooling-allocator / warm-instance-per-worker
fallbacks noted in docs/08 Phase 9 were not needed. The occasional
higher-than-typical max samples (hundreds of µs, still far under the 500 µs
p50 gate) are consistent with ordinary scheduling jitter, not a systematic
cost — no sample in any run has been anywhere near N1.
Security logging
- Events: ACL block, rate-limit trip (aggregated, not per packet), session cap reached, health flap, config reload (who/what via the admin API), resolver error bursts.
- No plaintext of game payload in logs (privacy); metadata only.
08 – Roadmap
Incremental. Each phase is usable on its own.
Status legend: ✅ done · 🔜 next · ⬜ planned.
Phase 0 – Skeleton ✅
- ✅ Project setup (Cargo workspace:
wayhouse-config,wayhouse-core,wayhouse), CI (fmt + clippy-D warnings+ tests), test harness. - ✅ Config loading + validation + immutable snapshot behind
ArcSwap. - ✅ Structured logging (
tracing),/healthz,/readyz,/metrics,build_info.
Phase 1 – L4 TCP proxy ✅
- ✅ TCP listener with
SO_REUSEPORT, one accept task per core. - ✅ Static listener→pool mapping,
round_robin+least_conn. - ✅ Bidirectional pump; shared idle timeout (neither direction moved bytes), teardown on error, connect timeout, half-close.
Linux
splice(2)zero-copy fast path landed in the perf pass (ADR 17) —socket → pipe → socketin the kernel, bufferedtry_read/try_writefallback elsewhere, both behind the sameproxy::pumpfn. - ✅ Active
tcp_connecthealth checks (rise/fall), passive connect-failure feedback, backend healthy/unhealthy state, per-backend session caps. - ✅ Hot reload on
SIGHUP/ config-file change: rebuild snapshot, atomic swap, backend health carried over by address. Listener bind/protocol/pool-mapping changes still need a restart (full listener reconfiguration is phase 5). - ✅ Core metrics: connections, bytes, duration, backend connect errors, pool backend counts, healthcheck results, LB selections, config reload/version.
- Result: usable as a plain TCP game server front proxy.
Phase 2 – L4 UDP proxy ✅
- ✅ UDP listener with
SO_REUSEPORT, one recv task per core, worker-local (lock-free) session table. Ingress batches — onerecvmmsg(2)pulls up toRECV_BATCHdatagrams per wakeup (perf pass, ADR 18). Idle expiry is a single-level timing wheel (perf pass, ADR 19). Egress batches withsendmmsg(2)on both legs (perf pass, ADR 31). - ✅ Per-session
connect(2)upstream socket + a reply-pump task per session. - ✅ Session affinity: a
consistent_hashpool (hash_on: src_ip | src_ip_port). The per-worker sticky table that first provided it was removed (#56): withSO_REUSEPORTit kept only ~34% of clients on 4 workers. - ✅
udp_probehealth check (send_hex/expect_hex_prefix). - ✅ Per-backend session caps (shared with TCP) + per-pool idle timeout; amplification guard (no reply without an established session).
- Result: covers the majority of real-time game servers.
Phase 3 – Routing intelligence ✅
- ✅ Route rule list with priorities (
listeners[].routes, first match wins; barepool:normalised to onealwaysroute). - ✅ Matchers:
always,port(destination port),client-cidr(source IP). - ✅
first-bytesmatcher —prefix(hex:/ascii:, ≤ 512 B) and/orlength: { min, max }; TCP peek / first UDP datagram. (Regex-over-first-bytes moved to Phase 9 — it is a sniffer concern, not afirst-bytessub-form.) - ✅
consistent_hashbalancer (rendezvous/HRW hash, poolhash_on: src_ip | src_ip_port) — session affinity without a sticky table. - ✅ SNI peek matcher (
sni,hostexact /*.suffix/.suffix; ClientHello peeked, not terminated; TCP only). - ✅
dstmatcher (destination IP vscidrs). ✅ UDP prefix listener (prefix: <cidr>, one wildcardIP_PKTINFOsocket serving the whole routed prefix, replies from the hit address) + TCPfreebind. ✅ push resolver (POST /route-hint, per-listenerroute_hint: true). - ✅ Sniffer API seam —
wayhouse_core::sniff::Sniffer→RouteHint, thesniffermatcher, and the listener wiring that feeds a hint into routing. No built-in sniffers ship (game-specific parsing does not belong in the proxy binary); asniffer:route only matches once a sniffer is loaded. The loader is Phase 9. - Result: multiple games/regions behind one port (via
dst/sni/first-bytes; game-protocol sniffing once sniffers land). - ✅ F1.4’s port-range listener (
docs/01-requirements.md) —bind: "host:lo-hi"(e.g."0.0.0.0:30000-30999") spawns one real socket per port (×workers,SO_REUSEPORT-shared) under one listener config, for dedicated-server fleets that dynamically pick a port per match/instance (Agones-stylehostPortranges, Source/UE servers auto-incrementing past a taken default port) and for scheme B (subdomain-per-port) at real scale. This requirement was written down at project start and not carried into an implementation slice for a long time — caught by a documentation audit, not by design — then closed out right after.wayhouse_config::ListenerConfig::bind(primary/lowest port) +extra_binds(the rest); capped at 1024 ports per range. Mutually exclusive withprefix(which needs exactly one wildcard socket). Theportroute matcher (which already supported a"lo-hi"range within one listener’s routes) is what actually splits pools by port within the range — the bind range just makes the sockets exist.
Phase 4 – External routing logic ✅
- ✅ Slice 1:
resolvers:config +action: { resolver: <name> }; theResolvertrait + async routing loop inwayhouse-core;HttpResolver(reqwest) inwayhouse;poolresults;on_error: reject | fallback_route. - ✅ Slice 2: result cache — configurable key (
src_ip/src_ip_port/sni/routing_key/first_bytes:a:b), positive/negative TTL,max_entriesLRU (lrucrate),on_error: stale_ok. - ✅ Slice 3: gRPC transport (
tonic+prost,proto/resolver.proto+build.rs; CI installsprotoc). - ✅ Slice 4:
Resolution.target(fixed instance, pool-less connect path inproxy.rs/listener_udp.rs— no health / cap / guard).sticky_keywas dropped from the protocol (#54): nothing could recover the key on a later request, andconsistent_hashplus the result cache cover affinity. - ✅ Post-phase (data-plane completion):
resolvers:reloads live —wayhouse_core::ResolversgainedArcSwapinterior mutability (likeSniffers), and the reload task rebuilds + swaps the clients when (only when)ResolverConfigdiffers. Trade-off: a rebuild resets eachCachedResolver’s LRU cache. - ✅ Post-phase (data-plane completion):
backend_sources:reloads live —wayhouse_core::SourceManager(the discovery analogue ofListenerManager) owns one refresh task per poolsourcebehind a private stop channel and reconciles them on every reload by diffingSnapshot::sources(pool →SourceConfig, newly echoed). Added / removed / re-parameterised entries start / stop / restart their task; a removed pool also drops its cached discovered set.wayhouse-corestays HTTP-free — thewayhousebinary supplies aSourceFactory(DiscoveryFactory) that rebuilds the concrete adapter. - Result: matchmaker integration, token→instance routing.
Phase 5 – Operations & zero-downtime ✅
- ✅ Hot reload (SIGHUP + file watch), atomic snapshot swap. (phase 1)
- ✅ Slice 1:
enabled/draining/disabledbackend states —AdminStateonBackend, excluded from new-session selection (incl. UDP affinity) while existing sessions drain; carried across reload by address;PATCH /pools/{p}/backends/{addr};wayhouse_pool_backends{state=draining|disabled}. - ✅ Slice 3:
POST /admin/drain/POST /admin/undrain(flipreadyzwithout stopping the data path) +GET /config(plaintext snapshot view withdraining/active_conns). - ✅ Slice 4: backends CRUD —
POST /pools/{p}/backends {addr}/DELETE /pools/{p}/backends/{addr}; edits held in a runtimeBackendOverlaylayered on the file config (survives a file reload). An edit callsrequest_reload()and the existing reload path rebuilds viaSnapshot::build_with_overlay. - ✅ Slice 2: graceful draining of the proxy instance — a
ConnTrackercounts in-flight TCP connections + UDP sessions;SIGINT/SIGTERMstops the accept/recv loops andRuntime::shutdown_with_gracewaits for the tracked work to finish, bounded bysettings.shutdown_grace_sec(default 30). UDP listeners keep pumping established sessions until they idle out; new datagrams while draining are dropped (reason="draining"). - ✅ Slice 5: runtime listener add / remove / rebind —
ListenerManagerowns one task group per listener; a reload reconciles them by name (SO_REUSEPORTmakes a same-bind rebind gapless). - ✅
GET /sessions— live connection / session registry on the connection tracker (id → {proto, listener, peer, local, pool, backend, age}), exposed as a filterable plaintext admin endpoint. (data-plane completion, list C) - ✅ Passive health signals from the data path. (phase 1)
- Result: a production-ready deploy/update cycle.
Phase 6 – Client-IP preservation ✅
- ✅ Slice 1: PROXY protocol v1/v2 (TCP) — per-pool
proxy_protocol: none | v1 | v2; one header prepended to the upstream connection before any client bytes (wayhouse_core::proxy_protocol),wayhouse_proxy_protocol_headers_total. - ✅ Slice 2: v2-UDP variant —
proxy_protocol: v2-udp(UDP-only, validated) prepends the v2 binary header to the first datagram of each session; later datagrams are untouched. - ✅ Slice 3: transparent mode (TPROXY) for TCP —
transparent: true(Linux) binds the listen socket withIP_TRANSPARENTand every upstream connection with the real clientip:portas itsIP_TRANSPARENTsource;docs/04carries the nftables + policy-routing setup. - ✅ Slice 4: UDP transparent mode —
IP_TRANSPARENT+IP_RECVORIGDSTADDRon the listen socket (originalip:portper datagram), a client-boundIP_TRANSPARENTupstream socket, and a per-sessionIP_TRANSPARENTreply socket bound to the original destination.IPV6_TRANSPARENTlisten binds via the socket2 0.5 → 0.6 bump. Mutually exclusive withprefix. - Result: backends see the real client IP (PROXY protocol or fully transparent, TCP + UDP).
Phase 7 – Security & hardening ✅
- ✅ Slice 1: CIDR allow/deny filter chain — per-listener
allow/denyCIDR lists, checked on the client source IP before routing (TCP accept + UDP first datagram).denywins; a non-emptyallowis default-deny. Blocked = silent drop +wayhouse_filter_blocked_total{listener,filter="acl"}. Linear scan of the (small)Cidrlist; established UDP sessions are not re-checked per datagram. (Slice 6 replaced the scan with a radix trie.) - ✅ Slice 2: per-listener token-bucket rate limit on new connections / new
UDP sessions —
rate_limit: { per_ip, per_net }(ratepermits/s +burst),per_netkeyed by /24 (v4) / /64 (v6), checked after the ACL. A permit needs every configured bucket; excess dropped silently +wayhouse_filter_blocked_total{filter="rate_ip"|"rate_net"}. OneMutex<HashMap>per listener shared across workers, lazy prune of idle buckets. Established UDP sessions keep a scan-free steady path. - ✅ Slice 3: global caps —
settings.limits.{max_connections, max_udp_sessions, max_new_sessions_per_sec}(all optional; startup-only). LiveAtomicUsizecounters for TCP conns / UDP sessions + a token bucket for the new-session rate, held in onewayhouse_core::limits::GlobalLimitsshared by every worker. A new connection / session over a cap is refused before allocation (existing untouched) +wayhouse_filter_blocked_total{filter="max_conn"|"max_udp"| "max_new_rate"}. RAIILimitGuardreleases the slot on connection / session end. - ✅ Slice 4: UDP first-packet gate —
first_packet_gate: trueon a UDP listener opens a session only when the first datagram is positively recognised (a non-rejectsniffer hint, or a matchingfirst_bytesroute). Checked before theroute_hintlookup; unrecognised ⇒ no session, no reply,wayhouse_datagrams_dropped_total{reason="first_packet_gate"}.validate()rejects it on TCP or with nothing to gate on. - ✅ Slice 5: automated amplifier-checklist tests
(
crates/wayhouse-core/tests/amplification.rs) — no unsolicited / duplicated replies, no error reply to a dropped datagram, reply size == backend payload (proxy adds nothing toward the client), rate limit enforced before any state change. Checklist indocs/07now ticked. - ✅ Slice 6: ACL longest-prefix-match trie —
wayhouse_config::CidrSet, a binary radix trie over address bits (v4 / v6 separate), built once per listener spawn from theallow/denylists.Acl::permitsnow does a bounded bit-walk instead of a linearCidrscan, so large threat-feed block lists cost the same as a handful of entries. Config, semantics andGET /configoutput unchanged. - ✅ Slice 7: optional GeoIP country filter —
settings.geo_db(a MaxMind Country.mmdb, opened once at startup /--check) + per-listenergeo: { allow, deny }(ISO 3166-1 alpha-2), checked right after the CIDR ACL on the client source IP, same precedence.wayhouse_config::GeoAclholds the decision;wayhouse_core::geo::GeoDb(dep:maxminddb) does the lookup. Fails closed if the DB isn’t loaded.wayhouse_filter_blocked_total{filter="geo"}. - ✅ Slice 8:
cargo-fuzzharnesses for the untrusted-input parsers (crates/wayhouse-config/fuzz/):extract_sni(TLS ClientHello reader),route_match(matchers +extract_sni+ host patterns over a fuzzed first-bytes buffer),parse_config(parse_stron arbitrary input). Seeds infuzz/seeds/,make fuzz, and a nightly CI job. No crashes in the initial runs (10M+ execs onextract_sni). - ✅ Slice 9: per-source concurrent connection / session cap —
per_source: { max_per_ip, max_per_net }on a listener bounds how many connections / UDP sessions are live at once from one client IP / /24 (v4) / /64 (v6), whererate_limitbounds the rate.wayhouse_core::src_conns::SourceLimiter(live counters + RAIISourceGuard), checked afterrate_limit, released on connection close / session eviction.wayhouse_filter_blocked_total{filter="src_conn_ip"|"src_conn_net"}. - ✅ Slice 10:
crates/wayhouse-bench— a single-host latency/load harness (make bench) that measures the proxy’s added request→response latency (p50 / p99) against NFR N1 (< 0.5 ms) / N2 (< 2 ms), with an optional busy-connection load knob, informational single-stream throughput and a loose idle-RSS-per-connection number. N3 (aggregate throughput), N4/N5 (500k / 1M) and N9 (HA) still need dedicated hardware + a real load generator. - Result: hardened against common L4/7 abuse; per-connection overhead
measurable via
make bench.
Phase 8 – Discovery & scaling ✅
- ✅
BackendSourceseam inwayhouse-core(discovery.rs: trait +Discoverylast-known-good cache +refresh_loop), concrete adapters in thewayhousebinary (discovery.rs:DnsSrvSourceviahickory-resolver,ConsulSource/KubernetesSourceviareqwest). Same HTTP-free seam as resolvers. - ✅ Level-triggered: a source returns the current address set; the runtime
diffs it against the live set. One control-plane refresh task per source
(
refresh_interval_sec), never on the data path (zero data-path cost — latency ledger). - ✅ Fed through the existing snapshot rebuild
(
Snapshot::build_with_sources←reload::apply): one snapshot writer. Precedence:discovered set (or file seed) ∪ overlay-added − overlay-removed, then health / admin state. - ✅ Degrade to last-known-good: an errored / empty refresh keeps the previous
set (never clears the pool) +
wayhouse_discovery_refresh_total{result}/wayhouse_discovery_backends. - ✅ Config: top-level
backend_sources:list referenced bypools[].source(exactly one oftargets/source).staticis folded into the pool’s targets at load time. - ✅ k8s lists the service’s EndpointSlices (
discovery.k8s.io/v1) with the in-pod SA token + CA, and (post-phase-8) watches them from the listedresourceVersion: a pod change triggers a fetch at once, while the poll interval stays as the resync safety net. - ✅ HA operations chapter in
docs/06— anycast vs. L4 LB, capacity planning per instance, dashboards & alerts. - Result: dynamic backend fleets, horizontal scaling.
- ✅
backend_sources:live reload (SourceManager) — landed in the data-plane-completion pass; see Phase 4’s post-phase note.
Phase 9 – Sniffer loader ✅
Game-protocol sniffers load into a running proxy from disk, sandboxed, never
compiled in and never a fork. A sniffer: route resolves its name against the
loaded set. The official sniffers (a2s, minecraft, regex_firstbytes, …) live in
wayhouse-proxy/sniffers; the ABI crate they
link is crates/wayhouse-sniffer-abi/.
Locked decisions
- Sandbox:
wasmtime, core module, no WASI. A narrow ABI — the guest exportsmemory,alloc(u32) -> u32, andsniff(ptr: u32, len: u32) -> u64(packedptr<<32 | len;0= not recognised); the result bytes are a compact encoding ofRouteHint. No WASI, no host functions ⇒ a sniffer cannot touch the filesystem, clock, or network. - Time bound: epoch interruption. A host thread calls
Engine::increment_epoch()everycall_timeout_ms; a sniffer that overruns traps. (Fuel is optional on top.) - Memory bound:
StoreLimitsmax_memory+ one instance per call, fromsettings.sniffers.max_memory_bytes. - Host lives in the
wayhousebinary.wasmtimeis a binary-only dep, likereqwest/hickory-resolver; theSniffertrait and theMatchContext.sniffwiring stay inwayhouse-core(crate-boundary rule 7). - Registry threading. The free fn
sniff::sniffer(name)(global,'static) becomes anArc<Sniffers>map (HashMap<String, Arc<dyn Sniffer>>) threaded runtime →ListenerManager→ workers, exactly likeArc<Resolvers>/Option<Arc<GeoDb>>. This is the one real refactor. - First-party sniffers ship built, the same way:
a2s,minecraft, and a generic boundedregex-firstbytes(keepsregexoff the core routing path).snistays a nativeMatcher— it is not a sniffer. - Latency is a gate, not an assumption. Bench the WASM boundary vs. NFR N1
(< 0.5 ms added) before wiring it on the per-connection path. Fallbacks, in
order:
InstancePre+ a pooling allocator; one warm instance per worker reset between calls; ultimately the out-of-process Phase-4 resolver.
Config (settings.sniffers)
dir (*.wasm, loaded at startup, rescanned on reload), call_timeout_ms,
max_memory_bytes, optional modules: [{ name, sha256 }] for supply-chain
pinning. Per-listener sniffer: is unchanged; the input is already capped at the
route’s peek_len() ≤ PEEK_MAX.
Slices (all done — see HANDOVER.md codebase map + git history for detail)
-
✅ Slice 1: registry threading (pure refactor) —
sniff::snifferglobal fn →wayhouse_core::sniff::Sniffers, anArc<Sniffers>threaded to every listener worker likeArc<Resolvers>/Option<Arc<GeoDb>>. No built-in sniffers register in production; nowasmtimeyet. -
✅ Slice 2:
settings.sniffersschema (dir,call_timeout_ms,max_memory_bytes,modules: [{ name, sha256 }]) →wayhouse_config::SniffersConfig. Config-only — nothing reads it yet. -
✅ Slice 3:
WasmSniffer(crates/wayhouse/src/sniffer_loader.rs) — a sharedwasmtime::Engine(epoch interruption) + epoch-ticker thread, a freshStore/Instanceper call under aStoreLimitsmemory cap and a one-tick epoch deadline. ABI: guest exportsmemory,alloc(len) -> ptr,sniff(ptr,len) -> packed|0(later widened by ADR 16a); host decodes a compactRouteHint— any bad pointer/length/UTF-8 isbad_output, never a panic.build_sniffers(&SniffersConfig)scansdir, verifiessha256pins, fails closed on a bad sniffer (likegeo_db).wayhouse_sniffer_calls_total{name,result}/wayhouse_sniffer_call_seconds{name}. -
✅ Slice 4: live
dirrescanning on reload —SniffersgainedArcSwapinterior mutability (mirrorsRouteHints) soreplace()from the reload task is visible to every worker instantly; a scan error keeps the previous sniffer set. Engine params stay startup-only. -
✅ Slice 5: first-party sniffer crates (built at the time in a standalone sniffers workspace in this repo, moved to
wayhouse-proxy/sniffersin #183; never a dep ofwayhouse/wayhouse-core) —wayhouse-sniffer-abi(guest-side ABI helper) +a2s(Source-engine query packets),minecraft(Handshakeserver address, the BungeeCord/Velocity virtual-host trick),regex-firstbytes(a bounded pattern-matcher template, later made genuinely configurable by ADR 16a). Now:make sniffers-fetch; CI jobsniffers-e2e. -
✅ Slice 6: WASM-boundary latency bench vs. N1 (
sniffer_loader::tests::wasm_boundary_latency_vs_nfr_n1,#[ignore]d, run in thesniffersCI job). Result: p50 8–10 µs, p99 12–26 µs across all three sniffers — comfortably inside N1, so the fresh-Store-per-call design needs none of theInstancePre/ warm-instance fallbacks held in reserve. Found along the way: the epoch ticker runs on wall-clock time for the loader’s whole lifetime, socall_timeout_msis a ceiling, not a per-call guarantee — documented indocs/07“Sniffer sandbox guarantees”. -
✅ Slice 7 — phase 9 complete: end-to-end test driving a live
Runtime- real TCP connection through the real
build_sniffersdirectory scan (a WAT fixture compiled inline, no wasm toolchain needed) — not#[ignore]d, runs in everymake check.
- real TCP connection through the real
-
Result — phase 9 complete: runtime-loaded, sandboxed game-protocol sniffers (
a2s,minecraft) plus aregex-firstbytestemplate, all measured inside NFR N1;regexfirst-bytes matching lives in an optional sniffer, never in core. Known follow-ups, not blocking: per-source cap LRU eviction — seeHANDOVER.md. (GET /sessionssince landed — data-plane completion.) -
✅ Post-phase-9 (data-plane completion): per-sniffer config. ADR 16a widens the guest ABI to
sniff(in_ptr, in_len, cfg_ptr, cfg_len);settings.sniffers.modules[]grows aconfigstring, marshalled into a second linear-memory region on every call.a2s/minecraftignore it;regex-firstbyteswas rebuilt around it — a tiny[key:NAME|] [@OFFSET ](hex:…|ascii:…){|…}pattern language, so it is a genuinely runtime-configured bounded matcher and not the hard-coded HTTP template.
Phase 10+11 (merged) – PoC: single controller + single aggregator ✅
Full design: 10-distributed-control-plane.md
(“Fleet topology”, “The controller”, “The aggregator”, “The admin GUI”). This
is the first working release of the two new services, deliberately
scoped down from the full design: one standalone controller tier, one
aggregator tier, replicas: 1 on each, no slave role, no HA, no adoption
flow. Those are real, already-designed extensions (see docs/10) but are
explicitly a later release, not part of this one — ship something that
works end-to-end first. No change to the hot path or the Snapshot
invariant in either slice group.
Scope call locked for this release: operator intent (backend overlay/admin-state, route hints) stays exactly as it is today — per-instance, via the existing admin API — and does not move into the controller yet. The controller in this release owns structural config only. The aggregator’s intent fan-out (below) is a thin proxy to each instance’s existing admin verbs, not a new durable intent store. Moving intent into the controller’s revision log is a follow-on once this ships.
Controller slices (wayhouse-controller, structural config distribution)
- ✅ Skeleton binary +
store::Store— Tier-1 store as an embedded KV (sled, ADR 20), single node, no Raft/etcd for this release.open/current_revision/get/current/put;putassigns the next monotonic revision and persists it + thecurrentpointer in onesledtransaction, then flushes.wayhouse-controllerbinary opens the store and servesGET /healthz(same observability floor aswayhouse). Nothing callsPOST /configyet — that’s slice 2. - ✅
POST /config: runs the samewayhouse_config::parse_str(parse +validate()) a proxy runs on a file reload, thenStore::put. Rejects (422, error body) and leaves the current revision untouched on an invalid submission.GET /configreturns the current revision’s raw text- an
X-Config-Revisionheader (404before the first submission).
- an
- ✅
GET /config/subscribe?since=<revision>: SSE. Sends the catch-up range (Store::revisions_after(since)) then tails abroadcast::Sender<u64>fed bysubmit_config; a lagging subscriber (aRecvError::Lagged) just re-runs the catch-up query from wherever it left off —Storenever forgets a revision, so there is no delivery state on the writer side (docs/10principle 5). 5 new tests, incl. one that forces a lag and confirms no revision is skipped. - ✅
wayhousegains--controller <url>(mutually exclusive with--configvia clapconflicts_with).controller_client::fetch_currentdoes the initialGET /config(mirrorswayhouse_config::loadin file mode);controller_client::runthen holds the subscribe connection and feeds every accepted revision through a new sharedreload::apply_config(the rebuild/reconcile tailreload::applyand the controller path both call — only how theConfigwas obtained differs). On disconnect: reconnect with capped exponential backoff (500 ms → 30 s) from the last-applied cursor; the last-applied snapshot keeps running the whole time (freeze-on-last-known-good, never clear — verified live: killing the controller mid-session left the proxy serving traffic on its last config while retrying). An invalid pushed revision is logged and skipped (cursor still advances — it must not be replayed forever on every reconnect), the same “bad reload keeps the old snapshot” rule as a file reload.--controller-token(added later, slice 11f testing) is whatwayhousepresents to a--auth-token-protected controller — every other cross-service link in this fleet has a token pairing, and this one was missing until driving the real UI against a real fleet surfaced it. - ✅
GET /config/revisions(history: revision, size,currentflag),GET /config/revisions/{revision}(a past revision’s raw text),GET /config/revisions/{revision}/diff[?against=<revision>](asimilar-crate line diff againstcurrentor another revision,+/-/-prefixed plain text),POST /config/rollback/{revision}(never rewrites history — re-submits that revision’s bytes through the same validate-then-Store::putpath asPOST /config, so it’s just an ordinary new revision to every subscriber, no special-casing anywhere else).--auth-token <token>onwayhouse-controllergates the whole/config*surface with a bearer-token check (/healthzstays open); a shared secret, not RBAC — appropriate for this release’s one-controller scope. 8 new tests; verified live end-to-end over real HTTP (401 without the token, history/diff/rollback all round-tripped againstconfig.example.yaml).
Aggregator slices (wayhouse-aggregator, fleet view + operational verbs)
- ✅ Skeleton binary (new crate, no
wayhouse-core/wayhouse-configdependency — the aggregator stays fully decoupled from the data-plane crates) +POST /ingest.ingest::IngestStore: an in-memory, latest-write-wins map keyed by self-reportedinstance— deliberately never persisted, see the “stateless and ephemeral by design” note in the crate’slib.rs(an aggregator restart loses nothing that isn’t about to be re-pushed on the next tick). Payload is a summary (pool/backend health + admin state, session counts), not the full live session registry — that already exists per-instance (GET /sessions), break-glass style. 12 new tests; verified live over real HTTP. - ✅
wayhousegains--aggregator <url>(+--aggregator-instance,--aggregator-interval-sec, default 10s) — independent of--controller, pushing state and pulling config are unrelated axes.aggregator_clientbuilds anIngestPayloadstraight from the liveRuntimeHandle(snapshot().poolsfor pool/backend summaries,sessions()filtered byProtofor TCP/UDP counts) andPOSTs it every tick; the wire shape is duplicated inwayhouserather than addingwayhouse-aggregatoras a dependency (same reasoning ascontroller_clienthand-parsing the controller’s SSE JSON instead of depending onwayhouse-controller). No retry buffer — a deliberate deviation from this line’s original wording:IngestStoreis latest-write-wins state, not an event log, so replaying an old failed push after a fresher one already landed would make the aggregator’s view older; a failed push just logs and is superseded by the next tick’s fresher snapshot. (Push, not pull — see “Fleet topology” indocs/10for why: no inbound network path to a proxy’s admin port is ever needed, at any deployment size.) 1 new test (build_payloadagainst a realRuntime); verified live end-to-end over real HTTP (WAYHOUSE_LOG=debugshowed two successful pushes 2s apart, matching--aggregator-interval-sec 2). - ✅
GET /fleet/pools//fleet/sessions//fleet/healthz— served directly fromIngestStore(no fan-out RPC needed, the data already arrived), each entry carryinglast_seen_ms_ago(the aggregator’s own clock)./fleet/healthzflags an instancestalepast 30s (~3xwayhouse’s default push interval) — the thresholdIngestStoreitself deliberately doesn’t have. NoGET /fleet/configin this release:IngestPayloadis state, not config content — the controller already owns that (GET /config//config/revisions). A fleet-wide “which revision is each instance running” view is real and useful (an optionalconfig_revisionfield onIngestPayload) but needscontroller_clientandaggregator_clientto share state insidewayhousethat today are deliberately independent — deferred, not dropped. 8 new tests (incl. a test-onlyIngestStore::insert_stateseam to test the staleness threshold without a real 30s wait); verified live end-to-end over real HTTP. - ✅ Intent-verb fan-out (
wayhouse-aggregator/src/fanout.rs), thin and stateless — the aggregator decides nothing, stores no intent, just relays using each instance’s self-reportedadmin_url(a newIngestPayloadfield,http://{settings.admin.listen}). Two shapes: targeted (POST /fleet/instances/{instance}/drain|undrain— draining an instance only ever means one instance, so this passes that instance’s own response straight through,404for an unknown name) and broadcast (POST /fleet/pools/{pool}/backends,PATCH/DELETE .../backends/{addr},POST /fleet/route-hint— no shared owner across instances this release, so applying fleet-wide means calling every known instance’s own admin API independently via atokio::task::JoinSet, reporting per-instance results; one bad instance never fails the others). 9 new tests using real mock instance HTTP servers (reachable, unreachable, and a realJsonextractor to catch content-negotiation bugs) — one of them (a_broadcast_sets_content_type_even_if_the_caller_ never_did) is a regression test for a real bug the live smoke test caught: the broadcast forwarded a caller’s body without ever settingContent-Type, so a caller that omitted it got415from the target’s ownJsonextractor even though the body was valid JSON — fixed by settingapplication/jsonexplicitly on every forwarded body, since these endpoints are always JSON regardless of what the caller remembered to send. Verified live end-to-end over real HTTP (targeted drain flipped the real instance’s/readyzto503; broadcast add-backend landed on the instance’s real/pools). - ✅ Bearer-token auth, three independent secrets for three independent
hops (never one token threaded through everything):
wayhouse-aggregator --auth-tokengates its own API (/ingest,/fleet/*, fan-out writes;/healthzstays open);settings.admin.auth_token(newwayhouse-configfield) closes the “admin API has zero auth” gap onwayhouse’s own admin API the same way (admin.rsgains arequire_bearermiddleware, mirroringwayhouse-controller’s);wayhouse --aggregator-tokenis what a proxy presents pushing to/ingest;wayhouse-aggregator --instance-tokenis the separate secret the aggregator presents out to every instance’s admin API when fanning out (settings.admin.auth_tokenon the instance side must match). 3 new tests (admin auth gate, aggregator auth gate,wayhouse-configparsingauth_token). Verified live end-to-end over real HTTP through every hop: unauthenticated aggregator read →401; authenticated read showed the real pushed data; unauthenticated instance admin call →401; a fan-out drain (through the aggregator, correctly presenting--instance-token) →200, confirmed real by the instance’s own/readyzflipping to503.
All 10 slices of the phase 10+11 PoC’s controller + aggregator halves are now done — the remaining slices (11–13) are the Web UI, integration tests, and docs polish.
Web UI + tests
Slice 11 is a dedicated wayhouse-ui process — a BFF (backend-for-frontend),
not a static SPA calling the controller/aggregator bearer-token APIs
directly. Design refinement made when actually starting it, now locked in
docs/10 (“The admin GUI”): the browser gets its own session-cookie login
on wayhouse-ui, wholly separate from every machine-to-machine bearer token
(--auth-token/--instance-token/--aggregator-token/
settings.admin.auth_token), none of which a browser ever holds — wayhouse-ui
holds the controller’s and the aggregator’s tokens itself and calls both on
the operator’s behalf. wayhouse-ui has no store and no fleet data of its own
(nothing outlives a restart beyond active sessions) — it is not a third
authority, it authorizes nothing itself beyond “is this a valid session.” A
React + Vite + TypeScript SPA it serves itself (tower-http::ServeDir), one
process, one port for the operator. No proxy admin port, and no machine
token, is ever exposed to a human directly.
11a. ✅ wayhouse-aggregator gains GET /fleet/subscribe (SSE, bearer-gated like
the rest of /fleet/*) — the machine-to-machine feed wayhouse-ui (11d)
subscribes to for live updates: current merged fleet state
(FleetInstanceView — pools + sessions + a stale flag, one entry per
instance) on connect, then a resend, debounced 150 ms against a burst,
on every accepted POST /ingest. Reuses the exact
catch-up-then-broadcast shape wayhouse-controller’s /config/subscribe
already proved out, applied to state instead of a revision log — no
cursor/replay semantics needed, a Lagged subscriber just means
“rebuild now,” same as any other signal. 3 new tests (immediate initial
send, resend-after-debounce, a 5-push burst collapsing into exactly one
resend); verified live over real HTTP against a real wayhouse push loop.
11b. ✅ New crate wayhouse-ui (lib + bin, no wayhouse-core/wayhouse-config dependency —
stays as decoupled as wayhouse-aggregator is). --ui-password:
POST /ui/login {password} issues a random 256-bit session id
(session::SessionStore, in-memory — a restart just logs everyone out,
the same “ephemeral, nothing durable” posture the aggregator already
has), returned as an HttpOnly, SameSite=Lax cookie (not yet marked
Secure — noted as a gap for a TLS-fronted deployment, not silently
ignored; since 2026-10-02 a UI serving native TLS, --tls-cert, sets
Secure itself, behind a proxy the proxy must); POST /ui/logout clears it; GET /ui/session (gated by the
new require_session middleware) lets the frontend check login state
on load. None (--ui-password omitted) leaves the UI open, consistent
with every other optional-auth surface in this fleet. 9 new tests;
verified live end-to-end over real HTTP through the whole cycle:
unauthenticated → 401, wrong password → 401, right password → a
cookie that unlocks the gated route, logout → 401 again.
11c. ✅ wayhouse-ui proxies reads + slice-9 operational verbs to
wayhouse-aggregator (--aggregator-url/--aggregator-token) — a new
aggregator_proxy.rs, thin and stateless like wayhouse-aggregator::fanout
it calls through to: GET /api/fleet/pools/sessions/healthz,
POST /api/fleet/instances/{instance}/drain|undrain, backend
add/patch/delete, route-hint. Translates the browser’s session cookie
into the aggregator’s bearer token server-side — the browser never
holds it. 503 if no --aggregator-url was configured at all, 502
if it was but couldn’t be reached, both handled cleanly (no panic). This
is phase 10’s “operational” GUI capability level (docs/10):
read-heavy plus these verbs, no structural config editing yet. 6 new
tests (incl. a real mock-aggregator round-trip proving the token and
request body are forwarded correctly). Verified live end-to-end over
the real four-hop chain: unauthenticated browser call → 401; logged
in → GET /api/fleet/pools showed the real pushed data; a drain
through the full wayhouse-ui → wayhouse-aggregator → wayhouse path landed for real,
confirmed by the instance’s own /readyz flipping to 503.
11d. ✅ wayhouse-ui’s GET /ws/fleet: a WebSocket to the browser, fed by a
single shared crate::fleet_feed subscription to wayhouse-aggregator’s
slice-11a SSE feed (one aggregator connection total, fanned out to
every browser tab — not one per tab). fleet_feed::run reuses wayhouse’s
own controller_client’s hand-rolled SSE parsing (a chunked-body loop
splitting on blank lines) and reconnects with the same capped
exponential backoff on disconnect; a latest cache means a browser
connecting between two pushes gets the current view immediately rather
than waiting for the next one. A Lagged WS subscriber resends
latest rather than replaying — same “this is state, not a log”
reasoning as wayhouse-aggregator’s own subscribe_fleet_worker. Gated by
require_session like everything else the browser reaches (a WS
upgrade is an ordinary GET until the 101 handshake). 5 new tests,
3 of them against a real WebSocket client (tokio-tungstenite) and a
real axum::serve listener, not mocks. Verified live end-to-end over
the full five-hop chain with a throwaway probe client: real wayhouse →
real wayhouse-aggregator (SSE) → real wayhouse-ui (fleet_feed) → a
WebSocket client, watching live pushes arrive in real time.
11e. ✅ wayhouse-ui proxies the controller’s config API too
(--controller-url/--controller-token, new controller_proxy.rs):
GET/POST /api/config, GET /api/config/revisions(+/{rev}(/diff)),
POST /api/config/rollback/{rev} — phase 10’s “full management” GUI
level, straightforward since wayhouse-ui already holds a separate token
per service. The controller’s POST /config body is raw YAML text
(its handler takes a plain String, not JSON), forwarded byte-for-byte
with no content-type forced on it, unlike the JSON bodies
aggregator_proxy forwards. The diff route forwards the incoming
query string (?against=) via axum::extract::RawQuery onto the
proxied URL. 6 new tests. Found and fixed a real bug along the way:
both proxy helpers (here and in aggregator_proxy, plus
wayhouse-aggregator’s own fanout::proxy_to_instance) forwarded only
status + body, silently dropping every response header — caught
immediately by a test asserting X-Config-Revision survived the hop,
which it didn’t. Fixed with a shared proxy_util::forwardable_headers
(strips only the hop-by-hop headers connection/transfer-encoding/
content-length, forwards everything else verbatim) and a matching
fix + regression test in wayhouse-aggregator. Verified live end-to-end
over the full chain against a real wayhouse-controller: submit → list
revisions → diff → rollback → GET /api/config correctly showing
X-Config-Revision: 3 (the new post-rollback revision, not a rewind).
11f. ✅ Frontend: React + Vite + TypeScript in crates/wayhouse-ui/web/ (own
package.json, never a Cargo workspace member — same reasoning as
crates/wayhouse-config/fuzz/; make ui runs npm install && npm run build,
output goes to dist/). wayhouse-ui gained --static-dir (default
crates/wayhouse-ui/web/dist), served as a fallback under whatever the API
routes don’t claim, via tower-http::services::ServeDir (new
dependency). No client-side routing — one page, view state (which tab)
lives in React state, not the URL; nothing here needs a deep link yet.
Login (session-cookie form), FleetView (per-instance/pool/backend
table live over 11d’s WebSocket, drain/undrain, backend add/patch/
remove, route-hint — phase 10’s “operational” level), ConfigView
(editor + submit + revision history/diff/rollback — phase 10’s “full
management” level). src/api.ts holds every fetch call; wayhouse-ui’s
session cookie is the only credential the browser ever sends — never a
bearer token, per the whole point of the wayhouse-ui redesign. Verified
live end-to-end with the real built frontend served by a real
four-process fleet (wayhouse + wayhouse-controller + wayhouse-aggregator +
wayhouse-ui, all running together for the first time): index.html and a
JS asset served with correct content-type, login, a config submission
and a fleet-pools read through the served UI’s own proxy paths, and a
drain issued through the full chain landing for real (confirmed by the
instance’s own /readyz flipping to 503).
- Slice 11 (all of 11a-11f) is now complete. The whole
wayhouse-uiBFF — session login, fleet reads/ops proxying, the live WebSocket, config editing/history proxying, and the actual frontend serving all of it — has been verified live end-to-end, repeatedly, against real runningwayhouse/wayhouse-controller/wayhouse-aggregatorprocesses, not just against unit tests. - The 11f frontend itself was a functional PoC, not a finished operator UI (confirmed by the user actually clicking through it in a browser; since redesigned — see “Later / optional” below) — every backend path it drives is real and solid, but the UI layer is deliberately bare: no styling/design pass, no confirmation dialogs before a destructive action (drain, remove-backend fire immediately), no loading states beyond a bare “loading…”, no client-side routing, plain unstyled tables and forms. A real frontend overhaul is future, separate work — tracked under “Later / optional” below — deliberately deferred rather than polished now, since it doesn’t block anything else in this phase.
- ✅ Integration tests: N
wayhouseinstances + 1 controller + 1 aggregator — subscribe/reconnect/freeze-on-disconnect, push/ingest, fan-out partial failure, config reject-keeps-previous. New cratecrates/wayhouse-fleet-tests(workspace member, part ofmake check): spawns the realwayhouse/wayhouse-controller/wayhouse-aggregatorbinaries as child processes on loopback with OS-assigned ports and drives them over real HTTP, rather than an in-process harness — matching how every earlier slice was actually verified (see HANDOVER.md) and specifically able to catch the wire-shape class of bug slices 11e/11f already found live. 4 tests, all green.wayhouse-uiisn’t spawned here — nothing in the 4 listed scenarios exercises it, and it has no state of its own to assert on beyond what slice 11’s own tests already cover. - ✅ Docs:
docs/06gained a “Fleet control plane” section (full endpoint reference forwayhouse-controller/wayhouse-aggregator/wayhouse-ui, and updated the now-stale “not persisted or fleet-synced today” / “no built-in fleet-wide view” callouts in “Multi-instance operations” to point at it);README.mdstatus block now covers phase 10+11; this status legend;HANDOVER.md.
- Result: one controller to distribute config to a fleet, persistently and
with an audit trail, and one aggregator to view and operate that fleet from
one screen — a real, working v2 release. The
standalone/slavehierarchy, intra-tier HA, adoption, and moving intent into the controller are the next release on top of this, not blocking it. - All 13 slices of phase 10+11 are now complete.
Phase 12 – Fleet hierarchy, HA, and shared intent ✅
Full design: 10-distributed-control-plane.md (“Fleet topology”, “Adoption”). Builds on phase 10+11’s single-tier PoC — additive, no rework of what shipped there.
- ✅ Slice 1 (
wayhouse-controllerrole):standalone/slaverole, static and install-time (--role, never inferred from connectivity). Astandalonetier is unchanged from phase 10+11. Aslavetier (--parent-url+--parent-token) never accepts a write directly (POST /configand/config/rollback/*both403) — insteadparent_clientseeds from the parent’sGET /configat startup, then subscribes toGET /config/subscribeand relays every accepted revision into its own store (its own local revision numbers, not the parent’s — only the payload shape is shared). Reuseswayhouse’s owncontroller_client’s reconnect-with-backoff shape; a lost parent freezes the slave on last-known-good and keeps serving/relaying it downward, same “never clear” rule as the proxy-to-controller hop. Verified live: root submits a revision → slave relays and serves it with its ownX-Config-Revision; a direct write to the slave is403; killing the root leaves the slave serving what it had and retrying with backoff. Not yet built at the time: aggregator-side hierarchy, intra-tier HA (Raft/etcd), intent migration into the revision log, RBAC, canary rollout, adoption. - ✅ Slice 2 (
wayhouse-aggregatorhierarchy):--parent-url(+--tier-name,--parent-token,--parent-push-interval-sec) makes awayhouse-aggregatortier also push its own merged view up to a parent aggregator’sPOST /ingest, on a fixed interval, via newparent_push.rs— “a tier’s aggregator is itself a valid leaf to its parent aggregator” (docs/10). Deliberately namespaces rather than merges: every instance this tier currently knows is pushed individually with itsinstancerenamed"{tier_name}/{instance}", so the parent’sIngestStore//fleet/*need no new payload shape or special-casing — it just sees more, longer-named instances (namespacing only touches the copy sent upward; this tier’s own local names and/fleet/*view are untouched). 3 new tests, incl. one against a real mock parentaxum::servelistener. Verified live: a child aggregator ingestingproxy-1shows up on a real parent aggregator’sGET /fleet/sessionsasregion-a/proxy-1. Not yet built at the time: intra-tier HA, intent migration, RBAC, canary rollout, adoption. - ✅ Slice 3 (operator intent → the controller’s revision log):
pool-scoped operator intent — backend add/remove, backend admin state,
route-hint — now has a fleet-wide, persisted path through
wayhouse-controller, alongside (not replacing) direct per-instance admin API calls. Newwayhouse-controller::intentmodule: a secondsledlog (its own<data_dir>/intentdatabase, reusingStoreunchanged),POST /intent(validates the op’s shape — addr/IP parse, known state — before it’s ever broadcast, since there’s nowayhouse_config::validate()to lean on for a bare op) andGET /intent/subscribe, the identical catch-up-then-tail shape config uses. Deliberately excludes whole-instance drain/undrain (it targets one instance, not “every instance with pool X” — stays on the aggregator/direct-admin path) and resolver pins (still an open question,docs/01). Newwayhousemoduleintent_client.rssubscribes whenever--controlleris set and applies each op through the exact sameRuntimeHandlecallscrate::admin’s handlers make — an intent op is a new source for an existing mutation, not a new code path. 15 new tests across both crates. Real pre-existing bug found and fixed along the way, via this slice’s live smoke test: in--controllermode, nothing was watchingRuntimeHandle::reload_requested()at all —reload::runis the only thing that ever did, and it’s only spawned in file-config mode — so the phase-5 admin API’s ownPOST/DELETE /pools/{pool}/backends(backend overlay edits) silently never took effect for any--controllerinstance, before this slice existed. Fixed with a newcontroller_client::watch_admin_reloads, spawned alongside--controller: debouncesrequest_reload()notifications and re-fetches- re-applies the controller’s current config (rather than caching a local
copy, so there’s nothing to drift from the controller’s own view).
Verified live end-to-end:
wayhouse-controller+wayhouse --controllerrunning for real, aPOST /intentbackend_addlanded inwayhouse’s ownGET /poolsoutput within one debounce window. Not yet built at the time: intra-tier HA, slave-tier intent relay (only config relayed through a parent then), RBAC, canary rollout, adoption.
- re-applies the controller’s current config (rather than caching a local
copy, so there’s nothing to drift from the controller’s own view).
Verified live end-to-end:
- ✅ Slice 4 (slave-tier intent relay): the intent-log counterpart to
slice 1’s config relay. New
wayhouse-controller::intent::relay, structurally identical toparent_client(subscribe-with-backoff, land viaIntentState::apply_revision— the same role-gate bypass) with one difference: nofetch_initialseed step, since an intent log has no single “current document” to seed from —since=0’s catch-up on the first subscribe already replays the parent’s whole history, unlike config’s “must serve something the moment a fresh slave comes up.” Aslavetier now relays both logs from its parent, still never originating either directly (403on both/configand/intentwrites). 4 new tests. Verified live with two realwayhouse-controllerprocesses: root submits an intent op → slave relays and serves it under its own local revision number; direct writes to the slave still403; killing the root freezes the slave on both logs, retrying with backoff. Not yet built at the time: intra-tier HA, RBAC, canary rollout, adoption. - ✅ Slice 5 (adoption):
POST /admin/adopt {"parent_url":...}flips a runningstandalonetier toslavewithout a restart (docs/10“Adoption”). Solves the design doc’s two open correctness questions the same simple way: adoption is refused (409) unless both the config and intent stores are still empty — a tier with its own revision history must be replaced by a fresh one to join a hierarchy, not reconciled in place; an empty store trivially has nothing in flight to reorder either. New sharedrole::RoleHandle(anArc<RwLock<Role>>, replacing the plainRolefieldAppState/IntentStateheld) so the flip is visible to both write gates instantly, from every clone. On success: seeds from the new parent’s config (mirrorsmain.rs’s own boot-time seed) and spawns the sameparent_client/intent::relaytasks a--role slaveboot would have — a freshly-adopted tier is indistinguishable from one that started as a slave. 6 new tests. Verified live with two realwayhouse-controllerprocesses: adopting a fresh child returned{"seeded_config_revision":1}, itsGET /configimmediately served the root’s config, and a direct write to it was403from that point on. Still not built: intra-tier HA, RBAC, canary rollout.
Slices 6–8 (2026-09-05 design session, now all built) — each has its own
docs/10 section (with wire shapes, storage layout, and rejected
alternatives) and a docs/09 ADR (21–23):
- ✅ Slice 6 — Intra-tier HA (
wayhouse-controlleronly; the aggregator’s HA design — stateless replicas behind one address, no consensus needed — stays design-only, not built): embeddedopenraft0.9, one Raft group per controller tier replicating both the config and intent logs. Newwayhouse_controller::hamodule: asled-backedLogStore(RaftLogStorage/RaftLogReader, its own<data_dir>/hadatabase — durable across a restart, unlikeopenraft‘s own in-memory reference implementation this was adapted from), aStateMachineStore(RaftStateMachine/RaftSnapshotBuilder) that applies a committed entry by calling the exact sameAppState/IntentState::apply_revisiona direct write already used, areqwest-basedRaftNetworkposting to peers’/raft/*routes (gated by a new peer-only--ha-token), andha::client::propose_write— the one callapi::submit/intent::api::submit_intentmake instead ofapply_revisiondirectly when--ha-peersis set: proposes via Raft, and transparently HTTP-forwards to the current leader (never a redirect) if this replica isn’t it, so no client anywhere needs to know HA exists. New CLI:--ha-node-id,--ha-peers id=host:port,...(identical on every replica; each boots and callsraft.initialize()with the same static set — harmless no-op on every node but the one that wins the race),--ha-token. Scope cut at the time, built since (2026-10-05): HA and theslaverole were mutually exclusive in this slice; the leader-only upward relay with a replicated cursor, designed indocs/10, now exists. 16 new tests. Verified live with a real 3-node cluster: booted nodes 1/2/3, confirmed node 3 elected leader; submitted a config write to node 1 (not the leader) — transparently forwarded,200,{"revision":1}; submitted to node 2 —{"revision":2}; all three nodes’GET /configagreed on revision 2. Then killed the leader (node 3) and confirmed real failover: node 1 was elected the new leader within seconds, and both a config write (revision 3) and an intent write both succeeded through the surviving 2-node majority. - ✅ Slice 7 — Staged / canary rollout: one monotonic config log, never
forked — revisions tagged
{promoted, canary_groups}in a newstagesledtree (a sibling ofStore’s own trees, opened via a newStore::db()accessor;Storeitself gains nothing, stays exactly the content-agnostic log it always was).POST /config?stage=canary&group= <name>submits a revision visible only to a subscriber reporting that group (GET /config?group=<name>,GET /config/subscribe?group= <name>); a plainPOST/GETwith nostage/groupis byte-for-byte the pre-slice-7 behavior (every revision defaultsStage::promoted).POST /config/promote/{revision}is the one deliberate exception to “every change is a new revision” — flips an existing revision’s visibility in place.GET /config/revisionsgainedpromoted/canary_groupscolumns.subscribe_worker’s catch-up/tail now filter every candidate throughStage::visible_to, holding an invisible revision back (without advancing the subscriber’s cursor past it) until a later signal — its own promotion, or any newer submission — makes it either visible or moot. HA-aware:WriteRequest::Confignow carries itsStagethrough the Raft log, and a newWriteRequest::Promote(revision)variant replicates a promotion the same way. Deliberately config-only, not the intent log — no clean partial-rollout meaning for a single already-narrow op (seedocs/10). 7 new tests. Verified live: submitted a promoted v1 and aregion-a-canary v2; a plainGET /configandGET /config?group=region-bboth still showed v1;GET /config?group= region-ashowed v2;POST /config/promote/2then made v2 the plainGET /configanswer too. Seedocs/10“Staged / canary rollout (design)”, ADR 22. - ✅ Slice 8 — RBAC and audit: multi-operator accounts (
--users-file,argon2PHC hashes,wayhouse-ui --hash-passwordto produce one) and three roles (viewer/operator/admin) enforced inwayhouse-ui— the controller/aggregator keep their existing single shared-token gates unchanged, per the design’s explicit continuation of the phase 10+11 slice-11 divergence.--ui-passwordis kept (not replaced) as a legacy single-shared-secret, implicitly-adminmode. Newwayhouse_ui::role::Role(Viewer < Operator < Admin, derivedOrd) and aSessionStorethat now maps a session id to{role, username};crate::auth::check_role(min)gates three separately-route_layered sub-routers (viewer/operatorfromaggregator_proxy,viewer/adminfromcontroller_proxy) —401for no/invalid session, a new403for a valid session below the route’s minimum. A newX-Actorheader (the session’s username, if any) rides every writewayhouse-uiproxies;wayhouse-controllerrecords it per revision in a newactorssledtree (GET /config/revisionsgained anactorfield — through HA too:WriteRequest::Confignow carriesactoralongsidestage, and leader-forwarding preserves the header so whichever node ends up proposing the write has it);wayhouse-aggregatorforwards it to each instance and logs(instance/pool, actor, verb)viatracing(a convenience, not a durable record — this aggregator holds no durable state by design). 17 new tests acrosswayhouse-ui/wayhouse-controller/wayhouse-aggregator. Verified live: hashed a password with--hash-password, logged in as that user against a realwayhouse-uiwith--users-file, submitted a config through the fullwayhouse-ui→ realwayhouse-controllerchain, and confirmedGET /config/revisionsshowed"actor":"alice"on the resulting revision. Seedocs/10“RBAC and audit (design)”, ADR 23.
Phase 12 is now fully built — all 8 slices (hierarchy, both relay
logs, adoption, intra-tier HA, staged/canary rollout, RBAC and audit) are
implemented, individually verified live, and covered by make check. The
design in docs/10 is realized: config/intent consistent and durable
across an arbitrarily large, regionally structured fleet, no single point
of failure at any tier, and per-role accountability for every change.
Phase 13 (the regional health fabric, Tier 2) is now fully built — see
below.
Phase 13 – Regional health fabric ✅
Full design: 10-distributed-control-plane.md
(Tier 2). Advisory, rebuildable, off the data path. Fully built
(2026-09-05) — all 5 slices below, verified live including two real
multi-process tests in crates/wayhouse-fleet-tests; see “Mechanism (built)” in
docs/10 and ADR 24 in docs/09 for the locked design this implements.
Mechanism: membership via embedded
foca (SWIM); per-backend health as a last-writer-wins (instance, backend) register piggybacked on foca’s own broadcast/anti-entropy;
HMAC-SHA256 over a per-domain pre-shared key, not mTLS.
Slices:
- ✅ Config schema (done):
settings.failure_domain+settings.gossip {bind, seeds, quorum_fraction, psk}inwayhouse-config(raw + resolved types,validate()rejects one without the other),config.example.yaml,docs/05. - ✅ Membership (done): new
wayhouse-core::gossipmodule wrappingfoca::Foca(SWIM) over a plain UDP socket, spawned byRuntime::start_with_discoveryonly whensettings.gossipis set (wayhouse/src/main.rspassescfg.gossip.clone(); startup-only, likegeo/sniffers— a reload does not start or stop the mesh). HMAC-SHA256-tagged/authenticated datagrams (bad or missing tag ⇒ dropped silently +wayhouse_gossip_auth_rejected_total);wayhouse_gossip_members/wayhouse_gossip_messages_totalinmetrics_defs.rs. This slice’sNoCustomBroadcastmeans no application payload rides the mesh yet — pure membership. 8 new tests (6 unit incl. two real two-process-equivalent in-tokio SWIM convergence tests; a wrong-PSK-never-joins test). Verified live with two real separatewayhouseprocesses on real sockets (not in-process): both converged towayhouse_gossip_members 1with real send/receive traffic on/metricswithin the SWIM probe period. - ✅ Per-backend health broadcast (done):
BackendHealthRegister {addr, up, changed_at, origin}is a last-writer-wins payload piggybacked via a newBroadcastMerger: foca::BroadcastHandler<SocketAddr>—foca’s own anti-entropy re-gossips it until fully propagated, no second sync pass.GossipHandle::publish_backend_health(addr, up)(anmpscchannel into the mesh task; the actualhealth.rscaller is slice 4) asserts this instance’s own verdict;GossipHandle::domain_votes(addr)/quorum_down(addr, fraction)read the merged view ((0, 0)votes ⇒quorum_downis alwaysfalse— an empty view never overrides, only a real quorum can). 5 new tests: puredomain_votes/quorum_downunit tests, and a real two-process-equivalent in-tokio test proving a published register actually reaches the other instance through foca’s own gossip (not a direct map share) and thatquorum_downreads it back correctly. One real bug found by that test, not by inspection: an early draft merged the register into the domain map before callingfoca.add_broadcast, which madefoca’s own internalreceive_item(data, None)call see an already-known key and silently skip queuing it for dissemination — fixed by lettingadd_broadcastperform the one and only merge (thesender: Nonecase inBroadcastHandler::receive_itemexists exactly for this “adding it myself” path). - ✅ Pool/health integration (done):
Backendgaineddomain_down: AtomicBoolalongside the existinghealthy(untouched);is_healthy()=local_healthy() && !domain_down_flag(). NewBackend:: observe_domain(quorum_down)can only set/cleardomain_down, neverhealthy— a backend still only returns healthy on this instance’s ownrisestreak.health.rs::sweepgained an optionalGossipFabric(handle +quorum_fraction,Nonewhensettings.gossipis unset): every active-check result also callsGossipHandle:: publish_backend_health(addr, ok)(only for backends this sweep itself just probed — the “only ever asserts what it checks itself” rule from the design), and after every sweep, every backend’sdomain_downis refreshed fromGossipHandle::quorum_down.Runtime::start_with_discoverybuilds oneGossipFabric(ifsettings.gossipis set) and hands it to both the health task and the gossip task, sosweepnever has to reach across tasks.wayhouse_backend_domain_down{pool,backend}gauge added. 5 new tests: 3 purepool.rsunit tests (domain-down overrides but localrisealone clears it; domain-down clearing never revives a locally-down backend;domain_downcarries across a reload by address, mirroring howhealthyalready does) + 2health.rstests, one provingsweepnever touchesdomain_downwith no gossip fabric, one driving a real single-node mesh through the full real pipeline (channel → mesh task →add_broadcast→ merge) and confirming a repeatedsweepeventually reads its own published verdict back and overrides the backend down. - ✅ Verification (done,
crates/wayhouse-fleet-tests/tests/gossip.rs): two real multi-process tests, spawning actualwayhousebinaries the same way every other fleet slice was verified.domain_quorum_overrides_an_ instance_with_a_lenient_local_thresholdgives 3 real processes the same real (unreachable) backend but a per-instance-differentfallthreshold (2 instancesfall: 1, onefall: 1000) — a genuinely mixed local view achieved with plain config, no faked wire data — and confirms the lenient instance’s combinedis_healthy()still goes false purely from the domain quorum, live proof of “an instance cannot ignore a domain-wide outage.”instances_with_different_psks_never_merge_and_ neither_gets_stuckconfirms two real processes with mismatched PSKs never converge (wayhouse_gossip_membersstays0on both) yet each still correctly runs its own local-only health checks and neither process hangs or crashes — the “fully rebuildable, falls back to local-only” property with a real empty mesh, not a config that merely looks empty.
- Each instance publishes its per-backend
up | down; consumes the domain view. - Health decision becomes quorum-weighted: unhealthy on local
fallor domain quorum-down; healthy only on localrise; Tier-1force-downoverrides. - Cold start / total partition ⇒ identical to today (own checks only).
- Result: faster, multi-vantage-point backend health across a domain; one bad vantage point no longer flaps a pool.
Phase 14 – Backend transport ✅
Full design: 11-backend-transport.md. Closes the
gap docs/01-requirements.md’s Assumptions section states outright
(“Backends are reachable over a trusted internal network”) — not true once
proxy instances are distributed globally (phase 10–13’s whole point) in
front of game servers on a different network entirely. Complete — all 7
slices built, see ADR 25 in docs/09.
Mechanism (design, locked 2026-09-05 follow-up session — see docs/11
“Locked decisions”): unmodified WireGuard via defguard/wireguard-rs
(unifies kernel-netlink and boringtun-userspace config behind one API) as
the tunnel data plane between an edge proxy and an origin — no new crypto/
framing protocol, and one shared interface per side with many peers, not
one interface per origin. connect_backend/connect_upstream/
health.rs’s dials need no changes, since a tunneled backend is just a
routable SocketAddr once the interface exists. An origin is modeled as a
new BackendSource (ADR 12a) — backend_sources[].type: tunnel — not a
new schema concept. The two genuinely new pieces: a small origin-side
wayhouse-agent crate that manages the local WireGuard interface and registers
its pubkey/backend addresses, and a “backend peers” registry on the
existing Tier-1 wayhouse-controller that distributes peer configuration to
every subscribed edge proxy the same way config revisions already are.
WireGuard’s own roaming + keepalive mean only the proxy side ever needs a
public endpoint — an origin behind a home NAT needs no port forwarding.
Slices, ordered by dependency (each was buildable and testable before the next needed it; all now built):
-
✅ Config schema:
backend_sources[].type: tunnel(wayhouse-configraw + resolved types,validate(),config.example.yaml,docs/05) — parses and validates with no runtime behavior behind it yet, same “schema first, mechanism after” shape phase 13 slice 1 used. Fields: at minimum the origin’s expected public key and which pool(s) it feeds; exact shape decided in this slice, not locked indocs/11. -
✅
wayhouse-controller’s backend-peers registry: a new resource alongside the config-revision and intent logs (its ownsledtree/DB, matchingStore’s own precedent) —POSTfor an agent to register (pubkey, allowed backend addresses, last-known endpoint) and a subscribe endpoint mirroringGET /config/subscribe’s catch-up-then- tail shape. Buildable and independently testable with nothing butcurl/the crate’s own HTTP tests — no real WireGuard,wayhouse-agent, orwayhouseintegration needed yet, same as how the config/intent logs were each built and tested standalone before any client integrated with them. -
✅ New
wayhouse-agentcrate: creates/maintains one local WireGuard interface viadefguard/wireguard-rs, registers with slice 2’s registry on startup and on change. Verifiable standalone: point a realwayhouse-agentat a realwayhouse-controller, confirm its registration lands and a real local WireGuard interface comes up with the right key. -
✅
wayhouse’s subscribe-and-reconcile task: mirrorscontroller_client.rs’s shape, subscribes to slice 2’s registry, and reconciles the proxy’s own shared WireGuard interface’s peer list (viawireguard-rs) to match. Verifiable standalone against slice 3’s real agent: the proxy’s interface should show the agent as a peer within one subscribe cycle, independent of any actual game traffic yet. -
✅ The new
tunnelBackendSource(wayhousebinary, same seam as the existing DNS-SRV/Consul/Kubernetes sources):fetch()resolves an origin’s currently-registered backend address(es) from the same peers registry slice 4 already subscribes to. This is what actually lets a pool’ssource: <origin>produce live, tunnel-internal backend addresses through the existing discovery reconcile path — no changes needed toSnapshot::build_with_sourcesitself. -
✅ End-to-end live verification: a real
wayhouse-controller+wayhouse-agent(+ a plain TCP echo backend) +wayhouseproxy in four Docker containers (NET_ADMIN+/dev/net/tunon the two WireGuard sides — this project’s own dev sandbox has neither, so containers were the practical way to get aCAP_NET_ADMIN-capable host), a real payload round-tripped through client →wayhouse’s public listener → WireGuard tunnel (boringtun userspace backend) →wayhouse-agent’s interface → the echo backend → back, confirmed stable (a live handshake surviving many re-registration cycles, not just a one-shot connect) — seeHANDOVER.mdfor the two real bugs this surfaced and fixed:wayhouse-agentnever added the edge proxy as a peer at all (closed with--peer-pubkey/--peer-endpoint), andtunnel_client::runtore down and rebuilt the WireGuard session on every re-registration even when nothing changed (boringtun panics on a same-pubkeyconfigure_peer, and the constant churn never let a handshake stabilize) — fixed by skipping a registration identical to the last one applied. -
✅ Proxy-peers registry — the mirror-image direction: slice 6’s end-to-end verification shipped
wayhouse-agent --peer-pubkey/--peer-endpointas a static single-proxy pin, which proved the tunnel data plane but doesn’t scale — a growing proxy fleet, or a proxy added after an origin was already deployed, needed the origin’swayhouse-agentrestarted with new flags to learn about it. Closed by mirroring slices 2+4’s mechanism in the other direction: a newwayhouse-controller“proxy peers” registry (wayhouse_controller::proxy_peers,POST/GET /proxy-peers(+/{name}),GET /proxy-peers/subscribe) that everywayhouse --tunnel-*instance registers itself into (newwayhouse:: proxy_register, mirrorswayhouse-agent::register), and a newwayhouse-agentsubscribe-and-reconcile task (proxy_subscribe.rs, mirrorswayhouse’s owntunnel_client.rs, including the same remove-then-add + skip-unchanged fix slice 6 needed) that reconciles every registered proxy onto the origin’s interface.--peer-pubkey/--peer-endpointstill work alongside it as a manual pin — the two converge to the same state, no conflict. Live-verified (2026-10-01) bymake tunnel-e2escenario 3: a proxy started after the origin is learned by the already-running agent with no restart, and carries traffic. That run also found a real bug: every proxy peer gotAllowedIPs 0.0.0.0/0, so a second proxy stole the first one’s route. Fixed 2026-10-02 with the tunnel address authority (docs/11“Address authority”): peers are/32s, andtwo_proxies_share_one_originnow passes in the lab.
Still open (see docs/11 “Open questions”): the CGNAT/both-sides-restrictive- NAT fallback (documented v1 limitation, no code) — does not block starting slice 1.
Later / optional
- QUIC-CID-aware sniffer & session keying.
- Cross-instance session handover (shared session state) — still out of scope; the phase 10–13 control plane shares config and health, never sessions.
- eBPF/XDP pre-filter to drop floods before user space.
- Optional TLS/DTLS wrapping (proxy terminates, backend plain).
wayhouse-uileftovers — phase 10+11 slice 11f shipped a functional PoC; the visual redesign (commitd86c786,docs/10“The admin GUI” → Redesign) and then confirmation dialogs for destructive actions, in-flight button states, a vitest frontend suite and a table-driven header-forwarding contract test (crates/wayhouse-ui/src/proxy_util.rs) landed. Still open: a confirmation step on config submit, and browser-level end-to-end tests.deploy/— reference Dockerfile (five targets on distroless), compose control-plane demo and plain k8s manifests, smoke-tested by the CIdeployjob (docs/12; blocking since 2026-10-02).- Phase 14 follow-ups — the multi-proxy
AllowedIPsfix and the controller-allocated tunnel address authority landed 2026-10-02 (docs/11“Address authority”); the CI-friendly tunnel e2e exists (make tunnel-e2e, CI jobtunnel). Lease expiry and tunnel-pool pruning landed 2026-10-04; live address change remains (GitHub issue #40); the UI view landed 2026-10-03, releasing from it 2026-10-04. IPv6 tunnel networks and IPv6 underlays landed 2026-10-03 (docs/11“Address authority”, specdocs/superpowers/specs/2026-10-03-ipv6-tunnel-design.md), with--tunnel-readdressfor a changed network.
Milestone cuts
- MVP: phase 0–2 (L4 TCP+UDP, static, health, metrics).
- v1.0: + phase 3–5 (routing, resolver, zero-downtime).
- v1.1: + phase 6–7 (client IP, hardening).
- v1.2: + phase 8 (discovery, HA operations docs).
- v1.3: + phase 9–10+11 (sniffer loader; single-controller + single-aggregator PoC — fleet config distribution & operational Web UI). Both additive — the data-plane contract is unchanged.
- v2.0: + phase 12–13 (fleet hierarchy / HA / shared intent; regional health fabric) — the multi-region, no-single-point-of-failure realization of the phase 10+11 PoC — see 10-distributed-control-plane.md.
- v2.1: + phase 14 (WireGuard backend transport —
wayhouse-agent, backend / proxy-peers registries; proxies reach origins on a different network) — see 11-backend-transport.md. - v0.3 (in progress): web front door — Caddy packaged behind a
webpool (issue #297):web:config (#360), generatedvia: wayhouseloopback listeners,GET /weband the end-to-end tests (#368 to #370) are built; the renderer (wayhouse-web-sync), the Caddy image and ACME are separate slices.
09 – Technology choices
Language
Recommendation: Rust
- Predictable latency with no GC pauses — critical for the added RTT (NFR N1/N2).
- Zero-copy data path (
splice,sendmmsg) maps well; fine control over allocations (slab, buffer pools). - Safe parsers for peek/sniffers (no buffer overflow in the exposed path).
- Ecosystem:
tokio/monoio(io_uring),socket2,nix,arc-swap,hashbrown,governor(rate limit),prometheus/metrics,rustls(only if TLS wrapping),tonic(gRPC resolver).
Alternative: Go
- Faster to develop, very good networking standard library, easy cross-compilation.
- GC pauses are small today but measurable under millions of sessions; more tuning needed.
- Fine if the team is Go-oriented and there is headroom on p99 < 2 ms.
Not recommended
- C/C++: maximum control, but a safety risk in the exposed parser path.
- Languages with a heavy runtime (JVM, Node) for the data path — only conceivable for the control plane.
Runtime / IO model
| Option | Assessment |
|---|---|
tokio (epoll) | mature, broad, good enough; the default choice for v1 |
monoio / glommio (io_uring, thread-per-core) | best latency/throughput, SO_REUSEPORT + shared-nothing matches the design exactly; younger, smaller ecosystem |
| io_uring directly | maximum control, high effort |
Plan: v1 on tokio with a thread-per-core layout (SO_REUSEPORT, a LocalSet per
worker). An io_uring backend as a later optimization behind an IO abstraction.
Key libraries (Rust)
As-built, not the original pre-implementation shortlist — several items below were planned to use an external crate and shipped as a small hand-rolled equivalent instead, once the actual shape of the problem (tiny backend sets, one map per worker, no need for a general-purpose engine) made the extra dependency not worth it. Still-open v2 choices are marked as such.
- Sockets/syscalls:
socket20.6 (bind options incl.IP_FREEBIND,IP_TRANSPARENTv4/v6 viaSockRef),nix(IP_PKTINFO/IP_ORIGDSTADDRrecv + reply cmsgs;recvmmsgUDP ingress — ADR 18;sendmmsgUDP egress — ADR 31;spliceTCP pump — ADR 17). - Data structures: plain
std::collections::HashMapfor the UDP session table (worker-local, no sharding needed —hashbrownwas considered but adds nothing at this scale). ACLs are a hand-rolled binary radix trie over address bits (wayhouse_config::CidrSet) rather than theip_network_tablecrate — kept thewayhouse-config“serde + thiserror only” dependency rule intact. Consistent hashing is a hand-rolled rendezvous (HRW) hash over the healthy backends (stdDefaultHasher) — nohashringdependency; the backend set is tiny, so HRW’s linear scan is cheaper than maintaining a ring.weightedis likewise a plain weighted round-robin over the cumulative-weight line (one atomic tick per selection, no smooth-WRR per-backend state) — blocky ordering is fine at this backend-set size. - Config:
serde+serde_norway,notifyfor file watch. No env-var overlay exists (thefigmentidea from the original plan was dropped — the YAML file +SIGHUP/watch reload covers the actual need). - Snapshot swap:
arc-swap. - Rate limit: a hand-rolled token bucket (
wayhouse_core::ratelimit, aMutex<HashMap<key, Bucket>>, monotonic refill) rather thangovernor— one bucket type, two key shapes (per-IP / per-net), not worth a general GCRA dependency. - Metrics:
metrics+metrics-exporter-prometheus. - Tracing:
tracing+tracing-subscriber. Noopentelemetryexporter —/metrics(Prometheus) is the only telemetry sink today. - Resolver:
reqwest(HTTP) +tonic/prost(gRPC), both in thewayhousebinary only;wayhouse-coredefines theResolvertrait. gRPC codegen viatonic-buildat build time (needsprotoc). - Admin API:
axum(small, on an internal interface). - Distributed control plane (v2, ch. 10): Tier-1 store — the phase 10+11
PoC’s embedded
sled(ADR 20) is kept for HA too, replicated via embeddedopenraft(ADR 21) rather than switched toetcdor git; Tier-2 health gossip —foca(SWIM) with a hand-rolled(instance, backend)LWW register piggybacked on its broadcast/ anti-entropy (ADR 24, phase 13, fully built). Lives inwayhouse-coreitself (wayhouse_core::gossip), not thewayhousebinary — plain UDP, no HTTP/CLI surface, so it fits the crate boundary rule the same wayhealth.rsalready does. RBAC password hashing —argon2(ADR 23), inwayhouse-ui. Phase 12 (hierarchy, both relay logs, adoption, HA, staged/canary rollout, RBAC — ADRs 20–23) and phase 13 (ADR 24) are both fully built —docs/08-roadmap.md. - Backend transport (phase 14, ch. 11):
defguard/wireguard-rs(kernel-netlink primary,boringtunuserspace fallback, behind one API) for WireGuard interface management — used by thewayhouse-agentcrate andwayhouse’s--tunnel-*reconcile task (ADR 25, built). No new crypto/framing code: the tunnel protocol is unmodified WireGuard. - PROXY protocol: a small custom v1/v2 encoder (
wayhouse_core::proxy_protocol) — nopppdependency; write-only (the proxy never parses an inbound header) made the hand-rolled version simpler than adopting a parsing-capable crate. - Tests: a custom
Stats/percentile harness (wayhouse-bench) rather thancriterion— matches the project’s own latency-ledger reporting shape instead of criterion’s statistical-comparison model;cargo-fuzzfor the untrusted-input parsers (crates/wayhouse-config/fuzz/).
Platform
- Primarily Linux (x86-64 + arm64). Kernel ≥ 5.10 for a stable io_uring option.
- Kernel features used:
SO_REUSEPORT,splice,recvmmsg/sendmmsg,IP_TRANSPARENT/TPROXY,TCP_NODELAY, SYN cookies,NOTRACK. - Container: distroless/scratch image, only the required capabilities.
- No Windows/macOS in the data path (only a dev build with a reduced fast path).
Architecture decisions (ADR short form)
| ADR | Decision | Rationale | Rejected options |
|---|---|---|---|
| 1 | L4-agnostic core, L7 only as optional read-only sniffers | one proxy for all games; no protocol reverse-engineering in the core | per-game proxy; L7 termination |
| 2 | Thread-per-core + SO_REUSEPORT, thread-local session state | no locks on the hot path; linear scaling | a global session map with sharding locks |
| 3 | Immutable config snapshot + atomic swap | reload without blocking the data path | an RWLock on the live config |
| 4 | No shared session state between instances (v1) | complexity/latency; anycast reconnect is enough | Raft/Redis session store |
| 4a | (v2, ch. 10) Config and operator intent become fleet-shared via an ordered durable store (Tier 1); backend health becomes shared within a failure domain via gossip (Tier 2). Sessions stay unshared — ADR 4 holds for sessions. | operators need one authoritative config, persisted intent, and multi-vantage health; sessions do not benefit enough to justify the coupling | one global store for everything (cross-region health is noise); keep everything per-instance (intent lost on restart, not fleet-consistent) |
| 5 | Client-IP preservation optional (PROXY protocol or TPROXY) | different backend capabilities / network setups | forcing a single method |
| 6 | External routing logic via a resolver callback + cache | matchmaking stays outside; the proxy understands no tokens | hard-coding routing rules in the proxy |
| 7 | Rust | GC-free latency, safe parsers | Go (GC), C++ (memory safety) |
| 8 | UDP: a connect(2) socket per session | return path without a table lookup, kernel sender filtering | one socket + manual demux |
| 9 | UDP v0: one spawned reply-pump task per session; idle expiry by a 1 s sweep (the first cut also kept a bounded sticky table; removed in #56, affinity is a consistent_hash pool) | ships the vertical slice without recvmmsg/timing-wheel/LRU machinery; each is a drop-in later | building the batching + timing wheel + LRU up front |
| 10 | dst prefix listener: one wildcard IP_PKTINFO / IPV6_RECVPKTINFO socket per prefix, real dest per datagram, reply source via sendmsg cmsg — using nix (safe wrappers) | keeps the zero-unsafe invariant; nix was already slated for recvmmsg / TPROXY / splice | one socket bound per address (does not scale); raw libc + unsafe cmsg walking |
| 11 | Graceful shutdown drain: a watch<usize> connection counter (ConnTracker / ConnGuard) + wait_for(==0), bounded by shutdown_grace_sec; UDP recv loop enters a drain state instead of returning | no new dep; race-free wait; per-conn cost is one send_modify on open/close, nothing per byte | tokio_util::task::TaskTracker + CancellationToken (extra dep); a global Mutex<HashSet<JoinHandle>> |
| 12 | Transparent mode (TPROXY): per-listener transparent: bool. TCP — IP_TRANSPARENT on the listen socket + a client-ip:port-bound IP_TRANSPARENT upstream TcpSocket. UDP — IP_TRANSPARENT + IP_RECVORIGDSTADDR on the listen socket (recvmsg for the original ip:port), a client-bound IP_TRANSPARENT upstream socket, and a per-session IP_TRANSPARENT reply socket bound to the original destination. IP_TRANSPARENT via socket2 SockRef (bumped 0.5 → 0.6 for set_ip_transparent_v6), origdst cmsg via nix — still zero unsafe | one flag turns on the whole path; the reply socket restores source ip:port exactly (a pktinfo cmsg can only set the source IP, not the port); socket2 0.6 was already in the tree via hyper-util | PROXY protocol only (needs backend support); an IPV6_TRANSPARENT raw libc + unsafe setsockopt; reusing the prefix-mode sendmsg cmsg reply path (wrong source port) |
| 12a | Backend discovery: a level-triggered BackendSource seam in wayhouse-core (fetch → Vec<SocketAddr> = the current set; the runtime diffs it, like listener reconcile), one control-plane refresh_loop task per source, results fed through Snapshot::build_with_sources on the existing reload path (one snapshot writer). Concrete clients (hickory-resolver for SRV, reqwest for Consul / k8s) in the wayhouse binary; k8s lists EndpointSlices on the poll interval and additionally watches them, and Consul holds a blocking query on its list index (BackendSource::changed wakes refresh_loop early; the tick is the resync net; refresh_loop coalesces a burst of signals into one fetch). An errored / empty refresh keeps the last-known-good set. | matches the immutable-Snapshot invariant and lets a Tier-1 store (ch. 10) reuse the same reconcile; degrade-in-place instead of draining a pool on a discovery blip; HTTP / DNS deps stay out of wayhouse-core | add/remove deltas from each source (drift-prone); a full k8s informer that owns the cache and feeds events straight into the set (the watch only signals; fetch stays the single source of truth, so a lost event costs latency, never correctness); clearing the pool on an empty result |
| 13 | (v2, ch. 10) Level-triggered, pull-based config distribution: instances subscribe to the Tier-1 store, receive a snapshot + revision cursor + change stream, and feed each revision through the existing validate() → Snapshot::build → ArcSwap::store path. The store is one more writer; wayhouse-core is untouched, the client lives in the wayhouse binary (same seam as resolvers). | an instance offline for a while catches up from its cursor with no writer-side delivery state; a bad revision is rejected exactly like a bad file reload | push distribution (needs per-instance delivery tracking); a new data-plane config transport replacing the file |
| 14 | (v2, ch. 10) A separate wayhouse-controller: Tier-1 writer + fleet read-aggregator + the GUI’s only backend + all authn/authz/audit. Proxies never peer through it. HA = N replicas behind a leader lock for writes; its outage freezes changes, not traffic. | keeps consensus / auth off the data-plane nodes; one place to secure and audit | leader election among the proxy instances themselves; auth on every proxy admin API |
| 15 | (v2, ch. 10) Regional health authority: local active/passive checks decide; the failure-domain gossip view is quorum-weighted advice — unhealthy on local fall or domain quorum-down, healthy only on local rise, Tier-1 force-down overrides. | one flapping instance can’t poison the pool (quorum gate on “down”); one instance can’t ignore a domain-wide outage (the or); a stale remote “up” can’t revive a locally-unreachable backend | trust the shared verdict outright; share health globally (noise across reachability classes) |
| 16 | (phase 9) Sniffers run as wasmtime core modules with no WASI and a 3-function ABI (memory, alloc, sniff(…)->packed u64 returning an encoded RouteHint; the sniff signature is widened by ADR 16a). Bounds: epoch-interruption deadline (call_timeout_ms, host ticker thread) + StoreLimits memory cap; input already ≤ the route’s peek_len(). wasmtime is a wayhouse-binary dep; the Sniffer trait + a runtime-threaded Arc<Sniffers> registry stay in wayhouse-core. First-party a2s / minecraft / regex-firstbytes sniffers ship built the same way. | game-specific parsing must never be in the agnostic core or a fork; no WASI ⇒ a sniffer can’t reach fs / clock / net; epoch interruption pre-empts a runaway parser; the registry seam mirrors resolvers so wayhouse-core gains no sniffer runtime | compile sniffers in (fork per game); extism (heavier, less control over fuel/epoch); the component model + WASI (larger surface, bigger dep); an out-of-process sniffer per connection (latency — kept only as the fallback if the in-process boundary misses NFR N1) |
| 16a | (phase 9, amends ADR 16) Per-sniffer config via a widened sniff signature. sniff(in_ptr, in_len) → sniff(in_ptr, in_len, cfg_ptr, cfg_len); the host allocs + writes the module’s settings.sniffers.modules[].config string into a second linear-memory region on every call (a fresh Store per call, so it is re-marshalled each time), cfg_len == 0 when unset. config is baked onto the WasmSniffer at load/rescan time; the wayhouse-core Sniffer trait is unchanged (config is host-side). | the ABI is a third-party contract that freezes at 1.0 and there are zero external sniffers today, so the clean break is at its cheapest now; a uniform 4-arg signature (config always passed) has no “call configure if the module exports it” branch and no two-class sniffer model; passing config as a second (ptr,len) region reuses the exact mechanism the input already uses | an optional configure(ptr,len) export (compat-shim shape the pre-1.0 guardrail rejects; permanent host branch; two sniffer classes); a length-prefixed cfg blob prepended to the input region (overloads one region with two meanings; every sniffer must parse the framing even to ignore it); config-without-pin as a first-class path (deferred — a modules[] entry still requires its sha256) |
| 17 | (perf pass) TCP pump uses splice(2) on Linux, behind the same proxy::pump fn. Each direction owns one O_NONBLOCK pipe pair for the connection’s life; bytes move socket → pipe → socket via nix::fcntl::splice (feature zerocopy) driven by TcpStream::try_io readiness. Non-Linux — and a pipe2 failure — fall back to the buffered try_read / try_write loop (also over &TcpStream, no into_split). Half-close is propagated with socket2 SockRef::shutdown(Write). | no userspace copy and no 32 KiB×2 per-connection heap buffers on the steady path — the win is CPU / memory-bandwidth / RSS at high connection counts (gates N3–N5), not loopback RTT; drop-in behind the existing fn so every forwarding test still covers it | keep the buffered copy (a userspace bounce per byte); sendfile (file→socket only); io_uring (bigger dep + runtime — revisit if the pipe hop shows up in a profile); one shared pipe for both directions (couples the two directions’ backpressure) |
| 18 | (perf pass) UDP ingress batches with recvmmsg(2) (nix, Linux) — one syscall per readiness wakeup pulls up to RECV_BATCH (16) datagrams into a per-worker RecvBatch (16 × 64 KiB buffers, ~1 MiB/worker); the existing per-datagram route / filter / forward loop then runs over the batch. A fresh MultiHeaders per call (nix does not reset msg_namelen / msg_controllen between calls, so reuse would truncate a v4→v6 address or a cmsg). Non-Linux keeps one recvmsg per call behind the same RecvBatch API. The prefix / transparent dest cmsg parse moved into the batch path (dst_from_cmsg). | the listen socket is the single contended c2s funnel — amortising its recv syscall is the highest-leverage UDP change; buffers are per-worker not per-session, so no RSS blow-up; drop-in behind RecvBatch so every UDP forwarding test still covers it | sendmmsg egress batching in the same slice (per-session reply buffers would 16× the RSS, or force per-datagram allocs — deferred as its own change); reusing one MultiHeaders (address / cmsg truncation footgun); io_uring multishot recv (bigger dep) |
| 19 | (perf pass) UDP idle expiry is a single-level timing wheel (IdleWheel, WHEEL_SLOTS=512 one-second slots). A session is filed in the slot for its idle-deadline second; a tick drains one slot, evicting entries whose live last_ms shows real idleness and re-filing the rest (a datagram moved the deadline, or idle_ms > the wheel span). Exactly one entry per live session. The per-datagram hot path is unchanged — it still only bumps the atomic last_ms. | tick cost drops from O(sessions) (the old 1 s retain scan) to O(slot + re-files); activity refresh stays lazy so the datagram path pays nothing; a too-long idle_ms degrades to a re-check every WHEEL_SLOTS s, never a miss | per-datagram wheel re-insertion (moves work onto the hot path); a hierarchical wheel (more code, unneeded at 1 s granularity / ~9 min span); a BinaryHeap by deadline (O(log n) per op, worse cache behaviour) |
| 20 | (v2 PoC, ch. 10) Tier-1 store: an embedded KV (sled), single-node, in wayhouse-controller. Each accepted config submission gets a monotonic revision key; subscribers get a full snapshot + cursor, then a change stream over the same store. No Raft/etcd for this first release — the controller is a single standalone node with no HA yet (see roadmap “PoC” note). | a proper KV beats pushing config files around (crash-consistent, queryable by revision, no ad hoc file-locking) while staying a pure Rust, zero-ops embedded dep — appropriate for a single-node PoC; swapping the backing store for a distributed one (etcd, or Raft over the same KV) is an additive later-release change behind the same store trait, not a rewrite | etcd/Consul (an extra service to run for a PoC that is explicitly single-node); a flat versioned JSON file (no crash-consistent multi-key transactions, reinvents what a KV already gives); embedding Raft now (HA is explicitly deferred past this release) |
| 21 | (phase 12 slice 6, built — wayhouse-controller only; the aggregator’s HA design further down stays design-only) Intra-tier controller HA: embedded openraft 0.9, one Raft group per tier replicating both the config and intent logs together, sled unchanged as the state machine. Writes propose a Raft entry and only commit on the leader; a non-leader replica transparently HTTP-forwards a write to the current leader rather than redirecting. Reads are served by whichever replica got the request, straight from its local sled — no ReadIndex linearizable-read protocol. Cluster membership is static at bootstrap (--ha-peers, identical on every replica — each calls raft.initialize(), a harmless no-op on every node but whichever wins the race); dynamic membership change is deferred. A slave tier’s upward relay runs leader-only, with its cursor (the highest parent revision absorbed) replicated beside each log (built 2026-10-05). Raft RPCs ride the same axum server on a /raft/* prefix gated by a new peer-only --ha-token. | continues ADR 20’s “embed, don’t run an extra service” story instead of trading it away the moment HA enters the picture — etcd would add real operational machinery for a component whose write volume is human-paced, not a hot path; a hand-rolled Raft would reimplement exactly the subtlety (log matching, leader lease safety, snapshotting) a maintained, audited crate already gets right for a security-sensitive component; relaxed reads are justified by the same “freeze on last-known-good” principle (4) the whole chapter already leans on — a follower a few commits behind is the same acceptable staleness as a subscriber on its last replica during an outage, never a divergent one, since sled’s local apply only ever appends; transparent forwarding keeps every existing client (wayhouse, wayhouse-ui, curl) ignorant that HA exists at all | etcd as the Tier-1 backing store (extra service, operational drift from ADR 20’s own reasoning); a hand-rolled Raft/Paxos; two independent Raft groups (config, intent) per tier (two leader elections, two peer lists, possible disagreement about who leads); an HTTP redirect to the leader instead of a transparent forward (pushes leader-awareness onto every client); ReadIndex linearizable reads (real cost for no benefit this design already doesn’t need); dynamic membership on day one (real complexity deferred until a deployment actually needs to resize a live group) |
| 22 | (phase 12 slice 7, built) Staged / canary config rollout: one monotonic revision log (never forked), each revision tagged in a separate stage sled tree with {promoted, canary_groups}. An instance self-reports an optional canary_group when subscribing; “current for group G” = the highest revision that is promoted or has G in canary_groups, computed by an O(revisions) backward scan rather than a maintained index. POST /config/promote/{revision} flips promoted in place — the one deliberate exception to “every change is a new revision,” since promotion changes visibility of existing content, not the content itself. Canary staging applies to config only, never the intent log. | one log avoids ever having to reconcile two branching histories later, which a forked-log design would eventually force; self-reported group membership matches the trust level every other self-reported fact in this control plane already has (IngestPayload::admin_url, an instance’s own --role); an O(revisions) scan is proportionate to a human-paced, hundreds-to-low-thousands-of-entries log — the same “don’t build for a scale problem that doesn’t exist yet” bias the rest of this codebase already uses; intent ops are already narrowly single-scoped (one backend), so “stage this whole document to a subset” has no equally clean meaning for “stage this one op” | a forked/branching revision history per canary group (real merge/reconciliation problem, for a feature meant to be simple); a controller-verified (not self-reported) group membership (real feature, not needed yet, ties into the still-open region-scoped-intent question); an index-backed “current per group” lookup (premature at this scale); extending canary staging to the intent log (no clean partial-rollout semantics for a single op) |
| 23 | (phase 12 slice 8, built) RBAC lives in wayhouse-ui (the only human-facing process), not the controller/aggregator. --users-file (username + argon2 password hash + one of three roles — viewer/operator/admin — matching the API surface’s existing verb tiers) replaces --ui-password for multi-operator deployments; --ui-password is kept as a legacy single-shared-secret (implicitly admin) mode rather than removed. SessionStore maps a session id to a Role; a new require_role(min) middleware layers per route group next to require_session. Audit: wayhouse-ui adds X-Actor: <username> to every write it proxies; wayhouse-controller records it per revision in a new actors sled tree (GET /config/revisions gains an actor field) — a durable per-revision attribution, not a separate durable audit log. | keeps the phase 10+11 slice-11 divergence from docs/10’s original “RBAC lives on the controller” text consistent, rather than half-reverting it — the controller/aggregator still gate on one shared machine token each, exactly as built; argon2 is the currently-conventional choice for this exact job, the same “defer to a maintained crate for a security-critical, well-solved problem” reasoning ADR 16 already used for sniffer sandboxing; three roles match every route in the existing surface without inventing a permission matrix nothing here needs yet; per-revision actor reuses the controller’s already-durable revision history instead of standing up a second audit-log store for a three-role, single-human-facing-process model | RBAC enforced on the controller/aggregator (couples authz to the machine-token model those two intentionally kept simple); a general permission-matrix / policy-engine model (solves a problem this API surface’s three natural tiers don’t have); forcing every existing single-operator deployment onto --users-file (pure friction, no correctness upside below two identities); a separate durable audit-log service (disproportionate to this model’s size) |
| 24 | (phase 13, built — 2026-09-05) Tier-2 regional health fabric: membership via embedded foca (SWIM), per-backend health as a last-writer-wins register piggybacked on foca’s own broadcast/anti-entropy, HMAC-SHA256-authenticated over a per-domain pre-shared key (not mTLS). New wayhouse-core::gossip module (control-plane, spawned only when settings.gossip is set); Backend gains one additive domain_down: AtomicBool next to the existing healthy flag — is_healthy() becomes healthy && !domain_down. Local rise/fall streaks are still the only thing that can set healthy; the gossip module can only ever set domain_down, never clear healthy directly, matching “return to healthy only on this instance’s own say-so.” postcard (new dep) encodes the wire format. | SWIM’s failure-detector subtlety (indirect probing, suspicion timeouts, incarnation numbers) is exactly the “a maintained, audited crate already gets this right” case ADR 21 already established for Raft — reimplementing it by hand is a correctness risk for a security/availability-relevant component, not a simplification; foca’s own broadcast invalidation already gives eventual convergence for a payload this small, so no second anti-entropy framework is needed on top; a shared-secret HMAC is proportionate to an advisory-only signal that can never independently move traffic (only nudge a decision local checks already gate) — full mTLS’s certificate lifecycle is proportionate to Tier 1, which actually accepts writes, not to this | hand-rolled SWIM (reimplements exactly the subtlety a maintained crate already gets right); etcd/Consul-backed membership (a real extra service for an ephemeral, advisory, deliberately-AP signal — contradicts both ADRs 20/21’s “embed, don’t add a service” thread and Tier 2’s own AP design); full mTLS mesh (real cert provisioning burden disproportionate to a signal that can only nudge, never act); a general CRDT-sync/anti-entropy library on top of foca (duplicates what foca’s broadcast handler already does for this payload size) |
| 25 | (phase 14, built — docs/11) Backend transport for globally-distributed proxies: unmodified WireGuard via defguard/wireguard-rs (unifies kernel-netlink and boringtun-userspace config behind one API) as the tunnel data plane — one shared interface with many peers, not one per origin — with a new origin-side wayhouse-agent crate and a new “backend peers” registry on the existing Tier-1 wayhouse-controller for automated key/peer distribution. An origin is modeled as a new BackendSource (ADR 12a) — backend_sources[].type: tunnel — not a new schema concept. The tunnel protocol/crypto is 100% reused, not reimplemented; the only new code is the origin-side interface manager, the control-plane distribution registry, and one new discovery-source implementation. connect_backend/connect_upstream/health.rs’s dials need no changes — a tunneled backend is just a routable SocketAddr on the shared interface’s subnet. | continues the ADR 21/24 “defer to a maintained, audited implementation for a subtle, security-relevant mechanism” reasoning a third time; WireGuard already supports the exact NAT shape this needs (only the proxy side needs a public endpoint; the origin dials out once and roaming + keepalive hold the mapping) with zero new protocol work; a fleet-scale peer registry is the one genuinely missing piece, and it fits the same distribution shape (subscribe + reconcile) ADR 13’s config distribution already established; defguard/wireguard-rs over a hand-rolled kernel/userspace split continues this project’s own “prefer a maintained wrapper over hand-rolled plumbing” bias (ADR 10/12’s nix usage); modeling an origin as a BackendSource reuses ADR 12a’s seam exactly rather than growing a second, parallel discovery concept | a hand-rolled tunnel/framing protocol (reimplements exactly the subtlety a maintained protocol already gets right); adopting rathole/frp directly (standalone tools with their own protocol, no fleet/health/pool awareness, not embeddable); adopting Headscale wholesale (means running real Tailscale clients or reimplementing its coordination protocol — bigger than the one registry actually needed); a bespoke QUIC-based tunnel (real protocol work for no benefit over reusing WireGuard); wireguard-uapi + boringtun hand-joined separately (two APIs to reconcile instead of one); one WireGuard interface per origin (N routing tables/subnets for isolation AllowedIPs already gives per peer); a new origins:/pools[].origin schema concept (duplicates what backend_sources already does) |
| 26 | (built — 2026-10-02) Custom CA trust via --ca-file <PEM> on every binary, additive to the compiled-in Mozilla roots, applied through one shared reqwest-only crate wayhouse-http that holds the extra roots as a set-once process global. wayhouse_http::{builder, client} replace reqwest::Client::{builder, new} at every production call site; each call still builds a fresh client, so no connection-pool or timeout semantics change. The file is fully validated at startup (readable, ≥1 certificate, rustls accepts it). | works in distroless images with no system store; explicit and testable (a fleet test runs a real binary through a private-CA TLS terminator); additive so nothing that works today can break; a set-once global keeps the ~20 call-site diffs to one token each instead of threading a certificate list through every signature; wayhouse-http depends only on reqwest, so wayhouse-aggregator/wayhouse-ui stay free of the data-plane crates | rustls-tls-native-roots (would parse the whole OS store on every Client::new(), and several call sites build a client per request/reconnect; changes trust for every deployment implicitly — can still be added later behind wayhouse-http); threading certificates explicitly through every call path (large signature churn for no behavioural gain); replacing the built-in roots instead of adding to them (breaks public endpoints for anyone who sets the flag) |
| 27 | (built — 2026-10-02; wayhouse-aggregator, wayhouse-ui and wayhouse’s admin API (settings.admin.tls) too, via the shared wayhouse_http::tls::{TlsArgs, serve}) Native TLS for wayhouse-controller (--tls-cert/--tls-key) via a custom axum::serve::Listener in wayhouse-http (wayhouse_http::tls), rustls (ring) with a hot-swappable certificate resolver that re-reads the PEM files when their mtime changes (polled every 30 s); TLS handshakes run in per-connection tasks with a 10 s timeout. A broken replacement keeps the current certificate; bad files at startup are fatal. | no new third-party crate (tokio-rustls/rustls/arc-swap were already in the tree) and every binary keeps the same axum::serve(listener, app) shape; handshakes outside accept() avoid head-of-line blocking by a stalled client; mtime polling renews certbot/cert-manager certificates without a restart or a SIGHUP path the controller doesn’t have; the shared module makes the aggregator/UI/admin API one flag each later | axum-server’s rustls support (a second serving API and dependency tree for one feature); the handshake inside Listener::accept (one slow client stalls every accept); restart-to-renew (an outage per renewal); inotify-based reload (platform-specific, and a 30 s poll is plenty for certificate lifetimes) |
| 28 | (built — 2026-10-02, owner’s request) Security scanning in CI is informational: a trivy job (Trivy over the five deploy/ images from docker save tarballs, plus Cargo.lock and the UI’s package-lock.json; HIGH/CRITICAL with a fix) and an audit job (cargo audit over every Cargo.lock, every push/PR + nightly). Both continue-on-error; results on the run summary, as warning annotations and as artifacts (JSON, SARIF). Trivy runs from the official aquasec/trivy image pinned by digest as a docker:// step; cargo-audit comes prebuilt via the pinned taiki-e/install-action. | the owner wants visibility, not a gate (a new CVE in the distroless base shouldn’t block unrelated PRs); a release binary has no embedded crate list, so the image scan can’t see Rust crates — the lockfile scans stand in, and cargo audit adds the RustSec-only advisories Trivy (GHSA only) misses; a digest-pinned image whose binary was checked byte-identical to the checksum-verified release can’t be swapped later; a separate job keeps the scans’ outcome visible as its own pipeline stage | aquasecurity/trivy-action / setup-trivy (their tags were hijacked in March 2026 and a backdoored Trivy 0.69.4 published); a job-level container: on the Trivy image (GitHub can’t run JavaScript actions in the Alpine image); blocking scans; GitHub’s Security tab via SARIF upload (a private repo needs GitHub Code Security — a follow-up); one job per image (the ~120 MB vulnerability DB would be fetched five times) |
| 29 | (built — 2026-10-03) TLS handshake flood limits in wayhouse_http::tls::TlsListener: a 3 s ClientHello deadline (via LazyConfigAcceptor, inside the 10 s handshake timeout), at most 16 pending handshakes per source (IPv4 address / IPv6 /64, closed at once over it), and a global cap of 512 that admits the new connection and aborts the oldest pending handshake. Fixed constants (HandshakeLimits), no flags. | bounds tasks and fds under a many-source flood without the lockout a refuse-at-cap global limit has (an attacker must open 512 connects within a real client’s one-RTT handshake to evict it); per-source /64 because one IPv6 host owns a /64; the short ClientHello deadline makes an idle connect cost 3 s, not 10 | no cap (a many-source flood bounded only by the fd limit, where EMFILE locks everyone out); a global cap that refuses at the limit (~cap idle connects lock every client out — tried and dropped 2026-10-02); a per-source connection rate limit (more state, and the pending cap already bounds what a source holds); flags per binary (no operator has asked; HandshakeLimits keeps that a small change) |
| 30 | (retrospective record of ADR 20/21, written 2026-10-04) sled 0.34 stays the controller’s embedded store (config/intent logs, stage, actors and address trees, and the openraft log and state machine), accepted as a known dependency risk, with no migration planned until the HA log store is next reworked. sled is used through a narrow surface: open, open_tree, ordered keys and range/prefix scans, flush/flush_async, and multi-tree transactions, all confined to wayhouse-controller. The risks are real and accepted knowingly. The stable line, 0.34.7, was published 2021-09-12, and the only newer releases are 1.0.0-alpha.* (the latest, alpha.124, is from 2024-10-11), so the crate has been pre-1.0 and effectively unmaintained for years; it is behind the fxhash and instant unmaintained-crate warnings from cargo audit (informational; they do not fail the target); the on-disk format is not guaranteed stable across the 1.0 rewrite, so moving to 1.x is a migration, not a bump; and a crash or bug in the store would hit the Raft log, which is the worst place for it. Mitigations already in place: sled is used only inside wayhouse-controller (eight source files, the main seams being store.rs and ha/log_store.rs), so a swap is bounded to one crate; Raft replication means a damaged replica can be rebuilt from a peer’s snapshot (ha_snapshots test); the file-lock release race is handled with a retry on reopen (HANDOVER, known flakes); and write volume is human-paced, not a hot path. Revisit when the HA log store is next changed, an advisory lands against sled itself, or a data-loss bug is reproduced. At that point evaluate redb first, then fjall (see alternatives); do not migrate opportunistically. | a migration now would touch the config store, the Raft log store and the state machine at once, and buys nothing a user can see; the advisories are informational; the surface used is small and confined to one crate, so a later swap is bounded work; pure Rust and zero-ops (ADR 20) still hold; a rewrite of the controller store is better done together with a deliberate HA-log change than as drive-by churn | redb (pure Rust, copy-on-write B-tree, ACID, stable file format, 4.x and actively released as of 2026-10-02; the leading candidate: it has multi-table write transactions and range scans, but no built-in watch/subscribe and its single-writer model needs the change-stream logic re-expressed on top); fjall (pure Rust LSM-tree, 3.x and actively released as of 2026-10-03; cross-keyspace write batches and prefix/range iteration, a good fit for an append-heavy log, but a larger and younger dependency tree, and compaction and background threads add operational behaviour to reason about); sled 1.0 alpha (the maintained line of the same crate, but still alpha with a new on-disk format and no stability promise, so it trades one risk for a bigger one); SQLite via rusqlite (very mature and well understood, but a C dependency, which cuts against the pure-Rust, zero-ops choice of ADR 20); RocksDB (mature, but a large C++ build and a heavy dependency for a human-paced log); a hand-written append-only log plus snapshots (full control, but reimplements crash consistency, the exact subtlety ADR 21 chose to delegate) |
| 31 | (perf pass, built — 2026-10-05, closes #51) UDP egress batches with sendmmsg(2) on both legs. The reply pump pulls whatever is queued on its connected upstream socket with one recvmmsg into a per-thread ReplyBatch (16 × 64 KiB, ~1 MiB per worker thread, shared by every session on that thread, never held across an .await) and hands it to the client with one sendmmsg (one address, and in prefix mode one shared pktinfo source cmsg, for the whole batch). The ingress loop groups consecutive datagrams of one (client, dst) flow that hit an existing session and forwards the run with one sendmmsg on the session’s connected socket. A partial send resumes at the first unsent datagram; a full client socket parks a right-sized copy of only the unsent tail and awaits writability. Non-Linux keeps one datagram per syscall behind the same send_batch / ReplyBatch API. | the RSS objection in #51 (a RECV_BATCH × MAX_DATAGRAM buffer per session would be 16× the RSS) does not apply since #122 moved reply buffers to a per-thread scratch, so the batch costs ~1 MiB per thread, not per session; replies of one session share the destination so sendmmsg fits without per-datagram allocation; a connected upstream socket needs no per-message address | per-session reply batches (16× RSS); per-datagram allocation; io_uring (bigger dep); batching only the reply leg (the forward leg is the same win for client→server game traffic) |
Risks & mitigations
| Risk | Mitigation |
|---|---|
| io_uring portability/bugs | an abstraction, tokio/epoll as the default backend |
the splice fast path does not cover all cases (TLS peek leftover bytes) | a clean fallback buffered path, property tests |
| the session table as a memory DoS | hard caps + LRU eviction + first-packet gate |
| sniffer parsers as an attack surface | (phase 9) run as wasmtime core modules, no WASI; epoch-deadline + memory cap + input ≤ peek_len; #![forbid(unsafe)] + fuzzing in the sniffer crates; sha256 pinning |
| (phase 9) WASM call latency blows the NFR N1 budget | measure the boundary before wiring it on the per-connection path; InstancePre / warm per-worker instance; out-of-process resolver as the fallback |
(phase 9) wasmtime dependency size / build time | binary-only dep; engine cache in CI; wasm32-unknown-unknown target added once |
| TPROXY network setup is error-prone | thorough docs + a preflight check command |
| resolver latency in connection setup | cache, tight timeout, stale_ok, fallback route |
| (v2) a bad Tier-1 revision blackholes the whole fleet | validate() on controller and instance; signed revisions; instance-side sanity bound (pool > 0 → 0 targets ⇒ warn + keep); canary rollout; one-key rollback |
| (v2) Tier-1 store / controller outage | instances serve their last local replica indefinitely; only changes stop |
| (v2) rogue host injects “all backends down” into the health fabric | Tier-2 is advisory + locally checked (never revives on remote “up”); every gossip datagram is HMAC-tagged with a per-domain PSK, bad/missing tags dropped silently (ADR 24) |
| (phase 12) a lost Raft quorum in an HA controller tier | degrades to the same “changes frozen for this subtree” behavior a single-node outage already has (ADR 21) — never a new failure mode, just a bigger blast radius to lose quorum on |
| (phase 12) a canary revision never gets promoted and silently rots | it stays visible only to its declared canary_group forever, invisible to GET /config/revisions’ promoted pointer for everyone else — an operator problem (forgot to promote or roll back), not a correctness one (ADR 22) |
(phase 12) a wayhouse-ui operator account’s password hash leaks | argon2 (a deliberately slow KDF) bounds offline cracking cost; role scope still limits blast radius to whatever that account’s role permits, never full fleet authority unless the leaked account was admin (ADR 23) |
| (phase 13) the gossip mesh partitions or empties out entirely | fully rebuildable by design — an instance with zero peers just falls back to its own local checks, identical to pre-phase-13 behavior, never a stuck or wrong verdict (ADR 24) |
10 – Distributed control plane (v2)
Status
Phases 10 through 13 are all now built — see the phase-by-phase status
notes below and 08-roadmap.md for the slice-by-slice
record. This chapter remains the design source of truth for the mechanisms
those phases implement. The v1 data plane and the single-instance
control plane (chapters 02, 05,
06) are unchanged by this: every mechanism
below is either an additional writer feeding the same validated Snapshot
swap, or an additional input to the same per-backend health flag. The hot
path gains no network call and no lock.
The controller and aggregator are each a recursive tree of tiers (one
tier per failure domain, down to as small as one instance), so the same
design covers a single-instance install and a globally distributed fleet with
no separate mechanism per size — see “Fleet topology” below. Control (Tier 1)
pulls root → leaf with a static standalone/slave role per tier; the
aggregator pushes leaf → root with a homogeneous schema at every hop.
Locked for the first release (phase 10+11 in docs/08-roadmap.md): one
standalone controller tier + one aggregator tier, replicas: 1 each, no
slave role, no HA, no adoption, operator intent stays per-instance as it is
today (only structural config moves into the controller). The hierarchy,
intra-tier HA, adoption, and shared intent are phase 12 — a real, already
fully designed extension, deliberately built after something works
end-to-end, not before.
Phase 12 status (2026-09-05): fully built, all 8 slices (hierarchy, both
relay logs, adoption, intra-tier HA, staged/canary rollout, RBAC + audit) —
see docs/08-roadmap.md.
Phase 13 status (2026-09-05): fully built, all 5 slices (config schema,
SWIM membership via embedded foca, the per-backend health broadcast,
Backend/health.rs integration, and multi-process live verification in
crates/wayhouse-fleet-tests) — see “Mechanism (built)” under “Tier 2 —
regional health fabric” below, ADR 24 in docs/09, and
docs/08-roadmap.md for the slice-by-slice build record.
Why
Today every proxy instance is fully independent (ADR 4):
- Structural config is a YAML file per instance, hot-reloaded on
SIGHUP/ file-watch. Distribution (image bake, ConfigMap,consul-template, Ansible …) is the operator’s problem. - Runtime state splits in two, and neither is shared or persisted:
- operator intent — the phase-5 backend overlay (
POST/DELETEa backend), backend admin state (draining/disabled), route hints. Lives on live atomics /ArcSwaptables. Lost on restart. Applied one instance at a time by whoever calls that instance’s admin API. - observed health — the active/passive checker’s
AtomicBoolper backend. Each instance computes it locally.
- operator intent — the phase-5 backend overlay (
That is fine for a handful of hand-managed instances. It does not give a fleet operator (or a GUI that “manages everything”):
- a single authoritative place to view and edit config,
- changes that apply fleet-wide and survive restarts,
- an audit trail,
- health that benefits from more than one vantage point.
This chapter plans the smallest set of additions that provide those without touching the data path or the “one immutable snapshot” invariant.
Principles
- The data plane does not change. The hot path still reads a local
immutable
Snapshotplus local runtime state. No per-connection call to a store, no added lock. Everything here is control plane. - One authority per fact. Every piece of state has exactly one writer tier (below). No fact is writable from two places.
- Level-triggered everywhere. Sources publish the current value; the
instance reconciles by diffing against what it holds. Idempotent, and the
same shape as
ListenerManager::reconcileand the discovery adapters (phase 8). - Degrade to last-known-good. A control-plane outage freezes state, it never clears it — the same rule a flapping discovery source already has to obey (“don’t nuke the pool”).
- Pull, not push. Instances subscribe / poll. An instance that was down catches up from its cursor on reconnect. No “did the push land” bookkeeping, no per-instance delivery state on the writer.
The two-axis split
Map the new state onto the predicate the data plane already computes:
Backend::takes_new_sessions() == is_healthy() && admin_state() == Enabled
└── observed ──┘ └────── intent ──────┘
| Axis | Question it answers | Authority | Scope | Mechanism |
|---|---|---|---|---|
| intent | should this receive traffic? | operator / config | global | ordered, durable, versioned store — Tier 1 |
| observed | can we reach it, from here? | the instances themselves | one failure domain | gossip / anti-entropy — Tier 2 |
Plus a third bucket that stays purely instance-local, never shared: per-listener rate-limit / concurrent-connection buckets (sized per node by design), UDP session tables (worker-local; cross-instance session handover remains out of scope — see ADR 4), per-connection state.
The split is not a new concept bolted on — it follows the seam the code has had since phase 5.
Tier 1 — global config & intent store
Contents
- Structural config: listeners, routes, pools (with their static targets),
balancers, filters, resolvers,
settings— everything chapter 05 puts in the YAML file. - Operator intent:
- backend membership overrides (the phase-5
BackendOverlay), - backend admin state (
draining/disabled, plus a new fleet-wideforce-down), - route hints (
POST /route-hinttoday), - resolver pins (future — a manual
src → pool/targetoverride that outlives a single hint TTL).
- backend membership overrides (the phase-5
- Fleet feature flags.
Shape
- One logical writer — the controller (below). Readers are every proxy instance.
- Ordered, versioned, durable log. Each accepted change is a monotonic revision. Quicksilver-style: central write, asynchronous fan-out, and every instance keeps a local materialized replica that it reads in-process.
- A proxy subscribes on startup: it receives a full snapshot + a revision cursor, then a change stream. On disconnect it reconnects from its cursor; on a total store / controller outage it serves its last replica indefinitely.
- Each received revision goes through the existing
validate() → Snapshot::build → ArcSwap::storepath. An invalid revision is rejected and logged, the previous snapshot kept — byte-for-byte the behaviour of a bad file reload today. Non-structural intent (admin state, hints) updates the live atomics / tables directly, exactly as the admin API already does.
Relationship to the YAML file
- The file stays a first-class config source. An instance setting
config_source: file | store | file+storeselects the base; infile+storethe file is the structural base and the store is an overlay (matching how the phase-5 overlay already layers on the file). - The store is authoritative for intent even in
filemode, so a restarted instance recovers the overlay / hints / admin-state it had. This closes the “runtime mutations don’t persist” gap directly.
What it is not
- Not on the hot path. Not consulted per connection or per datagram.
- Not a session store. Not shared session state (ADR 4 stands for sessions).
Tier 2 — regional health fabric
Contents
- Per-backend reachability as observed within one failure domain: a small
record per
(instance, backend)—up | down, last change, check age. - Later, optionally: per-domain capacity / load signals for smarter load shedding.
Failure domain = reachability-equivalence class
- The set of instances that reach the backends over the same network path —
an AZ, a region, a rack row. Set per instance (
failure_domain:/region:insettings), or discovered. - Rationale. Sharing “backend X is down” is only valid between instances
whose view of X is the same. Across regions it is noise: a
DC1 ↔ Xpartition says nothing aboutDC2 ↔ X, and acting on it would wrongly pull X for DC2.
Shape
- AP, not CP. SWIM-style membership + gossiped per-backend state, or a CRDT
(a per-
(instance, backend)last-writer-wins register). Small, ephemeral, partition-tolerant, self-healing. No election. No quorum write. - Fully rebuildable. If the fabric is empty (cold start, total partition), each instance simply uses its own checks — precisely today’s behaviour.
- Authenticated. Messages are signed / carried on a mutual-TLS mesh so a rogue host cannot inject “everything is down”.
How an instance decides health (authority model)
Local checks stay in charge; the domain view is quorum-weighted advice:
- Mark unhealthy when this instance sees
fallconsecutive failures OR a quorum (e.g. > ⅔) of the domain’s instances currently report it down. - Return to healthy only when this instance sees
riseconsecutive successes — never on someone else’s say-so (a stale “up” must not revive a backend this instance genuinely cannot reach). - A Tier-1
force-downalways wins over any Tier-2 “up”.
This removes both failure modes: one flapping instance cannot poison the pool
(quorum gate on “down”), and one instance cannot ignore a domain-wide outage
(the OR).
Mechanism (built, 2026-09-05 — designed and implemented same day)
Membership: embedded foca (SWIM), not hand-rolled. Same reasoning ADR 21
already used for Raft: SWIM’s subtlety — indirect probing, suspicion timeouts,
incarnation numbers to un-suspect a member that was never actually down — is
exactly the kind of protocol correctness this codebase defers to a maintained,
audited crate for rather than reimplementing. foca is transport-agnostic (an
application provides the socket and timers), which fits this codebase’s
existing preference for owning its own plain UDP socket (net.rs) rather than
depending on a batteries-included clustering framework. One foca::Foca
instance per wayhouse process when gossip is enabled, identified by
(instance_name, gossip bind addr).
Application payload: a per-backend LWW register, piggybacked on foca’s own
broadcast/anti-entropy. foca’s BroadcastHandler extension point already
re-gossips “active” application data alongside membership traffic with a
decreasing invalidation count until it’s fully propagated — exactly the
anti-entropy behaviour Tier 2 needs, so no second gossip pass or separate CRDT
sync framework is layered on top. The payload itself:
#![allow(unused)]
fn main() {
struct BackendHealthRegister {
addr: SocketAddr, // the backend this is about
up: bool, // this instance's own local health.rs verdict
changed_at: u64, // this instance's monotonic clock at the last flip
origin: InstanceId, // who is asserting this
}
}
Merge rule: last-writer-wins by changed_at, ties broken by origin — fully
deterministic, no coordination, no vector clock. An instance only ever
publishes a register for a backend it actually health-checks itself — it
never relays or invents an opinion about a backend outside its own snapshot,
which is what makes “everyone in a domain reaches the same backends the same
way” (the failure-domain definition above) hold in practice, not just in
theory.
Wire format and auth. postcard (new workspace dep) for a compact binary
encoding of both foca’s envelopes and the broadcast payload — no schema
evolution concerns here the way wayhouse-config’s YAML has, since this is
ephemeral wire state, not anything persisted. Every datagram carries an
HMAC-SHA256 tag (new hmac dep; sha2 is already a workspace dependency via
crates/wayhouse/Cargo.toml, promoted to a wayhouse-core dependency too) computed over
a per-domain pre-shared key (settings.gossip.psk); a bad or missing tag is
dropped silently and bumps a metric, the same “malformed input is discarded,
never trusted, never a panic” posture sniff.rs already has for sniffer
input. Deliberately not full mTLS: a client-cert mesh is proportionate to
Tier 1, which actually accepts writes; Tier 2 is advisory-only and can never
independently move traffic (see the authority model above — it can only
nudge a decision local checks already gate), so a shared-secret HMAC is the
right amount of ceremony for what’s at stake, not the maximum available.
What the shared key does not give is a per-instance identity, so the vote
registers are bounded rather than authenticated: a register counts only if its
origin is a member the mesh currently knows (this instance included), its
changed_at may sit at most a minute ahead of the local clock, and the map
holds at most 256 origins per backend and 8192 backends. The origin is not bound
to the sender, because members relay each other’s broadcasts. A holder of the key
therefore does not even need to join: they can send registers that claim the
origin of an existing member, which counts as that member’s vote, and a
changed_at up to a minute ahead holds off that member’s real updates for that
long. A holder who also joins under several identities can vote with each of
them. Tier 2 is advisory (it can only nudge a decision the local checks already
gate), so this is accepted for 0.2.0 rather than answered with per-member
signatures and new key material. Treat the key like the other fleet secrets and
keep it out of instances you do not trust.
Config schema (built exactly as designed — settings.gossip.psk is a
plain string, not env-substituted like the ${GOSSIP_PSK} sketch below;
this codebase has no env-substitution mechanism anywhere else in
wayhouse-config either, so that stayed a design-stage placeholder, see
config.example.yaml for the real, commented-out syntax):
settings:
failure_domain: "eu-west-1a" # optional; omitted = gossip fully disabled,
# byte-for-byte today's behaviour
gossip:
bind: "10.0.1.4:7946" # this instance's own reachable address (its
# identity and vote origin; 0.0.0.0 is refused)
seeds: ["10.0.1.5:7946", "10.0.1.6:7946"]
quorum_fraction: 0.66 # default; the ">⅔" from the authority model
psk: "${GOSSIP_PSK}"
validate() rejects gossip set without failure_domain or vice versa — a
domain identity with no mesh, or a mesh with no domain identity, is always a
misconfiguration, never a valid degenerate case (unlike standalone with no
children, which is a valid degenerate case for the controller).
Health integration — additive, not a rewrite of pool.rs. Backend gains
one new field, domain_down: AtomicBool, alongside the existing healthy: AtomicBool (untouched — still driven only by observe()’s local rise/
fall streaks, exactly as today). is_healthy() becomes healthy.load() && !domain_down.load(). A new wayhouse-core::gossip module (control-plane, sits
next to health.rs, spawned by runtime.rs only when settings.gossip is
present) owns the foca instance, maintains the merged per-backend register
map, computes the domain-quorum verdict per backend on every membership
change, and calls a new Backend::observe_domain(quorum_down: bool) that only
ever sets domain_down — it can never clear healthy, and can never set
healthy true on its own, matching “return to healthy only on this instance’s
own rise streak” above word for word. AdminState::Disabled (Tier-1
force-down) already gates selection independently of both flags, so “Tier-1
force-down always wins” needs no new mechanism — it already does, today.
New metrics (metrics_defs.rs, once built): wayhouse_gossip_members (gauge,
per domain), wayhouse_gossip_messages_total (counter, sent/received), a per
backend wayhouse_backend_domain_down (gauge 0/1 — whether the domain quorum is
currently overriding to down), and wayhouse_gossip_auth_rejected_total (counter —
bad HMAC or malformed datagram).
Rejected alternatives (full reasoning in ADR 24, docs/09): hand-rolled
SWIM (reimplements exactly the subtlety a maintained crate already gets
right); etcd/Consul-backed membership (a real extra service for an ephemeral,
advisory, AP-by-design signal — contradicts both the “embed, don’t add a
service” thread through ADRs 20/21 and Tier 2’s own AP framing above); full
mTLS (real certificate lifecycle for a signal that can only nudge, never
independently act); a general anti-entropy/CRDT-sync library on top of foca
(foca’s own broadcast invalidation already gives eventual convergence for a
payload this small — a second framework would duplicate it).
Explicitly out of scope for the first slice of this feature (docs/08
Phase 13, same “don’t half-land a phase” discipline as everywhere else):
per-domain capacity/load signals (docs/10 already flagged these as “later,
optionally”); failure_domain discovery (cloud-metadata autodetection,
etc.) — v1 is configured-only, matching the same “never inferred, always
explicit” bias already used for the controller’s standalone/slave role.
Fleet topology: aggregation and control form the same hierarchy, opposite directions
The goal is a single design that scales unmodified from one instance to a globally distributed fleet — not a different mechanism per size. Both new components generalize into a tree of tiers (a tier is typically a failure domain / region, but can be as small as one instance):
-
Aggregation (metrics, health, session/pool views) is
Push, leaf → root. Each proxy pushes its own state up to its tier’s aggregator; each aggregator, in turn, pushes (or is pulled — see below) a merged view up to its parent aggregator, if it has one. The schema is homogeneous: a parent aggregator’s input (a child proxy, or a child aggregator’s merged view) looks the same either way, so the merge logic is the same function applied recursively — no distinction between “a leaf” and “a subtree” at the parent’s level.- Why push, not pull, here (a deliberate exception to principle 5): pull would require every parent to hold a network route down to every leaf, which is the reachability problem the whole hierarchy exists to avoid — a proxy behind restrictive egress-only networking, NAT, or in a partner’s network can push out without ever accepting an inbound admin connection from anywhere, including its own tier’s aggregator. The cost: every proxy/aggregator carries a small outbound client with retry/backoff and a short local buffer so a momentary parent hiccup doesn’t drop a push.
- Losing the parent link degrades to “my local aggregator still has the full local view; the parent’s view of me goes stale/absent.” No data loss below the cut, no effect on the data plane anywhere.
-
Control (structural config + operator intent, Tier 1) is
Pull, root → leaf, unchanged from the single-tier design above except generalized to N hops: a proxy subscribes to its tier’s controller (full snapshot + cursor, then a change stream); aslavecontroller subscribes to its parent the same way and relays what it receives to everything under it. This is principle 5 applied recursively, not a new mechanism.
Controller role: standalone vs. slave — a static, install-time fact
Every controller tier is configured, not elected, into one of two roles:
standalone— a root authority. It accepts writes directly (config API, intent API) and is the top of its own subtree. A single-instance deployment is astandalonecontroller with no children and no parent — the degenerate case falls out for free, no special-casing.slave— has a parent (parent_addr+ a token). It never accepts a write directly; every write it receives from below is forwarded up, and it relays the parent’s revision stream to everything below it. If it loses the parent, it freezes on last-known-good — exactly the existing Tier-1 rule (“a control-plane outage freezes state, it never clears it”), now applying at every hop, not just proxy-to-single-controller.
The role is never inferred from connectivity. A slave losing its parent
does not, and must not, promote itself to standalone — that is precisely how
two regions would end up independently accepting conflicting intent for the
same fact during a network partition rather than a real failure (violates
principle 2, “one authority per fact”). Promotion is only ever an explicit
operator action (see Adoption, below).
Intra-tier HA is an orthogonal knob
standalone/slave answers “does this tier have a parent.” It says nothing
about how many processes back that tier. Separately, every tier picks a
replica count:
- 1 node — fine for a single proxy or a small region that accepts freezing (not losing — the last-good state is still served) on that one node’s failure.
- A small consensus group (Raft/etcd-style) — for a tier that must keep
accepting new writes (if
standalone) or stay live for its children (ifslave) through a single node loss. This is the same mechanism either way; astandaloneroot cluster and aslaveregional cluster both run the same consensus group internally, they just differ in whether accepted writes originate locally or get forwarded to a parent.
So a deployment picks two independent settings per tier —
role: standalone | slave × replicas: 1..N — never a fixed shape baked into
the software. A lone proxy: standalone + 1. A globally distributed fleet: a
standalone Raft group at the root, slave groups (sized to taste) at each
region.
Adoption (built — phase 12 slice 5)
Status: built (phase 12 slice 5, 2026-09-05) —
POST /admin/adopt, seedocs/06“Adoption” and ADR 21/docs/09. Was not part of the phase 10/11 slices. The two “correctness details to solve when this is built” below are both resolved in the shipped version (it refuses with409unless the child’s config and intent stores are still empty).
Turning a standalone tier into a slave of a newly-introduced parent — e.g.
an operator adding a higher tier’s address + token in the admin UI after the
fact, rather than at install time — is a one-time, operator-triggered role
flip, not a new mechanism: the child starts the same subscribe flow (full
snapshot + cursor + change stream) against the new parent that any slave
uses from boot. Two correctness details to solve when this is built, not
now:
- The child’s own revision history must not outrank the parent’s once adopted — post-adoption, the parent is the sole authority for every fact the child used to originate itself.
- Adoption should require the child to be quiescent (caught up, nothing in-flight) before the flip, so a write doesn’t get reordered across the transition.
The controller (wayhouse-controller)
A new, optional service, deployed as one tier per the topology above (root
standalone, or slave under a parent tier). Proxies never talk to each
other through it — it is the Tier-1 writer for its subtree, and (transitively)
the GUI’s route to any fact in the fleet.
- Owns its tier’s Tier-1 store — embedded (Raft in the controller), or
backed by etcd / a SQL DB / a git repo. This is an ADR-level choice,
deferred (see Open questions). A
slavetier’s store is a materialized replica of what its parent relays, not an independently-written store. - Config API (accepted only at a
standalonetier, or forwarded up by aslave). Submit a full or partial config; the controller runs the samevalidate()the proxy does, assigns a revision, publishes down its subtree. History, diff, one-key rollback, staged / canary rollout (a subset of instances take a revision first). - Intent API. The phase-5 admin verbs (
drain backend, add / remove backend,route-hint, …) but fleet-wide (within the tier’s subtree) and persisted — each writes a Tier-1 revision instead of poking one instance. Direct per-instance admin stays as break-glass. - Auth / RBAC / audit log live here. The proxies’ own admin APIs stay
internal-only and are locked to their controller’s identity (or its
network); a
slavecontroller’s link to its parent carries its own token, independent of any proxy-facing credential. - HA is the intra-tier replica-count knob above: N controllers in one
tier behind a leader lock for writes originating at that tier; reads are
stateless. A whole tier’s outage stops changes for its subtree, not the
data plane — every instance under it serves its last replica. See
“Intra-tier HA (design)” below for the concrete mechanism (
openraft, one Raft group per tier replicating both logs).
The aggregator (wayhouse-aggregator)
A new, optional, stateless service, one instance (or a small horizontally- replicated group — no consensus needed, it holds no durable state) per tier. It is the Tier-2-adjacent read path: fleet view + fan-out for the phase-5 operational verbs, entirely separate from the controller’s Tier-1 write path.
- Ingests pushes from its tier’s proxies and from any child aggregators (a tier’s aggregator is itself a valid “leaf” to its parent aggregator — the homogeneous-schema point above).
- Serves the merged view for its own tier immediately (
GET /fleet/*), and separately pushes (or is pulled, if a deployment prefers that at the top where reachability isn’t a constraint) that merged view to its parent, if it has one. - Fans out the phase-5 intent verbs (drain, add/remove backend, route-hint, drain an instance) to every instance in its subtree at once, reporting partial success per instance rather than failing the whole call.
- Trust model. Two credentials keep telemetry and control apart:
--ingest-tokenunlocks onlyPOST /ingest(what everywayhouseand child tier pushes with),--auth-tokengatesGET /fleet/*and every fan-out verb; setting--auth-tokenwithout a different--ingest-tokenis refused at startup. A pusher can therefore report state but not drain or edit anyone. Each push’s self-reportedadmin_urlis where the fan-out later sends--instance-token, so it is checked at ingest: with--instance-url-allow(CIDR, hostname or*.suffix, repeatable) its host must match an entry (a hostname entry trusts whoever controls DNS for that name); without, it must be an IP literal equal to the pushing connection’s source address, i.e. an instance can only name itself (a push through NAT, a load balancer or a TLS terminator, or a tier relaying its children, needs the allowlist). A refused push is a400. The fan-out also refuses to forward a path parameter that is empty,./.., or holds/,\,?,#(a400), and percent-encodes the rest when building the instance URL, so a verb cannot be steered to another admin path. - Carries no authority — it never decides anything, only observes and relays observations up, and relays operator intent verbs down to the instances that actually hold the atomics. The controller (Tier 1) is the only place a decision is durably recorded.
Intra-tier HA (design)
Status: the
wayhouse-controllerhalf is built (phase 12 slice 6,openraft,--ha-node-id/--ha-peers/--ha-token— seedocs/06and ADR 21). The aggregator half below (stateless replicas behind one address) stays design-only — it needs no code, just running N replicas. The heading keeps “(design)” because other docs and code comments cite it by that name.
Answers “how does one tier survive a node loss” — orthogonal to standalone/
slave (that answers “does this tier have a parent”). Two very different
components, two very different designs:
The aggregator: stateless replication, no consensus
wayhouse-aggregator already carries no durable state (IngestStore is
in-memory, latest-write-wins, deliberately unpersisted — see its own
lib.rs). HA for a tier’s aggregator is therefore just running N
identical, uncoordinated replicas behind one stable address (a VIP, DNS
round-robin, or a k8s Service) — no leader, no consensus, no shared
storage:
- Every proxy in the tier pushes to the tier’s one stable address, never
to a specific replica — whichever replica the load balancer picks that
tick ingests the push. Same rule for a child aggregator’s upward push
(
--parent-urlnames the tier’s address, never a node). - A
GET /fleet/*read also goes through the same stable address, so a client sees some replica’s view, not necessarily the freshest — replicas are not required to agree with each other at any instant. - Accepted inconsistency: two replicas can hold different subsets of a tier’s instances until every instance’s next push interval (default 10s) reaches whichever replica currently answers reads. This is not a regression from the single-replica design — it already showed up as “stale/absent” in the Failure behaviour table, HA just spreads it across replicas instead of across time. Self-heals every push interval with zero coordination code.
- Rejected: gossiping ingested state between aggregator replicas so they converge faster. Adds a real distributed system (membership, anti-entropy) to a component whose entire design point is “carries no authority, forgets nothing important because everything re-arrives on the next tick” — not worth it for shaving one push interval off a view that was never meant to be strongly consistent in the first place.
The controller: Raft, replicated log, single leader accepts writes
wayhouse-controller does hold durable, order-sensitive state (two revision
logs), so its HA needs an actual consensus protocol — a tier that must keep
accepting new writes (standalone) or stay live for its children
(slave) through a node loss needs agreement on “what got written and in
what order,” which a stateless replica set cannot give.
- Library:
openraft(a Raft implementation, storage-and-transport agnostic), not a hand-rolled consensus algorithm and not an externaletcddependency. Rationale, extending ADR 20’s reasoning: this project has already chosen to embed a KV (sled) rather than run an extra service for the Tier-1 store; embedding Raft continues that “one binary, no external service” story instead of trading it away the moment HA enters the picture.etcdwould work but reintroduces exactly the operational dependencysledwas chosen to avoid, for a fleet-control-plane component whose own write volume is human-paced (config/intent changes, not a hot path) — a full etcd cluster is a lot of operational machinery for that load. A hand-rolled Raft was never seriously considered: Raft’s subtlety (log matching, leader lease safety, snapshot/log truncation) is exactly the kind of thing worth a maintained, audited crate rather than a bespoke implementation for a security-relevant (per the existing “Tier 1 is a high-value target” security note) component. - One Raft group per controller tier, replicating both logs together.
Not two independent groups (one per log): a tier’s config log and intent
log already share a lifecycle (both come from the same set of peers, fail
over together, and a
slavetier already relays both from the same parent) — one group means one leader election, one peer list, one leader lease to reason about, instead of two that could disagree about who leads. The Raft log’s state machine applies an entry to whichever of the twoStores (config or intent) the entry names; each entry carries a{log: "config" | "intent", bytes}envelope. - State machine = the existing
sledStore, unchanged.openraft’s storage trait needs a state machine and a log store; the state machine is today’sStore::put/get/revisions_afterexactly as built (slice 1), fed byopenraftapplying a committed entry instead of a direct HTTP handler call. The Raft log itself (openraft’s own append-only log of proposals, distinct from the config/intent revision logs it carries) lives in a thirdsledtree,<data_dir>/raft, on every replica. That log is purged peropenraft’s snapshot policy (a snapshot every 5 000 entries, the last 1 000 entries kept); a follower or learner behind the purge point is caught up by snapshot, which replaces its config and intent stores (revision numbers, stage and actor included). - Writes go through Raft; reads don’t.
POST /config,POST /intent, andPOST /admin/adoptpropose a Raft entry and wait for it to commit before responding (this is where “who is the leader” matters).GET /config,/config/subscribe,/config/revisions*,GET /intent-side reads, and/healthzare served by whichever replica received the request, straight from its own localStore— noReadIndex/ leader-lease read protocol. This is a deliberate relaxation of Raft’s usual linearizable-read guarantee, justified the same way the whole chapter already justifies it: principle 4 (“freeze on last-known-good”) already accepts a subscriber seeing a slightly stale replica during a partition; a follower a few commits behind its leader is the same acceptable staleness, not a new risk. Never a risk of serving state out of order —sled’s local apply only ever appends, so a follower’s view is always a strict prefix of the leader’s, never a divergent one. - Non-leader write handling: transparent HTTP forward, not a redirect. A
replica that receives a write it isn’t the leader for forwards the request
to the current leader over HTTP, or HTTPS for
https://peers (openrafttracks the current leader; the follower proxies the request body byte-for-byte, sameforwardable_headerspatternwayhouse-ui’s proxies already use) and relays the leader’s response back verbatim. The forward is bounded (10 s, then504: the write may still have been applied), so a half-open leader never hangs the follower’s handler. Rejected: an HTTP redirect (307+Location) — every existing client of this API (wayhouse,wayhouse-ui, a human withcurl) would need new leader-following logic; a transparent forward means literally nothing downstream of this ADR needs to know HA exists. The one-hop latency cost only applies to writes (rare, human-paced), never to reads or to the data plane. The forward re-sends the caller’s ownAuthorization,Content-TypeandX-Actor; the leader re-checks the token (the--ha-tokenis never substituted). So every replica must run the same--auth-token, and with plain-http--ha-peersentries the caller’s bearer token crosses the network between nodes in cleartext — usehttpspeers (--ca-file) when that network is not trusted. - Raft RPCs travel over the same
axumserver, a separate route prefix (/raft/*), gated by a separate peer-only shared secret (--ha-token, distinct from--auth-token) rather than mTLS or a new transport — mirrors every other cross-service credential in this fleet (--instance-token,--parent-token, …), which are all shared-secret bearer tokens over plain HTTP on a trusted internal network, not a new trust model for this one link.openraft’s async network trait is implemented as a thinreqwest-based client, matching every other outbound HTTP call in these crates. - A
slavetier’s upward relay runs only on the leader (built 2026-10-05).parent_client/intent::relay(slices 1 and 4) become leader-only tasks — every follower would otherwise apply the same parent revision independently and each assign it a different local revision number, corrupting the replicated log. On a leader failover, the newly-elected leader must resume the relay from where the group left off, not where it personally last ran one — so the relay’s cursor (last_applied_parent_revision, one per log) becomes part of the replicated state machine (a small extra key perStore, committed alongside each relayed entry) instead of a process-local variable. This is the one genuinely new piece of state HA introduces beyond “replicate what already exists.” As built: the cursor is arelay_cursortree beside each log, written in the samesledtransaction as the relayed revision and replaced by a snapshot install; the leader proposesRelayConfig/RelayIntententries carrying the parent revision; the state machine skips an entry whose parent revision is not above the cursor (a deposed leader’s late proposal), so no replica applies a parent revision twice. A new leader seeds a cold tier from the parent’s current config once (cursor0), then subscribes from the cursor. A--role slavetier keeps the cursor without HA too, so a restart no longer replays the parent’s intent history.POST /admin/adoptis refused on an HA controller: the role is a per-process setting, and a replicated role flip is not built. - Cluster membership: static at bootstrap, dynamic afterwards (built 2026-10-03).
--ha-peers 1=host1:9901,2=host2:9901,...(or1=https://host1:8443,..., built 2026-10-02 — docs/12 “HA replicas over TLS”) on every replica forms the initial voter set (openraft’s single-step static bootstrap).--ha-peersis only read when a cluster is first bootstrapped (raft.initializeis refused once the log is non-empty), so restarting with a new list changes nothing for existing members. A running group changes throughGET/POST /admin/ha/membersandDELETE/PUT /admin/ha/members/{id}: a new or replacement node starts with--ha-join(it never initializes) and is added by the leader, which first asks the node who it is (/raft/whoami) so a node id is never pointed at another node’s address; remove a node, orPUTa new address (including anhttps://one), the same way. Design:docs/superpowers/specs/2026-10-03-ha-replicated-address-allocation-design.md, recipe in docs/12 “HA membership”. - The tunnel registries are replicated state too (built 2026-10-03). The
backend-peers and proxy-peers registries and the address book are applied by the
state machine, next to the config and intent logs, so a registration made on any
node is seen on the others with the same tunnel address and a new leader keeps
allocating without collisions. The leader records the cluster’s
--tunnel-networkonce (ha/cluster_state.rs); registry entries are applied against that recorded network, never a node’s own flag. On the first HA start the leader imports one node’s pre-HA registry data (ha/import.rs, docs/12 “Upgrading a single controller to HA”). replicas: 1(today’s shape) needs no code path change. A one-node Raft group trivially elects itself leader and commits every entry immediately (no network round trip) —openrafthandles the degenerate case for free, so the phase 10+11 single-controller deployment shape isn’t a special case of the HA design, it’s theN=1instance of it.
What this buys, and what it costs
Per the existing Failure behaviour table, a standalone (or slave) tier’s
single-node outage today “freezes changes for its subtree” — every proxy
under it keeps running on its last replica, which is already the acceptable
degradation this whole design is built around. HA’s benefit is narrower than
it might sound: it turns “changes are frozen until an operator restarts the
node” into “changes keep flowing through the surviving majority,” for
deployments where that freeze window is unacceptable (a large region, or a
root tier serving many children). It adds real operational cost (N processes
per tier instead of 1, a peer list to manage, a new failure mode — a lost
Raft quorum, which degrades to the same frozen-changes behavior a
single-node outage already has) for that benefit, which is why docs/08
correctly scopes it as an explicit later slice, opted into per tier via
replicas: 2..N, never a default.
Staged / canary rollout (design)
Status: built (phase 12 slice 7) on the controller side —
POST /config?stage=canary&group=<name>,GET /config?group=<name>,GET /config/subscribe?since=<rev>&group=<name>,POST /config/promote/{revision}, and the{promoted, canary_groups}stagesledtree. Seedocs/06and ADR 22. Not yet wired on thewayhouseside: a realwayhouse --controllerinstance has no way to enroll itself in a group yet — there is nosettings.controller.canary_groupconfig field (the design sketch below), andcontroller_clientdoes not pass&group=. The feature is exercisable today only with a manualcurlsubscribe. The heading keeps “(design)” becausewayhouse-controller’s module docs cite it by that name.
A subset of a tier’s instances take a config revision before the rest of the fleet — “The controller” section above already named this as part of the Config API; this is the concrete mechanism.
Instances self-report a rollout group
(Design as sketched; the wayhouse-side field below is not built — see the Status
note above.) Every wayhouse instance gains an optional
settings.controller.canary_group: <string> (default: unset, meaning “not
enrolled in any canary group” — the overwhelmingly common case, and the entire
mechanism is invisible to an instance that never sets it). controller_client’s
GET /config/subscribe?since=<cursor> gains a &group=<canary_group> query
parameter (omitted when unset).
Self-reported, not independently verified — the same trust level
IngestPayload::admin_url already has in the aggregator: every instance in
a tier already holds that tier’s controller/aggregator credentials and is
therefore already inside the trust boundary this whole control plane
operates within. Verifying group membership against some external source of
truth would be a real feature (ties into the open “region-scoped intent”
question) but isn’t needed for “let an operator try a change on the three
instances they labelled canary before the other three hundred.”
One log, revisions tagged with a rollout stage — not a forked history
A canary revision is still one entry in the same monotonic config log (never a second, parallel history to reconcile later) — it just carries rollout metadata alongside its bytes:
{ "promoted": bool, "canary_groups": ["groupA", "groupB"] }
stored in a new small sled tree (stage, keyed by revision number,
alongside revisions/meta) rather than folded into the revision bytes
themselves — Store::put’s existing content-agnostic contract (never
inspects what it’s storing) stays exactly as it is; the API layer writes an
extra stage entry in the same transaction.
POST /config(today’s plain submission) implicitly writes{promoted: true, canary_groups: []}— byte-for-byte today’s existing behavior when the feature is never used. This is the compatibility anchor: nothing about slices 1–5’s tests or wire shape changes.POST /config?stage=canary&group=<name>writes{promoted: false, canary_groups: [<name>]}(repeatable —&group=may appear more than once for a multi-group canary).POST /config/promote/{revision}flips that revision’spromotedtotruein place — the one exception to “every accepted change is a new revision” in this whole design, deliberately: promotion isn’t new content, it’s a visibility change to content that already exists and already has a revision number: minting a second revision with identical bytes just to mark “this one’s fleet-wide now” would makeGET /config/revisionsshow two entries for one real change, and would need its own reconciliation if a promoted revision needs a further canary step (it doesn’t — promotion is terminal).
What a subscriber actually receives
“Current, for group G” is defined as: the highest-numbered revision that
is either promoted or has G in its canary_groups. GET /config?group=<G> and the /config/subscribe?group=<G> catch-up range both
compute this by scanning stage from the current pointer backward until a
match — accepted as an O(revisions) operation rather than adding an
index, matching this codebase’s general bias against building for a scale
problem that doesn’t exist yet: this runs at human-paced write rates against
a log sized in the hundreds to low thousands of entries for any deployment
this design targets, not a hot path and not wayhouse-core. Revisit with an
index (e.g. a sled tree keyed by group, pointing at its latest visible
revision) if a real fleet’s revision count ever makes the scan measurable.
An instance with no canary_group set behaves exactly as it does today:
it only ever sees promoted revisions, so an in-flight canary is completely
invisible to the rest of the fleet until someone explicitly promotes it.
Rollback and diff are unaffected; intent is explicitly out of scope
POST /config/rollback/{revision} keeps re-submitting the target revision’s
exact bytes as a new, immediately-promoted revision (a rollback is
presumably urgent, never something to stage) — no change to slice 5’s
rollback semantics. GET /config/revisions gains promoted and
canary_groups columns; diff is unaffected (it already compares two
revisions’ bytes, unrelated to visibility).
Canary staging applies to config revisions only, not the intent log — a deliberate scope cut, not an oversight. An intent op is already a narrowly targeted, single mutation (add one backend, patch one backend’s state); “give this whole document to a subset of the fleet first” has a clean meaning for a structural config swap and no equally clean one for “apply this one backend-add to a subset” (an add/remove is either safe everywhere it makes sense or it isn’t — there’s no natural partial-rollout story for a single op the way there is for a whole document). Revisit if a real use case surfaces; not designed here.
RBAC and audit (design)
Status: built (phase 12 slice 8) —
wayhouse-ui --users-file(argon2 hashes,viewer/operator/admin),--ui-passwordkept as legacy single-secret mode,X-Actoron every proxied write, and the controller’s per-revisionactorssledtree (GET /config/revisionsgained anactorfield). Seedocs/06and ADR 23. The heading keeps “(design)” because other docs cite it by that name.
docs/10’s original text placed “Auth / RBAC / audit log” on the
controller. The phase 10+11 slice-11 redesign (see HANDOVER.md) already
diverged from that once, for a good reason that still holds: wayhouse-ui is
the only place a human ever authenticates — the controller and
aggregator each still gate on one shared machine bearer token apiece, and
that stays true here too. So this design keeps that split and completes
it: RBAC is enforced in wayhouse-ui, the one place with human identity;
the controller gains only what it’s missing to make the resulting audit
trail meaningful — knowing who (not just that) a revision came from.
Multiple human identities, replacing the single shared password
wayhouse-ui --ui-password (one shared secret for every operator) is replaced by
--users-file <path>, a YAML list:
users:
- username: alice
password_hash: "$argon2id$v=19$..."
role: admin
- username: bob
password_hash: "$argon2id$v=19$..."
role: operator
argon2(theargon2crate, pure Rust) for password hashing — the conventional, currently-recommended choice for this exact job; notbcrypt(older, smaller work-factor ceiling) and not a hand-rolled scheme (this is precisely the kind of security-critical, well-solved problem this codebase already defers to a maintained crate for, the same reasoning ADR 16’s sniffer sandboxing and this session’s HAopenraftchoice both use).- A companion
wayhouse-ui --hash-passwordCLI mode (prints an argon2 hash for a password read from stdin) is the intended way an operator populatesusers-file— never handling plaintext passwords in a config file at rest. --ui-passwordis retained as a legacy single-shared-secret mode (mutually exclusive with--users-file) rather than removed outright: unlike this codebase’s usual “clean break over compat shim” pre-1.0 stance, forcing every existing single-operator deployment to mint ausers-filefor a single-user setup is pure friction with no correctness upside — RBAC only matters once there is more than one identity to distinguish. A single-password deployment implicitly runs asadmin.
Three roles, matching the verb tiers the API surface already has
viewer— everyGET: fleet reads, config/revision reads and diffs. No verb that changes anything.operator—viewer+ the phase-5 intent verbs already fanned out through the aggregator (drain / undrain an instance, backend add / patch / delete, route-hint) and their controller-intent-log equivalents (slice 3). Cannot touch structural config or fleet topology.admin—operator+ config submit / rollback / promote (staged rollout, above) andPOST /admin/adopt(a role/topology change, correctly gated at the top).
Three roles, not a fully general permission-matrix model: every route in
wayhouse-ui’s existing surface already falls cleanly into one of these three
buckets (see docs/06’s “Fleet control plane” endpoint reference), so a
richer model would be solving a problem this API surface doesn’t yet have.
Region/subtree-scoped roles (“operator, but only for region X”) are
explicitly deferred — wayhouse-ui only ever talks to the root tier (per the
existing “GUI served only by the topmost tier” rule) and today’s intent
verbs have no region selector to scope against (the same open question
docs/10 already tracks as “Region-scoped intent”); scoped RBAC is only
meaningful once that lands.
Enforcement point and session shape
session::SessionStore’s HashSet<String> (a session id is either valid or
it isn’t) becomes a HashMap<String, Role> (a session id maps to the role
its login resolved to). auth::require_session is joined by a new
require_role(min: Role) middleware, layered per route group in
api::router exactly the way require_session itself is layered today —
viewer-level routes get require_session alone, operator-level routes
get require_session then require_role(Operator), admin-level routes
require_role(Admin). A role check failing is 403, distinct from
require_session’s 401, so the frontend can tell “not logged in” from
“logged in, not allowed” and render accordingly.
Audit trail: the controller learns who, not just what
Every accepted revision (config or intent) already answers “what changed and
when” via GET /config/revisions. It has never recorded who submitted
it — wayhouse-controller only ever sees wayhouse-ui’s single shared
--controller-token/--aggregator-token, identical for every human behind
it. Closing this gap needs one addition at each hop:
wayhouse-uisets a newX-Actor: <username>header on every write it proxies to the controller or aggregator (aggregator_proxy/controller_proxy, usingproxy_util’s existing header-forwarding path in reverse — an outbound header added, not an inbound one preserved).wayhouse-controller’ssubmit()/intentsubmit_intent()readX-Actor(Option<String>, defaulting to"unknown"for a request that didn’t carry one — e.g. a directcurlagainst the controller’s own token, which stays valid break-glass access and simply produces a less specific audit entry) and store it in a newactorssledtree, keyed by revision number, alongsidestage— same pattern, another small parallel tree rather than wideningStore’s core content-agnostic contract.GET /config/revisionsgains anactorfield per entry.wayhouse-aggregatordoes the same for the fan-out verbs it forwards, logging(instance, actor, verb, timestamp)— in-memory only, matching its existing “carries no durable state” design; a durable fleet-wide audit log (as opposed to “the last actor value attached to a controller revision, which is already durable viasled”) is not designed here — the controller’s revision history already gives a durable, complete audit trail for every fact that has lasting effect (config, intent); the aggregator’s own verb log is a convenience for “who just did this,” not a compliance record.
This is intentionally a thin audit story, not a full authorization/audit framework — proportionate to a three-role model with one human-facing process, not a multi-tenant compliance product.
The admin GUI
Served only by the topmost standalone tier — never by a slave tier.
Every regional controller/aggregator stays a machine-to-machine API (serving
its own subtree, relaying to its parent) with no browser-facing frontend at
all; only the root hosts the GUI, because it’s the one place with full-fleet
authority and it keeps auth/RBAC/session state a single fact in a single
place, instead of N regional logins each needing to agree on who can do what.
Serving a UI per tier would reopen exactly the problem the hierarchy was
built to close: an operator not knowing which region’s login is
authoritative for a given action, and RBAC that must be kept consistent
across every tier instead of living in one.
A dedicated wayhouse-ui process is the GUI’s home — not the controller, not
the aggregator. The GUI needs both: reads/operational verbs from the
aggregator, config/revisions from the controller. Bolting human login onto
either one taxes it with a concern that isn’t its own: a session store is
a form of authority (“who’s allowed to act”), which sits wrong on the
aggregator (deliberately “carries no authority”); and putting the browser’s
whole surface on the controller couples presentation to Tier-1’s write path
for no reason. wayhouse-ui is a pure BFF — human session auth in front, holding
the controller’s and aggregator’s own machine credentials to call each on
the operator’s behalf, authority over neither. It’s the one piece of this
picture with no “real” state of its own (no store, no fleet data, nothing
that outlives a restart beyond active sessions), so it costs nothing to add
as a fourth (later: a third-per-tier) always-optional process; it never
gains its own write path around the controller.
A regional tier’s raw API is still reachable directly (curl / CLI, on its own internal network) as break-glass during a root outage or partition — the same status the phase-5 direct-per-instance admin API already has next to the aggregator/controller. That is deliberately not a served web UI: break-glass access is for an operator who already knows exactly what they’re doing to one region, not a second, parallel product surface to build and secure.
Two capability levels, shippable in order:
- Operational — needs no Tier-1 store. Fleet-wide view + the phase-5 verbs, via the stateless aggregator hierarchy. This is roadmap Phase 10 and is the “Web UI for the admin API” listed in chapter 08’s Later / optional. A first deployment is a single aggregator tier — the hierarchy above is what a deployment grows into with more regions, not a prerequisite for phase 10’s first release.
- Full management — structural config editing, revision history, rollback, diff, RBAC. Needs Tier 1 + the controller hierarchy. Roadmap Phase 11.
No proxy admin port is ever exposed to a human; the GUI carries all authentication and authorization.
Redesign (2026-09-05): the GUI gained a real fleet tree (instances group
under a self-reported settings.group path, e.g. "eu/frankfurt/cluster-a"
— a display-only label alongside the existing admin_url/sessions fields
in IngestPayload, unrelated to the Tier-1/Tier-2 hierarchy above), a
schema-driven settings form over the same submitted YAML text (a raw-YAML
panel stays underneath so every field — including ones the form has no
control for yet — is always reachable), and a Sniffers page for managing
sniffer modules. (Later, 2026-10-03: a read-only Tunnel addresses page over
the controller’s GET /tunnel/addresses, docs/11 “Address authority”; and, 2026-10-21, a read-only Certificates page over the controller’s /acme/sites, /acme/gateways and /acme/account, acme.md.) Sniffer management needed new backend surface: wayhouse gained
GET/POST /admin/sniffers + DELETE /admin/sniffers/{name} (writes into
settings.sniffers.dir, then reuses the existing live rescan-on-reload
mechanism — no new hot-reload path), fanned out fleet-wide via
wayhouse-aggregator’s POST/DELETE /fleet/sniffers[/{name}] and a targeted
GET /fleet/instances/{instance}/sniffers. Turning settings.sniffers on
from nothing is still startup-only, per its existing design.
Versioning
Every surface that two components share carries a version, and a node that cannot
understand its peer says so instead of misbehaving. The design (the N / N-1 window,
the upgrade order) is in
docs/superpowers/specs/2026-10-05-component-versioning-design.md;
this is what is built.
| Surface | Carrier | Rule |
|---|---|---|
Component HTTP/JSON, SSE and raft routes (controller /proxy-peers, /peers, /config/subscribe, /intent*, /tunnel/addresses, /raft/*; aggregator POST /ingest; proxy admin API, leniently) | X-Wayhouse-Protocol: <major>.<minor> (wayhouse_http::protocol) | clients send it on every request (wayhouse_http::builder()); a server answers 426 to another major or an unparsable value, with wayhouse protocol <peer> is not compatible with this node (<ours>): upgrade the older side, and echoes its own version on every response. A request with no header is refused too (a caller that predates versioning, per #192); the aggregator’s fan-out reaches the proxy admin API through the same check, but those routes are also called by hand, so there a missing header is accepted and only another major is refused (route_group="proxy"; the fan-out result shows the 426 text). Checked on connect: an open SSE stream is never torn down mid-stream. |
| Gossip (UDP) | a magic byte then a version byte after the timestamp, inside the HMAC | a datagram with a valid MAC and another version is dropped before decoding and counted (wayhouse_gossip_version_rejected_total) |
| Config document | schema_version | see 05 “Schema version” |
| Controller store | meta/format in each sled database | a higher value is refused at open, naming the path |
The controller’s /config* operator routes (except /config/subscribe), the UI, /healthz, /metrics and the aggregator’s /fleet/* routes are for
operators and the UI and are never gated.
Rule: a breaking wire change (a removed or retyped field, changed semantics, a
new required field, a changed raft RPC payload) bumps PROTOCOL_MAJOR
(crates/wayhouse-http/src/protocol.rs), even while the product is 0.x; an additive
one (new optional field or route) bumps PROTOCOL_MINOR. Protocol numbers are
independent of the product version. The gossip frame is versioned on its own: a
breaking gossip change bumps GOSSIP_VERSION (crates/wayhouse-core/src/gossip.rs)
and not PROTOCOL_MAJOR, since it does not touch any HTTP route; an additive one
needs no bump if older nodes ignore it. A datagram with another gossip version is
dropped, so there is no N / N-1 window for gossip: during a rolling upgrade nodes of
different gossip versions stop sharing health and fall back to their own checks
(the data plane is unaffected). A refused request counts into
wayhouse_protocol_mismatch_total{route_group="controller"|"aggregator"|"raft"|"proxy"}.
Peers remember the protocol version each other answered with and gate newer optional
fields on it (wayhouse_http::peers); the fleet view flags version skew. The operator
runbook, the compatibility window and a table of versions per release are in
upgrading.md.
Not covered: the raft log and snapshot storage beyond the store marker, and the WireGuard tunnel itself.
Failure behaviour
| Component down | Data plane | Config changes | Health accuracy | Fleet view |
|---|---|---|---|---|
| Tier-1 store / all controllers | unaffected — serves last replica | frozen | unaffected | unaffected (aggregator is a separate path) |
a slave controller tier | that subtree’s proxies keep last replica | frozen for that subtree only; other subtrees unaffected | unaffected | unaffected |
a slave/child aggregator tier | unaffected | unaffected | unaffected | that subtree’s data ages out of the parent’s view; the subtree’s own local view is still fully served |
| Tier-2 fabric (a domain) | unaffected | unaffected | falls back to each instance’s own checks | unaffected |
| a single proxy instance | LB / anycast routes around it | n/a | its Tier-2 records age out of the domain view | ages out of its tier’s aggregator view |
partition domain ↔ parent controller | the domain keeps running on its last replica | that domain frozen | intra-domain health still gossips | domain still fully self-servable via its local controller/aggregator |
partition domain ↔ parent aggregator | unaffected | unaffected | unaffected | domain still fully self-servable locally; only the parent’s rolled-up view is missing that domain |
| partition splitting a domain | both halves keep running | — | each half reaches quorum among itself; may diverge until healed (acceptable — health is advisory + locally checked) | each half’s local aggregator/controller still serves that half |
Security notes
- Tier 1 is a high-value target — a bad revision can blackhole the fleet.
Mitigations:
validate()on both controller and instance; a revision must be signed by the controller; an instance rejects a revision that fails a sanity bound (e.g. “a pool that had > 0 targets now has 0” → warn and keep the previous snapshot); staged / canary rollout; one-key rollback to any prior revision. - Tier 2 is lower value (advisory, rebuildable) but still authenticated — signed messages / mTLS mesh.
- The GUI is the only operator-facing component, and it is served solely by the root tier. All authn / authz lives there, once, rather than being kept consistent across every regional tier’s own frontend.
Open questions
Resolved by this session’s design pass (each now has its own section above,
plus an ADR in docs/09): Tier-1 backing store / HA mechanism (sled +
embedded openraft, one group per tier, ADR 21) — the “git-as-source-of-
truth” option was dropped once the concrete design confirmed sled already
does everything needed and a git-backed store would need its own
Raft-equivalent replication story anyway; one revision stream or two
(kept two, config and intent, confirmed by their independent slice 1/3/4
implementations and reaffirmed by “canary staging applies to config only” in
the rollout design — the two logs have different visibility and staging
needs, splitting was the right call); adoption flow (built, slice 5).
Still open:
- Tier-2 transport: an existing gossip crate (
focafor SWIM, amemberlist-style mesh) vs. a minimal hand-rolled CRDT sync. Measure message volume with realistic backend / instance counts before committing. - Affinity as Tier-2 state: should the resolver result cache
become shared-within-a-domain state (so a rehashed client keeps its instance’s
affinity)? Overlaps chapter 03 scheme C. (
sticky_keywas removed, #54.) - Region-scoped intent: is “drain backend X” ever meant to apply to one domain only, needing intent records with a failure-domain selector? Also now a prerequisite for region-scoped RBAC (see “RBAC and audit” above, which explicitly deferred it for the same reason).
- Controller ↔ discovery: when phase-8 discovery already supplies backend membership from k8s / Consul / DNS, the controller’s intent surface shrinks to overrides on top of discovery. Confirm the precedence order (discovery ∪ overlay − removed, then admin state) and where it is evaluated.
- Aggregator push transport: a bespoke small protocol (HTTP + a batched JSON body, simplest, consistent with the rest of the admin API) vs. an existing wire format (OTLP for metrics-shaped data) — the OTLP path buys interop with existing collectors but the fleet/pool/session view isn’t metrics-shaped, so a bespoke shape is likely still needed alongside it.
- Push buffering bound: how much history a proxy/aggregator holds locally
when its parent link is down before it starts dropping — a fixed ring
buffer sized in the same spirit as the existing recv-buffer caps
(
docs/06), not unbounded growth. - Dynamic Raft membership was deferred here and is built (2026-10-03): see “Intra-tier HA (design)” above and docs/12 “HA membership”.
- A durable, fleet-wide audit log (as opposed to the per-revision
actorfield this session’s RBAC design adds, which is durable but lives one field per revision, not as its own queryable log): deferred in “RBAC and audit” above as disproportionate to a three-role, single-human-facing- process model.
11 – Backend transport
Status: built (2026-09-06). All 7 Phase 14 slices are implemented and
verified live end-to-end in Docker (see docs/08-roadmap.md “Phase 14” for the
slice-by-slice record and HANDOVER.md for the bugs the live run surfaced).
This chapter remains the design source of truth for the mechanism. The design
was locked in a 2026-09-05 session at the depth of the phase-12/13 pre-build
sessions; the “Locked decisions” and “Open questions” sections below are kept
as written then.
Problem
Every requirement and architecture decision so far assumes what
docs/01-requirements.md states outright under
Assumptions: “Backends are reachable over a trusted internal network.”
That’s true for a single-region deployment (proxy and game servers in the
same DC/VPC). It stops being true the moment the fleet is what phase 10–13
were actually built for: proxy instances distributed globally (edge
PoPs, one per region) in front of game servers that are not on those
proxies’ local network — a different DC, a different cloud, a rented box at
a small hosting provider, sometimes behind a home NAT.
Today connect_backend (crates/wayhouse-core/src/proxy.rs:138) and
connect_upstream (crates/wayhouse-core/src/listener_udp.rs:844) do a plain
TcpStream::connect/UdpSocket::connect to a bare SocketAddr — there is no
seam between “route resolved to this address” and “dial it” for anything
else to hook into. health.rs’s active checks dial the same way
independently. Closing this gap is Phase 14.
Don’t reinvent the wheel
Before designing anything new, what does this problem already have solved prior art for?
- Steam Datagram Relay (Valve) is the closest large-scale precedent: game clients never see the origin server’s IP; traffic is relayed through Valve’s own network, and origin servers register only with the relay fabric. Proprietary infrastructure, not reusable, but it validates the shape of the answer: a relay/tunnel network in front of an origin that never needs a public inbound port of its own.
- rathole / frp: exactly this problem in miniature (a lightweight,
high-performance reverse-tunnel proxy for NAT traversal, written in Rust —
ratholespecifically). Real, working, maintained tools — but they’re standalone binaries with their own framing/auth protocol, not something to embed as a library, and they know nothing about pools, health checks, or this project’s control plane. Good validation that “origin dials out to a relay with a public IP” is the standard shape; not something to depend on directly. - WireGuard itself, unmodified, is the actual answer for the tunnel data plane. It already solves the hard, security-critical part (an authenticated, encrypted point-to-point link) as a small, audited, extremely widely deployed protocol — exactly the “defer to a maintained, audited implementation for a subtle, security-relevant mechanism” reasoning ADR 21 (Raft) and ADR 24 (SWIM gossip) already established twice in this codebase. Critically, WireGuard already works when only one side has a public, reachable endpoint: the origin-side peer needs no public IP or open inbound port at all — it just needs to be able to send outbound UDP to the proxy’s known public endpoint once, and WireGuard’s own roaming (the far side updates its record of “where that peer’s packets are coming from” on every received, authenticated packet) plus a keepalive keeps a NAT mapping open indefinitely. This is true even for an origin behind a home router — no port forwarding, no static IP, ever required on the origin side. (The one case this doesn’t cover — both sides behind restrictive/CGNAT-style NAT with no cooperating public peer at all — doesn’t apply here, since the proxy side is edge infrastructure and always has a real public IP.)
- Headscale (a BSD-3 self-hosted Tailscale-protocol coordination
server) is the closest prior art for automated WireGuard key/peer
distribution at fleet scale — proof that “a control plane that hands out
WireGuard peer configs” is a well-trodden, sensibly-scoped idea. Adopting
it wholesale would mean either running real Tailscale clients (a large,
general-purpose product with its own protocol, unrelated to pools/health/
routing) or reimplementing enough of its coordination protocol to be
compatible — neither is smaller than extending the control plane this
project already runs (
wayhouse-controller) with one narrow registry. Its idea is reused; the software is not.
Conclusion: nothing about the tunnel itself needs inventing. Real,
unmodified WireGuard (kernel module where available, boringtun’s
userspace implementation as a portable fallback) is the entire data plane.
The only genuinely new code this project needs is (a) a small origin-side
helper that manages a local WireGuard interface and registers itself, and
(b) a narrow extension to the existing Tier-1 control plane
(wayhouse-controller) that distributes peer configuration automatically instead
of by hand — both thin, both specific to fitting this into pools/health/
routing, neither a reimplementation of cryptography or tunnel framing.
Shape
┌─────────────┐ WireGuard ┌──────────────┐ ┌──────────────┐
│ wayhouse (edge, │◄───────────►│ wayhouse-agent │◄──────►│ game server │
│ public IP) │ tunnel │ (origin, │ plain │ process │
│ │ │ no public │ local │ │
│ pool target│ │ IP needed) │ traffic │ │
│ = tunnel- │ └──────────────┘ └──────────────┘
│ internal IP│
└─────────────┘
▲
│ peer config (pubkeys, allowed IPs)
▼
┌───────────────────────┐
│ wayhouse-controller │ (existing Tier-1 store, ADR 20/21) —
│ + new "backend peers" │ new small registry, distributed the
│ registry │ same way config revisions already are
└───────────────────────┘
- The data plane never changes. Once a WireGuard interface for a given
origin exists on the proxy host, that origin’s backends are just IP
addresses on that interface’s subnet —
connect_backend/connect_upstream/health.rs’s dials need zero code changes, because fromwayhouse’s point of view a tunneled backend looks exactly like any other routableSocketAddr. This is the same reasoning that already lets this project’s transparent-proxy mode (ADR 12) and every other listener option layer onto the existing pump without touching it. wayhouse-agent(new, small binary, its own workspace crate — locked, matching thewayhouse-controller/wayhouse-aggregator/wayhouse-uiprecedent of one small process per concern, keeping WireGuard dependencies out of the proxy-criticalwayhousebinary entirely). Runs next to the actual game server process. Creates and maintains one shared local WireGuard interface with every proxy PoP it’s paired with as a peer on that same interface (locked — the standard WireGuard shape: one interface, many peers, each scoped by its ownAllowedIPs; per-origin interfaces would multiply routing-table entries for no isolation benefitAllowedIPsdoesn’t already give per peer). Registers its public key + which backend address(es) it fronts withwayhouse-controller. It does not proxy or inspect game traffic itself — the WireGuard interface does that, and the real game server binds normally on its own loopback/interface address, exactly as it would with zero knowledge that a proxy is involved.- Interface management via
defguard/wireguard-rs(locked) — a multi-platform Rust library unifying kernel-netlink andboringtun- userspace configuration behind one API, used by bothwayhouse-agent(the origin’s interface) andwayhouse’s new reconcile task (the proxy’s shared interface, all origins as peers). One abstraction instead of hand-rolling the kernel-vs-userspace branch ourselves on top of two separately-focused crates (wireguard-uapi+boringtun) — continues this project’s own “prefer a maintained wrapper over hand-rolled plumbing” bias (nixforrecvmmsg/TPROXY/splice, ADR 10/12). wayhouse-controllergains a “backend peers” registry — a new resource alongside the existing config-revision log and phase-12 intent log, not a replacement for either.wayhouse-agentwrites its own registration (pubkey, allowed backend addresses, last-known endpoint); every edgewayhouseinstance subscribes to the resulting peer table the same way it already subscribes to config (controller_client.rs’s existing shape, ADR 13) and reconciles its shared WireGuard interface’s peer list to match (newwayhousetask, same shape ascontroller_client::run, usingwireguard-rsabove). Revoking/ rotating an origin’s key is a controller-side removal that propagates the same way a config change does — no manual per-proxy key exchange, ever, for a fleet of N proxies × M origins.- …and a mirror-image “proxy peers” registry, built in slice 7. The
bullet above only solves proxy→origin discovery.
wayhouse-agent’s “every proxy PoP it’s paired with as a peer” premise two bullets up needs the other direction too: an origin has to learn about every proxy, including ones added after it was deployed, without being restarted. So everywayhouse --tunnel-*instance also registers itself (pubkey + its own public endpoint — always known, unlike an origin’s) with a second registry, and everywayhouse-agentsubscribes to it and reconciles proxies onto its own interface, the exact mirror of the first bullet’s mechanism run in the other direction.wayhouse-agent --peer-pubkey/--peer-endpoint(a manual static pin, what slice 6’s first end-to-end verification used before this existed) still work alongside it for a bootstrap proxy or a deployment too small to bother with the registry — they converge to the same interface state a registered proxy would reach anyway, so there’s no conflict between the two paths. - Edge restarts: a per-process
boot_id(built 2026-10-03). A restartedwayhouse --tunnel-*has a fresh interface but no endpoint for any origin (origins may sit behind NAT), so only the origin can re-handshake — and with kernel WireGuard the origin’s session to the old process still looks valid, so it used to wait for the 120 s rekey (~2.5 min outage). Every proxy registration therefore carries a randomboot_id(128-bit hex, new per process start; the controller accepts 1–64 of[A-Za-z0-9-]and stores it as-is).wayhouse-agentcompares whole registrations, so a changedboot_idre-sets the peer (remove + add), which drops the dead session; the re-added peer’s persistent keepalive starts a new handshake at once. Optional on the wire in both directions: an older proxy sends none (no change from before), an older controller drops it, an older agent ignores it. - Key material stays out of the config-revision log. A peer table is security-sensitive, high-churn (origins come and go), and has nothing to do with routing/pool structure — it belongs in its own resource on the same Tier-1 store, mirroring how the intent log (phase 12) already lives alongside the config log rather than inside it.
- Config schema: an origin is a new
BackendSource(locked) — reuses the existing level-triggered discovery seam (ADR 12a:fetch() -> Vec <SocketAddr>, the runtime diffs it, same reconcile path DNS-SRV/Consul/ Kubernetes sources already use) instead of inventing a parallel “origins: / pools[].origin” concept. A pool referencing an origin looks exactly like a pool referencing any other dynamic source today:pools: [{ name: p, source: my-origin }],backend_sources: [{ name: my-origin, type: tunnel, ... }]. The “tunnel” source’sfetch()returns the origin’s currently-registered, tunnel-internal backend address(es) from the backend-peers registry — no new schema concept, one newBackendSourceimplementation and one newbackend_sources[].typevariant. This also means an origin’s backend address(es) can change (the agent re-registers) without touchingwayhouse-configat all, the same “discovered set, not a static file list” property every other dynamic source already has.
Address authority (built 2026-10-02)
wayhouse-controller allocates tunnel-internal addresses, so no operator chooses (or
mis-chooses) one. Design and decisions:
docs/superpowers/specs/2026-10-02-tunnel-address-authority-design.md;
IPv6 (built 2026-10-03):
docs/superpowers/specs/2026-10-03-ipv6-tunnel-design.md.
- Allocation. Start the controller with
--tunnel-network fd49:89c1:4b5e:60::/64(IPv6,/64to/120; the default in the docs and examples, and a ULA cannot clash with an origin’s own LAN) or--tunnel-network 10.60.0.0/16(IPv4,/16to/30). One family per controller and one address per peer; choose IPv4 when a game server binds0.0.0.0only, since it cannot be reached on an IPv6 tunnel address. Either family holds at most 65 534 entries (pins included), because allocation scans the pool;capacityinGET /tunnel/addressesis the smaller of that and the host count. The network and all-ones addresses are never handed out, and IPv4 written as IPv6 (::ffff:a.b.c.d) is refused. A registration that omits its address is allocated the lowest free host address; the allocation is sticky per(role, name). A registration may instead pin an address, which is granted only if free (409otherwise). One global address space is shared by origins and proxies. Without--tunnel-networkthe controller is pin-only: it enforces uniqueness but allocates nothing, and accepts pins of either family (mixing them is the operator’s responsibility: peers of different families cannot reach each other). - Names and keys. A registration name is 1 to 64 characters of
A-Za-z0-9._-(subscribeis reserved), so it can always be fetched and deleted by name. One WireGuard public key belongs to one name within a registry: a second name presenting a key that another name holds is refused with409, because peers are keyed by public key and the two registrations would overwrite each other’s routes. Re-register under the same name to move to a new key; deleting a name frees its key. - Underlay. Independent of the tunnel’s family: WireGuard endpoints
(
--endpoint,--tunnel-endpoint,--peer-endpoint, written[2001:db8::7]:51820for IPv6) and the controller URL (http://[fd99::1]:7070) may be IPv4 or IPv6. - Changing the network. At startup the controller refuses to run when stored
addresses fall outside
--tunnel-network, naming up to ten of them. Restore the previous network, or pass--tunnel-readdress: the controller then starts, warns once, and gives each such peer a new address at its next registration. The running peer logs the change and keeps its old address until it restarts, so its traffic is interrupted until then. Peers that are offline keep their old entry until they register again;DELETEthe ones that will not come back.--tunnel-readdressis refused without a network and together with--ha-peersor--ha-join, and is harmless when nothing is outside the network. - Startup.
wayhouse-agentandwayhouse --tunnel-*register before bringing their interface up (the answer is its address), persist the answer next to their key, and can start from it while the controller is down. Forwayhouse --tunnel-*that fallback is not instant: with the controller down it first spends its ~30 s registration budget, then falls back to the saved address (<tunnel-key-file>.address), logging the last error and, if a pinned address differs from the saved one, that the pin needs a restart once the controller is back.408/429count as “controller unavailable”, any other4xxrefuses startup. - Address changes. Every re-registration (default every 30 s) answers with the
address the controller currently holds. If it differs from the one the interface
carries (an operator released it, or a lease expired and it was reallocated), the
agent or
wayhousemoves the interface without a restart, keeping its key, listen port and peers, on both backends: it deletes the old address with a netlink call of its own and assigns the new one (live_interface.rsandnetlink_addr.rsin both binaries). The library can add an address but not remove one, and itsremove_interfacecannot be relied on (it fails in DNS cleanup where no resolver tool exists). The old address goes first, because deleting an IPv4 primary address would otherwise take the new one with it. Established sessions survive. The new address is saved for a restart only once it is up; if assigning it fails the old one is put back and the next re-registration retries. The other side follows on its own: each proxy and origin re-reads the registry and re-routes the/32(/128). - Routing. Every peer is a host route (
/32, or/128for IPv6): origins route each proxy’s tunnel address (this fixed the earlierAllowedIPs 0.0.0.0/0bug where a second proxy stole the first one’s route), proxies route each origin’s. Backends must be on the registrant’s own address;--backends :25565means “my address, port 25565” (stored as[fd49:89c1:4b5e:60::1]:25565on an IPv6 network). - Release.
DELETE /peers/{name}/DELETE /proxy-peers/{name}free the address and emit a tombstone that subscribers turn into a WireGuard peer removal.GET /tunnel/addresseslists the table with astaleflag (--tunnel-stale-after, default 14 days) and a daily log warning. - Lease expiry. With
--tunnel-lease-ttl(at least2h; default0= off) an owner not re-registered for that long is released exactly like aDELETE: tombstone, address freed, and a pool’stunnelsource clears the origin’s backends (wayhouse_discovery_refresh_total{result="withdrawn"}). The controller sweeps every tenth of the TTL (between 1 min and 1 h), starting an hour after it starts (or after a node becomes HA leader), so a controller that was down longer than the TTL does not expire everyone before they re-register. Under HA only the leader sweeps and each expiry is a replicatedExpireentry that re-checkslast_seenwhen it applies, so a re-registration that commits first wins. An owner that comes back after expiry is allocated afresh and may get a different address. Set the TTL well above the agents’ and proxies’ register interval (the 2 h minimum is only a floor for the HAlast_seenrefresh); hours to days is typical. A claim is kept even when the same registration then fails (backends not on the claimed address, or a storage error), so the corrected retry gets the same address; a stream of distinct names with bad backends can therefore use up the pool. The registration endpoints are bearer-gated, andDELETEplus the stale flag are the remedy.wayhouse-uishows the same table on its Tunnel addresses page (GET /api/tunnel/addresses, proxied with--controller-url/--controller-token). Each row has a Release button (admin role, behind a confirmation) that proxies the registryDELETE(DELETE /api/tunnel/origins/{name}→/peers/{name},/api/tunnel/proxies/{name}→/proxy-peers/{name}). - High availability (built 2026-10-03).
--tunnel-networkworks with--ha-peers: both registries and the address book are replicated through Raft, so every node serves the same registrations and addresses, and a registration orDELETEmade on any node (a follower forwards it to the leader) is allocated once, cluster-wide. The leader records its--tunnel-networkwhen the cluster first initializes and that recorded network applies from then on. Give every node the same--tunnel-network: a node whose flag differs logs anERRORnaming both networks and answers registry writes503, though an unchanged re-registration is still answered200. Until the network is recorded (the first seconds of a cluster, or while an upgrade waits for a node, docs/12) registry writes answer503(“cluster is initializing its registries”). An unchanged re-registration proposes nothing; an hourlylast_seenrefresh is the only periodic write. Pin-only mode works the same way. Upgrade and membership notes: docs/12. - Limits. Transparent mode (
transparent: true) does not apply to tunnel backends: config load rejects it on a listener whosepool:or route pool actions use atunnelsource. A resolver route can still return a tunnel pool at runtime; the proxy then connects without the client’s source address, and a client/backend family mismatch on any pool is logged at most once a minute. Dual-stack tunnels and live address changes are future work (GitHub issue #40).
Open questions
- What happens when hole-punching genuinely fails (both the origin’s and its upstream NAT are restrictive enough that even the proxy-has-a-public-IP case doesn’t establish a path — rare, but real for some consumer ISPs/CGNAT)? Out of scope for v1; the honest fallback is a documented limitation, with a relay-of-last-resort (closer to Steam Datagram Relay’s actual shape) as a possible v2, not designed now.
- Tunnel-internal address collision/exhaustion — resolved 2026-10-02: see “Address authority” above.
- Does this replace or sit alongside the existing “trusted internal network” assumption? Alongside — a same-network deployment still needs none of this and keeps working exactly as today; backend transport is additive, opt-in per origin.
Locked decisions (2026-09-05, follow-up session)
- WireGuard interface config:
defguard/wireguard-rs, not a hand-rolledwireguard-uapi+boringtunsplit. - Interface topology: one shared interface with many peers, not one interface per origin.
- Config schema: an origin is a
BackendSource(backend_sources[].type: tunnel), not a neworigins:/pools[].originconcept. wayhouse-agent: its own workspace crate, not awayhouse --agentmode.
12 – Container images & deployment
How to package the five binaries for Docker/Kubernetes, and — the part that isn’t obvious going in — how a proxy that needs to listen on many, changing ports actually works once it’s namespaced by a container runtime.
Container images
The reference build is deploy/Dockerfile: one file, a
shared rust:1-trixie builder and seven runtime targets (the five binaries, wayhouse-minimal, below, and
wayhouse-caddy, web proxy) on
gcr.io/distroless/cc-debian13:nonroot (Caddy on its own pinned base; see deploy/README.md;
make deploy-images builds all seven; make deploy-scan runs an informational Trivy
scan of them and of the lockfiles they’re built from — CI’s deploy and trivy jobs).
wayhouse-minimal is wayhouse built with --no-default-features (issue #62): no WASM sniffer loader,
gRPC resolver, dns_srv source, Tier-2 gossip fabric or WireGuard tunnel client, so the
binary is much smaller. It takes the same arguments as wayhouse; a config or flag that needs a
dropped feature (settings.sniffers, a grpc resolver, a dns_srv source, settings.gossip,
a tunnel source, --tunnel-iface) is refused at startup and under --check, naming the
cargo feature.
The published ghcr.io images are public and need no login to pull.
None of these five binaries shell out to an
external command at runtime (WireGuard interface management in wayhouse/wayhouse-agent
goes through kernel netlink directly via defguard/wireguard-rs, not the
ip/wg CLIs) — so the runtime image needs nothing but the binary and its
dynamic library dependencies (glibc). It does not need a system CA bundle either: the
HTTP clients (--controller, --aggregator, --tunnel-controller-url, the HTTP
resolvers) verify against a root bundle compiled into the binary, plus whatever
--ca-file adds — see
TLS for the fleet services for what that implies. The builder and runtime base
must be on the same Debian release, or a binary can fail to start on an older glibc.
[profile.release] sets strip = true, which trims roughly 15-20% off every binary.
Measured image sizes (docker images, stripped release binaries, deploy/Dockerfile
on distroless/cc-debian13:nonroot, CI run 36922142697, 2026-10-01):
| Binary | image size |
|---|---|
wayhouse | 47.4 MB |
wayhouse-controller | 37.9 MB |
wayhouse-agent | 34.3 MB |
wayhouse-ui | 34.3 MB (includes the built frontend) |
wayhouse-aggregator | 33.3 MB |
For comparison, an earlier 2026-09-07 measurement on cc-debian12 gave 45-65 MB.
The base image dominates, not the binary — debian:trixie-slim alone is
~118 MB before anything is added; distroless/cc-debian13 (glibc +
libgcc/libstdc++, no shell, no package manager) is a fraction of that and
was confirmed to actually run these binaries (no runtime shell-out, so
distroless’s missing shell is never a problem). Trade-off: no
docker exec ... sh for interactive debugging, and the nonroot variant
runs as a fixed non-root uid (fine here — nothing in this project needs
root once its sockets are bound; see --tunnel-*’s capability
requirements below for the one exception).
wayhouse and wayhouse-controller are the two heaviest either way, for real
reasons: wayhouse links wasmtime (phase 9 sniffers) and
defguard_wireguard_rs/boringtun (phase 14 backend transport);
wayhouse-controller links openraft + sled for the HA/Raft log store
(phase 12). wayhouse-agent/wayhouse-aggregator/wayhouse-ui don’t carry those.
Examples
deploy/compose/ is a runnable control-plane demo (controller,
aggregator, UI, one wayhouse pulling its config from the controller) with a tunnel
override; deploy/k8s/ has plain manifests (the proxy as a
hostNetwork DaemonSet, plus the RBAC its kubernetes sources need). Both are reference only (the images themselves are published to GHCR on version tags by .github/workflows/release.yml). CI smoke-tests the compose demo and schema-validates the manifests (the deploy
job, blocking since 2026-10-02; first green run 2026-10-01). Aggregator intent fan-out (drain etc.) goes to the admin_url
each wayhouse reports, by default http(s)://<settings.admin.listen>; when that
address is not reachable from the aggregator (0.0.0.0, loopback, a container
port mapping, a TLS terminator in front), set wayhouse --aggregator-admin-url. The
k8s DaemonSet reports http://<node IP>:9900, and the aggregator presents
--instance-token (the admin auth_token). The compose demo cannot use
fan-out: its wayhouse admin API listens on the host’s loopback only, which the
bridge-networked aggregator cannot reach.
The aggregator separates the credentials: --ingest-token is what the wayhouse
instances push with (--aggregator-token on wayhouse; it unlocks only
POST /ingest), --auth-token is for wayhouse-ui and operators (/fleet/*). Setting
--auth-token requires a different --ingest-token; the aggregator refuses to
start otherwise.
It only stores an admin_url whose host is the pushing connection’s own source
IP, or one covered by --instance-url-allow (CIDR, hostname or *.suffix,
repeatable); set the allowlist when the aggregator sees a different source
address than the one reported (NAT, a load balancer, a TLS terminator, the
compose bridge, or a child tier relaying through --parent-url). A refused push
gets a 400 and a warning in the aggregator’s log. A hostname entry trusts
whoever controls DNS for that name, so prefer CIDRs where the addresses are stable. See docs/10, “The
aggregator”.
Networking: a proxy that binds many, changing ports
wayhouse is designed to add and remove listeners live — ListenerManager
(crates/wayhouse-core/src/listeners.rs) reconciles the running listener set
from config on every SIGHUP/reload, and bind: "0.0.0.0:30000-30999"
(docs/01 F1.4, docs/08 roadmap) spawns one real socket per port in a
range under a single listener config, for fleets that hand out one port
per match/instance. None of that is Docker- or Kubernetes-aware — it’s
just bind()/listen() calls inside the container’s network namespace.
And that’s exactly the problem: neither platform lets a running
container dynamically “claim” a new externally-reachable port just by
calling bind(). Port exposure is a static declaration in both:
- Docker:
-p hostport:containerportsets up a NAT/iptables rule at container start.EXPOSE/-Pis documentation, not a live mechanism. There’s no API for a running container to add another published port — the container has to be recreated with a new-plist. - Kubernetes: a
Service’sspec.portsis a fixed list, and a container’s ownports:field in the Pod spec is documentation only (Kubernetes doesn’t firewall ports you didn’t declare, and it doesn’t publish ones you didn’t declare either). Nothing watches a container’s live sockets and reconciles aServiceto match them.
Two patterns actually work, and wayhouse already has one foot in each:
- Host networking (
docker run --network host/hostNetwork: trueon the Pod) — the container shares the host’s network namespace directly, so everybind()wayhousedoes is immediately live with zero orchestration involvement. This is the closest match towayhouse’s own reload-driven listener reconciliation: add abind:entry, send SIGHUP (or let file-watch pick it up), and the new listener exists — in a container exactly like bare metal. Cost: no network-namespace isolation for that pod/container — an acceptable, often expected, trade-off for the thing that sits at the actual network edge. - A pre-reserved port range — publish/expose a large contiguous range
up front (Docker supports a range:
-p 20000-40000:20000-40000/udp; Kubernetes needs eitherhostNetworkscoped to a node-level reserved range, or enumeratinghostPortper container — there’s no native range syntax in a Pod spec) and only actuallybind()the subset needed at any given time viawayhouse’s ownbind: "host:lo-hi"range listener. This is the same shape Agones (the standard Kubernetes game-server operator) uses for itsGameServerpods —hostNetwork: trueplus one port pulled from a node-level reserved pool — because Kubernetes Services can’t do dynamic port publishing either;docs/08’s roadmap entry for the range-bind feature draws this comparison explicitly.
Host networking is the simpler default when the proxy doesn’t need
network-namespace isolation from other workloads on the node (usually
true for an edge process). Reach for the reserved-range pattern when
isolation is a hard requirement, or when running many wayhouse instances per
node and the orchestrator should own port allocation/collision-avoidance
across them.
--tunnel-* (phase 14): CAP_NET_ADMIN and /dev/net/tun
wayhouse --tunnel-* and wayhouse-agent create a real WireGuard interface
(docs/11) — kernel-netlink by default, falling back to boringtun
userspace with --tunnel-userspace/--userspace. Both backends need
CAP_NET_ADMIN, and the userspace fallback additionally needs
/dev/net/tun present in the container. Grant them explicitly:
docker run --user 0 --cap-add=NET_ADMIN --device=/dev/net/tun ...
--user 0 matters for the nonroot images in deploy/: Docker and Kubernetes put an
added capability only in a non-root process’s bounding set, so uid 65532 would still
get EPERM creating the interface.
securityContext:
runAsUser: 0
runAsNonRoot: false
capabilities:
add: ["NET_ADMIN"]
volumes:
- name: tun
hostPath: { path: /dev/net/tun }
This is a real, already-verified requirement, not a guess: docs/08
phase 14 slice 6’s end-to-end verification ran in exactly this
container shape (crates/wayhouse-agent + wayhouse --tunnel-* in Docker
containers with NET_ADMIN + /dev/net/tun) because the project’s own
dev sandbox lacked CAP_NET_ADMIN even in the bounding set. A container
without both of these fails loud at interface bring-up, before any
listener binds or backend source is built — it does not silently run
without tunnel support.
The CI-runnable make tunnel-e2e (rootless network namespaces,
docs/superpowers/specs/2026-10-01-tunnel-e2e-design.md) verifies the tunnel
logic with the same binaries and flags, but it does not exercise container
capabilities — NET_ADMIN and /dev/net/tun as documented above stay the
deployment requirement.
Authentication at startup
Every fleet API is open when no token is configured. Startup now enforces:
- Non-loopback bind needs auth.
wayhouse-controller,wayhouse-aggregatorandwayhouse-ui(--listen) andwayhouse’s admin API (settings.admin.listen) refuse to start on a non-loopback address without a token (--auth-token,--ui-password/--users-file,settings.admin.auth_token). If the network boundary really is your only control, pass--insecure-no-auth; it logs a warning instead. - HA needs
--ha-token.wayhouse-controller --ha-peers/--ha-joinrefuse to start without it, with no opt-out:/raft/*and/admin/ha/memberswould otherwise accept anyone. HA peers should usehttps://URLs (see below), since the token travels in each request. - Minimum secret length: 16 bytes for
--auth-token,--ha-token,settings.admin.auth_tokenandsettings.gossip.psk. Generate one withopenssl rand -hex 32.--ui-passwordand the outbound tokens (--controller-tokenetc., which must match the remote side) are not checked.
TLS for the fleet services
Without the settings below, wayhouse-controller, wayhouse-aggregator, wayhouse-ui and wayhouse’s
admin API serve plain HTTP. Run as-is across a network, that exposes:
- the
--auth-tokenbearer token on every request, and the/admin/adoptcalls; - the full config text, on
GET /configand the SSEGET /config/subscribe; - every origin’s registration (WireGuard public key, public endpoint, fronted
backend addresses) on the
/peers*and/proxy-peers*routes.
Two ways to encrypt it: native TLS (below), or a reverse proxy
you run that terminates TLS, with the controller listening only on loopback or a
private network (--listen 127.0.0.1:9901, or a private bridge/pod network). The
aggregator, the UI and wayhouse’s admin API serve native TLS the same way.
Native TLS
wayhouse-controller --listen 0.0.0.0:8443 \
--tls-cert /etc/wayhouse/tls/fullchain.pem --tls-key /etc/wayhouse/tls/privkey.pem
--tls-certis a PEM chain, leaf first (a Let’s Encryptfullchain.pemworks);--tls-keya PEM private key (PKCS#8, SEC1 or PKCS#1). Both or neither. With them,--listenspeaks HTTPS only — every route, including the SSE streams.- Renewal without a restart: both files are checked every 30 s and a changed pair is swapped in for new connections. A broken or half-written pair (no certificate, no key, key not matching the certificate) is logged and the current certificate stays in service until a good pair appears. Bad files at startup stop the controller with an error naming the file.
- Clients use
https://URLs; with a private CA they add--ca-file. HA replicas can each serve native TLS and name their peers--ha-peers 1=https://ctl-1:8443,…(plus--ca-file), with no terminator at all. - TLS handshakes run in their own tasks, so a client that connects and stalls cannot
hold up others. A client must send its ClientHello within 3 s and finish the
handshake within 10 s. At most 16 handshakes may be pending per source (an IPv4
address or an IPv6 /64); more are closed at once. At 512 pending handshakes in total a
new connection is still accepted and the oldest pending handshake is dropped, so a
flood of idle connects cannot lock real clients out. A source may also open at most 20
new connections a second on average (bursts of 64), so it cannot cycle connects under
its pending cap. A limit being hit logs a warning (at most once a minute) and
counts into
wayhouse_tls_handshakes_refused_total/wayhouse_tls_handshakes_evicted_totalon every binary’s/metrics(docs/06). The limits are flags:--tls-max-pending(512),--tls-max-pending-per-source(16),--tls-new-per-source-per-sec(20;0turns the rate limit off) and--tls-new-per-source-burst(64); raise the per-source ones for a fleet that reaches the service from behind one NAT. No client certificates (mTLS). wayhouse-aggregatortakes the same two flags with the same behaviour. Instances then push to--aggregator https://…(plus--ca-filefor a private CA), and the same goes for a child tier’s--parent-urlandwayhouse-ui --aggregator-url.wayhouse-uitakes the same two flags. Serving HTTPS itself, it marks the session cookieSecure(without the flags it doesn’t — a browser drops aSecurecookie onhttp://and login would loop). The live view’s WebSocket works over HTTP/2 too: a browser on h2 opens it as an extendedCONNECT, which the UI accepts (and the session cookie counts in whichevercookieheader h2 splits it into). Plain HTTP is not redirected; serve only HTTPS on the port browsers use.wayhouse’s admin API is configured in the YAML, next tolisten:settings.admin.tls: { cert: <chain.pem>, key: <key.pem> }(both required, plus the optional handshake limitsmax_pending,max_pending_per_source,new_per_source_per_sec,new_per_source_burst, same meaning as the flags above; startup-only likelisten; the files renew like the flags above;wayhouse --checkloads them). The admin URLwayhousereports to the aggregator then becomeshttps://<settings.admin.listen>, so the certificate needs that address as a SAN (an IP SAN for an IPlisten), and the aggregator takes--ca-filefor a private CA. As before, the reported URL is the literallistenaddress — a wildcard bind (0.0.0.0) is not reachable as an admin URL, TLS or not. With--controllerone YAML reaches every instance, so put each host’s own certificate at the same path. Health probes against the admin port must then use HTTPS (Kubernetes:httpGet.scheme: HTTPS).
Verified by wayhouse-fleet-tests: controller_native_tls.rs (wayhouse --check fails on
UnknownIssuer without --ca-file and passes with it; /config/subscribe streams over
TLS; --tls-cert alone is refused), aggregator_native_tls.rs (a wayhouse pushes to an
https:// aggregator, read back over HTTPS), ui_native_tls.rs (login over HTTPS
sets a Secure cookie that unlocks /ui/session), admin_native_tls.rs (an
aggregator fans an intent verb out to an https:// admin API; wayhouse --check names a
bad settings.admin.tls.cert) and ha_tls.rs
three_replicas_replicate_over_native_tls; certificate loading, renewal and the
listener are unit-tested in crates/wayhouse-http/tests/tls_{certs,server}.rs.
Proxy configuration
Four routes are long-lived server-sent-event streams — /config/subscribe,
/peers/subscribe, /proxy-peers/subscribe and /intent/subscribe. The controller
sends keep-alives, but a proxy that buffers responses or enforces a short read
timeout will stall or drop them, so those must be off/long.
Caddy (certificates from Let’s Encrypt automatically):
controller.example.com {
reverse_proxy 127.0.0.1:9901 {
flush_interval -1 # stream SSE immediately
}
}
nginx:
server {
listen 443 ssl;
http2 on;
server_name controller.example.com;
ssl_certificate /etc/letsencrypt/live/controller.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/controller.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9901;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # SSE
proxy_read_timeout 1h; # idle SSE streams
}
}
These two snippets are not tested in this repository (CI has neither proxy, and an end-to-end https test would need a certificate the clients trust — see the limits below). Check them against your proxy’s version.
In Kubernetes the equivalent is an Ingress (or Gateway) with TLS in front of the
wayhouse-controller Service; make sure its response buffering and timeouts allow SSE.
Pointing the clients at it
Every HTTP client in the fleet uses reqwest with rustls and builds its requests from
the base URL as given — no code forces a scheme — so these take an https:// base URL:
wayhouse --controller, wayhouse --tunnel-controller-url, wayhouse-agent --controller-url,
wayhouse-ui --controller-url / --aggregator-url, wayhouse-controller --parent-url, and the
parent_url in a POST /admin/adopt body.
To check it works:
curl -fsS https://controller.example.com/healthz
wayhouse --check --controller https://controller.example.com --controller-token "$TOKEN"
Private or self-signed CA. Every binary (wayhouse, wayhouse-agent, wayhouse-controller,
wayhouse-aggregator, wayhouse-ui) takes --ca-file <PATH>: a PEM file with one or more CA
certificates to trust for all of that process’s outbound HTTPS, in addition to the
built-in Mozilla roots (it never replaces them, so public endpoints keep working).
Non-certificate PEM sections (a private key pasted into the same file) are ignored. A
missing or unreadable file, one with no certificates, or a malformed certificate stops
the process at startup with an error naming the file. The file is read once, at
startup: SIGHUP / a config reload does not re-read it, so restart the process after
changing it (e.g. when rotating the CA).
wayhouse --check --controller https://controller.internal:8443 --ca-file /etc/wayhouse/ca.pem
This exact shape — wayhouse --check against a real wayhouse-controller behind a TLS
terminator whose certificate a private CA signed, failing without --ca-file and
passing with it — is the wayhouse-fleet-tests test ca_file.rs, run by make check.
The two proxy snippets above are still untested.
A TLS or connection failure is logged with its cause (e.g. invalid peer certificate: UnknownIssuer), not just “error sending request” — that is usually the first sign a
--ca-file is missing or wrong.
HA replicas over TLS
Put a TLS terminator in front of each replica’s listener and give --ha-peers
base URLs instead of host:port (same list on every replica), plus --ca-file if the
terminators’ certificates come from a private CA:
wayhouse-controller --listen 127.0.0.1:9901 --ha-node-id 1 --ca-file /etc/wayhouse/ca.pem \
--ha-peers 1=https://ctl-1.internal:8443,2=https://ctl-2.internal:8443,3=https://ctl-3.internal:8443
Raft RPCs (/raft/append, /raft/vote, /raft/snapshot) and writes a follower
forwards to the leader then go over HTTPS. id=host:port still means plain HTTP, and
the two forms can be mixed. An entry that is not http(s)://host[:port] (another
scheme, a path, a query, user info) stops the replica at startup naming the entry.
Verified by wayhouse-fleet-tests ha_tls.rs: three replicas that reach each other only
through private-CA terminators elect a leader, forward writes and all serve the last
one with --ca-file, and never elect a leader without it.
Only at bootstrap. --ha-peers is read when the cluster is first initialised;
after that each member’s address lives in the replicated Raft membership. Changing
--ha-peers on an existing cluster does not change the addresses replicas use. To
move a running cluster to https:// peers, change each member’s address with a PUT
(see “HA membership” below).
HA membership
Members are changed on a running cluster with the admin routes, gated by
--auth-token (pass it as a bearer token) and answered by any node, which forwards a
change to the leader. X-Actor is logged.
# A new or replacement node always starts empty with --ha-join (never --ha-peers: a
# node with --ha-peers calls initialize and could split the cluster).
wayhouse-controller --listen 0.0.0.0:7070 --ha-node-id 4 --ha-token "$HA_TOKEN" \
--tunnel-network 10.60.0.0/24 --ha-join
curl -H "Authorization: Bearer $TOKEN" http://ctl-1:7070/admin/ha/members # voters, learners, leader
curl -H "Authorization: Bearer $TOKEN" -d '{"id":4,"addr":"ctl-4:7070"}' \
http://ctl-1:7070/admin/ha/members # add (waits for catch-up)
curl -H "Authorization: Bearer $TOKEN" -X DELETE http://ctl-1:7070/admin/ha/members/2 # remove
curl -H "Authorization: Bearer $TOKEN" -X PUT -d '{"addr":"https://ctl-3.internal:8443"}' \
http://ctl-1:7070/admin/ha/members/3 # change an address
An add answers once the new node has caught up, by log or, after the log was purged,
by snapshot; a large catch-up can take minutes (a forwarded add waits up to five).
Before an add or a PUT the leader asks the node at the given address who it is
(/raft/whoami, behind --ha-token) and refuses (422) unless it answers with that
node id, and an add also unless the node’s log is empty (or it is already a learner of
this cluster): this keeps a node id from being pointed at another node’s address, which
openraft warns can produce two leaders. Other answers: 409 the node is already a
voter, 404 unknown id, 422 removing the last voter, 503 the node is unreachable or
there is no leader. Removing the current leader is allowed. A joined node restarts with
--ha-join again. --ha-join and --ha-peers together, and --tunnel-readdress with
either, are refused at startup. Verified by wayhouse-fleet-tests ha_tunnel_addresses.rs
(join, remove the leader, move a node to a new port) and ha_snapshots.rs (catch-up by
snapshot after a purge).
Upgrading a single controller to HA
A controller that already holds registrations (origins, proxies, addresses) from before
HA can become the first node of an HA cluster. Restart it with --ha-node-id and the
--ha-peers of the new cluster next to the other, empty nodes. Every node sets aside
pre-HA data it finds as peers.pre-ha/, proxy-peers.pre-ha/ and
tunnel-addresses.pre-ha/ under --data-dir (it never deletes them), and the cluster’s
leader imports exactly one node’s copy, whichever node wins the first election.
- The leader asks every voter what it holds (
/raft/whoami) before initializing, so registry writes answer503until every voter has answered; a voter that is down delays the upgrade.--ha-import-source <node-id>(the same value on every node) waits only for that node;noneimports nothing. - When more than one node has pre-HA data (for example a pin-only
--ha-peersdeployment where each node kept its own registries) nothing is initialized: the leader logs anERRORnaming the nodes and their counts, and registry writes stay503until you restart the nodes with--ha-import-source <node-id>. The other nodes’ set-aside data stays untouched (oneWARNeach). - Imported registrations are re-written as new revisions that continue after the source’s
last revision number, so every edge’s
sincecursor from the single-node days stays valid. Tombstones the old node wrote are carried over too (the entry lists the names whose newest log entry is a removal), so a subscribed edge that had not received one still drops the peer, and a registry with no live registration keeps its log head.ImportContentis a Raft log entry: a node on an older build applies anImportwithout those tombstones, so upgrade every node of the cluster before the first initialization. - Clients’ pinned addresses that used to collide across nodes now get
409: uniqueness is cluster-wide, where it was per node before. - Snapshots persisted by a build older than this feature (before 2026-10-03) are not
supported: a follower refuses to install one (
ERRORnaming the snapshot format) rather than read it as empty registries and an empty book. Start such a node from empty storage, or let the cluster re-initialize as above. - The registries compact their logs: each keeps one entry per name (its registration, or
the tombstone of a removed one), so a registry log and the Raft snapshot stay bounded by
the number of distinct names ever registered, not by how often they re-register.
Revision numbers have gaps afterwards; a subscriber’s
sincecursor is unaffected.
Limits (read these before relying on it)
- No system certificate store. The clients trust the Mozilla root bundle compiled
into the binary (
reqwest’srustls-tls/webpki-roots) plus--ca-file, not the OS store orSSL_CERT_FILE. For a private or internal CA — or a self-signed certificate — pass it with--ca-file. No client certificates (mTLS). - HA over TLS (native TLS on each replica, or a terminator per replica; see “HA
replicas over TLS”): bootstrap with
https://peers, or move a running cluster with onePUT /admin/ha/members/{id}per member (see “HA membership”). Withhost:portpeers, replica traffic is plain HTTP; then keep the replicas on a private network —--ha-tokenis a shared secret, not encryption. The controller logs a warning for each non-loopbackhttp://peer. - Upgrading a gossip mesh (the datagram format gained a MAC’d sender timestamp,
security review O4): upgrade all instances in a failure domain together. While old
and new instances are mixed they cannot exchange membership or health, so the
domain quorum derived from gossip is unreliable until the rollout finishes (local
health checks keep working). Old-to-new datagrams are counted in
wayhouse_gossip_stale_rejected_total. Instance clocks must also agree within 30 s. - The UI behind a proxy sees plain HTTP, so its session cookie is
HttpOnly; SameSite=Laxbut notSecure: add it, redirect HTTP to HTTPS (and consider HSTS) at the proxy — or use the UI’s native TLS, which setsSecureitself. - A TLS proxy is a trust boundary. It sees every token and registration in the clear. Run it on a host you control — or use native TLS where it exists.
Certificates from the controller
With --acme the controller can issue the web front door’s certificates; a gateway host
fetches them with wayhouse-certsync, which ships in the wayhouse image. See
acme.md for the gateway token, the files it writes and
how to run it.
Tunnel addressing
Tunnel-internal addressing (the WireGuard-side space between proxies and origins) is
not an open question any more: wayhouse-controller --tunnel-network allocates the
addresses, so nothing here hand-picks one. See docs/11 “Address authority”.
Choosing the network. Use an IPv6 unique local address (ULA) network by default:
generate a random /48 once and take a /64 from it, so it cannot clash with any
origin’s own LAN or another deployment’s tunnel:
printf 'fd%02x:%02x%02x:%02x%02x::/48\n' $(od -An -N5 -tu1 /dev/urandom)
# e.g. fd49:89c1:4b5e::/48 -> --tunnel-network fd49:89c1:4b5e:60::/64
Choose an IPv4 network (/16 to /30, e.g. 10.60.0.0/16) instead when a game server
binds 0.0.0.0 only: it cannot be reached on an IPv6 tunnel address. The family is
fixed per controller; changing it later needs --tunnel-readdress (docs/11).
Containers. The tunnel interface needs IPv6 enabled in its network namespace.
Docker starts containers on a network without IPv6 with
net.ipv6.conf.all.disable_ipv6=1; for a container with its own network namespace set
sysctls: { net.ipv6.conf.all.disable_ipv6: "0" } (Compose) or the equivalent
securityContext.sysctls entry (Kubernetes). With host networking, as in
deploy/compose/compose.tunnel.yml and the DaemonSet’s hostNetwork, the host’s own
setting applies and such a sysctl is refused, so the host must not have IPv6 disabled.
This document otherwise covers only the container/orchestrator-facing side of the proxy’s public listeners.
Upgrading a fleet
How to move a running deployment to a newer version one component at a time, without a
flag day. The design and its reasoning are in the
component versioning spec;
this page is the operator’s view of what is built. Releases and tags are in
RELEASING.md.
Compatibility rules
- Window: N and N-1. Two adjacent minor versions of the product (0.N and 0.(N-1)) are meant to run side by side during an upgrade. While the product is 0.x, “minor” is the second number.
- Protocol major must match. A node that receives another major (or an unreadable
X-Wayhouse-Protocolheader) answers426and counts it inwayhouse_protocol_mismatch_total. A breaking wire change bumps the major and, to keep the window honest, must ship with a gate that also admits the previous major for one product minor and routes those requests to a compatibility shim; every other major still gets426. Today the gate admits only the current major and no shim exists, because no second major exists yet. - Protocol minors are additive. A sender only uses a newer optional field or route with a peer that has said it understands it: every request carries the caller’s version and every response the server’s, and a peer whose version is not known yet gets the baseline. Receivers ignore unknown JSON fields.
- Config is the exception to “controllers first”: the config
schema_versionis the minimum schema a document needs, and the controller refuses (422) a document newer than the lowestmax_config_schemaany live proxy registration reported. Upgrade every proxy before you use a config field the new version introduced. - Store: a controller refuses to open a store written by a newer build and migrates an older one once, on open.
What is and is not checked: the header check enforces protocol majors only. The
N / N-1 product window itself is a rule kept by review (see the AGENTS.md row for wire
changes) and exercised by tests that inject an older protocol minor into a real fleet
(crates/wayhouse-fleet-tests/tests/mixed_versions.rs). Once two releases exist, the
non-required compat workflow also runs the previous release’s images against the
current ones.
Version table
One row per release, newest first. A CI check
(.github/scripts/check_upgrading_doc.py) fails when the first row disagrees with the
constants in the source, so a protocol, schema, store or ABI change cannot land without
its row. Use unreleased for the product column until the release is tagged.
| Release | Protocol | Config schema | Store format | Sniffer ABI |
|---|---|---|---|---|
| unreleased | 1.2 | 3 | 1 | 0.1 |
| 0.2.0 | 1.1 | 2 | 1 | 0.1 |
| 0.1.0 | 1.0 | 1 | 1 | 0.1 |
Upgrading from 0.1.0 to 0.2.0
0.2.0 is the first release with a real upgrade step. Things to know before you start:
- Gossip does not cross the version. 0.2.0 gossip frames carry a magic byte and
GOSSIP_VERSION; a 0.1.0 node and a 0.2.0 node drop each other’s datagrams. While a fleet runs both, health votes and domain quorum are not shared between old and new nodes, and each node falls back to its own checks. Existing player traffic is not affected, but nodes can briefly disagree on backend health, so new sessions may be placed differently on old and new nodes. This is the one exception to the N and N-1 window (see docs/10). - Registry names. A name must be 1 to 64 characters of
A-Za-z0-9._-and notsubscribe; see “Dedupe first” below for the check to run before the first replica is upgraded. - Bundled sniffers moved. The official WASM sniffers now live in
wayhouse-proxy/sniffersand are no longer built from this repository. Get them from that repository’s releases, install them from the UI’s registry page, or copy them into the directory named bysettings.sniffers.dir(see sniffers.md).sniffers.lockin this repository only pins the releases the CI loader tests run against; it has no effect on a deployment. - Config schema 2.
listeners.plugin_routesis a schema 2 field. A 0.1.0 proxy cannot parse it, so use it only after every proxy runs 0.2.0. The controller enforces this on submit (422naming the lowest live proxy maximum). A proxy that does not announce a schema (0.1.0) is sent only revisions that need schema 1: one that first starts after the first schema 2 revision exists gets the newest schema 1 revision, not the newer ones, and keeps it until it is upgraded. - Plugin ABI. The version table has no plugin column: the plugin ABI is 0.1 and the host requires an exact minor match while the major is 0.
Order of operations
- Aggregator and UI (the read side).
- Controller tiers, leaf
slavetiers first and the root tier last. Aslavetier is a child controller under a parent. Inside the window either order works because of the gating rule above; going leaves-first keeps the root, which owns writes, on the version your operators have run longest. - Raft HA inside one tier, see below. A tier made of several controllers is a different mechanism from the parent and child tiers.
- Proxies and agents, a few at a time: drain, upgrade, rejoin.
- Config fields introduced by the new version, only after every proxy runs it.
Check the fleet view in the UI between steps: an instance more than one minor behind the newest, on another protocol major, or that refused a request for an incompatible protocol in the last five minutes is red; one minor behind is yellow.
The fleet view lists only the instances that push to the aggregator, which are the
proxies. Controllers, the aggregator, the UI and agents do not appear in it. For those,
read wayhouse_build_info on each component’s /metrics between steps (its version and
protocol labels).
Controller tiers
Upgrade a tier completely (all its replicas, see below) before moving to its parent.
Parent-to-child and child-to-parent calls both carry protocol versions, so a child one
minor older than its parent is sent only baseline fields. A slave tier relays writes
upward through its leader; a tier mid-upgrade keeps relaying as long as the protocol
major matches.
The baseline-fields rule for a child one minor older is not covered by an automated
test for the tier path (the fleet tests exercise a proxy against a controller, not a
slave tier against its parent), and neither is the relay cursor across versions (a child
resuming the parent’s revision stream after one side upgraded). Treat the first tier
upgrade in your environment as a rehearsal, and read the “N and N-1” window above as
tested for proxy against controller only.
Raft HA inside a tier
Replicas of one tier replicate through Raft; /raft/* carries the same protocol header
check, and changing a raft RPC payload counts as a breaking wire change.
- Upgrade the followers one at a time. Wait for the upgraded node to catch up
(
GET /admin/ha/membersshows it as a voter again) before touching the next. - There is no leader-transfer command. To move leadership, stop the leader process; the followers elect a new leader within the election timeout and writes resume through it.
- Upgrade the former leader last.
Dedupe first. Since 0.2.0 a tier’s replicated registries refuse a registration whose
WireGuard public key another name already holds (409). That check runs when a log entry
is applied, so a 0.2.0 node can reject an entry that a 0.1.0 node accepted, and a fresh
node replaying an old log does the same; the replicas would then hold different registry
contents. Before the first replica is upgraded, list GET /peers and GET /proxy-peers on
the tier and DELETE every extra name that shares a public key with another, keeping the
one that is in use. Also rename every name outside [A-Za-z0-9._-], longer than 64 bytes, or
equal to subscribe: since 0.2.0 a new registration with such a name is refused at submit
time (422). Entries already in the log are not revalidated when applied, but a node
whose registration refresh is refused under 0.2.0 drops out of the registry.
This sequence is not covered by an automated test (the fleet tests do not run a rolling Raft upgrade); treat the first use in your environment as a rehearsal. A write that a follower forwards to the leader is sent with the follower’s protocol version, not the original caller’s; that is harmless inside the window because receivers ignore fields they do not know.
Proxies
A proxy upgrade relies on draining:
POST /admin/drain: the instance reports not ready, so the load balancer or anycast routes new clients elsewhere. The data path keeps running.- Wait for sessions to end (
active_connsinGET /config, or letshutdown_grace_secexpire). - Stop the old process, start the new one, check
GET /readyz, and let the load balancer bring it back.
A config reload that replaces a UDP listener no longer drops new flows while the old group drains (#186), but it ends that listener’s live sessions: the old workers close their sockets at once, and each client opens a fresh session in the replacement group on its next datagram (possibly from a new upstream source port). Plan a reload that changes a UDP listener like a short blip for its players. A listener that is removed, or whose bind address changed, still drains as described above. See A config reload that replaces a UDP listener.
Agents
Agents register with the controller like proxies do. Upgrade them after the controller tier they register with; an agent one minor older than its controller works against baseline fields.
Kubernetes
The same order applies, with a rolling update (maxUnavailable: 1) and the readiness
probe gating each step. Pending #88:
the packaged manifests for this do not exist yet, so this section describes the intent,
not a tested procedure.
Rollback
Roll back in the reverse order. A downgrade is safe inside the window for the protocol.
A controller store that a newer build has migrated cannot be opened by an older build
(FormatTooNew): restore the store from a backup taken before the upgrade, or keep the
upgraded controller. Config documents that use a field of the newer schema are rejected
by older nodes, so revert the controller to the previous config revision first.
Troubleshooting
| You see | It means | What to do |
|---|---|---|
426 with wayhouse protocol <peer> is not compatible with this node (<ours>): upgrade the older side | The two sides have different protocol majors. | Upgrade the older side; a log line with the same text and wayhouse_protocol_mismatch_total name the route group. |
config schema_version N is newer than this build supports (max M) | A document needs a newer schema than this node knows. | Upgrade the node, or submit a document that declares a lower schema_version and does not use the newer fields. |
`path` needs schema_version N or higher | The document uses a field newer than the schema_version it declares. | Set schema_version: N once every proxy supports it. |
Controller refuses a config with 422 naming a proxy’s maximum | A live proxy registration reports a lower max_config_schema. | Upgrade that proxy first. |
store <path> has format N, newer than this build supports (FormatTooNew) | An older controller was pointed at a store a newer build migrated. | Run the newer build, or restore the pre-upgrade store backup. |
Plugins
A plugin is a WASM module that reacts to a trigger and acts through capabilities the operator approved (design: plugin system and automation hooks). Plugins are not sniffers: sniffers run on the proxy data path.
Status. The host runtime is built (crates/wayhouse-plugin-host) and a standalone
controller can store plugin installs behind an admin API and ticks the enabled ones
(below); the web UI has a Plugins page on top of the install API (see “The Plugins page”). An HA tier replicates installs and state through Raft, serves the same API on every replica
and ticks on the leader only, and the module bytes follow the installs to every replica (see
“High availability”). Secrets, http, routes, webhooks and events are built; backends is not.
Module contract (ABI 0.1)
Link wayhouse-plugin-abi (it stamps the wayhouse.plugin-abi section and exports
alloc). The host accepts a module only when the section equals its own version (exact
minor match while the major is 0), and reads the section before compiling anything.
A second custom section, wayhouse.plugin-caps, holds the capabilities as JSON. It is read
without running the module and approvals are recorded against the module’s sha256:
{
"triggers": { "on_timer": true },
"tick_interval_secs": 30,
"log": true,
"state": { "max_bytes": 65536 },
"http": {
"hosts": [
{ "host": "panel.example", "port": 8443 },
{ "host": "10.0.0.5", "allow_private": true }
]
},
"secrets": [{ "name": "PANEL_TOKEN", "hosts": ["panel.example"] }]
}
Unknown fields are an error. tick_interval_secs is required with on_timer and at
least 10 and at most 604800 (one week).
Exports: memory, alloc(i32) -> i32, init(config_ptr, config_len), and on_timer() when
declared. The only imports allowed are these, in the wayhouse namespace:
| Import | Capability | Behaviour |
|---|---|---|
log(level, ptr, len) | log | levels 0 error, 1 warn, 2 info, else debug; lines over 1024 bytes are cut; lines past the per-call limit are dropped and counted |
state_get(kptr, klen, out_ptr, out_cap) -> i32 | state | value length, or -1 when absent; nothing is written when the value is longer than out_cap |
state_put(kptr, klen, vptr, vlen) -> i32 | state | 0, or -1 when over state.max_bytes, the key is over 256 bytes or not UTF-8, or the per-call write limit is hit |
| routes_set(ptr, len) -> i32 | routes | declares the plugin’s whole route set as a JSON array of {"host", "backend"}; 0, or -1 when refused (the reason is a warn line in the plugin’s log) |
| acme_dns_result(ok, ptr, len) -> i32 | on_acme_dns | answers an on_acme_dns call: ok 1 or 0 and a message of at most 512 bytes (UTF-8); 0, or -1 when ok is neither or the message is too long; the last call wins |
| http_request(ptr, len) -> i32 | http | sends the JSON request at ptr; returns the length of the response document, or -1 when the request is over the size limit |
| http_read(out_ptr, out_cap) -> i32 | http | copies the pending response document out; -1 (and keeps it) when out_cap is too small |
A call that uses a capability it did not declare traps that call (CallError::CapabilityDenied)
and leaves the host running.
Secrets and the http capability
secrets declares slots by name (an upper-case letter then A-Z, digits, _; up to 64 characters), each bound to hosts that the module also
lists under http.hosts; the operator approves the set with the module’s sha256 like any
other capability. http.hosts entries are exact lower-case host names or IP literals, with an
optional port (default 443); allow_private must be approved per host to reach private,
loopback or link-local addresses.
A slot’s hosts entries are host or host:port ([v6]:port for an IPv6 literal). A host:port
entry binds the slot to that approved port only. A bare host binds it to the host’s one approved
port, and a module that lists the host on several ports must name one (panel.example:8443), so a
secret never reaches a second port of the same host by accident.
The request. http_request takes UTF-8 JSON, {"method", "url", "headers", "body"}
(body base64, optional), and http_read returns {"status", "headers", "body"} (body
base64), or {"error": "..."} for a refused or failed call. The host enforces: https only; a
host and port the module declared; no proxy; no redirect to anywhere but another approved
host (up to 3 hops, each re-checked); at most 8 calls, 256 KiB of request and 1 MiB of response
body per call, 10 s per request and 20 s per guest call; a fixed header denylist (Host,
Content-Length, …). Connecting resolves the name once and checks every resolved address, so a
DNS answer that changes cannot reach a private address that was not approved; an IP literal is
checked the same way.
Secrets never reach the guest. A header value may contain ${secret:NAME}. The host expands
it, only in header values (not in the URL or the body) and only when the request goes to a host
the slot is bound to, and re-checks that for each redirect hop. The response (headers, body, error
text) and every log line are scrubbed of the secret’s raw, base64 and percent-encoded forms
before the guest or the status sees them.
Storage. Operators set a slot with PUT /plugins/{id}/secrets/{slot} and {"value": "..."}
(16 to 4096 bytes), clear it with DELETE and list the slots with GET /plugins/{id}/secrets
(set or not, the key id, ok or held: ...; never a value). Only approved slots are accepted.
A value is encrypted on the controller that received it with XChaCha20-Poly1305 under a cluster
keyring, bound to install, slot and key id, and only the ciphertext goes into the sled database,
the Raft log and snapshots. A follower seals the value itself and sends the ciphertext entry to
the leader (POST /raft/plugin-secret); it never forwards the plaintext request. Deleting an
install deletes its secrets. Changes are audit-logged (install, slot, key id, actor; never the
value) at target wayhouse_controller::plugins::audit, and a delete or rewrap asks every node to
purge the log, so old ciphertext does not outlive it for long.
Keys. --plugin-secret-key-file (or WAYHOUSE_PLUGIN_SECRET_KEY) names a keyring: one
base64 key of 32 random bytes per line (openssl rand -base64 32), the last line active, the
file readable by its owner only. Every controller of a tier needs the same keyring, and the file
is re-read when it changes. Without a key the secrets API answers 409 and a plugin that uses
secret slots is held: it is not run, GET /plugins/{id}/status shows held: ... (no key,
secret unset, key missing, or not authenticating), and other plugins keep running. Plaintext
is kept out of the store and the log, and the controller wipes the buffers it owns, but this is
best effort: the HTTP stack copies a request body and an expanded header value into memory
this project does not control (HeaderValue, the PUT body Bytes), and those copies are not
zeroed. Anyone who can read the controller’s or the proxy’s memory can read live secrets.
Plaintext HTTP on a non-loopback --listen refuses to start with --plugins and a key unless
--allow-insecure-secrets is given.
Rotating a key. (1) Append the new key to the keyring file on every controller. (2) POST /admin/plugins/secrets/rewrap on any controller: it refuses until every member reports the
active key (GET /admin/plugins/secrets/keys lists the key ids each holds), then re-encrypts
every secret under it; a slot an operator rewrote meanwhile is skipped and reported. A member that does not answer is skipped, so a rewrap also works while a replica is down; that replica catches up on its own once it is back, and until it has the new key in its keyring the plugins that use it are held. (3) Remove
the old key from the file. Removing it before step 2 holds the plugins that still use it
(held: key <id> missing) until it is put back.
Routes
The routes capability lets a plugin publish hostname routes:
"routes": { "hosts": ["*.mc.example.com", "play.example.com"], "backends": ["10.0.0.0/16"], "max_entries": 64 }
hosts are the names (exact or *.suffix) the plugin may claim, backends the networks its
backend addresses must be in, max_entries (1 to 256) the size of one set. A module cannot ask
for more of any of these than was approved. routes_set replaces the plugin’s whole set; the host
refuses a set with an entry outside the approval, a duplicate hostname or too many entries, and a
tick that does not call it leaves the set as it was. The set is committed in the same entry as the
tick’s state writes (term and revision checked, validated again on every replica), and only when it
changed.
Materialisation (#236). The controller does not rewrite the operator’s config. It publishes the
routes of every enabled install as an overlay (GET /plugin-routes, and a stream at
GET /plugin-routes/subscribe that proxies follow), and a proxy started with --controller
appends them to each TCP listener that opted in with plugin_routes: (config schema 2, see
docs/05-configuration.md). The listener says how the hostname is read (sni, or a sniffer).
Rules:
- Plugin routes go after the listener’s own routes, so operator routes always win, including an
alwaysfallback. - Each distinct backend address becomes a one-target pool named
plugin-<hash>. - Two installs claiming the same hostname: the earlier install wins; the later claim is not applied
and is listed under
conflictsinGET /plugin-routes, withowner(plugin:<install id>) and plugin name on every route. - Exact names match before
*.suffixpatterns, longer suffixes first. - Disabling or deleting an install withdraws its routes at once. A plugin whose ticks fail keeps its
last committed set, and so does one that stops calling
routes_set: the set stays until the next successful tick replaces it or the install is deleted or disabled. (The spec’s grace period with an alert is not built; see the follow-up issue.) - A controller started without
--pluginsanswers501(404on an older build) and proxies drop the plugin routes at once, keeping their own config; a controller that is only unreachable leaves the last overlay in place. A proxy without--controllerignores the overlay.
Webhooks
A plugin that declares "triggers": {"on_webhook": true} can be called by an outside system.
Webhooks are off until the operator starts the controller with a separate listener, never on
the admin port:
--plugin-webhook-listen 0.0.0.0:8444 --plugin-webhook-tls-cert hooks.pem --plugin-webhook-tls-key hooks.key
It needs --plugins. Plain HTTP on a non-loopback address is refused at start unless
--allow-insecure-secrets is given (the token and body would cross the network in clear text).
Enabling. POST /plugins/{id}/webhook (admin) creates a token for an install that was approved
for on_webhook and answers {"token": "whk_...", "path": "/plugins/{id}/hook"}. The token is
shown once; only its SHA-256 is stored and replicated. Calling it again rotates the token (the old
one stops working at once); DELETE /plugins/{id}/webhook turns the webhook off. Under HA the
call needs every member to run a build that supports it.
Calling. POST <listener>/plugins/{id}/hook[/suffix][?query] with Authorization: Bearer <token>. In order:
- A per-source rate limit (IPv6 by /64) runs first, valid token or not:
429. The source is the TCP peer address. Callers behind one load balancer or NAT therefore share a single bucket, and the limit applies to all of them together; there is no trusted-proxy header. Put the webhook listener where callers reach it directly; there is no setting to relax the source limit. - The token is checked in constant time, against a dummy hash when the install is unknown,
disabled or has no webhook. Every failure is the same
401. - A per-install rate limit:
429. - Only then the body is read, capped at 256 KiB:
413. - The call runs as one of the install’s serialized calls (at most 4 hook calls wait per install;
the timer is never rejected). A full queue is
429and is counted in the status (hooks_dropped); a plugin that fails is502.
The guest receives the method, suffix, query, headers (never Authorization or Cookie), the body
(base64) and an idempotency key (the Idempotency-Key header, else a hash of the install and
body). It answers with webhook_respond (status, headers, base64 body); only plain headers are
passed on, and the response is never cached. The call’s state writes are committed before the
answer goes out, so a 2xx means the state is durable; a commit that fails is 503 and the caller
should retry (use the idempotency key to make that safe).
HA. Any node may receive the request. A follower authenticates it itself and forwards it to
the Raft leader over the peer channel (/raft/plugin-hook/{id}, peer token, protocol gate), but
only over https peers: with plain-HTTP peers it answers 503 rather than send the body in clear
text. Run the webhook listener on every node you want to take requests, or point the sender at one.
Events
A plugin that declares "triggers": {"on_event": ["config_revision", "plugin_changed"]} (and
on_timer, which stays required) gets on_event(kind, payload) calls:
| Kind | Payload | When |
|---|---|---|
config_revision | {"revision": N} | the controller accepted a new config revision |
plugin_changed | {"id": "...", "change": "installed" | "enabled" | "disabled" | "deleted"} | an install was added, toggled or removed |
Events are hints, not a log. They use the same bounded hook queue as webhooks (at most 4
waiting per install), so a slow plugin loses events instead of building a backlog; the loss is
counted as hooks_dropped in the status. A restart, a failover (only the Raft leader delivers) or
a lagging feed also loses some. The list of kinds only grows. A plugin that must not miss a change
reconciles from the controller’s state on its timer and treats an event as a reason to do it
sooner. A backend-health event is not built yet.
ACME DNS-01 (on_acme_dns)
A DNS-provider plugin declares "triggers": {"on_timer": true, "on_acme_dns": true} (the timer
stays required, like for events), exports on_acme_dns(ptr, len) and answers with the
acme_dns_result import. The controller’s ACME runner calls it on the leader to set and
remove the TXT record a CA validates (acme.md). The request document is:
{
"op": "present",
"fqdn": "_acme-challenge.panel.example.com",
"value": "<txt value>",
"zone_hint": "panel.example.com"
}
op is present (add the TXT record) or cleanup (remove it); several values may coexist at
one name, so remove only the one in value. zone_hint is the host being validated; the plugin
finds the provider zone that contains it. The plugin does the provider call with its approved
http capability and ${secret:NAME} slots, and ends with acme_dns_result(1, ...) for done or
acme_dns_result(0, message) for a refusal. A call that never answers, traps or runs past the
call limit fails that order, and the runner still sends cleanup. The message is scrubbed of
every secret the call expanded and shows in the site’s last_error.
The plugin never sees an ACME account or certificate key. The trigger is additive: modules that do not declare it are unaffected, so the ABI minor does not change. Calls go through the same bounded hook queue as webhooks, so a slow provider fails one issuance and never builds a backlog.
Approval
PluginHost::load(bytes, approved) takes the capabilities the operator approved for the
module’s sha256. A module whose own declaration asks for more (a new trigger, log,
state, or a bigger state cap) fails with CapsNotApproved, so a module cannot grant
itself anything. It then runs with its own declaration, which is never wider than the
approved set.
Limits
Every call runs in a fresh Store with a memory cap, an epoch deadline
(Limits::call_timeout), a table-element cap (Limits::max_table_elements, checked at instantiation and on growth) and the log and state-write limits above. Modules are capped at
8 MiB. A call returns its Effects (state writes, log lines) and applies nothing itself:
the caller commits them as one entry, which is how the controller will tag them with the
leader term (automation hooks spec, “Commit rule”).
Compile bounds and the compile pool
Before a module is compiled, bounds::check walks it with wasmparser and rejects
structure out of proportion to a real plugin (ModuleError::TooComplex, naming the
bound): functions, types, locals per function (1000) and in total (50 000), block nesting depth, declared table (at most Limits::max_table_elements, 10 000) and
memory sizes, globals, imports, exports and total code bytes (Bounds, set through
Limits::bounds). CompilePool runs PluginHost::load on a fixed set of worker threads
behind a bounded queue: a full queue answers Busy at once and a slow compile answers
TimedOut (the compile itself cannot be interrupted, so it keeps its worker until done).
The controller must load modules through the pool, never on its main loop. The workspace sets panic = "abort" for the release profile, so a panic in a compile worker (for example a wasmtime bug) ends the process rather than silently shrinking the pool; the bounds exist to keep hostile input away from that path.
A plugin’s state holds at most 1024 keys (a state_put of a new key past that returns -1). A declared state.max_bytes above 1 MiB is rejected, and PluginHost::new refuses a zero
call_timeout.
Checking a built plugin
wayhouse-plugin-check <module.wasm> (crate wayhouse-plugin-host) loads the module with
its own declaration as the approved set, calls init and, if declared, on_timer once,
and prints what the host saw, including which capabilities were used. It exits 1 when the
host would reject the module, a call fails, or the plugin used a capability it did not
declare. Plugin repo CI is meant to run it on every build.
Installing a plugin (controller API)
Start a controller with --plugins (off by default; standalone or with --ha-peers). With
--role slave, or without the flag, /plugins answers 501 with the reason. The routes sit
behind the same admin bearer token as /config; X-Actor is recorded as created_by.
| Request | What it does |
|---|---|
POST /plugins/modules (raw .wasm body, up to 8 MiB) | Validates ABI and capability sections, keeps the bytes by sha256, returns {sha256, size, abi, capabilities} for the operator to read. |
POST /plugins {name, sha256, approved, config?, enabled?} | Compiles the stored module through the bounded pool with approved as the operator-approved set; 422 when the module declares more than approved or fails a bound, 503 when the compiler is busy. Records the install and returns it (201). |
GET /plugins, GET /plugins/{id} | The install records. |
POST /plugins/{id}/enable, /disable | Flip enabled; a disabled install is not ticked, and enabling it restarts its first-tick wait. |
GET /plugins/{id}/status | What the ticks did: ticks, last_tick_unix, last_ok, last_error, consecutive_failures and the last 100 log lines. In memory only; empty before the first tick. |
DELETE /plugins/{id} | Removes the install; the module blob goes with the last install that uses it. |
An install has a random id (route ownership will be plugin:<id>), a name (a-z, 0-9, -, up
to 64 characters), the approved capability set bound to the module sha256, and a non-secret
config (up to 64 KiB) that will be handed to init. Installs and blobs live in the
controller’s sled database; a standalone controller keeps them there, an HA tier keeps them in
the Raft state machine’s database, replicated (the blobs travel out of band, see “High availability”).
Ticks (standalone controller)
A scan runs once a second. An enabled install whose approved declaration has on_timer
is called every tick_interval_secs; the first tick comes one full interval after the
controller (or the enable) first sees the install, so enabling never runs a plugin inside
the request. The module is compiled on first use through the bounded pool, init runs
with the install’s config each time the module is loaded (first tick after a controller
restart, after disable then enable, and after a failed load) against the state already stored,
so it must tolerate existing state, and every call runs on the pool: a busy or slow pool
skips the tick (shown in the status) and never blocks the controller; a skipped tick is retried at the next scan, not a whole interval later. One call per
install is in flight at a time.
A call returns its state writes; the controller applies them as one batch only if the
install’s state revision is still the one the call read (compare-and-set), then bumps the
revision. A call that failed (trap, timeout, over a limit) commits nothing, is recorded in
the status, and the plugin is called again at its next interval. Deleting an install
removes its state; disabling keeps it, and enabling again resumes against it. created_by is a label taken from
the X-Actor header the caller sent, not a verified identity (the admin token is what authorises the call). This is the single-node form of the design’s term-checked commit; the HA form is below.
The Plugins page (web UI)
wayhouse-ui proxies the controller’s /plugins API (/api/plugins*, needs --controller-url). Listing is viewer-level; upload, install, enable, disable and delete need the admin role. The page lists installs with their approved capabilities in plain words. “Upload plugin” sends the module (up to 8 MiB) to the controller, shows what it declares and installs it only after the operator ticks the approval box; the UI approves exactly the declared set, never an edited one. A controller that does not serve plugins (off, slave) shows its reason instead of a list. Not in the page yet: registry install, secrets, config editing.
High availability
With --ha-peers every replica holds the same installs and the same per-install state, in the
log and in snapshots (module blobs are in neither; see “Module bytes”). The entries are an install, enable or
disable, delete (state goes with it), and one state commit per plugin call. Applying is
deterministic and a refusal is a normal answer, never a storage error: an install whose id
exists, a malformed install (id, name, sha256, size, created_by, config size), a state commit
over the host’s bounds, a stale revision, or a commit tagged with another term than the one the
entry was appended in changes nothing, on every replica alike. The plugin trees record the
highest Raft index applied, in the same transaction as the entry and restored from a snapshot, so
a replay after a crash or a snapshot install changes nothing.
The API. GET /plugins and GET /plugins/{id} answer from the replica that got the request.
Enable, disable and delete are proposed through Raft, and a follower forwards them to the leader
like the other write routes. An upload, an install and a status read run on the leader (a
follower forwards the whole request): the leader needs the module bytes to compile and check the
approval before it proposes the install, and only the leader has tick results. The leader checks
the record before proposing and every replica checks it again when applying.
The tick. Only the Raft leader ticks. A node that is not the leader drops its schedule,
its loaded plugins and its statuses; when it wins an election every install starts over with a
full interval of grace, so a new leader has applied the earlier terms’ entries before it runs a
plugin. A tick’s state writes are one PluginState entry tagged with the term the leader read the
state in and the state revision the call read: if leadership moved or another commit got in first,
the state machine drops it and the status says so. A commit is refused before it reaches the log
when over the host’s state limits (1 MiB, 1024 keys, 256 writes per call). Status is in memory on the leader and starts
empty after a failover.
Module bytes (slice 7). The Raft log carries install records only; the module (up to 8 MiB)
is a content-addressed blob each replica keeps in its own store and moves over the peer channel
(--ha-token, the protocol gate):
- The leader pushes a module (
PUT /raft/plugin-blob/{sha256}, verified against its hash on arrival) to every member and records the install only once a quorum of voters holds it, so an install cannot be stranded on a node that dies right after the upload. A quorum that cannot be reached answers503. - Every replica fetches the modules its installs reference and it lacks (
GET /raft/plugin-blob/{sha256}, leader first, then the others, verified before it is kept) every few seconds: a replica that was down, joined late or restored a snapshot catches up this way. Until it has the module a node runs nothing for that install; a leader that cannot find one logs a repeated warning, since it would otherwise silently run nothing. - Rolling upgrades: a new install is refused with
503unless every member answers/raft/whoamiwith the currentplugin_supportlevel (3; a build before module replication leaves it out). A member started without--plugins, or as a slave, reports0, so every replica needs--pluginsbefore plugins are installed or their webhooks, secrets or key rotation change. A leader without--pluginswould otherwise run no ticks and fail webhook forwards silently. - The same gate covers secret changes, so setting, deleting or rewrapping a secret answers
409while any cluster member (voter or learner) is unreachable. Revoking one credential therefore needs every member up; disabling or deleting the install still works as emergency containment. Existing installs keep working through an upgrade. Upgrade every replica before the first install; the gate does not cover a replica added later with an old build. - An install that lands on a leader that has meanwhile lost leadership answers
503 retryand is never forwarded for a second hop. - A controller removes the module blobs no install references (uploads that were never installed,
and cleanups a crash interrupted) once at startup, so upload and install belong in one sitting.
Under HA the sweep waits until the node knows a leader and has applied its whole log, because a
blob pushed for a committed install that is not replayed yet looks unused. Uploads no install
uses are capped at 64 MiB in total (
507), so an open controller cannot be filled with them. - A delete that lands between an install’s blob check and its commit removes the module on every replica; the leader puts it back and pushes it again once the install is recorded.
Turning HA on. Installs, state and modules of a controller that ran standalone are not carried into an HA tier (the tier keeps its own in the Raft state machine’s database, and there is no pre-HA import for plugins): install them again.
Snapshot restore. A plugin snapshot is replaced in one transaction (installs, state and the
cursor together). The other stores’ replace_stores write one database at a time and are left as
they are: a crash in between leaves the node’s last applied index unchanged, so openraft
installs the snapshot again, and installing is a full replace.
Sniffers: modules, manifests and the registry format
A sniffer is a WASM module that peeks at the first bytes of a connection or
datagram and returns a routing hint (a hostname or a tag). This page is the format
reference for authors and registry operators. The ABI itself is documented in
crates/wayhouse-sniffer-abi/README.md and the module doc of
crates/wayhouse/src/sniffer_loader.rs; the design and its reasons are in the
registry spec.
Sniffers are not plugins: plugins are integrations with other systems (for example the Pelican panel) and have their own design, see the plugin system design.
What the crate does
crates/wayhouse-registry holds every rule below as pure
functions (no network, no I/O): parsing and validating an index, picking a compatible
version, verifying a download, and generating an index from manifests. The web UI backend
uses it to browse and install, and the sniffers repository CI uses its
wayhouse-registry-gen binary to publish.
Module requirements
A module must be a core WebAssembly module with no imports, at most 8 MiB, and carry a
wayhouse.abi custom section (major then minor, both u16 little endian). While the
ABI major is 0 the host requires an exact match of the minor. Depending on
wayhouse-sniffer-abi adds the section for you.
Manifest (manifest.toml)
One per sniffer, written by the author. Unknown keys are an error.
| Key | Meaning |
|---|---|
name | Letters, digits, - and _ only (the proxy’s module name rule) |
description, license | Shown in the UI |
version | SemVer of this sniffer |
abi | major.minor the module was built for; must equal the section in the built module |
min_proxy | Oldest proxy version it runs on |
limits.max_memory_bytes, limits.call_timeout_ms | Resource limits the sniffer is designed for; the client rejects 0 and anything above 256 MiB / 10 000 ms |
config (optional), homepage (optional) | Documentation of the per-module config string; a link |
Index (index.json)
The same file format for the official registry and for external ones, served over HTTPS.
{
"schema": 1,
"kind": "sniffer",
"name": "wayhouse official sniffers",
"sniffers": [
{
"name": "minecraft",
"description": "...",
"license": "MIT OR Apache-2.0",
"versions": [
{
"version": "0.1.0",
"abi": "0.1",
"min_proxy": "0.1.0",
"url": "https://.../minecraft.wasm",
"sha256": "<64 lowercase hex>",
"size": 24576,
"signature_url": "https://.../minecraft.wasm.minisig",
"limits": { "max_memory_bytes": 1048576, "call_timeout_ms": 50 }
}
]
}
]
}
Rules a client enforces: schema is 1; kind is sniffer (plugin is reserved for the
plugin registry and rejected for now); names are unique and valid; versions are unique
and sorted newest first; every URL is https:// with a host and no user info; sha256 is 64 lowercase hex; size
is at most 8 MiB; the whole file is at most 1 MiB. Unknown extra fields are ignored so a
newer registry can still be read.
Choosing a version
For a fleet, the newest version whose abi matches the host and whose min_proxy is met
by every proxy. When none qualifies the reason is reported (for example “built for sniffer
ABI 0.2, but this proxy speaks ABI 0.1”). If proxy versions are unknown the proxy check is
skipped.
Verifying a download
In this order: size equals the index, sha256 equals the index, the signature (when one is published and the official key is known) verifies, and the module itself passes the requirements above and declares the ABI the index says. A signature that is present but invalid is an error, never a quiet downgrade to “unsigned”. Signatures (minisign) are optional for now, and an unsigned sniffer is shown as unsigned. The sha256 comes from the same index as the URL, so for an unsigned sniffer it only protects against corruption.
Generating an index
wayhouse-registry-gen generate --name "my registry" --base-url https://example.com/dl \
--previous index.json --out index.json sniffers/minecraft sniffers/a2s
wayhouse-registry-gen verify index.json
Each directory holds manifest.toml and <name>.wasm (and optionally
<name>.wasm.minisig). Artifacts are expected at
<base-url>/<name>-v<version>/<name>.wasm. Output is deterministic (sorted, fixed field
order), older versions are kept, and re-running with identical bytes changes nothing. A
published version is immutable: the same version with a different sha256 is refused, so
bump the version instead. A failed run leaves the existing file untouched.
If --previous is left out and --out already exists, --out is read as the previous
index, so a plain regenerate cannot replace a published version. If that file exists but is
not a valid index, the run fails instead of overwriting it; fix or remove the file first.
Installing from a registry (UI backend)
wayhouse-ui fetches indexes and artifacts itself, runs every check above, and only then hands
the verified bytes to the fleet upload, so a proxy needs no internet access and still re-validates
what it receives. The routes are GET /api/registries and GET /api/registries/{id}/sniffers
(viewer), POST /api/registries, DELETE /api/registries/{id} and
POST /api/registries/{id}/install (operator, the same level as uploading a module by hand).
- Flags.
--registries-file <path>keeps the list across restarts (JSON, written atomically, mode 0600; without it changes live in memory only).--no-default-registryhides the official registry. The official index ishttps://raw.githubusercontent.com/wayhouse-proxy/sniffers/main/index.json. - Risk. Every response says whether a registry is
officialorexternal. External registries are installed from at the operator’s own risk; the page shows that before every install. - Network guards. Only
https://is fetched, a redirect is held to the same rules, and a URL or hostname that resolves to a private, loopback, link-local or unique-local address is refused. Bodies are cut off at the size limits while streaming, and requests time out. The client does not useHTTPS_PROXY/HTTP_PROXY: a forward proxy resolves the destination itself and would bypass the address check, so the UI host needs direct outbound HTTPS to the registry. - ABI and
min_proxy. The ABI is this release’s. The aggregator does not report proxy versions yet, somin_proxycannot be enforced and the listing saysmin_proxy_checked: false. - Signatures. The official public key is not set yet, so signatures are not checked and installs
are reported
signed: false. See the maintainer to-do below. - Partial installs. The reply lists every proxy. A proxy that rejects the module because it pins
its sniffers (
settings.sniffers.modules) answers409with a reply startingpinned:; it is reported aspinnedtogether with thenameandsha256to add to its pin list. Any other refusal (including the409of a proxy withsettings.sniffersunset, which needs a restart) is reported with itsdetailtext, not as pinned.200means every proxy accepted,207some did or some are pinned,502none did and none is pinned.
The Sniffers page. Under “Install from a registry” the page lists the registries (a badge says
official registry or external, at your own risk), the sniffers of the selected one with the
version this release can run (or the reason none can), and an Install button. The install dialog shows
the version, size, memory and time limits, signature and config notes before anything happens; for an
external registry it also shows the risk text and needs a tick before it enables the button. Adding a
registry needs the same tick. After an install the dialog lists every proxy (installed, pinned,
failed with the reason); for pinned proxies it shows the name/sha256 entry to add to
settings.sniffers.modules, with a Copy button. When wayhouse-ui runs without --registries-file the
page warns that added registries are lost on restart.
Updates and rollback
Updating a sniffer is installing a newer version over the old one; the proxy keeps the old one so an operator can go back, and falls back to it on its own when a new file turns out to be unusable.
- The kept version. An upload over an existing module saves the replaced file as the dotfile
.<name>.wasm.previnsettings.sniffers.dir. Exactly one previous version is kept: a second update replaces it, so two updates followed by a rollback give the version before the last update only. The replaced file is not kept when it no longer loads (it would push out a working previous version) or when the upload is the same build. The*.wasmscan andGET /admin/sniffersskip dotfiles; the listing reportshas_previousandfallbackfor each module. Removing a module removes its previous version too. - Rollback.
POST /admin/sniffers/{name}/rollbackon a proxy swaps<name>.wasmwith.<name>.wasm.prevand requests a reload, so a second rollback undoes the first. It answers404when there is nothing to go back to. The fleet verb isPOST /fleet/sniffers/{name}/rollbackon the aggregator (/api/fleet/sniffers/{name}/rollbackin the UI, operator role); the reply lists every proxy, and one without a previous version shows its404without failing the others. - Pinned instances. A proxy with
settings.sniffers.modulesonly runs a version whose sha256 is pinned, so an update upload is refused (409 pinned:) until the new hash is in the pin list, and a rollback is409 pinned:and changes nothing when the previous version’s hash is not pinned. An update never changes a pin. A rolled-back file never leaves a state the next scan would reject. - Automatic fallback. When
<name>.wasmfails validation at a rescan (a bad file copied in by hand, for instance) the loader runs.<name>.wasm.previnstead, if it validates and, on a pinned proxy, matches the pin. It logs an error naming both files and the reason on every rescan, and the listing showsfallback: true. Without a usable previous version a bad file is skipped as before (or fails the scan, with pins). A call already running when a rescan swaps the module finishes on the old instance. - Checking for updates.
POST /api/registries/updates/check(operator) refetches every registry’s index (a cached one would hide a release) and asks each proxy what it runs. There is no timer and no background fetch: it runs when someone clicks “Check for updates”. Each installed build’s sha256 is looked up in the indexes. One row comes back per sniffer and build, with the instances running it, so a half-updated fleet shows two rows. A row carriesinstalled_version, andupdatewhen a newer version exists:compatible: true, orcompatible: falsewith areason(for example another sniffer ABI). A build whose hash is in no index is an unknown build, typically a module uploaded by hand or built locally. It hasknown: false, no update is offered, and the page says so rather than guessing from the name. An unreachable registry or proxy is listed in the reply (registries,instance_errors) and the rest still answers. - Updating. The Updates section’s “Update to X” button opens the install dialog for exactly that version: same limits, signature notes and risk confirmation as an install. The update itself is the fleet upload from “Installing from a registry”.
- The page. The module table shows a
fallback activebadge and a “roll back” button (with a confirmation that it acts on every instance) for modules that keep a previous version.
Maintainer to-do. Generate the minisign key pair offline, put the public half in
crates/wayhouse-ui/src/registry_keys.rs, and store the secret in the sniffers repository (see the
bootstrap plan). Until then everything installs unsigned.
Installing by hand
Download <name>.wasm from a sniffers release
(or build your own) and copy it into the directory named by settings.sniffers.dir, named
<sniffer-name>.wasm: the proxy names a sniffer after its file stem (so the regex sniffer is
regex_firstbytes). Then use the name in a route:
settings:
sniffers:
dir: "/etc/wayhouse/sniffers"
listeners:
- name: mc
bind: "0.0.0.0:25565"
routes:
- match: { type: sniffer, sniffer: minecraft, host: ["survival.example.net"] }
action: { pool: survival }
- match: { type: always }
action: { pool: lobby }
A config reload rescans dir live, so an added, removed or replaced module needs no restart.
settings.sniffers.modules: [{ name, sha256 }] optionally pins each file’s hash (sha256sum <file>.wasm).
An instance with settings.sniffers configured also accepts modules over its admin API, so
no filesystem access is needed:
curl -X POST --data-binary @a2s.wasm "http://<admin-listen>/admin/sniffers?name=a2s"
curl "http://<admin-listen>/admin/sniffers" # list, with sha256 + loaded state
curl -X DELETE "http://<admin-listen>/admin/sniffers/a2s"
This writes into the same dir and triggers the same rescan, so it is interchangeable with
copying files. wayhouse-aggregator fans the upload and delete out to every known instance
(POST/DELETE /fleet/sniffers[/{name}]), and the UI’s Sniffers page uses that fan-out. All three
routes answer 409 on an instance with no settings.sniffers block (turning sniffing on from
nothing is startup-only, see docs/05-configuration.md). If modules pins hashes, an upload whose
name and hash are not listed is refused with 409 (pinned:) and writes nothing; add the matching
name and SHA-256 to modules first.
What this repository tests against the official sniffers
Main builds no sniffer. The loader’s own tests use small WAT modules (accept, reject, trap,
timeout, memory cap, wrong ABI version). On top of that, sniffers.lock pins the official releases
by tag and sha256, and the sniffers-e2e CI job (make sniffers-fetch, then the #[ignore]d
wayhouse tests) loads exactly those bytes through the real loader on amd64 and arm64: the ABI
check, every sniffer’s recognition, and the latency gate. It is part of the required ci-ok
gate (#296), so a PR that changes the loader or sniffers.lock cannot merge with it red; it is
skipped, and counts as passed, on PRs that touch neither. The price is that an outage of the
sniffers repository blocks the PRs that do run it (re-run once, like any flake). It runs when
wayhouse or sniffers.lock changes, on the merge queue and in the nightly run. Community sniffers are tested by the CI of the
repository that hosts them, not here.
When the ABI changes (HOST_ABI, the wire format): update crates/wayhouse-sniffer-abi, bump the revision
the sniffers repo pins, release the rebuilt sniffers there (its Release workflow), then run
python3 .github/scripts/update_sniffers_lock.py here to pin the new tags and hashes from the
published index.json, and the e2e job is green again. The same command is how a newer official
sniffer release gets picked up.
Web proxy: HTTP(S) sites through Caddy
Wayhouse stays an L4 proxy. To publish web sites (a Pelican panel, Wings, any web UI) through
the same entry node as the game traffic, it forwards ports 80 and 443 to a packaged
Caddy and keeps the real client address with PROXY protocol v2.
wayhouse-web-sync turns the web: section of the Wayhouse config into Caddy’s configuration.
Design: issue #297, the spike findings (#355) and the
web routing spec.
client ──► wayhouse :80/:443 ──PROXY v2──► Caddy (127.0.0.1:8080 / :8443) ──► site upstreams
│ ▲
│ GET /web, GET /pools │ POST /load (unix socket)
└────────► wayhouse-web-sync ──┘
| Part | What it is |
|---|---|
web: config | Sites, upstreams, certificate files; validated by wayhouse --check (05) |
wayhouse-web-sync | Separate process in the wayhouse image. Reads the local admin API, renders, loads |
wayhouse-caddy | The image with the pinned Caddy, an unprivileged user and the admin socket |
Nothing here is on the game data path. If Caddy or the sync is down, only the web sites fail;
Wayhouse’s health check takes Caddy out of the web pool like any backend.
Configure
schema_version: 3
pools:
- name: web # Caddy's HTTPS listener
targets: ["127.0.0.1:8443"]
proxy_protocol: v2
health_check: { type: tcp_connect }
- name: web-http # Caddy's plain HTTP listener (redirects)
targets: ["127.0.0.1:8080"]
proxy_protocol: v2
health_check: { type: tcp_connect }
listeners:
- { name: https, bind: "0.0.0.0:443", protocol: tcp, pool: web }
- { name: http, bind: "0.0.0.0:80", protocol: tcp, pool: web-http }
web:
sites:
- hosts: [panel.example.com]
upstreams: [{ addr: "10.0.0.5:8080" }]
tls: { cert: /certs/panel.pem, key: /certs/panel.key }
- hosts: [wings1.example.com]
upstreams: [{ pool: wings1 }] # a Wayhouse pool, also one behind wayhouse-agent
upstream_tls: skip # Wings serves a private certificate, the tunnel is authenticated
health_path: /api/system
A complete example is deploy/compose/wayhouse-web.yaml. The
fields are described in 05 Configuration.
What the sync renders
One Caddy JSON document, loaded with POST /load on Caddy’s admin API:
- Two servers. HTTPS (
web.listen.https):proxy_protocolthentls, a route per site. HTTP (web.listen.http):proxy_protocolonly; a site with a certificate redirects to HTTPS (308), a site without one is served over HTTP. The redirect target ishttps://{host}{uri}with no port, so it is right only when HTTPS is served on 443; behind a non-standard port (for example 8081 to 8444 in a demo) browsers follow it to the wrong place. proxy_protocolaccepts headers only from loopback andweb.trusted_proxy(fallback_policy: REJECT): a connection from anywhere else that sends a header is dropped, so a client cannot claim another address.- Real client address:
X-Forwarded-Foris built from the PROXY header (a client-supplied one is replaced) andX-Real-IPis overwritten with the same address. - Every
reverse_proxyhasstream_close_delay: 1h, so a reload does not close WebSockets (Pelican consoles). Both servers useprotocols: [h1, h2]; there is no HTTP/3 listener. - Exact hosts match before
*.suffixwildcards, across all sites. upstreams: anaddras is; apoolwithvia: direct(default) as that pool’s enabled backends, read fromGET /poolson every cycle, so a drained or disabled backend leaves Caddy within one interval (Caddy cannot see Wayhouse’s admin state).via: wayhouseuses the generated loopback listenerweb-up-<pool>(localinGET /web). A site with no usable upstream answers503.upstream_tls: absent is plain HTTP;verify,skip(no verification) or{ ca_file: <path> }(a path on the Caddy side).health_pathadds an active check; passive checks are always on.- Certificates (
tls: { cert, key }) are read by the sync and rendered inline, not as file paths. Caddy does not watch certificate files, and loading an identical document changes nothing, so a renewed file only takes effect because it changes the document. The sync re-reads the files every cycle. - The
adminblock is always identical (listenequal to--caddy-admin,config.persist: false). Withoutpersist: falseCaddy writes inline private keys toautosave.json.
Caddy’s own ACME is off (automatic_https.disable). Certificates come from files; controller-issued
certificates are a separate work stream (#361 to #366). Running Caddy’s built-in ACME next to the sync
is not supported: every load replaces the whole configuration. The recipe items that ACME in Caddy
would need (http_port: 8080, alternate_port, disable_redirects) are deliberately deferred until
that work; the ACME design lives in
docs/superpowers/specs/2026-10-15-controller-acme-design.md (with the ACME work, #372).
Safety rules
The sync never hands Caddy an empty or partial document (POST /load with {} answers 200 and
removes every listener):
- It waits for the first good
GET /web. A failed read, a missingweb:section or an empty site list at startup send nothing, so Caddy keeps its base configuration. - A failed read of
/webor/pools, an unreadable or non-PEM certificate file, or a render error sends nothing and leaves Caddy on what it has (wayhouse_web_sync_errors_total). - Removing the last site (or the whole
web:section) after something was loaded sends a complete document with no routes, so the sites really stop. - If Caddy refuses a document (4xx, for example a bad CIDR) it keeps its previous configuration; the
sync logs it, counts it in
wayhouse_web_sync_rejects_totaland does not resend the same document. - An unchanged document is re-sent every
--resend-sec(60 s): an identical load is a no-op for a Caddy that kept its configuration and restores one that restarted. - The admin API is only reachable on a unix socket or a loopback address; the sync refuses any other
--caddy-admin.
HTTP-01 challenges
With controller-issued certificates (acme.md), a site can prove control
with HTTP-01. Give the sync the controller nodes and it adds one route to the HTTP server
(port 80), ahead of every site route and redirect:
wayhouse-web-sync --acme-challenge-upstream https://controller-1:7000,https://controller-2:7000
Only /.well-known/acme-challenge/* is forwarded, for every host, to the first controller
that is up. A site that serves its own files under that path is shadowed by it, so do not set
the flag if no site uses HTTP-01. All controllers must use the same scheme; for https, add
--acme-challenge-ca-file if the controllers’ certificate is not signed by a CA Caddy
trusts. The route is part of the rendered document, so it exists only while wayhouse has a
web: section with sites. The sync never sends Caddy a partial document.
Run
Command line of wayhouse-web-sync (--help has the rest):
| Flag | Default | Meaning |
|---|---|---|
--wayhouse-admin | http://127.0.0.1:9900 | The local wayhouse admin API (settings.admin.listen, https works) |
--wayhouse-token / env WAYHOUSE_ADMIN_TOKEN | none | Bearer token if settings.admin.auth_token is set |
--caddy-admin | /run/caddy/admin.sock | Caddy admin: unix socket or loopback ip:port |
--interval-sec | 5 | How often it reads Wayhouse (pool changes, renewed certificates) |
--acme-challenge-upstream | none | Controller URLs that answer ACME HTTP-01 (see below) |
--acme-challenge-ca-file | none | CA (path on the Caddy side) for an https --acme-challenge-upstream |
--metrics-listen | 127.0.0.1:9904 | GET /metrics; a non-loopback address needs --metrics-token |
Caddy has to start with a base configuration whose admin.listen is the same address: the image
ships deploy/caddy/base.json (unix//run/caddy/admin.sock).
Docker Compose: docker compose -f docker-compose.yml -f compose.web.yml up --build in
deploy/compose/. Kubernetes: label the gateway nodes and apply
deploy/k8s/60-web-gateway.yaml.
The wayhouse-caddy image is stock Caddy, pinned by version and digest in
deploy/Dockerfile (one place; bumps are manual, the Trivy job scans it like the
other images). It runs as uid 65532 on high ports with a read-only root filesystem and no
capabilities (the base image’s cap_net_bind_service file capability is removed, which would
otherwise make the binary fail to start once all capabilities are dropped), with data and config
volumes only for Caddy’s state. It carries the Apache-2.0 text and a NOTICE under /licenses; see
Third-party notices for the license and trademark decisions and the rule for
adding modules.
Modules (for example DNS-01) would mean building it with xcaddy instead; the Dockerfile says where.
Topologies
- Single node.
wayhouse, Caddy and the sync on one host, as in the compose file. Caddy listens on loopback. - Gateway tier. Run Caddy and the sync on 2 to 3 nodes per region; every other node routes its :80
and :443 to the
webpool whose targets are those gateways. Caddy then listens on a routable address (web.listen) andweb.trusted_proxylists the nodes that forward to it. Certificates are needed on the gateways only. - Origin behind
wayhouse-agent. The tunnel interface lives on the node that runswayhouse. Caddy has to see it, so it shares the host network namespace (network_mode: host,hostNetwork: true, as both recipes do). Withvia: directCaddy dials the origin’s tunnel address itself; the web service’s port must be among the backends the agent fronts (--backends). The real WireGuard path is exercised by the tunnel CI job; it is not part of the Caddy end-to-end test.
Operating notes
-
If Caddy starts before Wayhouse marks it healthy, the first connection from outside can be reset; once the
webpool’s health check passes (interval_sec, 5 s in the examples) traffic flows. Lower the interval if that gap matters. -
Metrics (
wayhouse_web_sync_*) are in 06 Operations. Alert ontime() - wayhouse_web_sync_last_ok. -
GET /webon the Wayhouse admin API (404 without aweb:section) is what the sync reads; use it to see what Wayhouse resolved. -
The Caddy end-to-end test (
crates/wayhouse-fleet-tests/tests/web.rs, CI jobweb-e2e) runs Wayhouse, the sync and the Caddy from thewayhouse-caddyimage against an origin: real client IP at the origin, a spoofedX-Forwarded-Forreplaced, a spoofed PROXY header dropped, a WebSocket upgrade, HTTPS with an operator certificate, the HTTP redirect. Run it by hand withCADDY_BIN=<caddy> cargo test -p wayhouse-fleet-tests --test web -- --ignored. -
Restart edge. If the sync restarts while Caddy still serves old sites and
web:now has zero sites, the sync stays idle (it never sends an empty document) and Caddy keeps the stale sites until a site exists again or Caddy restarts. -
Unrouted warning. The “no listener routes to the web pool” warning counts only direct pool routes; a resolver route or routes added later by
plugin_routescan reach the pool unseen, so the warning can be a false positive.
Certificates from the controller (ACME)
The controller can issue and renew TLS certificates from an ACME CA (Let’s Encrypt or any
other) for the sites you publish, and keep their private keys sealed. It is off by
default; start it with --acme. Design and reasoning:
superpowers/specs/2026-10-15-controller-acme-design.md.
Status: built are the sites, the sealed store, replication, the issuance and renewal loop,
the admin API, HTTP-01, external account binding, CNAME-delegated challenge zones, the
Certificates page of the web UI, the DNS-provider plugin hook (on_acme_dns,
#362) and the distribution of
certificates to gateways (#363,
Distribution). The first DNS-provider plugin,
acme-dns-cloudflare,
is in the plugins repository (below); for another provider, write one
against the hook in plugins.md.
Building without ACME
Issuance sits behind the acme cargo feature of wayhouse-controller, on by default. A
build with --no-default-features leaves out the ACME client, the runner and the DNS plugin
solver (and their dependencies: instant-acme, rcgen, x509-parser, hickory-resolver).
It still decodes, stores and replicates ACME state, so a mixed cluster stays consistent, but
--acme refuses to start and the /acme routes answer that ACME is not enabled.
What it does
You describe sites: a set of hostnames (or a *.zone wildcard), the gateways that
serve them and either the DNS plugin that can publish _acme-challenge TXT records (DNS-01)
or nothing at all (HTTP-01, below). The leader of the controller tier orders one
certificate per site, proves control with the site’s challenge, seals the new private key under the cluster secret key and stores certificate
and sealed key (replicated under HA). It renews when less than a third of the lifetime is
left, with backoff after failures.
The data path never speaks ACME and never parses HTTP.
Turning it on
wayhouse-controller --plugins --secret-key-file /etc/wayhouse/secret.keys \
--acme --acme-ca letsencrypt-staging --acme-accept-tos
--acme-cais required and has no default:letsencrypt,letsencrypt-stagingor anhttps://ACME directory URL. Tryletsencrypt-stagingfirst; its certificates are not trusted by browsers but its rate limits are generous. When you switch--acme-calater (staging to production), the controller creates a new account at the new CA and replaces theuntrustedrehearsal certificates at once; certificates already trusted are left alone.--secret-key-fileis the same keyring plugin secrets use (--plugin-secret-key-fileis the old name and still works; so doesWAYHOUSE_PLUGIN_SECRET_KEY).--acmerefuses to start without it.--acme-accept-tosagrees to the CA’s terms of service when the cluster’s one ACME account is created.- Not available on a
--role slavecontroller. - On a plain-HTTP listener that is not loopback the controller refuses to start with
--acme(the admin token and site changes would cross the network in clear text): serve TLS (--tls-cert/--tls-key), listen on loopback behind a TLS terminator, or pass--allow-insecure-secrets. - Certificates from
letsencrypt-staging, any CA whose host or path saysstaging, and a loopback test CA are stored with"untrusted": true(shown in the site’s status). They are for rehearsal only and must never be served to real clients.
Under HA every replica needs the same keyring. A replica without it holds issuance and says so; if it becomes leader nothing is issued until the key is there.
Sites
All routes need the admin bearer token like the rest of the controller API.
| Route | Does |
|---|---|
PUT /acme/sites/{name} | Create or replace a site. Body: names, dns_plugin, optional challenge, gateways, challenge_zone, enabled. |
GET /acme/sites | Every site with its certificate status and last error. |
GET /acme/sites/{name} | One site. |
DELETE /acme/sites/{name} | Remove the site, its certificates and its retry state. |
GET /acme/account | The account URL, for pinning CAA accounturi= at your DNS provider. No key. |
Rules: names are lowercase hostnames, optionally *.zone; a name belongs to one site
(409 otherwise); a wildcard needs at least one gateway (422), because a wildcard key
should live only on a few hardened nodes. No route returns a private key, a certificate
chain or ciphertext.
HTTP-01
"challenge": "http-01" proves control by answering GET /.well-known/acme-challenge/<token>
on port 80 of each name instead of publishing a TXT record. Use it when no DNS plugin fits
and the names are reachable from the internet; it cannot cover a wildcard (422), and
dns_plugin and challenge_zone are refused on such a site (400). The default is dns-01.
curl -X PUT -H "Authorization: Bearer $ADMIN" https://controller:7000/acme/sites/panel \
-d '{"names":["panel.example.com"],"challenge":"http-01","gateways":["edge-1"]}'
How the answer reaches the CA:
-
The leader gets the token and key authorization from the CA and commits them as one replicated entry, so every controller node, followers included, can answer.
-
The CA fetches
http://panel.example.com/.well-known/acme-challenge/<token>. Your gateway forwards exactly that path prefix to a controller node. Withwayhouse-web-syncthat is one flag,--acme-challenge-upstream https://controller-1:7000,https://controller-2:7000; with a Caddy you configure yourself:handle /.well-known/acme-challenge/* { reverse_proxy https://controller-1:7000 https://controller-2:7000 } -
GET /.well-known/acme-challenge/{token}on the controller needs no token: the body is what the CA is meant to read, the token is chosen by the CA per order, and anything unknown or expired is a plain404. The answer is withdrawn when the order ends. If the leader dies mid-order, the next pass drops what is left once it has expired (the order time plus the site’spropagation_timeout_secs).
List every controller node (as above) so the forward survives a node going down, or point it at the leader: a follower
that has not applied the entry yet answers 404, and the CA does not retry a failed
validation. The leader waits two seconds after publishing so a follower has applied the entry.
The expiry is set by the leader’s clock and checked on each serving node’s, which the five
minutes of slack covers as long as the nodes run NTP.
Rolling upgrade: the cluster-wide ACME level is now 4, so while any member still runs an older build, all ACME writes and issuance (DNS-01 renewals and gateway changes included) pause, as for the earlier levels. Once an HTTP-01 site exists, an older build cannot decode its entries, so do not downgrade past this release.
A site whose gateways do not forward the path fails the order with the CA’s own message (usually
unauthorized or connection) in the site’s last_error, and it counts toward the hourly
failure cap like any other failure. Holding port 80 open to the internet is the price of
HTTP-01; with DNS-01 nothing needs to be reachable.
CNAME-delegated challenge zones
If the DNS provider that holds your zone has no plugin, or you do not want to give a plugin access to it, point the challenge names at a zone you can automate. Create once, per name:
_acme-challenge.panel.example.com. CNAME _acme-challenge.panel.example.com.acme.other.net.
and set "challenge_zone": "acme.other.net" on the site. The DNS plugin is then asked to set
and remove the TXT record at _acme-challenge.panel.example.com.acme.other.net
(zone_hint is panel.example.com.acme.other.net), and example.com itself is never
changed. The controller still waits until the original name shows the value, so a missing
or wrong CNAME fails the order with a message that names the CNAME it expected. A wildcard
*.example.com uses _acme-challenge.example.com. challenge_zone is a plain hostname, not
a wildcard.
External account binding
Some CAs (ZeroSSL, Google Trust Services, …) tie an ACME account to a customer. Give the controller the binding the CA shows you when it starts:
wayhouse-controller --acme --acme-ca https://acme.example/directory --acme-accept-tos \
--acme-eab-kid KID --acme-eab-hmac-key-file /etc/wayhouse/eab.key
The key file holds the HMAC key as the CA prints it (base64url) and must not be readable by
group or others. The binding is used once, when the cluster’s account is created; the account
key is sealed like every other ACME secret, and the binding key is not stored. Every replica
needs the same flags, as with --acme-ca. If you change the CA, the account for the new CA is
created with whatever binding the flags carry at that time.
Distribution to gateways
A gateway host gets its certificates from the controller, not from the CA. Each gateway has
its own token; a token reads only the sites that list that gateway’s name in gateways.
| Route | Auth | Does |
|---|---|---|
POST /acme/gateways | admin token | Register a gateway. Body {"name": "edge-1"}. Answers 201 with the token, once. |
GET /acme/gateways | admin token | The gateways and the sites each may read. No token, no hash. |
DELETE /acme/gateways/{name} | admin token | Revoke: its token answers 401 at once on every replica. |
GET /acme/bundles | gateway token | The sites this gateway may read that have a certificate: {site, version, not_after}. |
GET /acme/bundles/{site} | gateway token | {version, chain_pem, key_pem, not_after, serial, untrusted}. |
The controller stores only the SHA-256 of a token and replicates only that. To rotate a
token, delete the gateway and register it again (a second POST of a taken name is 409).
GET /acme/bundles/{site} answers ETag: "<version>" and 304 to a matching
If-None-Match; a site the token may not read answers 404, the same as a site that does not
exist. Any controller node serves bundles, followers included, so a gateway can use the
nearest replica; a node without the cluster secret key answers 503 instead of a key. Every
served bundle writes one log line with the site, gateway and version, never the key.
The key travels in the bundle response in plaintext, so serve the controller over TLS (or
reach it on loopback). --acme already refuses a plain-HTTP listener on a non-loopback
address unless --allow-insecure-secrets is given. A deleted gateway may have copied the
keys it could read: the next renewal creates a new key for every site, so remove the gateway
from the sites it should no longer serve and, for a wildcard, renew early.
wayhouse-certsync
wayhouse-certsync runs on the gateway host (it ships in the wayhouse image next to
wayhouse-web-sync: start it with entrypoint: /usr/local/bin/wayhouse-certsync). Create the
gateway, put its token in a file only the sync user can read, and point the sync at every
controller replica:
curl -sS -H "Authorization: Bearer $ADMIN" -d '{"name":"edge-1"}' \
https://ctl-1:9443/acme/gateways # copy "token" into /etc/wayhouse/gw.token
chmod 600 /etc/wayhouse/gw.token
wayhouse-certsync --controller https://ctl-1:9443,https://ctl-2:9443 \
--token-file /etc/wayhouse/gw.token --dir /var/lib/wayhouse/certs \
--interval 60s --reload-cmd "caddy reload --config /etc/caddy/Caddyfile"
Every interval it lists the readable sites and fetches those whose version differs from
<dir>/<site>/meta.json. Files per site: fullchain.pem, privkey.pem (mode 0600) and
meta.json (version, not_after, serial, untrusted). Each file is written to a temp
file and renamed; the chain is replaced first, the key second and meta.json last, so
meta.json names a version only when both files of it are in place. A reader that opens the
chain and the key between the two renames can briefly see a new chain with the old key;
reload after the sync (--reload-cmd), or retry on a mismatch.
- Nothing is rewritten when the version is unchanged and both PEM files are intact; a deleted or truncated file is fetched again. A site is never downgraded: a controller that lists a lower version with an expiry no later than the installed one (a follower that has not applied the newest issuance) is ignored, while a recreated site with a later expiry is installed.
--reload-cmdruns after a change and on the first successful pass after startup,--onceruns included (a crash between the rename and the reload would otherwise leave the proxy on the old certificate). It runs without a shell (the command is split on spaces, so a path with spaces does not work; wrap it in a script), at most 30 seconds, and is retried on the next pass if it fails.- Gateway registration: if a forwarded
POST /acme/gatewaysanswers504, the write may have been applied while the token never reached you. Delete the gateway and register it again. Over plain-http--ha-peersthe freshly minted token crosses the peer link in clear text during the forward, like other forwarded tokens; use https peers. - The private key sits in plaintext in the heap of the serving controller node for the length
of a response, and in the
wayhouse-certsyncprocess while it writes the file. - When no controller answers, the files on disk stay as they are and the sync retries every
interval.
--controllertakes several URLs and tries the one that answered last first. A refused token (401) is logged with the hint to check the token file; nothing is deleted. - Files of a site that is no longer listed are left in place.
untrusted: truemarks a certificate from a staging or test CA; the sync logs a warning.--onceruns a single pass and exits non-zero on failure, for cron or a systemd timer.--ca-filetrusts an extra CA for an https controller. Plain http is accepted only for a loopback controller, or with--allow-insecure-secrets.- There is no push channel yet: changes arrive within one interval.
The synced files can also feed wayhouse’s own native TLS (the controller, aggregator and UI
--tls-cert/--tls-key, and settings.admin.tls): point them at
<dir>/<site>/fullchain.pem and <dir>/<site>/privkey.pem. Those servers re-read the PEM
files every 30 s when their mtime changes, so a renewal needs no --reload-cmd for them (see
12 Deployment, “Native TLS”). Run the sync as a user that can read the key.
The DNS plugin
A site’s dns_plugin is the id of a plugin install that is enabled and approved for the
on_acme_dns trigger (plugins.md); anything else is
refused with 400, and so is any site while the controller runs without --plugins. For each
name of an order the leader asks the plugin to set the TXT record, then looks the record up
itself (the host’s resolver, every 5 seconds, up to the site’s propagation_timeout_secs,
default 120) before it tells the CA to validate, and always asks the plugin to remove it
afterwards, also when the order failed. A plugin that does not answer, traps, refuses or is
disabled mid-order fails that order; the reason is the site’s last_error and the usual
backoff applies. If you disable the plugin or revoke the approval, orders fail with “not
available” until you restore it.
The first lookup comes 5 seconds after the plugin answered, so a caching resolver is not asked
before the provider serves the record. If an order still fails with “not visible to the
resolver”, a caching resolver (Unbound, systemd-resolved) may be holding the answer “does not
exist” for the zone’s negative TTL, up to 30 minutes at Cloudflare, and retries within that time
get the same answer. Flush that resolver’s cache for the name, or point the controller host’s
/etc/resolv.conf at a resolver that does not cache.
The plugin only ever sees the record name and value. ACME keys stay in the controller.
Cloudflare
acme-dns-cloudflare in wayhouse-proxy/plugins
sets and removes the TXT record through the Cloudflare API. Create an API token with
Zone:Read and DNS:Edit on the zones your sites use, upload and install the module with the
capabilities in its caps.json (the on_acme_dns trigger, the http host api.cloudflare.com
and the secret slot CLOUDFLARE_API_TOKEN), set the slot with PUT /plugins/{id}/secrets/CLOUDFLARE_API_TOKEN, and give the site "dns_plugin": "<id>". The
plugin finds the zone by trying the host and its parent names, adds the record with a 60 s TTL
and removes only the record that carries the challenge value. It talks to no other host.
Failure behaviour
- A failed order backs off from 5 minutes up to 6 hours and stops after 5 failures in an hour, so a broken DNS plugin cannot use up the CA’s failed-validation limit. The count and last error are in the site’s status and survive a leader change.
- Renewal starts at a third of the lifetime and not near the end, so there is room for
retries: alert on
wayhouse_acme_cert_expiry_secondsdropping below a third. A gateway whose controller is down keeps serving the files it has (below). - A CA rate limit (
429/rateLimited) pauses the site for the CA’sRetry-After(at most 7 days) instead of the normal backoff when that is longer. - A restart with an expired or nearly expired certificate renews at once.
Metrics
GET /metrics on the current leader (a follower does not issue and emits neither the
expiry nor the held series): wayhouse_acme_cert_expiry_seconds{site} (gauge, seconds
until notAfter, negative once expired), wayhouse_acme_issue_total{result="ok|error"}
(counter) and wayhouse_acme_held (gauge, 1 while this node has no usable secret key).
Security notes
Keys are sealed with XChaCha20-Poly1305 under the cluster keyring, bound to their site and
version, replicated only as ciphertext, and appear in plaintext only in the issuing leader’s
memory. Losing every copy of the keyring loses the sealed keys: certificates are then
reissued (the CA does not care), so keep the key backup the secret-storage design asks for
anyway. Rotation works as for plugin secrets
(superpowers/specs/2026-10-08-plugin-secret-storage-design.md).
Third-party notices
Wayhouse itself is MIT OR Apache-2.0. This page records the third-party software that ships
inside Wayhouse’s own images and the decisions behind it. Rust dependencies are audited in CI (cargo audit); this page is about what is
redistributed as a binary.
Caddy (wayhouse-caddy image)
| Item | Value |
|---|---|
| Software | Caddy 2.11.7, stock build, unmodified |
| License | Apache-2.0 |
| Pinned in | deploy/Dockerfile (version and digest), checked by deploy/check-caddy-image.sh |
| In the image | /licenses/LICENSE-APACHE and /licenses/NOTICE (deploy/caddy/NOTICE) |
Apache-2.0 allows redistribution. What we do to meet its terms: ship the license text, keep the
attribution in the NOTICE, and state changes. We change no Caddy source; the binary is copied
from the upstream image only to drop its file capability. Caddy’s own Go dependencies (BSD, MIT
and similar) are compiled in and are covered by the upstream project’s license files; the image
does not list them one by one. Upstream’s module list is caddy list-modules --versions.
Modules
There are none. The image is stock Caddy; the ACME and DNS-01 work is done by the controller,
not by Caddy plugins. If a module is added later (an xcaddy build --with <module> step in
the Dockerfile), it needs, before it may ship:
- a license that allows redistribution, confirmed from the module’s repository and recorded in
deploy/caddy/MODULES; - an entry in
deploy/caddy/NOTICEwith its version; - a green
deploy/check-caddy-modules.sh, whichmake deploy-lintruns: it fails when a--withmodule has no recognised license or is missing from the NOTICE.
A module without a license grant (as caddy-dns/route53 showed when checked in October 2026)
stays out of the image. caddy-dns/cloudflare is Apache-2.0.
Trademark
Upstream’s README states: “The name “Caddy” is trademarked“, registered to Stack Holdings GmbH, and asks that others call it “Caddy” or, to clarify, “the Caddy web server”. Apache-2.0 grants no trademark rights. The full policy page (caddyserver.com) could not be reached when this was written, so only the README wording is recorded here; re-check the policy page before 0.3.0.
Decisions that follow from the README wording alone:
- Our image is called
wayhouse-caddy, our product name first. The docs say “Caddy” when they mean the upstream software, and describe our image as “Wayhouse’s packaging of Caddy”. - We do not brand our build as “Caddy”, do not use Caddy’s logo, and do not suggest that the Caddy project endorses Wayhouse.
Security review — 2026-10
A time-boxed review of the whole workspace after the repository went public
(2026-10-03). Scope: auth on the admin / controller / aggregator / UI APIs,
token handling, TLS, input parsing, DoS limits, unsafe, and the CI
workflow. Findings are ranked by severity; line numbers are as of the commit
that adds this file.
Severity scale: High = remotely exploitable against a default-ish deployment; Medium = exploitable with a precondition (network position, operator choice, valid credentials); Low = hardening / defence in depth.
Fixed in this PR
| # | Severity | Finding | Where |
|---|---|---|---|
| F1 | Medium | Bearer tokens and the UI password were compared with ==, which returns at the first mismatching byte. An attacker who can reach any authenticated API can, in principle, recover the token byte by byte from response timing. All ten sites now use wayhouse_http::token_eq, a length-checked constant-time comparison (unit-tested). | crates/wayhouse/src/admin.rs:86, crates/wayhouse-controller/src/{auth.rs:34, intent/api.rs:100, peers/api.rs:150, proxy_peers/api.rs:142, addresses/api.rs:57, adopt.rs:80, ha/routes.rs:42}, crates/wayhouse-aggregator/src/auth.rs:35, crates/wayhouse-ui/src/api.rs:217; helper in crates/wayhouse-http/src/lib.rs |
| F2 | Medium | The CI workflow had no permissions: block, so every job got the repository’s default GITHUB_TOKEN scope. No job writes anything, so the workflow now declares permissions: contents: read at the top level. (Fork PRs already get a read-only token; this mainly protects push/schedule/workflow_dispatch runs from a compromised action or dependency.) | .github/workflows/ci.yml:36 |
| F3 | Medium | wayhouse-ui --users-file login rejected an unknown username instantly but ran Argon2 for a known one, so response timing enumerated usernames. An unknown user now verifies against a dummy Argon2 hash first. | crates/wayhouse-ui/src/api.rs:201, crates/wayhouse-ui/src/users.rs (verify_against_dummy) |
Timing attacks over a network are noisy and need many samples, so F1 is Medium by this scale (it needs network position and many samples), though the impact is full admin takeover. It is cheap to fix and every auth path shared the same pattern.
Open findings (not fixed here)
| # | Severity | Finding | Where | Suggested fix |
|---|---|---|---|---|
| O1 | Medium | Every API is open when no token is configured (wayhouse admin, controller, aggregator, UI without --ui-password/--users-file). Defaults bind loopback (127.0.0.1:990x), which is safe, but nothing warns when an operator binds a non-loopback address without a token. | crates/wayhouse-config/src/lib.rs:227, crates/wayhouse-controller/src/auth.rs:24, crates/wayhouse-aggregator/src/auth.rs:25, crates/wayhouse-ui/src/api.rs:193 | Log a warn! at startup (or refuse, behind an --insecure-no-auth flag) when the listen address is not loopback and no token/password is set. |
| O2 | Medium | UI sessions never expire and the store is unbounded. A stolen cookie is valid until the process restarts; in open mode anyone can grow the map with POST /ui/login. | crates/wayhouse-ui/src/session.rs:31-72 | Store a creation/last-seen time, expire after an idle and an absolute TTL, cap the map size, add Max-Age to the cookie. |
| O3 | Medium | No rate limit on POST /ui/login. With --users-file each attempt runs Argon2 (CPU-heavy), so login is both a brute-force target and a cheap CPU DoS. | crates/wayhouse-ui/src/api.rs:196, crates/wayhouse-ui/src/users.rs:66 | Per-IP token bucket on the login route (the data plane already has ratelimit.rs to borrow from); bound concurrent Argon2 verifications with a semaphore. |
| O4 | Medium | Gossip datagrams are HMAC-authenticated but carry no freshness (timestamp/nonce), so an on-path attacker can replay a captured health broadcast. LWW versioning limits the effect to re-asserting already-seen states, but a replayed “down” can briefly flap a backend. | crates/wayhouse-core/src/gossip.rs:362-380 | Include a sender timestamp in the MAC’d payload and drop datagrams older than a window. |
| O5 | Low | The SSE config-subscribe clients buffer until \n\n with no size cap, so a malicious or broken controller can grow memory without bound. The controller is a trusted peer (pinned URL, optional TLS + --ca-file), hence Low. | crates/wayhouse/src/controller_client.rs:149-168, similar in crates/wayhouse-agent/src/proxy_subscribe.rs | Cap the buffer (e.g. 16 MiB) and reconnect when exceeded. |
| O6 | Low | Most third-party actions are referenced by a mutable tag or branch rather than a commit SHA (only taiki-e/install-action and the Trivy image are pinned). With F2 the blast radius is read-only, but a hijacked tag could still exfiltrate cache contents. | .github/workflows/ci.yml (see uses: lines) | Pin to commit SHAs with a version comment; let Dependabot bump them. |
| O7 | Low | A short or guessable token is accepted as-is; there is no minimum length or entropy check on --auth-token, --ha-token, settings.admin.auth_token, or the gossip PSK. | CLI args in each main.rs, crates/wayhouse-config/src/lib.rs:201 | Reject (or warn on) tokens/PSKs shorter than ~32 chars at startup. |
Status as of 2026-10-04 (main at 97cc114, after #35)
Re-checked against current code. None of O1-O7 has been fixed. Line numbers in the table above are from the original review and have drifted; the current ones are below.
| # | Status | Evidence on main |
|---|---|---|
| O1 | Fixed | Fixed. A non-loopback bind with no token is refused at startup (--insecure-no-auth opts out with a warning), via wayhouse_http::policy::check_exposure. Previously: no startup check for a non-loopback bind without a token. Every bearer middleware still passes requests through when no token is set, e.g. crates/wayhouse-controller/src/auth.rs:24-34, crates/wayhouse-aggregator/src/auth.rs:25-35. |
| O2 | Fixed | crates/wayhouse-ui/src/session.rs: sessions expire after an idle timeout (default 30 min, --session-idle-timeout-secs) and an absolute max age (default 12 h, --session-max-age-secs), and the store is capped (default 1000, --max-sessions; oldest evicted). The cookie carries Max-Age. |
| O3 | Fixed | crates/wayhouse-ui/src/login_limit.rs: POST /ui/login is throttled per client address (IPv6 by /64; burst 10, then one per 3 s) and per username (burst 5, then one per 12 s), answering 429 with Retry-After. Argon2 verification runs on the blocking pool behind a 4-permit semaphore. Behind a reverse proxy the per-address bucket sees the proxy’s address, so only the per-username bucket is selective there. At the key cap new keys fail closed, so a wide spray of distinct usernames can briefly 429 a new legitimate key; usernames over 256 bytes are rejected before they are tracked. |
| O4 | Fixed | crates/wayhouse-core/src/gossip.rs: every datagram now carries a sender timestamp inside the MAC, and one more than 30 s from the receiver’s clock is dropped (wayhouse_gossip_stale_rejected_total). A replay inside the 30 s window is still possible, and instances need clocks within 30 s of each other. The wire format changed, so a mixed-version mesh does not converge during a rolling upgrade. Previously: only the payload was MACed. |
| O5 | Fixed | crates/wayhouse-http/src/sse.rs: all seven SSE subscribers (wayhouse, wayhouse-agent, wayhouse-controller, wayhouse-ui) go through EventBuffer, which keeps only the unterminated tail and fails the stream past 16 MiB (MAX_EVENT_BYTES); each client reconnects with backoff. |
| O6 | Fixed | Every uses: in .github/ is pinned to a commit SHA with the tag in a trailing comment, including the stable/nightly toolchain branches of dtolnay/rust-toolchain. Bumps are manual. |
| O7 | Fixed | Fixed for --auth-token, --ha-token, settings.admin.auth_token and the gossip PSK: minimum 16 bytes (wayhouse_http::policy::MIN_SECRET_LEN). --ui-password and outbound tokens are not checked. |
New since the review (HA and IPv6 work, #24-#35)
| # | Severity | Finding | Where | Suggested fix |
|---|---|---|---|---|
| N1 | Medium | The Raft peer endpoints (/raft/append, /raft/vote, /raft/snapshot, /raft/pre-ha, /raft/whoami) and the membership API (/admin/ha/members, add/remove/re-address) are open when --ha-token / the admin token is absent, the same pass-through as O1 but with more impact: an unauthenticated peer could append log entries (rewrite replicated config, registries and addresses), install a snapshot, or change cluster membership. Fixed: --ha-peers/--ha-join now refuse to start without --ha-token (ha::check_ha_token), and the O1 check covers the members API on a non-loopback bind. Previously nothing refused it. | crates/wayhouse-controller/src/ha/routes.rs:41-45 (ha_token), crates/wayhouse-controller/src/ha/members.rs:92-96 (auth_token), crates/wayhouse-controller/src/main.rs:94,303 | Refuse to start with --ha-peers/--ha-join unless --ha-token is set (and the admin token for the members API when the listen address is non-loopback). Fold into the O1 fix. |
| N2 | Low | /raft/* accepts bodies up to 32 MiB (MAX_RAFT_BODY), well above axum’s 2 MiB default. Needed for snapshot chunks and pre-HA imports, and the routes are token-gated, so it is only reachable by a token holder, or by anyone when N1 applies. | crates/wayhouse-controller/src/ha/routes.rs:21-33 | Keep, but raise N1 first; consider a smaller limit on /raft/vote and /raft/append. Fixed (limits are per route now): /raft/vote 64 KiB, /raft/snapshot 4 MiB (its chunks are 256 KiB), /raft/append stays 32 MiB because a pre-HA import travels as one log entry. |
| N3 | Low | Raft traffic carries the bearer token and all replicated state. It is encrypted only if the peer URLs are https://; plain http:// peers send the token in clear. | crates/wayhouse-controller/src/ha/network.rs:27,64 | Document that HA peers should use https:// and --ca-file, or warn on an http:// peer when a token is set. Fixed (warning): --ha-peers, PUT/POST /admin/ha/members log a warning for any non-loopback plain-http peer. It does not refuse, so existing deployments keep starting; loopback peers are exempt. |
The other HA and IPv6 additions (/tunnel/addresses, intent, registry and adopt
routes) all use wayhouse_http::token_eq bearer checks (see the token_eq call sites
in crates/wayhouse-controller/src/{addresses/api.rs:75, intent/api.rs:114, registry.rs:344, adopt.rs:80}), so F1 still holds. Not re-audited beyond that.
Aggregator token split and fan-out target (2026-10-05)
| # | Severity | Finding | Status |
|---|---|---|---|
| A1 | High | /ingest and /fleet/* (drain, undrain, backend edits, route-hint, sniffer upload/delete) shared one --auth-token, so every edge wayhouse that pushes telemetry held a credential that could also drive every other instance (#102). | Fixed: --ingest-token unlocks only POST /ingest, --auth-token gates /fleet/*, and the aggregator refuses to start with --auth-token but no distinct --ingest-token. |
| A2 | High | The fan-out called each instance’s self-reported admin_url with the fleet-wide --instance-token, so a pusher could name a host it controls and collect that token on the next operator call (#102). | Fixed: the URL is validated at ingest (own source address by default, or --instance-url-allow); a refused push is a 400. The fan-out client no longer follows redirects. |
| A3 | Medium | Decoded path parameters (pool, addr, sniffer name) were pasted into the instance admin URL, so an encoded ..%2F or %3F made one verb call another admin path with the instance token (#111). | Fixed: segments are validated (no empty, ., .., /, \, ?, #) and pushed through Url::path_segments_mut; the sniffer name goes through query_pairs_mut. |
Residual: a holder of the aggregator’s --auth-token can still drive the whole
fleet (one shared token, no per-verb roles), and --ingest-token is shared by
every pusher, so one compromised edge can overwrite another instance’s reported
state (a pushed instance name is not bound to a credential).
Checked and fine
- TLS (
crates/wayhouse-http/src/tls.rs): rustls only, no custom verifiers, nodanger_accept_invalid_certs;--ca-fileadds roots on top of the Mozilla set instead of replacing them. Cert/key are hot-reloaded. - PROXY protocol: the proxy only writes v1/v2 headers
(
crates/wayhouse-core/src/proxy_protocol.rs); it never parses untrusted ones, so there is no inbound parser to attack. - HTTP bodies: admin/controller handlers use axum’s
Jsonextractor, which enforces axum’s default 2 MiB body limit. Theto_bytes(.., usize::MAX)calls are all in tests. - Config parsing: YAML from a local file or the authenticated
controller,
deny_unknown_fields, validated into a resolvedConfig; cargo-fuzz harnesses exist forparse_config,extract_sniand route matching. - WASM sniffers (
crates/wayhouse/src/sniffer_loader.rs): wasmtime with epoch-interruption timeouts and a per-callStoreLimitsmemory cap. unsafe: only in the sniffer guest crates (crates/wayhouse-sniffer-abiand the sniffers inwayhouse-proxy/sniffers; the wasm ABI’sallocand raw input slices), which run inside the wasmtime sandbox. None in the host binaries.- Session ids: 256 bits from
thread_rng; cookie isHttpOnly,SameSite=Lax, andSecurewhen the UI serves HTTPS itself. - UI passwords (
--users-file): Argon2 PHC hashes. - Tunnel private key: written with mode
0600(crates/wayhouse/src/tunnel_client.rs:48). - Address book: refuses loopback, multicast, broadcast and unspecified
addresses (
crates/wayhouse-controller/src/addresses.rs:362). - CI: no
pull_request_target, nosecrets.*, untrusted refs passed to scripts viaenv:rather than inline${{ }}interpolation. - Secrets in history: scanned on 2026-10-03 (gitleaks + trufflehog), clean apart from the intentional test fixtures.