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 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.1The 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 things worth naming separately:
| Piece | File | What it is |
|---|---|---|
| The HTTP server | agent/main.go routes() | 108 route registrations, each wrapped in the role it needs |
| The staging area | agent/staging.go | The working set: set and delete operations held in memory until something commits them |
| The read cache and primer | agent/cache.go, agent/primer.go | What stops every page waiting on the router |
| The store | agent/store.go | state.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.
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:
Handler: recoverPanics(securityHeaders(observe(limitBodies(routes()))))recoverPanicsturns a panic in one handler into a 500 for that request instead of a dead router control plane.securityHeaderssets the response header set on everything.observerecords the Prometheus request metrics, labelled by route area rather than by full path, so a path parameter cannot explode the label cardinality —agent/security.gorouteLabel.limitBodiescaps 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:
| Wrapper | Defined in | Effect |
|---|---|---|
readOnly | agent/authhttp.go | Requires an authenticated principal of at least viewer |
writeable | agent/authhttp.go | At least operator |
adminOnly | agent/authhttp.go | admin |
requireLicense | agent/license.go | Answers 402 with the reason when the agent holds no valid licence |
requireFeature("fleet", …) | agent/license.go | Licence, and a plan whose payload carries that feature |
redactSecrets(RoleAdmin, …) | agent/security.go | Blanks private keys, pre-shared secrets and password hashes for anything below the named role |
The write plane is composed once and reused:
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:
| Endpoint | Used for |
|---|---|
/retrieve | showConfig — read a configuration subtree, optionally configFormat: raw |
/show | op-mode show commands, which answer with a plain string |
/configure | set, delete, commit, commit-confirm, confirm |
/config-file | load and save |
/image | image 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:
| Mode | What runs where | Used for |
|---|---|---|
| On-router | The agent and the UI are in the image, --api-url https://127.0.0.1 | The product |
| Off-router | The agent runs on another host and points at a VyOS API over the network | Development, and the path install/install.sh sets up |
| Fleet | One agent holds a JSON file of routers and reads their health, config and versions concurrently | agent/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.jsonlives under the data directory (/config/wheelhouseon an appliance) because/configis the VyOS partition that survives an image upgrade.flushwrites it atomically and durably — write,fsyncthe file, rename,fsyncthe directory — keeping the previous generation asstate.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.tsis written by hand against the same contract, and the endpoint table indocs/deploy.mdis 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-fileis set.
See also#
- The repository, directory by directory — where each of these pieces lives on disk.
- Building the agent — the source map, file by file.
- Adding an endpoint — the wrappers above, applied in practice.
- Architecture decision records — why the boundary is where it is.
- The configuration tree · Staging: the working set · Read plane, write plane, admin plane — the same three ideas, from the operator's side.
- What the agent asks the router — the call budget the cache and the primer exist to keep.
- On-router, off-router, fleet — the deployment table above, in operator terms.
- Endpoint index · Files and directories — what this page summarises, enumerated.
- About Wheelhouse — the licence boundary stated for a customer rather than a contributor.
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