Wheelhouse docs

Architecture#

Wheelhouse is three things stacked: a VyOS base that does the routing, a Go binary called wheelhouse-agent that drives it, and a React application the agent serves. The boundary between the second and the first is an HTTP API on loopback and nothing else — the agent links against no VyOS code, copies none, and reaches the router only by posting to endpoints VyOS documents. That boundary is what makes the agent and the UI proprietary programs beside GPL ones.

The three layers of a Wheelhouse router: clients on port 8443, the agent, and the VyOS base reached over its own HTTP API on loopback

The three layers#

The base#

VyOS, self-built from vyos-build and rebranded per upstream's trademark policy. It supplies the kernel, Debian, the configuration tree with its candidate/running split and its 100 archived revisions, and every daemon that moves a packet: FRR, Kea, pdns-recursor, nftables, conntrack, WireGuard, Suricata, podman and tc. Wheelhouse adds none of that and claims none of it.

The one interface into it is vyos-http-api. First boot mints an API key, turns the REST endpoints on, and binds the listener to 127.0.0.1 so nothing off the box can reach it — packaging/firstboot.sh:

set service https api keys id wheelhouse key "<generated>"
set service https api rest
set service https listen-address 127.0.0.1

The agent#

One static Go binary, main package, no plugins, no code generation. It is run by systemd as root with every secret passed as a file path rather than a flag value, because ExecStart is visible to every user on the box through /proc — packaging/wheelhouse-agent.service.

It holds four pieces:

PieceFileWhat it is
The HTTP serveragent/main.go routes()108 route registrations, each wrapped in the role it needs
The staging areaagent/staging.goThe working set: set and delete operations held in memory until something commits them
The read cache and primeragent/cache.go, agent/primer.goWhat stops every page waiting on the router
The storeagent/store.gostate.json under the data directory — accounts, sessions, API tokens, settings, the licence — with audit.jsonl beside it and state.json.bak as the previous generation

Five direct dependencies beyond the standard library — gorilla/websocket, golang.org/x/crypto, gopkg.in/yaml.v3, coreos/go-oidc/v3 and golang.org/x/oauth2, with three indirect ones behind them (agent/go.mod). Two of them are argued in architecture decision records. The Prometheus exposition is hand-written rather than imported.

The web UI#

A React + TypeScript single-page application built by Vite. It is not embedded in the binary: the agent serves whatever directory --ui-dir names, and the package installs the built assets at /usr/share/wheelhouse/ui. The only thing embedded in the binary is the app catalogue, //go:embed catalog.json in agent/catalog.go.

go
if cfg.UIDir != "" {
    mux.Handle("GET /", spaHandler{fileServer: http.FileServer(http.Dir(cfg.UIDir)), uiDir: cfg.UIDir})
}

An agent started without --ui-dir is an API and nothing else, which is the normal shape in development.

The path a request takes#

Every request through the agent passes the same four wrappers, applied in agent/main.go runDaemon:

go
Handler: recoverPanics(securityHeaders(observe(limitBodies(routes()))))
  • recoverPanics turns a panic in one handler into a 500 for that request instead of a dead router control plane.
  • securityHeaders sets the response header set on everything.
  • observe records the Prometheus request metrics, labelled by route area rather than by full path, so a path parameter cannot explode the label cardinality — agent/security.go routeLabel.
  • limitBodies caps the request body before the mux picks a handler, so nothing below it may assume a body is small.

Then the route's own wrappers run. There are five, and a route's line in routes() is a complete statement of who may call it:

WrapperDefined inEffect
readOnlyagent/authhttp.goRequires an authenticated principal of at least viewer
writeableagent/authhttp.goAt least operator
adminOnlyagent/authhttp.goadmin
requireLicenseagent/license.goAnswers 402 with the reason when the agent holds no valid licence
requireFeature("fleet", …)agent/license.goLicence, and a plan whose payload carries that feature
redactSecrets(RoleAdmin, …)agent/security.goBlanks private keys, pre-shared secrets and password hashes for anything below the named role

