Wheelhouse docs

Adding an app to the catalogue#

The app catalogue is one JSON file, agent/catalog.json, embedded in the agent binary with //go:embed. Adding an entry is a data change: no Go code, no UI code, no new page. What the entry declares is what installing it will stage into the router's configuration tree, what the UI will do with it once it is running, and what the operator should be warned about first. A test suite refuses most of the ways an entry can be wrong, and a separate script checks that the image you named actually exists.

What a catalogue entry is#

An app is a container declared in the router's configuration tree. Installing one stages set container name <name> … operations into the Commit Bar, so an install is a diff you review, a commit you can roll back, and state that survives an image upgrade. There is no separate package manager and no control socket.

Today there are 38 entries: 36 containers and 2 built-in feature modules. Re-count them:

bash
python3 -c "import json;a=json.load(open('agent/catalog.json'))['apps'];print(len(a))"

The file#

agent/catalog.json (structure)
{
  "version": "1",
  "network": "apps",
  "prefix": "10.99.0.0/24",
  "categories": ["DNS & filtering", "VPN & remote access", "Monitoring",
                 "Security", "Network services", "Management"],
  "apps": [ … ]
}

network and prefix are the container network every bridged app joins; planInstall stages set container network apps prefix 10.99.0.0/24 first when that network does not exist yet. categories is the display order — entries are sorted by declared category order and then case-insensitively by name, so dnsmasq does not sort after Unbound and look like a bug.

Every field an entry may declare#

The Go types are in agent/catalog.go; this is the same list in the order you will fill it in.

Identity#

FieldRequiredMeaning
idyesStable, unique. loadCatalog refuses a duplicate
nameyesWhat the store shows
categoryyesMust be one of the declared categories
blurbyesOne line: what it is
descriptionyesWhy you would put it on a router specifically
homepagenoUpstream's page
tagsnoSearch terms
kindnocontainer (the default) or builtin
closesnoNames a documented VyOS platform gap this entry fills
notesnoThe things that will bite the operator, in their own words

Deployment shape#

FieldMeaning
imageFully qualified and tagged: docker.io/adguard/adguardhome:latest
host_networkRuns on the host network; stages allow-host-networks and warns
ports{name, container, host?, protocol?, description?} — documentation, plus what web_ui and health refer to
volumes{name, source, destination, description?, read_only?, file?}. file: true marks a single-file bind mount, so preparation creates the file rather than a directory of that name
environment{name, value?, description?, required?, secret?}
capabilityLinux capabilities to add
privilegedWarns, loudly, in the install plan
memoryStages set container name X memory …

Integration — what the UI does with it#

FieldEffect
nav_label, nav_iconThe app gets its own left-nav entry once installed
web_ui{port, scheme?, path?} — an Open link built from the container's real address
health{port, path?} — a TCP connect, plus an HTTP request when a path is given
dashboardShow it on the Dashboard
hintsOffers surfaced on other pages. See below

Hints, and their placeholders#

A hint is an offer that appears on another Wheelhouse page once the app is running: install AdGuard Home, and the DNS page grows a card saying what to do and carrying the exact operations that would do it. The operator still commits them.

json
{
  "page": "dns",
  "title": "Point the resolver at AdGuard Home",
  "body": "…",
  "ops": [{ "op": "set", "path": ["service", "dns", "forwarding", "name-server", "{{address}}"] }]
}

Four placeholders are resolved from the router the hint is offered on:

PlaceholderFilled from
{{address}}The app's container address, once it has one
{{dhcp.network}}The router's first DHCP shared-network name
{{dhcp.subnet}}That scope's first subnet
{{dns.domain}}system domain-name, else the router's first authoritative zone

A router that cannot fill one keeps the advice and loses the operations, and the card says what is missing. The catalogue itself must never carry a site-specific value: an operation that named one lab's LAN 10.0.0.0/16 was staged, verbatim, on every router the catalogue shipped to. TestCatalogHintOpsCarryNoSiteSpecificValues in agent/catalog_test.go is what stops that recurring, and TestCatalogHintPagesAreReal refuses a hint aimed at a page that does not exist.

Built-in feature modules#

"kind": "builtin" is not a container at all. It is a native VyOS feature whose Wheelhouse page is gated by installation — the OPNsense model, where os-wireguard was a plugin UI over a kernel feature. WireGuard and Suricata IDS are the two that exist.

json
{
  "id": "wireguard",
  "kind": "builtin",
  "builtin": { "page": "wireguard", "config_path": ["interfaces", "wireguard"] }
}

The rules, from docs/apps.md and enforced by the tests:

  • Installing is instant and stages nothing. There is no image and no commit; the flag lives in the agent's own state, because it is a UI-surface preference rather than router behaviour.
  • Configuration outranks the flag. If the feature is configured on the router — by the CLI, by another operator, by anything — the page shows regardless. Wheelhouse never hides configuration that exists.
  • A builtin must declare both page and config_path, and must not carry an image. TestCatalogLoadsAndIsCoherent fails either way round.

What the tests refuse#

Run them before you ship an entry:

bash
cd agent && go test ./...

TestCatalogLoadsAndIsCoherent in agent/catalog_test.go walks every entry and rejects:

RefusedWhy
A missing id, name, blurb or descriptionThe store would show a blank card
A category not in the declared listIt would sort into nowhere
An image with no / or no . in the hostIt would pull from an implicit registry
An image with no tagThe reference is not reproducible even by name
An image naming two registries (docker.io/ghcr.io/…)A real mistake that fails only at install time
An image whose host is not docker.io, ghcr.io, lscr.io or quay.ioAn unexpected registry is a review decision, not a typo
A volume with a missing field, or a relative destinationVyOS refuses it at commit
An environment variable that is required and has a default valueIt would silently ship someone else's password
A web_ui with no portThe Open link would go nowhere
An incomplete hint, or a hint operation that is not set or deleteThe Commit Bar renders only those two

Verify the image exists#

The shape tests do not touch the network. scripts/check-images.py does:

bash
# Online: speaks the registry token protocol anonymously and HEADs each manifest.
python3 scripts/check-images.py agent/catalog.json

# What CI runs on every push: shape only, no network, so it cannot flake.
python3 scripts/check-images.py agent/catalog.json --offline

The online run is how three broken references — including a miniupnpd image that did not exist — were caught before anyone's install failed.

Writing the copy#

The three text fields have distinct jobs, and the difference is the whole value of the catalogue:

  • blurb — one line about what it is.
  • description — why you would put it on a router rather than on a server.
  • notes — what will bite the operator: a port 53 conflict, host networking, what it costs on a small box.

If an app needs a credential, mark it required and secret and leave value empty. The tests enforce that; the UI masks it.

Two things you cannot do#

See also#

Checked against#

agent/catalog.json · agent/catalog.go · agent/catalog_test.go · agent/apps.go · scripts/check-images.py · docs/apps.md · .forgejo/workflows/ci.yml

Updated 2026-09-02 development apps catalogue containers