1415 Commits

Author SHA1 Message Date
librelad
a96a3a69a1 fix(linkding): declare the admin keys its auth adapter writes
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>
2026-08-18 21:24:28 +01:00
librelad
cbbb978e50 feat(webui): make Overview board rows clickable end to end
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>
2026-08-18 21:18:45 +01:00
librelad
c9a513abc0 fix(webui): stop inventing a username on password-only app cards
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>
2026-08-18 21:13:30 +01:00
librelad
296c6ddff1 fix(webui): show generated admin passwords again on the app cards
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>
2026-08-18 21:09:33 +01:00
librelad
4a95b4c41e fix(webui): make the app detail category tag clickable and its icon legible
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>
2026-08-18 21:00:45 +01:00
librelad
c2fdfaccdb fix(stoat): tell the services the RabbitMQ password we gave the broker
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>
2026-08-18 20:56:52 +01:00
librelad
e8a2aa453e fix(auth): resolve slot-numbered credential keys, drop two dead ones
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>
2026-08-18 20:55:52 +01:00
librelad
80b94fb21f fix(stoat): write every bind-mounted file before anything that can fail
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>
2026-08-18 20:54:49 +01:00
librelad
ec98a83b48 fix(gitea,invidious): stop two secrets sharing one placeholder index
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>
2026-08-18 20:51:31 +01:00
librelad
4eb5fa6434 feat(matrix,stoat): run without a domain, on the LAN or a VPN
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>
2026-08-18 20:51:16 +01:00
librelad
c7df07ffc2 containers: trim overlong app card descriptions
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>
2026-08-18 19:57:34 +01:00
librelad
a0f0bc087e fix(matrix,stoat): derive the public host without reading the compose
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>
2026-08-18 19:55:32 +01:00
librelad
751a4578d6 webui: trim the field tooltips added for the slot rename
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.
2026-08-18 19:53:06 +01:00
librelad
01ba33e92b configs: trim overlong config-key descriptions to one-liners
The WebUI config editor renders these inline comments as field help.
Several had grown into paragraphs (updater, rootless networking, MTU,
install mode, backup engine); cut them back to a single line while
keeping option lists and **ADVANCED**/**DEV** markers intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:52:24 +01:00
librelad
a1541ace23 refactor(secrets): slot-number every generated config key
Makes the convention uniform: if a config key holds a generated value, its name
ends in a slot number. 42 keys across the catalog, up from the 9 database ones
done previously — admin passwords, app keys, tokens, HMAC and auth secrets,
generated usernames and database names. An app needing a second credential of a
kind now just adds _2; nothing is registered anywhere, since the tag name is
derived from the key by tags_processor_app_config_values.

Keys holding an operator-chosen value (CFG_NEXTCLOUD_ADMIN_USER=admin) keep their
names — the slot number is what marks a value as generated.

The rename would have silently cost seven keys their WebUI field mapping. The
frontend resolver matches a mapping key against a config key by equality, _suffix
or prefix_ (apps-manager.js findMatchingCFGKey), so the generic "ADMIN_PASSWORD"
entry stops matching CFG_GITEA_ADMIN_PASSWORD_1 — it neither ends with
_ADMIN_PASSWORD nor starts with ADMIN_PASSWORD_. Rather than loosen the matcher
(PORT_1 relies on its numeric suffix being part of the name), add explicit
entries. Did the same for eight keys that were already unmapped before this
change, so all 42 now render with a label and, where appropriate, masked: the
only one typed as text is Mastodon's VAPID public key, which is public by design.

Verified by simulating the resolver against every app config, and by running each
app in the catalog through fill -> hook -> templating: every secret tag
substitutes, no RANDOMIZED placeholder survives, every compose still parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:51:27 +01:00
librelad
65167463f9 fix(chat apps): tag every service so its IP actually substitutes
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>
2026-08-18 19:48:09 +01:00
librelad
b562059251 webui: trim overlong field tooltips to one-liners
Cut the tooltips that had grown into paragraphs (backup strategy,
version, monitoring, DB/secret fields, Dashy shortcuts) down to a
single line, matching the concise style of the rest of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:43:25 +01:00
librelad
4685320353 feat(secrets): real VAPID keypair for mastodon, slot-numbered DB passwords
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>
2026-08-18 19:42:50 +01:00
librelad
be7d6813ef revert(secrets): drop the digit constraint on generated passwords
Slots only need to be independent of each other, which the \b anchoring in the
RANDOMIZED* replacers already guarantees. Constraining the character mix was
solving a different problem than the one asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:35:05 +01:00
librelad
5706498565 fix(secrets): move app credentials into <app>.config, fix slot collision
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>
2026-08-18 19:33:40 +01:00
librelad
c11052b753 fix(mastodon): make tag annotations substitutable
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>
2026-08-18 05:36:28 +01:00
librelad
9919eea138 stoat: add the ex-Revolt stack as the closest Discord equivalent
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>
2026-08-18 05:28:46 +01:00
librelad
50059ea1b8 rocketchat: add Rocket.Chat with a single-node Mongo replica set
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>
2026-08-18 05:28:46 +01:00
librelad
c0025d8211 mattermost: add Team Edition as a low-friction chat app
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>
2026-08-18 05:28:31 +01:00
librelad
b9e334dc49 matrix: add Synapse + Element as a federated chat app
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>
2026-08-18 05:28:31 +01:00
librelad
9084280ea8 backup: add a mongo driver for live, consistent dumps
Rocket.Chat and Stoat are both MongoDB-backed, and the backup engine only
understood postgres, mysql/mariadb and sqlite — so a live snapshot of either
would have captured a torn data directory that may not even mount.

Adds mongo as a fourth kind: mongodump --archive on the backup side,
mongorestore --archive --drop on the restore side (idempotent, so the caller's
retry loop works the same as it does for pg_dump --clean), and a ping-based
readiness probe that also waits out a replica set electing its primary.

Credentials are optional. The shared sh preamble sets them from
MONGO_INITDB_ROOT_USERNAME/PASSWORD when present and passes nothing when not,
built with 'set --' so a password containing spaces survives word splitting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:28:20 +01:00
librelad
1e9e042d41 webui: shorten UPDATE_TYPE tooltip to a one-liner
Match the concise style of the other field tooltips instead of
explaining the whole update/rollback flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:22:09 +01:00
librelad
63f276523b stalwart: run as container-root so it can write its own data directory
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>
2026-08-18 05:14:19 +01:00
librelad
7ed1cddd5c stalwart: answer the setup wizard instead of handing it to the user
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>
2026-08-18 05:05:03 +01:00
librelad
0eda3104c7 stalwart: report a missing admin console without failing the upgrade
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>
2026-08-18 04:59:02 +01:00
librelad
4c80ea018d stalwart: check the admin console, not just readiness
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>
2026-08-18 04:54:32 +01:00
librelad
4fae22c89e fix(stalwart): use the official logo instead of my placeholder
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>
2026-08-18 04:23:33 +01:00
librelad
b238a0a6e8 fix(vikunja): real logo mark — the llama, without the wordmark
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>
2026-08-18 04:18:44 +01:00
librelad
55da05fb4e fix(vikunja): use the real Vikunja logo mark
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>
2026-08-18 04:15:55 +01:00
librelad
42995eb373 fix(webui): prune icons for apps that no longer exist
Removing Focalboard from the catalogue left its icon still being served:
the sync only ever ADDS, so every app ever dropped leaves a file behind
that the portal keeps offering for something that is gone. Same shape as
the task queue that only ever appended.

webuiPruneAppIcons runs at the end of the sync and removes only icons it
can match to a missing template — anything else in the directory is left
alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:14:55 +01:00
librelad
f596b36a73 chore: remove Focalboard from the catalogue
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>
2026-08-18 04:13:00 +01:00
librelad
183ba00690 chore: delete scripts/unused — 19 shelved app templates
Dead weight: 19 app directories, 101 files, none referenced by any live
code path and already export-ignored so they never shipped in a release.
Several were actively misleading — the mailcow attempt in there is what
the mail-server discussion kept having to explain around, and none of
them would survive contact with the current conventions (tag sentinels,
port manager, backup labels, update policy).

Verified before deleting: nothing outside the tree references it, and
none of the 19 duplicates a live app in containers/. Git keeps the
history if any of them is ever wanted back.

Function manifest and source arrays regenerated — no entries pointed
into the deleted tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:10:46 +01:00
librelad
b915e0731a fix(vikunja): run as the mount owner, and drop the docker socket
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>
2026-08-18 04:08:56 +01:00
librelad
cdf76040f1 feat(vikunja): add Vikunja; retire Focalboard rather than swap it
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>
2026-08-18 04:04:23 +01:00
librelad
298ccf4868 fix(speedtest): follow LibreSpeed to its maintained image
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>
2026-08-18 04:02:01 +01:00
librelad
b7f3d30170 fix(trilium): follow the project to TriliumNext
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>
2026-08-18 03:56:12 +01:00
librelad
614e895b5d fix(unbound): maintained image, and actually recursive this time
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>
2026-08-18 03:51:52 +01:00
librelad
ab811440e4 fix(pihole): use the official image, ported to Pi-hole v6
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>
2026-08-18 02:30:13 +01:00
librelad
ca266e9391 feat(updater): flag apps whose image upstream has stopped rebuilding
"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>
2026-08-13 01:09:03 +01:00
librelad
2c589b2a51 fix(updater): treat docker.io/ as Docker Hub, not a third-party registry
The registry helpers rejected any repo containing a dotted host segment,
which caught 'docker.io/authelia/authelia' — Docker Hub spelled out in
full. Those apps were silently skipped by tag enumeration and version
laddering. Strip the docker.io/ and index.docker.io/ prefixes before the
host check; genuinely third-party registries (ghcr.io, quay.io, lscr.io)
are still correctly skipped.

Found by auditing every app's anchor image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:55:04 +01:00
librelad
fc169e7a4a feat(updater): clean up superseded images after a stepped upgrade
The live Nextcloud 31→34 climb left 4.4 GB of images behind — one per
rung, each ~1.5 GB, all still present after it finished. On a small VPS
that is the difference between working and full.

`system reclaim` cannot help: it collects DANGLING images, and every rung
is a real tag, so all of them stay tagged and stay on disk. (Rolling apps
never hit this — moving a floating tag orphans the old image, which
reclaim then collects. It is specific to laddering.)

After a SUCCESSFUL climb only, remove the images stepped through, keeping
the immediately-previous version so a roll-back needs no download.
CFG_UPDATER_UPGRADE_PRUNE=false keeps everything. Never runs on failure,
where the older images are exactly what recovery may need.

Tested: a 3-rung climb removes 31 and 32 and keeps 33; a single-step
climb removes nothing (its previous version IS the rollback target); the
config switch disables it.

Found by looking at the box after the first real ladder run — the feature
worked, and then quietly cost 4.4 GB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:47:37 +01:00
librelad
98b7f7dd39 fix(updater): treat a flag in the version slot as a flag
`updater upgrade <app> --detach` parsed "--detach" as the target version
and refused with "no safe path from 31-fpm-alpine to --detach". It failed
safe, but blaming the version for a misplaced flag is a poor way to say
the flag is not supported here. Unknown options now say so.

Found during the first live ladder run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:34:40 +01:00
librelad
6b44b7dd59 fix(nextcloud): copy nginx.conf on install, so the web container can start
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>
2026-08-13 00:18:55 +01:00
librelad
046d69bad7 feat(updater): Upgrade button for cross-version moves
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>
2026-08-13 00:08:16 +01:00
librelad
0679fd65b2 feat(updater): stepped upgrade engine — climbs a ladder, verifying each rung
Ties the ladder and the verifiers together behind a new verb:

  libreportal updater upgrade <app> [version] [--dry-run]

Per rung, and every part is load-bearing:

  snapshot (fail-closed) -> set version -> pull -> up -> VERIFY -> next

On failure anywhere: restore THIS rung's snapshot, put the version back,
stop, and leave the app on the last version it actually verified at. The
ladder never continues past a doubt.

A snapshot PER RUNG rather than one at the start, because upstream
migrations are usually one-way — Nextcloud 32's schema cannot be undone
by putting the 31 image back. The recovery guarantee is "restore the
snapshot from sixty seconds ago", which only holds if every rung has one.

Two gates before anything moves. An app with no <app>_upgrade_verify is
refused outright: the generic health check cannot see a half-finished
migration, so laddering on it would be a guess wearing a safety label.
And a ladder that cannot be computed end to end refuses rather than
attempting a partial climb.

`updater upgrade` is a separate verb from `apply` on purpose: apply moves
you WITHIN a release line (and may be automatic), upgrade moves you
BETWEEN lines and is always a deliberate act. Dry runs execute inline so
the plan is instant to read.

updaterSetAnchorVersion rewrites the image tag AND its version sentinel
together — updating only the image would leave the sentinel advertising
the old version, and the next config regeneration would silently revert
the app.

Tested with stubs against the real code paths: the no-verifier gate holds
and changes nothing; a dry run has zero side effects; the happy path
snapshots at each current version before moving; a verify failure on rung
2 of 3 stops with the app on rung 1, restored, and never touches rung 3;
a failed snapshot moves no version and pulls nothing; a container that
will not start is rolled back.

NOT yet exercised on a live install — no app here needs a ladder. The
first real run should be a dry run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:04:33 +01:00