The write plane is composed once and reused:

go
licensed := func(h http.HandlerFunc) http.HandlerFunc { return writeable(requireLicense(h)) }

Reading never needs a licence. That is a product decision with a one-line implementation, recorded in ADR-003.

Talking to the router#

agent/vyos.go is the whole of the router client. It posts form-encoded or JSON bodies to five endpoints:

EndpointUsed for
/retrieveshowConfig — read a configuration subtree, optionally configFormat: raw
/showop-mode show commands, which answer with a plain string
/configureset, delete, commit, commit-confirm, confirm
/config-fileload and save
/imageimage add, delete and default

Timeouts are tiered by endpoint rather than global: /configure and /config-file get the long tier because a commit takes as long as it takes, and /container-image, /image, /traceroute, /reboot and /poweroff get their own — clientFor in agent/vyos.go.

Why there is a cache at all#

Every call into the VyOS HTTP API forks cli-shell-api on the router, and the router serialises its API: seven parallel reads take as long as seven sequential ones, and each one is 150–800 ms. Both facts are recorded in the comment at the top of agent/cache.go, which is also where the design that follows from them lives:

  • Configuration reads change only when someone commits, and the agent is the thing that commits, so they are cached until a mutation invalidates them. That is correctness, not just speed.
  • Operational reads drift constantly, so they carry a short TTL.
  • Both are served stale-while-revalidate: a stale hit returns immediately and starts one background refresh. A key already refreshing is not refreshed again.
  • A generation counter guards the race where a fetch that started before an invalidation completes after it, which would otherwise store a pre-commit answer with a fresh timestamp and serve stale configuration for the whole window. The entry is left stale instead.
  • That background refresh is detached from the request that noticed it, with a 30-second ceiling of its own, so a browser that navigates away cannot cancel the read that would have kept the next page fast.

agent/primer.go warms the cache at start-up and again after a commit, over a fixed list of configuration subtrees and op-mode reads. A 10-second ticker re-checks configuration only, so a commit's invalidation is repaired quickly and every other pass is a map lookup. It does not refresh op-mode reads on a timer: a stale entry is served at once and refreshed in the background anyway, so a periodic refresh would only cost an idle router a steady load. The calls are not staggered either — the router serialises them.

Warm reads answer in well under a millisecond, which is why the UI's loading indicator renders nothing for its first 400 ms (ui/src/components/ui.tsx): a warm response paints the page before the indicator would appear, so it shows only when something is genuinely slow.

Deployment modes#

PLAN.md §5 names three, and all three exist:

ModeWhat runs whereUsed for
On-routerThe agent and the UI are in the image, --api-url https://127.0.0.1The product
Off-routerThe agent runs on another host and points at a VyOS API over the networkDevelopment, and the path install/install.sh sets up
FleetOne agent holds a JSON file of routers and reads their health, config and versions concurrentlyagent/fleet.go, gated on a licence feature

What is not in this picture#

  • There is no database. State is one JSON file, not SQLite, whatever PLAN.md §5 sketched. state.json lives under the data directory (/config/wheelhouse on an appliance) because /config is the VyOS partition that survives an image upgrade. flush writes it atomically and durably — write, fsync the file, rename, fsync the directory — keeping the previous generation as state.json.bak, so at every instant at least one complete state file exists. Without the syncs, a power cut can order the rename ahead of the data and the router comes back with a truncated state file: no accounts, no tokens, no way in (agent/store.go).
  • The API description is generated; the client is not. agent/openapi.go holds one routeDoc per route and generates both GET /api/openapi.json and docs/api-endpoints.md, with a test that fails when a declared role disagrees with the wrapper routes() registered. The TypeScript client in ui/src/lib/api.ts is still written by hand against it — there is no generator.
  • There is no message bus, job queue or worker pool. Three goroutines run beside the server: the interface history sampler, the licence refresher and the primer, plus the reconcile loop when --reconcile-file is set.

See also#

Updated 2026-09-02 architecture agent vyos boundary