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, and it is also why the whole product can be understood by reading one Go package and one React tree.

The pieces of a Wheelhouse router: browser, script and Prometheus reaching the agent on port 8443; the agent holding the HTTP server, staging area, read cache and state file, and serving the built UI; below it the VyOS base reached only over its HTTP API on 127.0.0.1

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 /procpackaging/wheelhouse-agent.service.

It holds four things worth naming separately:

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). Each was a deliberate decision, and two of them are argued in architecture decision records. The Prometheus exposition is hand-written rather than imported for the same reason.

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.

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. It deliberately 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 bought nothing and cost an idle router a steady load. The calls are deliberately not staggered either, because the router serialises them.

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).
  • There is no OpenAPI document and no generated client. The TypeScript client in ui/src/lib/api.ts is written by hand against the same contract, and the endpoint table in docs/deploy.md is what stands in for a specification.
  • 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#

Checked against#

agent/main.go · agent/vyos.go · agent/cache.go · agent/primer.go · agent/store.go · agent/staging.go · agent/security.go · agent/authhttp.go · agent/license.go · agent/fleet.go · agent/catalog.go · agent/go.mod · packaging/firstboot.sh · packaging/wheelhouse-agent.service · docs/deploy.md · PLAN.md

Updated 2026-09-02 architecture agent vyos boundary