Wheelhouse docs

Building the agent#

wheelhouse-agent is one Go main package with no sub-packages, no code generation and five direct dependencies. Building it is a single go build; the release adds -trimpath, strips the symbol table, and stamps the version through the linker. This page gives the exact commands CI runs, explains the two variables that are set at link time, describes the three ways the binary can be invoked, and maps every source file to what it is responsible for.

The build#

bash
cd agent
go build -o wheelhouse-agent .

That is enough for development. The release build, from .forgejo/workflows/ci.yml, is:

bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
  -ldflags="-s -w -X main.version=1.2.3" -o wheelhouse-agent-amd64 .
PartWhy
CGO_ENABLED=0A static binary that runs on any glibc, including the image's
-trimpathKeeps the build host's directory names out of the binary
-s -wDrops the symbol table and DWARF; the binary is smaller and no less debuggable in the ways that matter here
-X main.version=…Stamps the version. Without it the binary reports the constant in agent/main.go

CI builds amd64 and arm64 from the same source in a loop. There is no arm64 image — the arm64 package exists so the agent can run off-router against an arm64 router.

main.version defaults to the string in agent/main.go. It is what wheelhouse-agent version prints, what GET /api/version reports, and what the release process is expected to keep in step with the tag.

main.licensePublicKeys is kid:base64[,kid:base64] — the licence server's public keys, by key id, compiled in so a router with no route to the internet can still verify what it holds. The default is the production server's public key, which is public and therefore fine in source (agent/license.go). A development build can override it:

bash
go build -ldflags "-X main.licensePublicKeys=<kid>:<base64>" -o wheelhouse-agent .

An empty value means no key can verify anything, so every licence is refused. That is the safe failure: a build without keys is a build that cannot be licensed, not one licensed for free.

What the binary can be invoked as#

registerFlags returns a subcommand, and main switches on it. Flags may sit on either side of the subcommand — splitSubcommand decides whether a bare token is a flag value or the command itself by consulting the flag table rather than guessing.

InvocationWhat it does
wheelhouse-agent (no subcommand)runDaemon(): opens the store, bootstraps the first admin, loads the catalogue, starts the listener and the background goroutines
wheelhouse-agent versionPrints the version and exits, before any config is read or any secret file is opened
wheelhouse-agent planPrints the operations that would bring the router to the desired-state file, and changes nothing. Exit 2 when it found work
wheelhouse-agent applyStages those operations through a running agent named by --agent-url; --commit --confirm-minutes N commits them instead

Three refusals in runPlanApply (agent/desired.go) are worth knowing before you meet them:

  • apply without --agent-url refuses. The staging area is the running agent's memory, so a one-shot process that filled its own copy would report success and lose the work on exit (ADR-001).
  • --commit without --confirm-minutes N refuses, in as many words: a mistake should be revertible.
  • apply needs a usable licence, given as --license-key-file because a one-shot command has no store to read one from. plan works without one — it is a read.

There are 51 flags in total. Count them with:

bash
grep -c 'flag\.\(String\|Bool\|Int\|Duration\)Var' agent/main.go

The source map#

Everything is package main in agent/. Open the file whose subject matches; there is no layer to trace through first.

The router side#

FileResponsibility
vyos.goThe client. Form and JSON posts to /retrieve, /show, /configure, /config-file, /image; per-endpoint timeout tiers; turning a 200 carrying an error field into a real error
opmode.goThe op-mode show reads the product depends on, wrapped once each
parse.goVyOS answers op-mode calls with fixed-width text tables; this is what turns them into structures
cache.goThe read-through cache, its two freshness classes, and the generation counter that keeps a slow fetch from re-poisoning it
primer.goThe warm-up: at start, and after every commit

The configuration plane#

FileResponsibility
staging.goThe working set of set and delete operations
desired.goThe desired-state document: format sniffing, flattening, diffing against the tree
reconcile.goThe optional loop that keeps the router aligned with that file
parity.goPower, images, and the other operations that are not configuration changes and so are not staged

The features#

FileResponsibility
wan.goUplinks. VyOS has no gateway object, so an uplink is whatever carries a default route, and there are five ways to get one
ids.goSuricata, driven entirely through service suricata so it diffs, commits and rolls back
gaps.goVRRP and conntrack sync, including what to report when keepalived is not running
apps.goApp lifecycle: install planning, health, updates, the two-commit restart
catalog.goThe catalogue types, the embedded catalog.json, install planning and hint expansion
fleet.goThe router list and its concurrent reads

Identity, safety and observation#

FileResponsibility
auth.goArgon2id, TOTP, tokens, roles
authhttp.goLogin, sessions, CSRF, and readOnly / writeable / adminOnly
admin.goUser and token administration, and the refusals that stop an admin leaving the router with no way in
oidc.goSingle sign-on as a relying party (ADR-002)
license.goToken verification, the daily refresh, and the write-plane gate
security.goBody limits, secret redaction, rate limiting, response headers, request metrics
store.gostate.json, its backup generation, and the append-only audit.jsonl that rotates beside it
tls.goCertificates, including generating a self-signed one into the data directory
metrics.goA hand-written Prometheus exposition, so the agent keeps its short dependency list
lock_unix.go / lock_other.goAn advisory lock on the data directory, built per platform

What runs beside the listener#

runDaemon starts four goroutines and no more:

GoroutineSourceAlways on?
Interface history samplerhistory.Run in agent/main.goYes
Licence refresherrunLicenseRefresher in agent/license.goYes; does nothing when --license-server is empty
Cache primerrunPrimer in agent/primer.goUnless --demo
Reconcile loopreconciles.Run in agent/reconcile.goOnly with --reconcile-file

On SIGTERM the server drains in-flight requests for up to 15 seconds rather than cutting an operator off mid-commit, and the unit gives it 20 (TimeoutStopSec=20).

Things that will surprise you once#

  • /health is unauthenticated and carries only {"status":"ok"}. The version used to be there; handing an anonymous caller the exact build to look up was closed deliberately. GET /api/version answers for anyone signed in, and wheelhouse-agent version answers on the box.
  • A secret file that is group- or world-readable is refused. Modes are checked, not assumed.
  • A second agent against the same data directory will not start. The advisory lock is what makes apply safe next to a daemon.
  • Two agents, one router, is not a supported shape. Nothing enforces it; the staging area is per-process.

See also#

Checked against#

agent/main.go · agent/license.go · agent/vyos.go · agent/cache.go · agent/primer.go · agent/staging.go · agent/desired.go · agent/reconcile.go · agent/store.go · agent/security.go · agent/lock_unix.go · agent/go.mod · packaging/wheelhouse-agent.service · .forgejo/workflows/ci.yml

Updated 2026-09-02 development agent go build