Wheelhouse

How it works#

Four steps. This page is the demo, written out, with the calls the interface actually makes shown beside each one.

Click, commands, staged diff, commit — and the confirm window that reboots the router back

1. Click#

You open Security → NAT, press Add forward, and a side panel slides in over the table. The table stays readable behind it; nothing in Wheelhouse uses a modal for real work.

The panel is a form: inbound interface, protocol, port, the address to translate to, a description. Two tick boxes offer the things people forget — the firewall rule that lets the forward through, and the hairpin pair for clients on the LAN who use the public address.

2. Read the commands#

Underneath the form, updating as you type, is the block the panel will stage. Not a summary of it. The commands.

What the panel will run
set nat destination rule 100 inbound-interface name eth0
set nat destination rule 100 protocol tcp
set nat destination rule 100 destination port 443
set nat destination rule 100 translation address 10.0.10.20
set nat destination rule 100 description 'web server'
set firewall ipv4 forward filter rule 100 action accept
set firewall ipv4 forward filter rule 100 inbound-interface name eth0
set firewall ipv4 forward filter rule 100 destination address 10.0.10.20
set firewall ipv4 forward filter rule 100 description 'allow web server'

There is a copy button. The spelling is the CLI's, quoting included, so the block pastes into a configure session on the console and produces the same change. That is not a coincidence — the renderer in the browser and the renderer in the agent are held to the same rule, and the agent's has a test that says why: a double quote left bare is two shell words, so description WAN "primary" previewed one way and committed another would be a lie in the one place the product cannot afford one (agent/staging.go, ui/src/lib/format.ts).

Every configuration node in the tree renders the same way, whether or not you are editing it. System → Config tree will show you any subtree of the running configuration as the commands that would recreate it.

3. Stage#

Pressing Stage sends the operations to the agent and nothing else happens. No commit, no partial apply, no daemon reload.

bash
curl -sk -X POST "$R/api/stage" \
  -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
  -d '[{"op":"set","path":["nat","destination","rule","100","protocol","tcp"]}]'
json
{"staged": 1,
 "ops": [{"op": "set", "path": ["nat","destination","rule","100","protocol","tcp"]}],
 "commands": ["set nat destination rule 100 protocol tcp"]}

Three things about that response are deliberate.

The working set lives on the agent, not in your browser. So a second tab sees it, and so does a colleague signed in from another machine. So does wheelhouse-agent apply --agent-url … and the optional reconcile loop. Every call that changes the queue hands the whole queue back, because a client that only appended locally could un-stage by an index that meant something different on the agent.

Only set and delete are accepted. An operation the Commit Bar could not spell honestly is refused at the door, with the reason, rather than at commit time with an error that does not name the request.

An empty path element is refused too. description '' would otherwise fail much later, in the router's voice, about something you would have to work backwards to find.

4. Commit#

The Commit Bar offers two buttons and they do different things.

Commit sends the queue to VyOS' /configure endpoint as one transaction. Either all of it lands or none of it does.

Commit-confirm (2m) sends the same transaction with a confirm_time, which arms the router's own mechanism:

json
{"success": true, "data": "\nInitialized commit-confirm; 2 minutes to confirm before reboot\n"}

That is the router talking. If nobody confirms inside the window, the router reboots into the previous configuration. Not a silent revert — a reboot. It is the safety property, and it is worth stating in those words wherever the button is offered, which is why the Commit Bar's countdown says it out loud.

Confirming is one more call, and the router answers:

json
{"success": true, "data": "Reboot timer stopped\n"}

And then#

Every commit is archived. The commit history is VyOS' revision list: revision 0 is the running configuration, revision 1 is the commit before it. The interface shows what a revision changed before you take it, and rolling back loads that archived configuration the same way the CLI command does.

Every commit is audited. The audit log records the actor, their role, their source address, the exact commands and whether the router accepted them. A request refused by the role check is recorded too, as denied <method> <path>.

A rejected commit shows the router's own words. When VyOS refuses a change, its error text is what appears — not a translation of it.

The same four steps, from a script#

Nothing above is a UI-only path. The interface uses the API you get, with an API token in place of the browser's session cookie:

bash
R=https://<router>:8443
T=wh_...                                    # an API token with the operator role

curl -sk -X POST "$R/api/stage"  -H "Authorization: Bearer $T" \
  -H 'Content-Type: application/json' -d @ops.json
curl -sk      "$R/api/staged"    -H "Authorization: Bearer $T"
curl -sk -X POST "$R/api/commit" -H "Authorization: Bearer $T" \
  -H 'Content-Type: application/json' -d '{"confirm_minutes":2}'
curl -sk -X POST "$R/api/commit/confirm" -H "Authorization: Bearer $T"

Token requests skip the CSRF header that browser sessions must send, which is why a token is the right credential for automation and a copied cookie is not.

Read on#

Updated 2026-09-02