Both apps ran `chmod -R 777` over their data dirs in install_post_start —
after the container has booted and written files as its own uid (grafana
472 -> host subuid 231543, prometheus nobody 65534 -> 296605). chmod by a
non-owner fails, so every REINSTALL printed "Operation not permitted" per
file and failed the step; a fresh install passed only because the dir was
still empty when it ran. Reproduced on a live install of both.
The permission is only needed on the DIRECTORY, so the container can
create its store on first boot. What it creates after that is its own and
must stay that way — chowning or chmod'ing it away is what would actually
break these apps. So: non-recursive 0777 on grafana_storage and prom_data.
prometheus's config dir is a separate case — the container only READS it —
so it gets a+rX,go-w instead. The go-w matters: a+rX only adds bits, so
without it prometheus.yml stays world-writable on every install the old
777 already touched, and prometheus obeys that file. Everything there is
written through runFileOp, i.e. by the owner, so owner-write is enough.
updateFileOwnership used `runSystem chown`, but the scoped sudoers grants
the manager root only for the fixed LibrePortal helpers and
systemctl/ufw/nft/sysctl — never a bare chown, which would be
root-equivalent. It was denied on every call ("I'm sorry libreportal"),
printing a red ✗ Error on every prometheus install, and its message
referenced an undefined $user_name so it read "with ownership". Use
runFileOp (runs as the owner of the data plane) and name the user.
Verified live: prometheus and grafana both installed fresh and reinstalled
with 0 errors; prometheus.yml went 0777 -> 0755 with prometheus still
healthy (200); grafana serving 200; grafana.db and prom_data/data keep
their container uids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Restricting permissions on the Synapse secrets" step chowned
$app_dir/data recursively, which also walks data/media_store — files
written by Synapse itself. Under rootless that is invisible (container
root maps to the docker install user, so everything is chownable), but in
rooted mode container root IS host root: the chown runs as the manager and
would fail per file, then fail the step, over files that must keep their
own ownership anyway. Same shape as the stoat fix, caught before it bit.
Scoped to the top-level files the hook actually writes — homeserver.yaml,
log.yaml, signing.key, .lp-admin-token — which is what the step name means.
Note this app was NOT producing the stoat symptom today: matrix's postgres
data lives at $app_dir/postgres, outside the directory being walked.
element/ keeps its recursive chown: one LibrePortal-written config.json
the container never writes.
Verified: reinstall clean, exit 0, homeserver.yaml + signing.key still
0600, media_store untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
instanceCreate re-namespaced the config KEYS (CFG_<TYPE>_* ->
CFG_<SLUG>_*) but left APP_NAME's VALUE at the type. installApp resolves
the app it operates on from CFG_<SLUG>_APP_NAME, so `instance create
bookstack fwtest` ran the entire install pipeline against the BASE app:
"Install bookstack", the base deployed dir, compose down/up on the
already-running base container, base DB row re-stamped — and the instance
never installed at all. Only the template dir was left behind.
Every base app ships APP_NAME == its own slug; stamp instances the same.
Instance install still does not complete after this: the cloned compose
keeps the type's tag namespace (#LIBREPORTAL|BOOKSTACK_APP_KEY_1_TAG|...)
while the config now defines CFG_<SLUG>_*, so the placeholders never
substitute and the pre-start guard refuses to launch. Fixing that is a
separate change to _instanceRewriteCompose.
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>
The previous note was wrong: it said lp-shot needs a session handed to it
in the environment and that agents should ask the maintainer for one.
It doesn't. The backend keeps {username, passwordHash, jwtSecret} in
frontend/.auth.json and mints cookies as jwt.sign({sub}, jwtSecret), so a
tool on the host signs the same token /api/auth/login would issue — no
password anywhere (the stored one is a bcrypt hash). The env overrides
are only for shooting a remote instance.
Also note the boot-splash wait, since a splash in the PNG now means boot
actually stalled rather than the tool firing too early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sourceScanFiles "app_configs" runs as the docker install user and walks
all of containers/. Container-created data dirs (e.g. <app>/postgres,
uid 231141 mode 0700 under rootless) aren't listable by that user, so
find printed a "Permission denied" line per dir into the middle of every
app install's output — noise that reads like the install is touching
other apps.
Prune unreadable/non-traversable dirs instead of descending into them.
They never hold a .config, and pruning keeps genuine find errors
visible where a blanket 2>/dev/null would not. Verified the scan returns
the same 10 configs, with no stderr.
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>
lp-shot now exists on this machine, and two things about it were not
written down anywhere: every WebUI route except / sits behind the login
so it needs a session in the environment, and screenshotting a change
means copying it into the bind-mounted live tree first.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>