Deploying the console
deploy/deploy.sh is retired as of 2026-09-18 and is now a stub that prints the command below. The console is deployed from the sibling monetize-impl checkout, in Rust, under test:
cd ../monetize-impl
cargo xtask refresh-front --only console # build, ship, swap, restart, verify
cargo xtask refresh-front --only console --dry-run # build and survey, change nothing
cargo xtask refresh-front --only console --no-build # ship what is already built
cargo xtask refresh-front --only console --rollback 20260918-101500
--only console ships gunnar-ui-server and the wasm bundle and nothing of monetize's — what the script used to do. Without it the same verb refreshes the whole front VM, monetize included.
The host is 95.111.204.164 and it is the verb's own default; --host overrides it. That default has named a box that no longer existed twice (the tailnet host until 2026-09-13, 212.147.245.191 until 2026-09-18), and both times the symptom was an ssh timeout — the failure nobody reads as "wrong target".
The verb's source is monetize-impl/deploy/front/, a detached workspace; the SSH transport is nornir_git::ssh (russh), which is the whole reason a bash script is no longer needed. src/site.rs is the never-touch list, src/verify.rs is every gate, and cargo test in that directory runs the state machine end to end against a scripted far end.
Two things the verb deliberately does not do, both of which the script did:
- it never runs
gunnar-ui-server --install. That rewrites/etc/gunnar-ui.env, and that file is on the verb's never-touch list beside the passkey store. The unit'sExecStartalready namescurrent, so a swap and a restart are enough; run--installon the box by hand whenlifecycle's declaration changes. - it never prunes
releases/. Keeping every release is what makes--rollback <stamp>work for any stamp rather than only for the last one, and it is also what will eventually fill the disk.
It also verifies on the box's loopback port rather than through https://www.gunnar.rs: it proves caddy is active, and it does not prove TLS serves the new bundle.
How the swap works
Each deploy lands in releases/<stamp> (deploy.sh used <timestamp>-<sha>) and is checked there, before anything in use is touched: the binary must be executable and must run on the host, the bundle must have an index.html, and it must contain the exact hashed wasm file this build produced. Only then does current change, by renaming a symlink over itself — one atomic syscall, with no moment where current does not resolve.
This replaces mv dist dist.old && mv dist.new dist, which is fine until the second half does not run. It did not run once, and the host was left with no bundle at all.
All of that is gunnar_ui_server::release as of 2026-09-13 — the staging checks, the swap, the rollback and the prune, in std::fs, driven against a temporary tree by cargo test. deploy.sh no longer carries it as a heredoc. One thing changed while porting it: a rollback on a host that has no previous is now an ANSWER (RollbackRefused::NoPrevious) and not an error, because a first deploy that does not come up used to send deploy.sh into no previous release to go back to from inside its own failure path.
Afterwards the deploy fetches the page the box serves and the hashed wasm file named in it. That is the point: a restart that quietly kept serving the old directory still answers /health. refresh-front does it twice over — by trunk's content-hashed name, read out of the served page, and by SHA-256 of the bytes under current/dist.
deploy.sh maintained a previous symlink and rolled itself back when the check failed. refresh-front does neither: it keeps every release and prints the exact --rollback <stamp> command on every path, success included, because "a half-tested rollback in a destructive tool is how one failure becomes two".
First-time setup on a host
The script deliberately does not create /etc/gunnar-ui.env. That file holds the admin token and the WebAuthn origin, and a deploy that can rewrite them silently is a deploy that can lock everyone out. What it does now is declare it: the keys, which of them are required, and the reason for every value live in gunnar_ui_server::envfile, and the binary renders them.
ssh root@HOST
mkdir -p /opt/gunnar-ui && cd /opt/gunnar-ui
ssh-keygen -t ed25519 -N "" -f console_key # the console's own identity
./gunnar-ui-server --env-template > /etc/gunnar-ui.env
$EDITOR /etc/gunnar-ui.env # replace every REPLACE-ME
chmod 600 /etc/gunnar-ui.env
./gunnar-ui-server --check-env # says every reason an install would refuse
Then enroll the console with gunnar, using a key that already holds an admin grant:
GUNNAR_UI_CONTROL=10.13.8.101:50051 GUNNAR_UI_IDENTITY=/opt/gunnar-ui/console_key \
./gunnar-ui-server enroll --admin-key /path/to/admin_key
The unit
There is no deploy/gunnar-ui.service any more. Every service in the suite goes through skidbladnir — unit generation, install, uninstall — and as of 2026-09-13 this console does too. The unit is declared in gunnar_ui_server::lifecycle:
gunnar-ui-server --unit # print it, write nothing
gunnar-ui-server --install # write it, enable it, start-or-restart it (root)
gunnar-ui-server --status # active + healthy, the health probe dialling GUNNAR_UI_BIND
gunnar-ui-server --uninstall # stop, disable, erase releases/ and incoming/
--install is what deploy.sh ran on the far side after the swap, so the unit was written by the release it names. refresh-front does NOT run it — it rewrites /etc/gunnar-ui.env, which is on that verb's never-touch list — so run it by hand when the declaration changes. It reads /etc/gunnar-ui.env first and refuses, having changed nothing, if the file is missing, if a required key is unset, if a placeholder survived, if GUNNAR_UI_DIR does not go through current, or if the file sells and the binary has no paid half in it (below). It then rewrites that file canonically — skidbladnir owns the EnvironmentFile= it is told about, so every key and value survives and the comments do not. The comments are in the declaration, which is where they can be kept true.
--uninstall erases releases/ and incoming/ and nothing else. Not data/, which is gunnar's own repository storage; not console_key, guest_key or state.redb. An uninstall that took the forge with it would not be an uninstall.
Selling requires two things, and this page used to name neither
A console that takes money needs the metered feature compiled into the binary and the monetize lines in /etc/gunnar-ui.env:
GUNNAR_UI_MONETIZE=https://monetize.ignalina.dk # the ONE monetize; no default, required once a bearer is found
GUNNAR_UI_PRODUCT=gunnar # the product this estate sells under; no default, required once a bearer is found
GUNNAR_UI_MONETIZE_TOKEN_FILE=/opt/gunnar-ui/monetize-token
GUNNAR_UI_BUY_URL=https://buy.stripe.com/…
metered is not a default feature. Without it the monetize client, the signup routes and /welcome are not compiled at all, App::signup_offered is a constant false, and the session gate answers a paying stranger "log in to use the console".
Found the hard way on 2026-09-12: the front had the env lines and a binary with neither route, GET /welcome?session=… answered 401, and two live card payments taken on 2026-09-11 had to be refunded. It was not a stale binary. deploy.sh ran a bare cargo build --release -p gunnar-ui-server, so the box was configured to sell while the artifact could not, and nothing in the deploy said a word about it.
And found again on 2026-09-18, one layer down: the server had been metered for days while the BUNDLE beside it was not, because --features went to cargo and never to trunk. Every money panel is #[cfg(feature = "metered")], so the routes were registered and the buttons that call them were not compiled.
Four things say it now, and none of them is a claim about the build command:
- The deploy declares the paid half for BOTH halves of the console.
artefact::GUNNAR_UI_SERVERandartefact::CONSOLE_BUNDLEcarry the samemetered, and a test asserts they are equal. Until 2026-09-18 only the first one did, andtrunkwas handed nothing — see the wasm marker below. - The binary stamps its own half.
gunnar-ui-server --versionends inmetered)orcommunity), andGET /api/versioncarries the same line. The deploy reads that off the artifact before it uploads anything (verify::metered_half_gate), so--no-buildcannot ship a community build left over from another run. - The bundle is asked the same question, by route. A wasm carries no
--version, so what is read is/increase/offer— the API pathpages::increasebuilds,#[cfg(feature = "metered")]on both sides. Not a UI word: those get rewritten, a route does not.verify::money_panel_gate. MEASURED on the front 2026-09-18: a metered server had been shipping beside a community bundle for weeks,/welcomeanswered 200, and an admin on/usage/<ns>was shown no way to buy room. --check-envrefuses the mismatch on the host. A binary with no paid half, on a host whose env file setsGUNNAR_UI_BUY_URLor a monetize bearer, is refused by name before the swap —Refusal::ConfiguredToSellButCannot. That is the strongest of the four: it is the staged artifact answering about itself, against the file it is about to run under.
GUNNAR_UI_BUY_URL is the sharp one. It does not go through the feature gate at all, so a community console with it set advertises a purchase, sends the person to the payment vendor, takes their card — and then cannot receive them back.
Two things that will bite
GUNNAR_UI_DIR must be /opt/gunnar-ui/current/dist. Not the unit's business: systemd applies EnvironmentFile= after Environment= whatever order they appear in, so a value in the unit is ignored and the file always wins. Point it anywhere else and the server starts, answers /health, and serves an empty console. The deploy asks the STAGED binary this before it swaps anything, and refuses.
WebAuthn needs a real hostname over HTTPS. Behind tailscale serve, the RP ID is the hostname without the port and the origin includes it:
tailscale serve --bg --https=8443 http://127.0.0.1:3210
A port of its own leaves anything already served at / alone.
When nobody can sign in
A passkey is bound to the relying party it was registered against, so a wrong GUNNAR_UI_WEBAUTHN_RP_ID at the moment the first one is registered — or a lost authenticator, or a box changing hands — leaves a console that is up, healthy, serving, and shut.
systemctl stop gunnar-ui
./gunnar-ui-server reset-state --passkeys # prints, changes nothing
./gunnar-ui-server reset-state --passkeys --yes # does it
systemctl start gunnar-ui
Without --yes it only prints what it would destroy, including the keys it would KEEP — which is how you tell whether --passkeys or --all is the scope you meant. --passkeys forgets the credentials and bumps the session epoch, so nobody stays signed in on a passkey that no longer exists; the session key, the per-person revocations and everybody's preferences stay. --all empties the store.
It refuses to run while the console is running, and that refusal is the point: redb holds the file lock. Do not rm state.redb instead. On a running console that removes the name and leaves the open file behind, so the process keeps serving from a store nobody can see and the next restart quietly starts a second one — the reset looks like it did nothing until a restart hours later, when it looks like it did everything.
Afterwards, GUNNAR_UI_ADMIN_TOKEN from /etc/gunnar-ui.env is the way back in, exactly as it was on the first day.