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:
python3 -c "import json;a=json.load(open('agent/catalog.json'))['apps'];print(len(a))"The file#
{
"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#
| Field | Required | Meaning |
|---|---|---|
id | yes | Stable, unique. loadCatalog refuses a duplicate |
name | yes | What the store shows |
category | yes | Must be one of the declared categories |
blurb | yes | One line: what it is |
description | yes | Why you would put it on a router specifically |
homepage | no | Upstream's page |
tags | no | Search terms |
kind | no | container (the default) or builtin |
closes | no | Names a documented VyOS platform gap this entry fills |
notes | no | The things that will bite the operator, in their own words |
Deployment shape#
| Field | Meaning |
|---|---|
image | Fully qualified and tagged: docker.io/adguard/adguardhome:latest |
host_network | Runs 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?} |
capability | Linux capabilities to add |
privileged | Warns, loudly, in the install plan |
memory | Stages set container name X memory … |
Integration — what the UI does with it#
| Field | Effect |
|---|---|
nav_label, nav_icon | The 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 |
dashboard | Show it on the Dashboard |
hints | Offers 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.
{
"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:
| Placeholder | Filled 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.
{
"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
pageandconfig_path, and must not carry animage.TestCatalogLoadsAndIsCoherentfails either way round.
What the tests refuse#
Run them before you ship an entry:
cd agent && go test ./...TestCatalogLoadsAndIsCoherent in agent/catalog_test.go
walks every entry and rejects:
| Refused | Why |
|---|---|
A missing id, name, blurb or description | The store would show a blank card |
A category not in the declared list | It would sort into nowhere |
An image with no / or no . in the host | It would pull from an implicit registry |
| An image with no tag | The 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.io | An unexpected registry is a review decision, not a typo |
| A volume with a missing field, or a relative destination | VyOS refuses it at commit |
An environment variable that is required and has a default value | It would silently ship someone else's password |
A web_ui with no port | The Open link would go nowhere |
An incomplete hint, or a hint operation that is not set or delete | The Commit Bar renders only those two |
Verify the image exists#
The shape tests do not touch the network. scripts/check-images.py
does:
# 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 --offlineThe 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#
- Building the agent —
catalog.jsonis compiled in, so a catalogue change is an agent build. - Adding a page to the web UI — how
feature:gating reaches the nav. - The test suites — where the catalogue tests sit in the whole.
- What CI does — the offline image check runs on every push.
- Apps are configuration — the idea an entry has to fit.
- Apps · Integration hints · App images — the screens an entry appears on, and what an operator sees of the pinning problem.
- Catalogue schema · The catalogue · Hint placeholders — every field, and every entry, enumerated.
- Ports and listeners — what a new entry's ports have to avoid.
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