The Tools tab has an interactive modal: when a list_users task completes it
parses the task log for EZ_USER lines and renders one row per account with
reset / promote / delete buttons. All four new apps failed its contract in every
respect, so running List Users produced log text and nothing else.
- The marker is EZ_USER, tab-separated as email, username, roles. Matrix and
Stoat emitted LP_USER in a different field order; Mattermost and Rocket.Chat
emitted no marker at all.
- Matrix and Stoat then consumed their own marker lines in the formatting loop
and printed only the pretty version, so nothing reached the log to parse.
- The row buttons look up tools by id: reset_password, set_admin, delete_user.
The deactivate tools were named deactivate_user / disable_user, so no delete
button rendered.
- Prefill only fills a field named email or username. Rocket.Chat's and Stoat's
identifier field was called user, so a row action would have opened with an
empty box.
- '-' placeholders are truthy, so the modal's `email || username` fallback
picked '-' over the real username for accounts without an email (rocket.cat).
The EZ_USER line now carries an empty string; '-' stays in the readable line.
Mattermost's listing is rebuilt on `mmctl --json`, which carries roles and
delete_at. The text listing has neither, and there is no --system-admin filter
on user list, so every account was reported as a plain user. Two parsing notes
that cost time: mmctl prints status lines both before and after the JSON, so it
needs raw_decode rather than json.loads; and --per-page above 200 makes it emit
a warning line ahead of the payload.
The modal's delete button also stops asserting "Delete user" over whatever the
tool actually does — it takes its label and icon from the tool, because most of
these deactivate and Matrix cannot delete at all.
Verified by replaying the modal's own parser over real tool output: 2 rows for
Matrix, 4 for Mattermost, 3 for Rocket.Chat, with admin and deactivated states
resolving correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same sweep as AUTH_PROFILE, run across all 110 key suffixes in the app configs.
CFG_GITEA_ADMIN_EMAIL and CFG_INVIDIOUS_ADMIN_EMAIL: both apps' auth adapters
take an email argument for createUser but never read or persist the config key,
so it sat empty forever. bookstack, mattermost and rocketchat do read theirs;
these two were copies that never got wired.
CFG_CROWDSEC_AUTO_UPDATE: superseded by UPDATE_TYPE (auto|manual), which crowdsec
also declares. The only AUTO_UPDATE readers left are CFG_GIT_AUTO_UPDATE and
CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE, neither of them per-app.
Not removed, because each is a gap in the code rather than a key to delete, and
deleting would cement the bug: CFG_CROWDSEC_HOST_SERVICE (documented as the unit
stop/restart hits, but only HOST_SERVICES is ever read), CFG_CROWDSEC_LAPI_HOST
and CFG_CROWDSEC_BOUNCER_NAME_TRAEFIK (the bouncer name is hardcoded in
crowdsec_install_host.sh, so editing the setting does nothing).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instance install (bugs found by running one end to end):
- The cloned compose kept the TYPE's tag namespace
(#LIBREPORTAL|BOOKSTACK_APP_KEY_1_TAG|...) while the config had been
re-namespaced to CFG_<SLUG>_*, so tagsProcessorAppConfigValues matched
nothing, the placeholders survived and the pre-start guard refused to
launch. Rewrite the tag names and *_DATA tokens too — narrowly, so an
app whose compose sets a real env var named after itself is untouched.
- Tools/hooks kept uppercase CFG_<TYPE>_ reads, so an instance
provisioned itself from the type's config and ignored its own values.
- Cloned hooks were never loaded: both loaders run at startup, before the
instance dir exists, so _appCallHook's `declare -F` found nothing and
every <slug>_install_* hook silently no-opped — for bookstack that is
the readiness probe and the admin bootstrap. Source the instance's own
scripts in-process, then regen arrays + manifest for later runs.
- bookstack's hook hardcoded the container name after `docker exec -e ...`
flags, where the rewriter can't see it, so an instance's admin bootstrap
ran against the BASE app's container — including a tinker DELETE of a
user. Target "$app_name" instead, and teach the rewriter the
container="<type>" assignment form used by auth adapters.
network_resources uniqueness:
UNIQUE(resource_type, resource_value) is right for 'ip' and 'port' but the
port-tag writer stores descriptive rows in the same table with INSERT OR
REPLACE, so every install DELETED the matching row from whichever app held
it. traefik_managed and url_accessible are booleans, so the whole table
could only ever hold one row of each. Observed live: installing a second
bookstack took all four traefik_managed/url_accessible rows from stoat and
bookstack, and removing that instance took the stolen rows with it.
Replace it with a partial unique index scoped to ip/port, and migrate
existing databases in place (SQLite can't drop a constraint, so the table
is rebuilt inside a transaction). The migration is invoked from
portUpdateComposeTags, not just databaseCreateTables — the latter only
runs from startPreInstall, which a working install never re-runs.
Verified: two bookstacks now hold port_tag_internal=80, traefik_managed
and url_accessible simultaneously; duplicate host ports and IPs are still
rejected; instance installs, serves HTTP 200, provisions its own admin in
its own database, and removes cleanly with no orphan rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven app configs declared CFG_<APP>_AUTH_PROFILE as a "capability tier for the
WebUI auth tools". Nothing read it — not a shell script, not the frontend, and it
was never emitted into apps.json, so the WebUI could not have acted on it even in
principle.
The job it was meant to do is already done, and done better: authAdapterCanDo
tests `declare -F authAdapter_<app>_<method>`, so what an app can do is derived
from the functions it actually implements. A declared tier is a second source of
truth that can only drift — traefik declared single_password while its adapter
implements setPassword only, and linkding declared nothing at all while shipping
a full multi-user adapter, and neither mismatch had any effect.
Removed the key and its comment from all eleven configs, and replaced the stale
contract note in auth_adapter.sh with what the dispatcher really does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Switching between private and public wrote the setting, reconfigured the
server and then asked the user to run `libreportal app install stalwart`
to make the ports actually change. That left a window where the WebUI
reported public while port 25 was still closed — or worse, reported
private while 25 was still open and listening. A mode switch that does not
move the ports is not a mode switch.
The tool now runs the install itself. Safe from here: tools are dispatched
inline rather than as their own task, so this is not a nested task and
cannot deadlock on the task lock, and nothing in Stalwart's install hooks
calls back into the tool. Provisioning inside that install is a no-op
because it skips once config.json exists.
Dropped the separate firewall rebuild — the install reallocates the ports
and rebuilds the rules from the result, so doing it beforehand only worked
from the old allocation and was then immediately redone.
Verified both directions on a real install: private -> public publishes 25,
public -> private removes it, the admin port keeps its existing random
allocation across both (no --reset-network, so bookmarked WebUI links do
not move), mailboxes survive with their original creation timestamps, and
re-selecting the current mode is a no-op.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A mail server is two quite different products wearing one name, and until
now LibrePortal only offered the hard one. Installing Stalwart meant being
handed a wall of DNS records, a red error about port 25 and a warning about
reverse DNS — all of it correct, none of it fixable by the installer, and
most of it irrelevant to someone who wanted mailboxes and a shared calendar
on their own network.
CFG_STALWART_MODE now names which one you are running:
private mailboxes, IMAP, CalDAV and CardDAV on your own network. Port 25
is not published at all; the client ports stay bound to the host
but are never opened through the firewall. No MX, no PTR, no
deliverability. Nothing to publish, so nothing is printed.
public the internet mail server, as before.
auto public if Traefik is installed, private if not, resolved at
install and written back so it reads as a real answer afterwards.
DKIM keys are generated in both modes even though private has no use for
them today — that is what makes switching later a setting change rather
than a key ceremony. The WebUI gets a "Mail Exposure" tool that flips the
setting both ways and reconfigures the server, plus a "Show DNS Records"
tool that prints the live zone including current DKIM keys.
Two things this had to get right, both found by testing rather than
reading. Port access lives in the shell as CFG_<APP>_PORT_n, not just in
the config file, and the compose file is built from the parsed shell
values — editing only the file left the config claiming port 25 was
disabled while the container published it anyway. And going public needs
an AcmeProvider to exist before a domain can reference one, so the switch
creates it; note that doing so registers an account with Let's Encrypt.
Verified through real installs: auto resolves to private with no Traefik,
port 25 is genuinely unpublished and absent from the compose file, the
client ports are skipped by the firewall as host-bound, and the tool
round-trips private -> public -> private with the config landing back
exactly where it started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bookstack: create-admin fails on a reinstall because the account is
already provisioned. That took the generic-failure branch, which printed
the upstream defaults (admin@admin.com / password) as "the" login — those
credentials were replaced on the first install, so the one line a user
would act on was the wrong one. Detect "already exists" and say the
existing account was kept and its password not reset.
Firewall: uninstall deleted only resource_type='port', orphaning the
port_tag_*/traefik_managed/url_accessible rows the rebuild reads. Every
rebuild then walked ports for long-gone apps and printed "Skipped: <app>
(app not found)" per row. Widen the uninstall delete to all non-'ip' rows
(the source), and prune already-orphaned rows in the rebuild (the
self-heal). Pruning requires both no container dir AND status != 1 in the
apps table, so a mid-flight install can't prune itself.
Verified on a live bookstack reinstall: admin path reports correctly,
firewall pruned nextcloud + stalwart once, second run silent, 11 rules
added / 0 failed throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things running the tools against a live instance exposed:
- roles.addUserToRole takes roleId + username and nothing else. Passing roleName
fails schema validation with "must NOT have additional properties", and
roleId + userId is refused for a missing username. Set admin was broken in
both directions.
- Rocket.Chat enables a password policy by default demanding lower, upper, digit
AND special at 14+ characters, while generateRandomPassword is alphanumeric.
Reset failed with "does not meet the server's password policy". Notably
users.create does NOT enforce the policy, which is why creating an account
worked and resetting the same account's password did not — an inconsistency
worth knowing about rather than guessing at. Generated passwords now carry one
character from each class appended, leaving the generated entropy untouched.
- Deactivation had no counterpart, so "reversible from Admin → Users" was only
true if you left the WebUI. Adds an Enable tool, matching Stoat's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rocket.Chat gets the full five — create, list, reset password, set admin,
deactivate — over its REST API. Two supporting changes make that possible:
- The first admin is now seeded at install from CFG_ROCKETCHAT_ADMIN_*, and
the setup wizard is marked completed. Previously the install left a wizard
for someone to click through, and, more to the point, left no account for
the tools to authenticate as. Rocket.Chat honours those env vars only while
no admin exists, so they are inert on every later boot.
- Calls go out with curl from the host rather than from inside the container.
The image ships node but no curl, and the base URL is read from the deployed
compose's ROOT_URL, which the APP_URL tag has already resolved to whatever
this install actually serves on.
Stoat gets three — list, disable, enable — and the adapter says plainly why it
stops there. Password reset would mean reimplementing its argon2 hashing in
bash, where being subtly wrong writes a hash nothing can verify and locks the
account out with no error at the time. "Make admin" would misrepresent the
model: Stoat's permissions are per-server bitfields on server_members, not a
global flag. Its service containers are distroless with no shell and it has no
admin CLI, so the database is the only durable handle.
Deactivate rather than delete in both, and the destructive actions refuse to
touch the account the tools authenticate as.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "should we stop creating an admin/pass on start" with the split the
catalog actually has, rather than one way for everything.
Ten apps need it: adguard, authelia, bookstack, matrix, nextcloud, owncloud,
pihole, rocketchat, stalwart, speedtest and headscale either pass the generated
password into the container or hand it to an install hook that creates the
account. There the password IS the working credential — dropping it would lock
you out. Left alone.
Three do not create an account at all: gitea, invidious and mattermost seed no
user (the first one comes from their own signup flow or the Create Account tool),
so the password minted at install named nothing. The WebUI credentials card
showed a password that could not log in. They now match linkding — an empty,
unslotted ADMIN_PASSWORD the auth adapter fills when the operator makes the first
admin, and keeps in step on later resets. Unslotted because the slot number marks
a value the installer generates.
mattermost's adapter also had linkding's bug: it persists ADMIN_PASSWORD but the
config declared only ADMIN_EMAIL, so the write was a no-op.
WebUI: rocketchat's generated admin password had no field mapping, so the card
could not show it. Added, plus a generic ADMIN_USER entry — six apps record an
admin username the card had no way to display.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five Tools-tab actions: create account, list users, reset password, set admin,
deactivate.
Driven by mmctl --local, which talks to the server's unix socket rather than the
REST API — no credentials to store, no token to expire, and it keeps working
when the admin account is locked out or the site URL is wrong. mmctl is also the
only route available: the v11 image is distroless with no shell at all, so every
call has to be a direct exec of a binary.
Two things found by running them:
- `user promote` / `user demote` convert between GUEST and member accounts and
have nothing to do with administrator rights. Granting system admin is
`roles system-admin` / `roles member`. The first version used the wrong pair
and failed with "Unable to convert the guest to regular user because is not a
guest."
- mmctl errors are multi-line: a summary line, then an indented bullet carrying
the part that explains anything. Reporting the first line alone surfaced
"1 error occurred:" and threw the reason away.
Deactivate rather than delete, deliberately: Mattermost's delete is a permanent
content purge, which is not something a single WebUI button should do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five Tools-tab actions backed by Synapse's admin API: create account, list
users, reset password, set admin, deactivate. All driven through
`docker exec matrix-synapse python` — the image has no curl, python is what
Synapse itself runs on, and talking to localhost:8008 means the tools work the
same LAN-only or behind Traefik and never depend on the published port.
Two Matrix facts are surfaced rather than hidden: a user ID is permanent, and
there is no delete — deactivation is terminal and burns the ID. The tool is
named "Deactivate" for that reason. Both destructive actions refuse to touch the
account the tools themselves authenticate as, which would otherwise lock the
Tools tab out of the server it manages.
The admin token is cached under data/. Logging in per invocation looked tidier
and was wrong: Synapse rate-limits /login, so a few tools in succession failed
with "Too Many Requests" — the tools were throttling themselves. One login,
reused, with a single re-login on 401 and a 429 retry that honours
retry_after_ms.
Also renames the Synapse logging config from log.config to log.yaml. The config
loader runs
find "$containers_dir" -maxdepth 3 -type f -name '*.config'
and `source`s every match as bash. The file lands at <app>/data/log.yaml, which
is exactly depth 3, so under its old name EVERY libreportal command sourced a
YAML document as a shell script. It printed "command not found" per line and
took a CLI call from 1 second to 100. Only resources/ is pruned from that scan,
not data/ — so *.config is effectively a reserved extension anywhere under an
app directory, not just at its root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
linkding_auth.sh persists ADMIN_USER and ADMIN_PASSWORD when the first admin is
created, and keeps the password in step on later resets of that account, but
linkding.config declared neither — so both writes were no-ops and the WebUI
credentials card never had anything to show. Predates the slot work; it only
became visible once authPersistCfg started warning instead of failing silently.
Added empty rather than RANDOMIZED*, because unlike bookstack or nextcloud
nothing seeds a linkding account at install — the first user is created from the
WebUI. A generated password would name an account that does not exist, and the
card would display a password that cannot log in. Unslotted for the same reason:
the slot number marks a value the installer generates, and this one is written at
runtime by the tool.
No AUTH_PROFILE key: nothing reads it (it exists only in a comment in
auth_adapter.sh), and adding an unread key is what was just cleaned up elsewhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each action-board row on /apps/overview already carries a navigation
button (Review / View / Open Backups). The whole row now triggers that
same navigation, so the small button is no longer the only hit target.
Rows opt in via a `nav` descriptor, so only plain go-to-that-tab rows
become clickable — rows with no action, and the "Update all" button
inside the updates row, are unaffected (updater actions are matched
first in the delegated handler, so Update all never double-fires).
The row is not given role="button": nesting the real buttons inside a
button role would break them for assistive tech. The inner button stays
the focusable, keyboard-reachable control; the row is a mouse-only
widening, with a hover state that lifts row and button together. A
whole-row click is suppressed when it ends a text selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Login Details card fell back to the literal 'admin' when an app declared no
user or email key, so speedtest — which has a single CFG_SPEEDTEST_PASSWORD_1
and no user concept at all — advertised "User: admin" to anyone reading its
card. There is no such account; the field was fabricated by the fallback.
Show the username row only when a user or email key actually exists, and the
password row only when a password key does. eoCredList already omits any row
whose value is null, so passing undefined for either half renders just the half
that is real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every app with a generated admin password displayed "(not generated)" in its
Login Details. The credential matcher anchored on
^CFG_<APP>_(ADMIN_)?(PASSWORD)$, which stopped matching when generated keys were
given slot numbers — CFG_MATRIX_ADMIN_PASSWORD_1 and friends no longer hit the
regex, so the lookup fell through to its placeholder.
All ten apps carrying an admin password were affected: adguard, authelia,
bookstack, gitea, invidious, matrix, nextcloud, owncloud, pihole and stalwart.
Allow an optional _<n> tail. The anchor is kept otherwise, so a database or
upstream credential still cannot be mistaken for a login — CFG_<APP>_DB_PASSWORD_1
does not match, which is the case the anchor was added for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The category pill on the app detail header was rendered inert — no click
handler at all — while the identical pill on the app cards navigated to that
category's filter view. Both now come from one AppsManager.renderCategoryTag(),
so the detail pill behaves like the card pill and the two can't drift again.
The pill's glyph was an <img> of a category SVG, and those SVGs hardcode
#1e90ff. That only ever matched the dark-blue theme; on nebula (the default,
accent #00d4ff) and any other theme the icon read as a dark smudge next to its
own label. It's now painted as a CSS mask filled with currentColor, so it
always matches the pill's text on every theme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose hands RabbitMQ a generated password, but the Stoat services fall
back to the defaults compiled into them — rabbituser/rabbitpass — so api, crond,
pushd and voice-ingress panicked on ACCESS_REFUSED and restarted forever.
The failure was easy to misread: the eleven services that never touch RabbitMQ
came up healthy and the web client answered on port 80, so the stack looked
almost fine while none of the messaging worked.
Write a [rabbit] section into Revolt.toml carrying the same credentials the
broker was given. Verified after the fix: all sixteen containers up, /api
returns the instance descriptor, /autumn answers, and /.well-known/stoat carries
the right URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only two of the four keys flagged as unused actually were. gitea and invidious
ADMIN_PASSWORD are written by their auth adapters through authPersistCfg, which
builds the name as CFG_${app^^}_${key} from a parameter — invisible to a literal
grep, which is why the earlier pass called them dead. They stay.
Worse, the slot rename broke that write path for five apps: adguard, bookstack,
gitea, invidious and nextcloud all persist ADMIN_PASSWORD, and the config now
holds ADMIN_PASSWORD_1. updateConfigOption only rewrites a key that already
exists, so the write became a no-op — the app's password would really change
while the config and the WebUI kept showing the old one.
authPersistCfg now falls back to the numbered slot when the bare key is absent,
so adapters never need to know how a credential is numbered and adding a slot
can't silently disconnect the adapter that writes it. When neither name exists
it warns and returns non-zero instead of failing silently, which surfaces a
pre-existing case: linkding's adapter persists ADMIN_USER and ADMIN_PASSWORD but
its config declares neither, and never did.
Deleted the two that really are dead: CFG_TRAEFIK_ADMIN_PASSWORD_1 (its adapter
uses CFG_TRAEFIK_USER/CFG_TRAEFIK_PASS from the system config) and
CFG_GLUETUN_CONTROL_SERVER_API_KEY_1, plus their WebUI field mappings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The install read the generated LiveKit credentials with a plain grep, but
secrets.env is chmod 600 and owned by the docker install user while the hooks
run as the manager — so the read returned nothing, the hook errored out, and
Caddyfile and livekit.yml were never written. Compose then refused to start,
because a bind mount whose source does not exist is not a soft failure.
Read secrets through runFileOp, and reorder so the Caddyfile and the three
URL-bearing files are written first: any step that can fail now comes after
every mount source already exists. The missing LiveKit keys are downgraded from
fatal to a warning for the same reason — losing voice is worth reporting, but it
is no reason to take the other fifteen services down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both configs defined a second secret in a later section starting the numbering
over, so CFG_GITEA_METRICS_TOKEN_1 and CFG_GITEA_ADMIN_PASSWORD_1 both read
RANDOMIZEDPASSWORD1 — and the replacer generates one value per distinct
placeholder and seds every occurrence, so the two came out identical. Same for
Invidious's HMAC key and admin password. Predates the slot rename.
Currently latent, since neither admin password is consumed by anything, but it
would silently pair a live secret with whatever gets wired to the other key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both apps demanded a domain and Traefik. That was over-constrained: LibrePortal
ships WireGuard, Headscale and private ports, so LAN and VPN-only is a
first-class deployment here, and Rocket.Chat and Mattermost already prove chat
apps work fine on http://<lan-ip>:<port>.
The gate on Matrix rested on a mistake of mine: server_name being permanent.
server_name and public_baseurl are independent — the identity can be a domain
you own with no DNS behind it while clients reach the server on a LAN address,
so federation can be switched on later by adding DNS and TLS, with no rebuild
and no lost history. CFG_MATRIX_SERVER_NAME now exposes exactly that, and the
install warns when it falls back to the machine's IP.
What is genuinely lost without a domain is stated where it belongs, at install:
Matrix cannot federate and Element's mobile apps want HTTPS; Stoat cannot do
camera or microphone, because browsers gate getUserMedia on a secure context
and a VPN does not change that, the check being on the URL scheme.
Both now derive their URL from the port that was actually allocated. Since ports
are only assigned during compose-up, each writes a best guess before start and
corrects it afterwards, restarting only when the value really changed.
Three bugs found while proving it works end to end:
- The Synapse image writes /data as its UID/GID env, default 991, which under
rootless is a host sub-UID owning nothing — so the generated signing key could
not be moved by the install user. Both the generate container and the service
now run as the same identity USER_TAG resolves to.
- Element's config.json is bind-mounted as a file, and docker silently creates a
DIRECTORY when the source is missing. An early return left exactly that
landmine, which then broke every later run. It is written first now, and a
stale directory is cleared.
- A successful admin registration was reported as an error: checkSuccess read $?
after an intervening [[ ]] test rather than the command's own status.
Verified with no domain and no Traefik installed: Synapse answers
/_matrix/client/versions and /health on http://<ip>:<port>, admin login returns
a token, and Element is configured against the corrected base_url.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine LONG_DESCRIPTION values had drifted well past the 90-140 char
range the rest of the catalog uses (stoat was 407). Cut them back
while keeping the caveats that matter — Rocket.Chat's user cap,
Stoat's resource weight, Matrix federation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both hooks read their host (and matrix its database password) back out of the
deployed docker-compose.yml. That cannot work: install_post_compose runs after
the compose TEMPLATE is copied but before dockerConfigSetupFileWithData fills
the tags, so at that point the file still holds raw placeholders. Matrix aborted
with "Database password was not generated in the compose file" even though the
password had been generated correctly — it just was not in the compose yet.
Derive the host from port_subdomains[0] + domain_full instead, both already in
scope from variables_init_app, applying the same empty/@/root rule as
tagsProcessorPortSubdomains so the computed name and the Traefik rule generated
later cannot drift apart. Matrix takes its database password from
CFG_MATRIX_DB_PASSWORD_1, which is where the secret is generated and remembered
and is the same variable the compose tag is filled from a step later.
The error messages now name the actual missing thing — the domain — rather than
blaming the compose file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches b562059 — the fourteen mapping entries added alongside the slot rename
were written before that landed and ran 88-100 chars against a median of 44.
Installing rocketchat failed with
invalid IPv4 address: ParseAddr("IP_DATA_2"): unable to parse IP
ipUpdateComposeTags allocates one IP per SERVICE_TAG_N annotation and fills
IP_TAG_i only where SERVICE_TAG_i exists. The four new apps tagged only their
primary service, so every sidecar — matrix's postgres, mattermost's postgres,
rocketchat's mongo, and fifteen of stoat's sixteen — kept a literal IP_DATA_n
in the deployed compose and docker refused to create the container.
Tag every service that carries an ipv4_address, index-aligned with its IP_TAG.
For stoat that also meant moving caddy from SERVICE_TAG_1 to _6 so the indices
line up with the IPs rather than the reading order.
mastodon had the same latent break (IP_TAG_2 and _3 untagged) and is fixed the
same way — it would have failed on first install for the same reason.
SERVICE_TAG carries the compose *key*, not container_name: 'libreportal app
restart <app> <service>' passes it to 'docker compose restart', which only
understands keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VAPID: the two values are the halves of one P-256 keypair, not independent
secrets — the browser verifies that a push is signed by the private key matching
the public key it subscribed with. The RANDOMIZED* generators mint each
placeholder on its own, so they produced two unrelated strings and web push could
never have worked. Generate the pair in mastodon_install_post_setup the way stoat
already does, encoded as Mastodon's webpush gem expects: unpadded URL-safe base64
of the 32-byte private scalar and the 65-byte uncompressed public point, sliced
out of the SEC1 DER. Verified by rebuilding the key from the emitted private half
and re-deriving the public point — openssl accepts it and the point matches.
Generated once and never rotated (rotation would invalidate every subscription),
but a pair of the wrong shape is replaced, so an install carrying the old
unrelated strings heals itself on next install — their public half is 42 chars
where a real point is 87.
Slots: CFG_<APP>_DB_PASSWORD -> CFG_<APP>_DB_PASSWORD_1 and likewise for
DB_ROOT_PASSWORD, across mastodon, owncloud, mattermost, matrix, nextcloud and
bookstack, so a database credential is always a numbered slot and a second one is
just _2. Renaming a key means reconciliation drops the old and adds the new
holding its placeholder, so an existing install regenerates unless the value is
carried over first — documented, including that the old file survives as
.<app>.config.bak.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five apps (mastodon, owncloud, mattermost, matrix, stoat) took their generated
secrets from the compose-side generator tags PASSWORD_TAG_<n>/RANDOM_TAG_<n>/
HEX_TAG_<n>/VAPID_TAG_<n>. Those mint a fresh secret on every templating run, so
a reinstall handed the app a new database password while its data volume kept the
one initdb was given, and the app came back up unable to open its own database.
Move them to <app>.config as RANDOMIZED* placeholders, reaching the compose via
the #LIBREPORTAL|<APP>_<KEY>_TAG| mechanism tags_processor_app_config_values
already provides. No new handler: the tag name is derived from the config key, so
this is a config line plus a tag per secret. Generation is unchanged — still
random on first install; the value is now remembered instead of re-rolled.
Also fixes two things this exposed:
- The RANDOMIZED* replacers matched unanchored. `sort -u` orders slots lexically
(1, 10, 11, 2), so slot 1's pattern rewrote the prefix inside slot 10's
placeholder and slots 10+ ended up holding slot 1's secret with a digit glued
on — derivable, and invisible because the values weren't byte-identical.
Anchoring with \b makes match order irrelevant. Verified at 20 slots across
all four placeholder types: 64 keys, 64 distinct values, no prefix collisions.
- generateRandomPassword drew from base64 without constraining the mix; measured
over 2000 draws, 1 in 40 contained no digit at all. Retry until the result has
both a digit and a letter, bounded so a pathological length can't spin.
owncloud gains a fix in passing: its compose seeded the admin account from
PASSWORD_TAG_2 while the WebUI displayed CFG_OWNCLOUD_ADMIN_PASSWORD, which was
generated separately and never used. Both now read the same value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tag manager reads `#LIBREPORTAL|<TAG>|<VALUE>` and takes <VALUE> as the
current literal to search for on that line, so it must equal the string in the
line body. Mastodon used `unconfigured` as the annotation value against bodies
like `PASSWORD_TAG_1_DATA` — nothing matched, nothing was ever substituted, and
the placeholder shipped as the live database password, SECRET_KEY_BASE, OTP
secret and VAPID keypair.
Nothing caught it either: `unconfigured` doesn't match `_DATA`, so
tagsManagerGetTagState reported the tags as configured, and the stale-tag gate
in dockerComposeUp (which tests the annotation value against
`^[A-Z][A-Z0-9_]*_DATA(_[0-9]+)?$`) let the app start.
Adopt the convention every other app already uses — body placeholder identical
to the annotation value, `<KIND>_DATA_<n>`. All 11 tags now substitute, the app
and postgres services agree on the same generated credentials, and an unfilled
tag is visible to the pre-start gate.
Existing 0.1.0 installs keep their literal credentials until re-installed, and
their Postgres was initialised with them, so a plain re-install desynchronises
the compose from the volume. Document both recovery paths in upgrade notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixteen containers: MongoDB, Valkey, RabbitMQ, MinIO and eleven Stoat services.
Servers, channels, roles and voice/video through LiveKit — the nearest thing in
the catalogue to Discord itself, at the price of being much the heaviest app in
it. Does not federate.
The compose service keys are deliberately kept identical to upstream's
(database, redis, api, autumn, ...) while container_name is prefixed stoat-.
Compose registers both on the network, so upstream's internal defaults keep
resolving and LibrePortal still gets the prefixed names its port, firewall and
backup layers key on.
Upstream's Caddy is kept as the internal path router and Traefik simply proxies
to it, which is upstream's own supported behind-a-reverse-proxy mode —
reimplementing eight path routes as Traefik labels would be a second copy to
keep in sync for nothing. The install hook is a non-interactive port of
generate_config.sh, and it never rewrites an existing secrets.env:
REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded, so
regenerating it would orphan the whole media store.
LiveKit's UDP media range is published literally rather than through the port
table, because the firewall rebuild emits /tcp rules only and a range declared
there would produce a wrong rule rather than no rule. Voice falls back to TCP
7881 until the range is opened by hand; the post-install notice says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rocket.Chat tails the Mongo oplog for realtime delivery, and a standalone
mongod has no oplog — so the database has to be a replica set even with one
member.
Uses the official mongo image rather than bitnami/mongodb (which upstream's own
compose uses) because Bitnami moved its catalog behind a paid registry and the
free tags are no longer dependable for a long-lived install. The cost is that
rs.initiate() is not automatic, so the post-start hook runs it once — guarded by
rs.status() so a reinstall over restored data doesn't re-initiate a live set,
and followed by a wait for the member to report itself primary.
Mongo runs without auth: enabling it on a replica set also requires a shared
keyfile for member-to-member auth, which is a lot of moving parts for a database
that is never published outside the docker network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One container against Postgres, with the polished desktop and mobile clients
that make it the least demanding of the four chat options.
Runs as the bind-mount owner via USER_TAG: the image bakes in USER mattermost
(uid 2000) so it never runs as root and cannot chown its own data directory,
which under rootless Docker means it dies on first write.
CFG_MATTERMOST_AUTHELIA stays false — OIDC/SAML is a paid tier here, so
forward-auth would block the native clients from the API without buying single
sign-on in exchange.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Synapse on Postgres plus the Element web client, on two subdomains: the
homeserver on matrix.<domain> (which becomes server_name, so IDs read
@alice:matrix.<domain>) and Element on element.<domain>.
Two hosts rather than one because server_name then matches the host Traefik
already terminates TLS for, so 'serve_server_wellknown: true' is all the
federation delegation needed and nothing has to be published at the apex
domain — which this app has no way to configure.
CFG_MATRIX_AUTHELIA is pinned false and documented: forward-auth in front of
/_matrix locks out every client and every federating peer, since they carry
Matrix access tokens and cannot follow a redirect. Real SSO goes through the
OIDC block in resources/homeserver.yaml instead.
The install hook generates the signing key once via upstream's own 'generate'
command and refuses to regenerate it over an existing install — a new key would
be rejected by every server that had cached the old one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the installer for real rather than testing the hook in
isolation. Stalwart never started: it failed to open its database with
"Permission denied" on /var/lib/stalwart, which meant no mail could be
stored and the setup wizard could not be completed by hand either.
The image runs as its own uid 2000. LibrePortal gives container directories
to the docker install user under rootless and to the manager under rooted,
and 2000 is neither, so the bind mounts were unwritable in both modes. This
was not something the new provisioning introduced — it predates it, and the
app has never been able to hold mail.
Running as container-root maps to whichever host user owns those
directories. Under rootless that is the unprivileged docker install user,
not host root.
Also stop discarding the server's error when setup fails. Both failures
that actually occur — a hostname under a TLD that does not resolve, and the
unwritable data directory above — name themselves precisely, and a bare
"setup failed" turns a one-line fix into guesswork.
Verified end to end through `libreportal app install stalwart` on a clean
install: setup applied, DKIM keys generated, postmaster mailbox created,
and the full record set printed from the server's own zone data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new Stalwart drops you into a five-screen wizard — hostname, domain,
storage backend, directory, logging, DNS — before it will do anything.
LibrePortal already knows the two answers that matter and the rest have
sane defaults, so asking is asking a question we can answer ourselves.
v0.16 exposes those wizard fields as a `Bootstrap` singleton, so the whole
thing is one `update` applied through the Stalwart CLI. The CLI is not in
the server image (upstream split it into its own repo), but it publishes a
multi-arch container, so we borrow the server's network namespace and run
it there — nothing installed on the host, nothing to clean up, arm64 works.
Setup now also:
- generates DKIM keys (Ed25519 + RSA) with rotation left switched on, and
requests a TLS certificate. That last one is easy to miss: Traefik only
fronts the admin port, so 25/465/587/993 never see its certificate and
clients would hit a self-signed one on 993.
- creates postmaster@<domain>. The generated zone points DMARC and TLS-RPT
reports there and nothing was creating it, so those reports bounced.
- prints the record set read back from the server rather than composed
here, so it includes the real DKIM public keys, MTA-STS, TLS-RPT and the
SRV records clients autoconfigure from. This hook used to tell the user
to go and fetch DKIM themselves; by that point the keys exist.
Optionally hands DNS to a provider API (Cloudflare/DigitalOcean/DeSEC),
which keeps the whole record set in sync and makes DKIM rotation safe to
leave on. Off by default: the token can write to your zone and lives in
the mail server's database.
Re-running is safe — provisioning is skipped once config.json exists, and
the plans use upsert so they reconcile rather than duplicate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed verify makes the engine abort and restore, and a restore cannot
put back a bundle that was never downloaded — it would roll a working
mail server back a version to fix a missing web page, then hit the same
empty GitHub fetch next time. So the console check now warns loudly and
returns 0; readiness stays the only gate.
Renamed to stalwart_upgrade_check_admin_ui so the name cannot be read as
part of the gate, and bounded its poll to a 60s grace window (capped by
the caller's deadline) — the upgrade result is already decided by then,
so there is no reason to hold the run open on a web asset. The unreach-
able-probe branch is advisory for the same reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stalwart v0.16 does not ship the WebUI in its Docker image — the admin
console is fetched from GitHub on first start. With no outbound HTTPS at
that moment the fetch fails silently: /healthz/ready still answers 200
because the mail server genuinely is serving, so both the installer and
the upgrade verifier reported success while /admin and /account 404'd
with nothing to explain why.
Install hook now probes /admin after the port-25 and PTR checks and, on
404, names the GitHub download as the cause rather than emitting a
generic failure. Upgrade verifier treats stable readiness as necessary
but not sufficient and confirms /admin before returning 0; the console
is polled under the same deadline because the bundle download runs
behind the server coming up, and failing on the first 404 would abort an
upgrade that was seconds from finishing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the drawn shield-and-envelope stand-in with the real mark from
stalw.art (/favicon.svg), in their #DB2D54.
Padded from the source's 159.95x139.07 to a square 159.95 viewBox with
the art vertically centred, matching every other catalogue icon — all of
which are square, so a non-square box would letterbox in the app tiles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second correction: the earlier version was only the outer blue disc.
This keeps all 10 artwork paths (circle, body, ears, muzzle, wool, face)
and drops the <g> holding the 'vikunja' wordmark, since a catalogue tile
wants the mark alone. Cropped from the wide 872.6x256.8 logo viewBox to
the mark's own 0 0 256 256, and the web-app attributes (class,
xml:space) removed.
Extracted programmatically from the supplied SVG rather than
transcribed, so no path data could be mangled by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces my drawn placeholder with the actual logo path supplied by the
maintainer.
Kept as .svg rather than .ico: webuiSyncAppIcon only looks for
<app>.svg then <app>.png, so an .ico would never be copied into the
frontend and the app would silently fall back to the default icon. SVG
is also the right format here — it is a single vector path that scales
to any tile size, where an .ico would bake in fixed raster sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retired last commit, deleted now at the maintainer's call. Mattermost
ended support in 2023, the community repo is asking for maintainers, and
the image had not been rebuilt in 1042 days — the staleness signal's
worst case after speedtest. Vikunja covers the same ground and is
actively developed.
Self-contained: every reference lived inside containers/focalboard/ plus
its generated manifest entries, so nothing else needed touching. Git
keeps the history.
Anyone with it already installed keeps a running container and their
data — removing the template only stops NEW installs. Their app will now
report as unknown in the App Center rather than offering an update,
which is the honest state for software with no upstream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by installing it. Two problems, one fatal and one worse.
The container crash-looped: the image runs as uid 1000, which under
rootless Docker maps to host sub-UID 232071 while the bind mounts are
owned by the install user, so Vikunja died on its first write to
/app/vikunja/files and restarted forever. Its own error message
diagnosed it exactly. Fixed with the existing USER_TAG mechanism the
portal container already uses — 0:0 under rootless (container root IS
the install user on the host), the real uid:gid under rooted — rather
than hardcoding either.
More seriously, the compose mounted the docker socket, copied in from a
template that needed it. A task manager has no business talking to the
daemon, and the socket is root-equivalent access on the host. Removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Focalboard is the one audit finding with no successor to follow.
Mattermost ended support in 2023, the community repo is openly asking
for maintainers, and the image has not been rebuilt in 1042 days.
Adds Vikunja as the replacement for NEW installs: lists, kanban, table
and gantt — the same job Focalboard did — from a project rebuilt 14 days
ago. One container on SQLite, no database sidecar, following the
catalogue conventions (tag sentinels throughout, category/title +
backup.db/backup.files labels, traefik block, gluetun markers).
VIKUNJA_SERVICE_PUBLICURL is wired to the existing APP_URL_TAG rather
than a hand-built URL. It is not optional for this app — get it wrong
and creating the first account fails with a bare "unauthorized" — and
APP_URL_TAG already resolves to https://<domain> behind Traefik or
http://<host>:<assigned-port> otherwise, so the port is never guessed.
(First attempt invented a PORT_DATA_1 tag that does not exist; checking
what the processors actually emit found the real mechanism, which
bookstack already uses.)
Focalboard is RETIRED, not deleted. Replacing an app in place would stand
still for anyone already running it — their data does not move to Vikunja
— so it keeps working, and instead:
* the description says plainly that it is unmaintained, why, and what
to use instead
* UPDATE_TYPE drops to manual, because there is nothing to update TO
and auto-pulling a 2.8-year-old tag is pure churn
Icon is a drawn placeholder, not the upstream trademark.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adolfintel/speedtest was last rebuilt 1624 days ago — 4.4 years, the
worst in the catalogue. LibreSpeed itself is fine: the project moved to
the librespeed org, and LinuxServer.io's build was rebuilt 2 days ago.
Chose lscr.io/linuxserver/librespeed over the org's own ghcr.io image
specifically because it is on Docker Hub: our tag enumeration and the
new staleness signal can query Hub but not ghcr, so this keeps the app
visible to the tooling that would catch it going stale again.
Not a tag swap. The LSIO image uses their house conventions, so the env
block is ported rather than copied: PUID/PGID/TZ (matching bookstack,
the catalogue's other LSIO app) and everything under /config instead of
/database. MODE, WEBPORT and ENABLE_ID_OBFUSCATION have no equivalent —
this image is standalone by design and always serves on 80 internally,
which is what our port mapping already assumed.
PASSWORD and DB_TYPE keep their names and meaning, so the existing
config keys still drive the results page and telemetry. DB_NAME is
deliberately left unset: the image picks a path inside /config, and
guessing one risks a database sitting outside the volume.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zadam/trilium was last rebuilt 810 days ago because the original author
handed the repository to the community project and put his own in
maintenance mode. The project is very much alive — triliumnext/trilium
was rebuilt 24 days ago. Same story as Pi-hole: we were pinned to an
abandoned original, not a dead project.
A genuinely clean swap, verified against upstream's own docs rather than
assumed: identical data path (/home/node/trilium-data), identical port
(8080), and TriliumNext states there are no special migration steps — it
opens an existing zadam database as-is. TRILIUM_DATA_DIR is now set
explicitly, matching upstream's compose.
URL metadata repointed at the maintained repo so the App Center links
somewhere that still gets commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mvance/unbound was last rebuilt 668 days ago. Replaced with
madnuttah/unbound (14 days): distroless, runs unprivileged as non-root,
listens on 5335 by default — exactly the "upstream behind a blocker"
shape — and publishes clean semver tags. klutchell/unbound is equally
fresh but defaults to port 53 (fighting Pi-hole/AdGuard for it) and its
tag namespace is CI build soup.
The shipped config was worse than the stale image. It was not a
recursive resolver at all:
interface: 0.0.0.0@53
forward-addr: 10.100.0.3@53 # "Local AdGuard" — a hardcoded IP
forward-addr: 9.9.9.9@853
So it listened on 53 (conflicting with any blocker on the same host),
forwarded to Quad9 — surrendering the "nobody sees my queries" property
that is the only reason to run Unbound in front of a blocker — and
pointed at AdGuard, inverting the dependency: AdGuard should point HERE.
Replaced with a drop-in at conf.d/libreportal.conf. The image's own
unbound.conf ends with `include-toplevel: conf.d/*.conf`, so ours ADDS
to a working recursive config the image author maintains rather than
replacing it — upstream keeps owning the parts that change between
Unbound releases. It contributes access-control (private ranges allow,
everything else REFUSE, so this can never become an open resolver for
amplification attacks), DNSSEC hardening, rebinding protection, and
cache sizing suited to a small VPS. Forwarding is included commented
out, with the trade stated rather than silently chosen.
Ports corrected to 5335:5335 — the old mapping assumed an image
listening on 53 internally. Added the libreportal.category/title labels
the app was missing (no traefik labels: it has no web interface).
Install hook copies the drop-in and repairs a stub directory first, the
same trap that kept Nextcloud's nginx from starting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app shipped cbcrowe/pihole-unbound — a third-party bundle last
rebuilt 841 days ago. Pi-hole itself is fine: the official
pihole/pihole was rebuilt 42 days ago. We packaged an abandoned fork,
not a dead project.
Not a tag swap. The official image is v6, which replaced nearly every
v5 environment variable with an FTLCONF_ equivalent — and the container
ACCEPTS the old names and ignores them, so a v5-style block looks
correct while configuring nothing, including the admin password:
WEBPASSWORD -> FTLCONF_webserver_api_password
WEBTHEME -> FTLCONF_webserver_interface_theme
PIHOLE_DNS_ -> FTLCONF_dns_upstreams (";" separates values)
DNSSEC -> FTLCONF_dns_dnssec
DNSMASQ_LISTENING -> FTLCONF_dns_listeningMode
REV_SERVER{,_TARGET,_DOMAIN,_CIDR} -> FTLCONF_dns_revServers, one
combined "<enabled>,<cidr>,<target>,<domain>"
FTLCONF_LOCAL_IPV4 -> gone in v6
listeningMode is ALL rather than the old "single": on a bridge network
queries arrive via the docker gateway, and "single" drops them.
The bundled unbound is gone, so PIHOLE_DNS_=127.0.0.1#5335 pointed at
nothing. New CFG_PIHOLE_UPSTREAM_DNS defaults to Quad9, with the config
documenting how to point it at the unbound app for full recursion. The
freed port slot becomes the (disabled) DHCP port, which v6 supports.
Volumes: v6 keeps config, databases and gravity under /etc/pihole, and
ignores /etc/dnsmasq.d unless explicitly re-enabled — so the old
two-mount layout is replaced by a single ./etc-pihole.
Variable names taken from the official v5->v6 upgrade doc, not memory.
Substitution verified end to end: every placeholder resolves and
revServers renders in the documented format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Up to date" answers one question — has the tag I track moved? — and an
abandoned project answers it reassuringly forever. The tag stays put, the
digest never changes, and the app reports as current while receiving no
security patches at all. Nothing in the UI could tell a healthy stable
app from a dead one.
An audit of all 34 anchor images found five in exactly that state:
speedtest (4.4y since rebuild), focalboard (2.8y — Mattermost dropped
support in 2023), pihole-unbound (2.3y), trilium (2.2y), unbound (1.8y).
The scan now records image_updated_at per app (one cheap Hub call inside
the existing registry window, cached between windows like everything
else) and emits stale_after_days from CFG_UPDATER_STALE_DAYS (365, 0
disables) so the UI and the config agree on one number.
Surfaced as an "unmaintained?" severity chip on the fleet row and a
dated explanation in the app detail. Phrased as an observation rather
than an accusation — plenty of small tools are simply finished — but it
does spell out the security consequence, because that is the part a user
cannot infer from "up to date".
Deliberately NOT a "needs action" row on the Overview board: it is not
fixable by pressing anything, and a permanently amber board teaches
people to ignore the board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by actually installing it. The compose bind-mounts
./resources/nginx.conf into the web container, but nothing ever copied
that file into the container tree, so Docker created a DIRECTORY in its
place and nginx died with:
error mounting ".../resources/nginx.conf" to rootfs at
"/etc/nginx/nginx.conf": not a directory
Worse than a hard failure: the app still recorded as installed. Three of
four containers came up, the DB and the app itself were fine, and only
the web front end was missing — a quiet, partial install.
Apps needing a resource file declare the copy in a hook (authelia does
exactly this); Nextcloud simply never had one. Adds
nextcloud_install_post_compose — after the compose file is written,
before permissions and `up` — which repairs any stub directory left by a
previous attempt and then copies the file.
The stub repair matters: without it the copy lands INSIDE the directory
(resources/nginx.conf/nginx.conf) and the mount fails identically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.
The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.
Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>