1468 Commits

Author SHA1 Message Date
librelad
6dbf2e6a55 Point vaultwarden's DOMAIN at APP_URL so it starts without a domain
Found by installing vaultwarden and one instance end to end. The template built
DOMAIN as https://<subdomain>, and blanking host_setup on a box with no
CFG_DOMAIN_n set (earlier in this branch) left it as a bare "https://".
Vaultwarden validates that value and exits:

  Error validating domain: empty host
  DOMAIN variable needs to contain the protocol (http, https)

APP_URL is already the address the app is reached at in both worlds —
https://vault.<domain> behind Traefik, http://<lan-ip>:<port> without it —
which is exactly what vaultwarden means by DOMAIN. Prior to the blanking the
value was "https://<app>." with a trailing dot, which started but pointed at a
host that never resolved, so this was broken before too, just quietly.

Verified: base and instance both come up and serve 200 on their own random
ports, each with DOMAIN set to its own address, and with distinct IPs and admin
tokens. Both were then removed; nothing left behind.

Four other apps interpolate the same legacy DOMAINSUBNAME_DATA into env vars
and get an empty value with no domain configured — gitea (DOMAIN, SSH_DOMAIN,
ROOT_URL), mastodon (LOCAL_DOMAIN), owncloud (OWNCLOUD_DOMAIN), jitsimeet
(PUBLIC_URL). They start rather than exit, so the breakage is quieter, and the
fix is not uniform: ROOT_URL/PUBLIC_URL want a URL like this one, while
DOMAIN/SSH_DOMAIN/LOCAL_DOMAIN want a bare host that APP_URL cannot supply.
Left alone pending that decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:45:21 +01:00
librelad
e9fceeab99 Enable multi-instance on vaultwarden and searxng
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.

Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.

Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.

The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.

Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.

WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.

Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:35:33 +01:00
librelad
39dad00455 Give vaultwarden and searxng random host ports
Both pinned an arbitrary host port — vaultwarden 8201:80, searxng 8083:8080 —
which was the only thing blocking them from being instanced. Neither number is
meaningful the way pihole's 53 or stalwart's 25 are, so both become
random:<internal> and portAllocate assigns each install (and each future
instance) its own. The ports appeared nowhere else: no hook, no compose, no
docs. Neither app is installed on the maintainer's box, so nothing to migrate.

Both now clear every instance guard. Of the eight apps the port guard caught,
that leaves six, all genuinely one-per-host.

Also made compose rewrite rules 2 and 3 skip commented lines, for the same
reason rule 1 already does. Spotted while verifying the above: vaultwarden
parks an optional exporter behind #, and rule 2 rewrote the container_name
inside that dead block while the service key above it kept the old name,
leaving it internally inconsistent. Harmless — rule 2 is anchored on
container_name: so it could never reach the image line — but there is no reason
to touch a commented block at all. Bookstack's rewritten identities remain
byte-identical to the running instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:25:50 +01:00
librelad
ef02b48966 Refuse instancing an app that pins a fixed host port
Audit of the per-app install hooks for singleton assumptions. The naming work
so far made identities unique, but a second copy still has to bind its own
ports, and `8201:80` is the same 8201 for every instance — the second container
simply fails at compose-up. `random:<internal>` is what makes an app
instanceable, since portAllocate then hands each instance its own host port.

Seven apps are caught: pihole (53 tcp+udp), stalwart (25/465/587/993), unbound
(5335 tcp+udp), traefik (443), searxng (8083), vaultwarden (8201), stoat
(7881). The message distinguishes the two cases, because they need opposite
fixes: an arbitrary pin like vaultwarden's 8201 should just become random,
while a DNS server on 53 or a mail server on 25 is genuinely one-per-host and
should never be instanced.

Runs before anything is cloned — this is a property of the app, not of the
instance. Bookstack is unaffected (all its ports are already random).

The rest of the hook audit found nothing further to fix:

- No hook writes to another app's config or deployed directory. The three that
  reference ${containers_dir}traefik / headscale only test [[ -d ]] to detect
  whether those are installed.
- Only two hooks read a foreign CFG_ namespace, and both are system-wide
  settings (CFG_DOCKER_INSTALL_TYPE, CFG_ENABLE_VIDEO), not another app's.
- No app declares a fixed container IP; all come from IP_TAG allocation.
- Host-level writes are limited to wireguard's sysctl IPv4-forwarding drop-in
  (global and idempotent) and its /etc/wireguard/params conflict probe. Traefik
  writes only under $containers_dir$app_name. Stalwart's /etc/stalwart path is
  inside its container.

Not mechanically checkable, so left as maintainer judgement: gluetun is a
network provider other apps join via network_mode container:gluetun-service,
and it plus wireguard hold NET_ADMIN and /dev/net/tun. Both are one-per-host
for reasons no guard can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:36:46 +01:00
librelad
e9bbe44601 Rename bookstack_db to bookstack-db for naming consistency
It was the only underscore-separated service name across all 38 apps; every
other helper uses a hyphen (nextcloud-db, matrix-postgres, owncloud-mariadb,
gitea-cache, mastodon-redis).

Beyond consistency this closes a naming collision by construction. Instance
slugs are <type>_<id> and may only contain [a-z0-9_] — the underscore is forced
there, because app configs are SOURCED and the uppercased slug becomes part of
CFG_<SLUG>_* variable names, which a hyphen would make invalid shell
identifiers. So a hyphenated helper name is one no slug can ever produce:
bookstack_home-db is unreachable, where bookstack_home_db was a name an
instance literally called "home_db" could also generate.

The four installed Bookstack apps were removed and rebuilt on the new template
(no data worth keeping, per the maintainer). All four are back up and serving
200 on the LAN, and their port rows join correctly to the renamed services.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:12:09 +01:00
librelad
64344bc5dc feat(updater): step apps to the next version automatically, one rung a day
Two halves: the ladder could not climb the commonest versioning scheme,
and nothing ever climbed it on its own.

The ladder stepped by bumping a tag's LAST numeric component, so
v1.158.0 went v1.158.1, v1.158.2, … and never arrived at v1.159.0. It
then failed closed, refusing to build a path. Synapse publishes
v1.159.0 and no v1.158.1 at all, so Matrix could not be laddered by the
button either — three-part semver minor bumps were simply unreachable.
updaterNextRung now considers a bump of every component, keeps the
candidates that exist upstream and takes the smallest: the next release
by definition, whether it lands in the patch position or crosses into a
new major. Shape discipline is unchanged, so 31-fpm-alpine still never
becomes 31-apache, and each rung is still probed, so none can be
skipped. updaterTagBumpAt moves here from the scan, its natural home,
which also breaks a source cycle.

updaterUpgradeAuto then climbs at most ONE rung per app per calendar
day, inside the install window, for apps set to auto. One rung because a
ladder run unattended can be several migrations deep before anyone
looks, and "restore the snapshot from a minute ago" stops comforting
once four have stacked; one a day so there is time to notice. It crosses
a major if that is genuinely the next release — refusing would strand an
app on the last version of its line forever — but one step at a time,
never as a leap. Two stamps: the target rung (a failure is not retried
until something newer ships) and the day.

Every rung goes through updaterUpgradeApp unchanged, so GATE 1 still
refuses any app without a real verifier, and the per-rung contract is
identical to the button: snapshot fail-closed, set version, pull, up,
verify, restore that rung and stop on any failure. History now records
the trigger instead of hardcoding "manual", including on the rollback
paths. CFG_UPDATER_LADDER_AUTO gates the whole thing separately from
CFG_UPDATER_AUTO, because "keep my apps patched" and "move my apps
between versions unattended" are different appetites for risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:55:51 +01:00
librelad
166acb9b7c Make instance hooks target their own container and directory
Audit of per-app hooks/tools found 19 of 33 apps whose helpers would have
operated on the BASE app after cloning. Two general causes, both fixed by
rewriting classes rather than patching apps:

- Container references escaped the rewrite whenever a flag sat between the
  docker verb and the target (`docker exec -u git gitea-service …`), since the
  old rule only matched a name immediately after the verb — and the hyphenated
  form missed the `<type>_` rule too. Hook trees now get the same discovered
  identity rename the compose does, reading names from the TYPE's compose since
  the clone has already been rewritten by then. Safe to apply broadly: the
  compose pass runs first and aborts for any app whose identities aren't
  <type>-prefixed, so a bare word like stoat's `api` never reaches it.

- Hooks that build the deployed path as "${containers_dir}<type>/..." instead
  of "$containers_dir$app_name/..." read and WROTE the base app's files —
  adguard's auth adapter edits AdGuardHome.yaml, so an instance would have
  rewritten the original's config. The trailing slash is optional in the match:
  dashy tests [[ -d "${containers_dir}dashy" ]] and gluetun cds into it, both
  ending at the quote. Only the first path component is touched, so
  ${containers_dir}prometheus/prometheus/... keeps its inner segment.

Re-audit: all 33 apps with hook trees are clean. Stoat still leaks, but it is
refused at the compose stage and never reaches this code.

Volumes audited too, and need no changes: no app uses named volumes, so the
./relative bind mounts every app uses resolve inside each instance's own
deployed dir. The absolute sources that exist are host or in-container paths
correctly shared read-only (/etc/localtime, /sys, /etc/ssl/certs). Jitsi's
${CONFIG} is set per-app by its own hook to $containers_dir$app_name/... and so
follows the slug.

Bookstack's rewritten tool tree is byte-identical to the live instance's across
all 8 files, so the running instances are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:51:04 +01:00
librelad
1d0f043bb5 fix(updater): anchor on the version sentinel, not the first image line
Stoat wore MongoDB's identity. Its services are named database / api /
events / …, so there is no stoat-service for updaterPrimaryImage to
match, and the fallback took the FIRST image line — mongo:8.0. Every
downstream fact inherited that: the app's version read 8.0 instead of
v0.15.1, its "8.3 available" chip was a MongoDB major dressed as a Stoat
release, its CVE scan covered mongo and none of the nine Stoat images,
and pressing Upgrade would have laddered the database 8.0 -> 8.3 beneath
a live sixteen-service stack.

The compose already says which image is the app's: every image line
carries a #LIBREPORTAL|<KEY>_VERSION_TAG| marker, and the one keyed on
the BARE app name (STOAT_VERSION_TAG, not STOAT_MONGO_VERSION_TAG) is by
construction the app's own version. 37 of 38 apps have exactly one; only
libreportal lacks it, and the scan skips that app anyway.

Ask the sentinel first, keep <slug>-service and first-line as fallbacks.
Verified across the catalogue: identical anchor for every app except
stoat, which is corrected. This is the ollama mislabel of P0 recurring
through a different hole — positional guessing — closed with the
metadata that was already there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:49:03 +01:00
librelad
9a7df822dc Rename every declared service when cloning an instance, or refuse
The compose rewrite assumed each app had exactly <type>-service and <type>_db.
That holds for Bookstack and almost nothing else: cloning Nextcloud left -db,
-redis and -web pointing at the ORIGINAL app's containers, and Matrix, Ollama,
Mastodon, Owncloud, Gitea, Jitsi, Invidious, Rocketchat and Mattermost all had
the same hole. Docker refuses a duplicate container name and two Traefik
routers sharing a name fight over the host, so those clones could not have
worked.

Service identities are now discovered from the compose itself — its
SERVICE_TAG_<n> markers plus its container_name values — and each is renamed.
Verified across all 38 shipped apps: 15 are fixed, 21 produce byte-identical
output to the old rule (Bookstack among them, so the running instances are
unaffected), and 2 are refused.

Details worth knowing:

- Separators compare as equivalent, so the app dir libreportal_catalog matches
  its libreportal-catalog-* services instead of being wrongly refused.

- Tokens are substituted longest-first through placeholders. \b has to end a
  token because per-port routers are named <service>-<portname>
  (traefik.http.routers.adguard-service-webui), which also means a short name
  could otherwise match inside a longer one — ordering is what prevents that.

- Commented-out lines are not harvested. Several templates park an optional
  sidecar behind # (adguard-exporter, pihole-exporter, wireguard-exporter);
  renaming those also mangled the image name in the same block, leaving a trap
  for anyone uncommenting it. Commented image: lines are skipped too.

- image: lines are genuinely excluded now. The old comment claimed service
  tokens "never appear in an image path", but libreportal builds a local image
  named after its own service and the old rule rewrote that reference.

An app with a service carrying no <type> prefix (stoat's api/database/minio,
prometheus's node-exporter/cadvisor) cannot be made unique mechanically, and
rewriting a bare word like minio would corrupt image: minio/minio. Those are
refused with an explanation and the partial clone is removed, rather than
handed back as an instance that silently fights the base app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:06:09 +01:00
librelad
74ee73da01 config: default Rocket.Chat and Stoat to automatic updates
The only two templates in the catalogue shipping UPDATE_TYPE=manual,
and both rationales turn out not to apply to what auto actually does.

Automatic updates act on update_available, which is digest-based: they
apply a REBUILD of the tag an app already tracks and never cross a
version line. Crossing lines is the stepped Upgrade, which is a
deliberate action and stays one.

So Rocket.Chat, pinned to 8.7.0 with mongo 8.0, cannot be walked across
a major by the automatic path — the failure its comment guarded against
was unreachable. And Stoat's nine stoatchat services are all pinned to
the same tag, so a pull moves them together or not at all; they cannot
"roll forward independently" into an API/events mismatch.

What manual did cost was real: neither app picked up security rebuilds
of the version it was already on. Rocket.Chat is carrying a critical CVE
at the time of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:56:42 +01:00
librelad
6aa6eb81a1 Fix WebUI service URLs and Traefik flag; add app icon to instance modal
Found while installing two LAN-only Bookstack instances — both in the same
no-domain path as the previous commit:

- apps-services.json advertised every app at http://localhost:<port>. The
  CFG_SERVER_IP override it reads is defined in no config file, so the lookup
  always fell through to the "localhost" default — a URL that only resolves for
  someone browsing on the server itself. Now falls back to $local_ip_v4, the
  same host APP_URL is stamped with.

- traefikManaged was inferred from `access == public`, a stated placeholder.
  Public only means the port is published on the host; it says nothing about a
  router. It reported true for both new instances despite their compose having
  traefik.enable:false. Now read from the port's own traefik column, gated on
  the app's domain actually being set — resolved per-app here rather than from
  $domain_full, which this generator never populates.

- The "New instance" modal led with bare text. It now shows the type's icon in
  the same .app-card-icon holder the grid cards use, so it's visually tied to
  the app the user clicked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:08:22 +01:00
librelad
48c024f69b fix(updater): stop the version display contradicting itself
Two display bugs the newer-version work made visible.

A fleet row showed a green "✓ Up to date" directly beside a
"1.159.0 available" chip. Both statements are individually true — you
ARE current on the line you track, and changing lines is a deliberate
act — but a row is a glance, not a place to reconcile two chips that
appear to disagree. The green all-clear now gives way to a neutral
"Newer version" whenever a newer release line exists; the chip still
carries the number and the tooltip still explains the move. The per-app
detail deliberately keeps "up to date" and is left alone: there the
badge arrives with a sentence explaining the distinction and an Upgrade
button, which is what makes it readable.

updaterDisplayVersion preferred the OCI version label unconditionally.
That label is inherited from the vendor's base image unless they
overwrite it, so it can describe the OS rather than the app: mongo:8.0
carries org.opencontainers.image.version=24.04, its Ubuntu base, and
Stoat's row read "24.04 → 8.0 · 02a0cc7" — not a version transition at
all. When the tracked tag is versioned we already hold an authoritative
version, so the label now wins only if the two agree on their leading
number. Keeps nextcloud 34 → 34.0.1, rejects mongo 8.0 vs 24.04, and
leaves rolling tags untouched since the label is the whole point there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 03:02:12 +01:00
librelad
e25c69e2a1 Make multi-instance work without a domain
An instance's isolation never needed a domain — its own slug, dir, secrets,
IP and randomly-allocated host port already make two copies independent. But
the routing layer assumed one, so a LAN-only box got a broken instance rather
than a port-served one. Four fixes:

- instanceCreate now rewrites the parent-service column of the cloned config's
  PORT_ rows to match the service names it stamps into the compose. That value
  is stored as network_resources.parent_service and joined against the
  compose-derived service names, so an instance left carrying the TYPE's
  service name matched nothing: it rendered in the WebUI with no port, no URL
  and no login row despite being up and reachable.

- `instance create --local` (plus a LAN-only toggle in the modal) forces every
  port to access=private, traefik=false, for a second copy that should stay
  off the domain even when one is configured.

- initializeAppVariables forces the traefik column false when no CFG_DOMAIN_n
  is set. Previously a traefik=true port with an empty domain stamped
  Host(`app.`) — a trailing-dot host matching nothing — and dragged APP_URL to
  https://app. with it, breaking every app that builds its links from APP_URL.
  host_setup is blanked for the same reason. The published host port is
  untouched; access type, not the traefik flag, gates allocation.

- APP_URL's direct host-port branch now prefers a new $local_ip_v4 (the source
  IP for the default route) over $public_ip_v4, which is the WAN address from
  an external resolver. LibrePortal never forwards ports, so the WAN address
  was unreachable for exactly the LAN/VPN clients that branch serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:53:43 +01:00
librelad
63b3af4cfc fix(tools): keep the Tools tab live while a tool runs
Locking every tab but Tasks is right for install/restart/backup: those jump
to the task log, so Tasks is the one tab you need. A tool run deliberately
stays where it was launched and brings its result back to Tools — greying
Tools out stranded the user on a tab they could no longer return to.

disableTabs() now takes the tab to leave alone, chosen per task type by
keepTabFor(). Same rule on the page-load path, which also stops yanking a
reload mid-tool-run over to the task log.
2026-08-19 02:38:16 +01:00
librelad
c26b7190c5 feat(tools): one small spinner toast per tool run
Running a tool from the Tools tab raised two full-size toasts around a few
seconds of work — "task started!" as the run began and "task completed!" as
it ended — and then opened the result modal that actually carried the answer.
The started one was stale by the time it was read and the finished one said
what the modal was already showing.

Tool tasks now go through LP_BACKGROUND_TASKS with a new `silent` flag (no
started toast, no finish line), and tools-manager raises a compact
"Running <tool>…" spinner toast for the duration instead, dismissed the
moment the result modal or the user list opens.

A failed list_users now falls through to the result modal too — it has no
account list to open, and the completion toast that used to report the
failure is gone.
2026-08-19 02:27:41 +01:00
librelad
f8d7dd139d fix(updater): load the ladder before probing for newer tags
updaterNewerVersionByProbe guarded on updaterTagExists being defined
and gave up when it was not. That function lives in the ladder, and a
cross-file function is not reliably loaded in the generator's context —
updaterAppPolicy a few lines below already carries an explicit source
fallback for exactly this. Without one the probe silently did nothing,
which is the failure mode it was added to remove.

Source the ladder when the function is absent, matching the existing
idiom. Verified by calling the probe with updaterTagExists undefined:
it now loads the ladder and returns v1.159.0 for matrixdotorg/synapse
instead of an empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:15:10 +01:00
librelad
abcfdc134a feat(validation): catch a container running a different secret than the config
Speedtest's config held one password while its container ran another, so the
WebUI credentials card advertised a login that could not work. Validation only
caught it by accident: the rename left a stale tag behind, and the tag-name check
fired on that. Had the rename kept the name, the divergence would have been
invisible — and it is the divergence, not the tag, that actually breaks someone's
login.

So compare them directly: for every app-prefixed tag in the DEPLOYED compose,
check the substituted value against the deployed config's. Live files only —
in the templates one side is a placeholder and the other a RANDOMIZED token, so
they could never agree.

Resolves the slot rather than giving up: a compose written before a key gained
its _<n> suffix still carries the old tag, so fall back to the numbered variant
and compare anyway. That is warned about, not passed over — the compose is due a
re-template — but the warning is separate from the failure, so a stale name with
matching values reports only the warning.

Verified both ways against a fixture of speedtest's real pre-fix state (warning
plus failure), the same fixture with values agreed (warning only), and the live
install across 39 apps (silent).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 02:01:46 +01:00
librelad
6861358809 fix(init): refuse to copy the install tree onto itself
copyFilesFromLocal ran `rm -rf "$script_dir"` before ever reading the source. When
source and destination resolve to the same path — which is what happens if you
run `sudo ./init.sh init` from inside /libreportal-system/install, an easy
mistake when re-running to re-bake the footprint — the rm deletes the source too,
the copy then fails, and the script exits.

The damage is worse than a failed copy: it exits before initRootHelpers and
before the sudoers tightening, so the install tree is gone AND the manager is
left holding the install-phase grant (ALL=(ALL) NOPASSWD: ALL) instead of the
scoped allowlist. I did exactly this on a live box; the tree was recoverable from
git, but the loose sudo rule is the part that matters.

Now: resolve both paths and refuse if they match, and validate the source before
destroying the destination rather than after. Both checks run ahead of the rm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:54:16 +01:00
librelad
bd10a9ab55 feat(update): detect a stale root footprint on git and local installs
footprint_update_needed only ever came from comparing the installed marker
against a channel manifest, so it could not fire on a git or local install —
they have no channel to ask. Those are exactly the installs whose code tree is
synced by hand, i.e. the ones most able to drift, and the drift was silent: the
helpers in /usr/local/lib/libreportal could sit behind the code that calls them
with nothing reporting it. That is how this box ended up running a crowdsec
helper with no bouncer-traefik-rotate action while the tool that needs it
shipped.

init.sh is what bakes the marker, so the install tree's own init.sh is
authoritative for every mode. lpInstallTreeFootprintVersion reads it and
lpFootprintStale compares. Wired into both non-release branches of the WebUI
status generator, and into the local branch of the interactive update check,
which is where a local operator actually looks.

Fails safe: a tree older than the marker, or a missing init.sh, reports current
rather than warning — verified alongside the real stale case.

Also gives webuiSystemUpdateCheck the self-reload guard webuiGenerateAppsToolsConfig
already documents. The WebUI task service sources these once at startup, so
without it an edited generator keeps writing the old JSON from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:44:05 +01:00
librelad
552f102517 init: bump footprint_version for the crowdsec helper change
scripts/system/libreportal-crowdsec gained bouncer-traefik-rotate and a
parameterised bind-lapi. That file is part of the root-owned footprint, which a
manager-run update deliberately cannot rewrite — so without a bump the installed
helper stays stale silently and `libreportal app tool crowdsec rotate_bouncer_key`
fails with a usage error, because the installed copy has no such action.

Bumping it is what makes the updater flag footprint_update_needed and ask for a
root re-install, per the rule in docs/contributing/development.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:35:38 +01:00
librelad
43a8252272 fix(validation): make single-app runs correct and audible
Two faults only visible by running the real command rather than the harness.

validateAppConfiguration never built the source index — that happened in
validateAllConfigurations. Called on its own the index was empty, so every tag
filled by a hook instead of a CFG key read as unbacked: `validation app matrix`
reported MATRIX_RUN_UID_TAG and MATRIX_RUN_GID_TAG as failures that
`validation all` correctly passed. A validator that contradicts itself depending
on how it is invoked is worse than one that is merely wrong.

It also printed nothing on success, so a clean single-app run looked identical to
one that never ran. It now reports either way, while the all-apps loop marks
itself so the per-app summary stays out of the bulk output.

Verified against the live install: matrix and mattermost both clean per-app, 39
apps clean under `all`, and running any subcommand mutates nothing (the
deliberately-kept AUTH_PROFILE orphans from configBackfillAllApps survive it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:34:10 +01:00
librelad
10d79cc297 feat(tasks): sort the app task list explicitly, and add a filter + search
The app-scoped Tasks tab never sorted. It rendered straight from
tasksManager.tasks and relied on loadTasks() having ordered it, so any path that
appends after the load — a task arriving from the event bus, a retry, a queue
merge — put that task wherever it happened to land rather than at the top. Sort
where the list is rendered instead of trusting it from three callers away.

Honest note on the reported symptom: a list_users task appearing mid-list could
not be reproduced from the stored records — replaying the sort over all 96 task
files puts the newest tool tasks first. What is demonstrably wrong is the
missing sort above, and a second latent fault it would mask: 8 of those 96
records carry a null createdAt (cron-created backups), and `new Date(null)` is
the epoch, so they sort as if from 1970 rather than as unknown.

Adds window.taskSortTime for that: createdAt when it parses, otherwise the
timestamp already embedded in the task id — the WebUI mints
task_<epoch_ms>_<rand> and the backend task_<epoch_s>_<hex>, distinguishable by
digit count. All three sorts now use it, so the global list, the app list and
the loader agree.

The filter bar is client-side over the already-loaded per-app array, so it is
instant and needs no reload: status chips (built from the statuses actually
present, with counts, so a chip can never return zero) plus a search over the
command and the task id — the id being what a deep link and a log URL both
carry, so pasting one finds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:33:29 +01:00
librelad
3ed912b5ed fix(validation): name the keys in the shared-secret failure
The duplicate-value check strips quotes off the value, then looked the keys back
up with grep -F "=$value" while the file stores ="$value" — so the lookup never
matched and the failure read "these keys share one value: — a secret should never
be reused", naming nothing. A failure report that cannot tell you which keys
collided is barely better than no check.

Found while confirming the check still holds now that configBackfillAllApps
(741edfd) resolves RANDOMIZED<n> during an update as well as an install, which
gives a shared placeholder a second way to reach a deployed config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:29:48 +01:00
librelad
998deddb5d feat(crowdsec): add the rotate tool, wire LAPI_HOST, drop two dead keys
The rotate action three places pointed at did not exist — crowdsec.config named
it, both recovery messages in the installer told you to run it, and CFG_CROWDSEC
_ACTIONS already listed "tools", but there was no tools/ directory at all. It
exists now: bouncer-traefik-rotate in the privileged helper (delete + re-add,
since cscli can neither re-issue nor print an existing bouncer's key), mirrored
into the config the same way the installer does, then Traefik restarted — it
holds the key file open and would otherwise keep presenting the revoked key.

CFG_CROWDSEC_LAPI_HOST was declared, documented and ignored: bind-lapi hardcoded
0.0.0.0:8080. The helper now takes <addr>:<port> and validates it the same way
the prometheus action validates its own, so the scoped sudoers still only sees a
fixed edit, and the installer passes the configured value.

Removed CFG_CROWDSEC_BOUNCER_NAME_TRAEFIK (the name is baked into the cscli calls;
a setting that cannot take effect is worse than none) and CFG_CROWDSEC_HOST_SERVICE
(documented as the unit stop/restart hits, but only the plural HOST_SERVICES is
read — the Services tab acts per-unit from that list), plus the now-orphaned
HOST_SERVICE field mapping.

scripts/validation/ needed registering in app_files.sh and cli_files.sh, which
are hand-maintained: without it the non-lazy path never sources the validator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:26:11 +01:00
librelad
7aed9102c4 fix(tools): keep the user on the Tools tab and show the result there
Running any tool jumped to the Tasks tab and left the user stranded there. That
is right for an install — long, log-heavy, worth watching — and wrong for a
tool, which is a short admin action whose answer is one line. Worse, half of
these are only meaningful back on Tools: List Users opens a modal over that tab,
and Create User Account returns a generated password that was being buried in a
log the user then had to go read.

Tools now stay put. On completion the tool's own outcome lines — the
isSuccessful/isError/isNotice output, ANSI stripped and framework boilerplate
filtered — are shown in a small result modal, with a View log button for
anything needing the full detail. list_users is left alone because the existing
account-list modal is already a better result view.

Also stops generate_arrays.sh walking scripts/dev. That directory is
`export-ignore`d, so it exists in a working clone but never in a shipped
install; generating a files_dev.sh entry from it wrote a reference into
files_source.sh that no install could satisfy, and the loader treats a missing
array file as a broken installation — every libreportal command stopped with
"files_dev.sh is missing from your LibrePortal Installation". Excluded alongside
unused/, system/ and release/. Regenerating also picked up scripts/validation,
which had never had an array file.

And Matrix's account listing prints its aligned line from python rather than
re-splitting the marker line in bash: TAB is IFS whitespace, so an empty display
name collapsed into the previous delimiter and shifted every later column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:25:19 +01:00
librelad
e14e295f3f stalwart: record that the public-side ACME path is not fully verified
The private direction of the mode switch is exercised end to end. The
public one has only ever run against a throwaway .test domain, where Let's
Encrypt rejects the contact address before the provider is created — so
everything past that call is reasoned rather than observed.

The plan shape IS confirmed up to that point: contact is a set, matchOn is
the directory URL, and a domain cannot reference automatic certificate
management without an acmeProviderId. What is unproven is the link holding
once the provider actually exists.

Saying so in the file beats leaving it in a chat log nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:23:45 +01:00
librelad
81c672c474 feat(validation): implement the config checks the CLI already advertised
`libreportal validation app|system|all|status` dispatched to four functions that
were never defined anywhere and were absent from the manifest, so every
subcommand failed. They exist now.

The checks are the ones that would have caught the bugs found while auditing the
credential rework, all of which were invisible at runtime — a mis-declared key
does not crash, it silently stops working:

  * two keys sharing one RANDOMIZED<n>, which gave Gitea's metrics token and its
    admin password the same value
  * a generated key with no slot number
  * an annotation whose value is absent from its line body, so the tag can never
    substitute — how 0.1.0 Mastodon shipped a placeholder as its live password
  * an auth adapter persisting a key the config does not declare, making every
    password reset a silent no-op
  * duplicate keys, keys under the wrong app prefix, and compose tags with
    nothing to fill them

Verified both directions: clean across all 39 apps today, and each of the seven
bug classes above is caught when reintroduced into a scratch copy of the catalog
(including the real 0.1.0 mastodon compose pulled from git history).

Version tags are exempt from the backing-key check: the updater builds both the
CFG name and the tag name from the slug at runtime, so neither literal exists to
find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:18:37 +01:00
librelad
e73f47ad46 generator: warn when the manifest indexes files git does not track
Regenerating the function manifest indexes what is on disk, which is
correct. The hazard is committing the result: an entry for a file git does
not have installs an autoload stub on every other clone, and the first call
to it unsets the stub, fails to source a file that is not there, and dies
with "command not found".

Easy to cause without noticing, and easy to cause repeatedly when more than
one person is working in the same tree — somebody else's in-progress file
is sitting under scripts/ whenever you happen to regenerate. It has already
happened twice today: once picking up a vendored dev helper, once picking
up an uncommitted validator.

Warn rather than skip. The scan is right to index them, and mid-work is a
normal state for a tree to be in; what is not fine is committing it. The
warning names the files, so the choice is obvious either way — commit them
alongside, or drop their entries first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:18:32 +01:00
librelad
f747083115 fix(updater): find newer versions the tag listing cannot see
Two holes that together left a versioned app reporting "up to date"
while a newer release was published.

Newer-version discovery enumerated a repo's newest 100 tags. Projects
that push a tag per commit drown their own releases in that window —
matrixdotorg/synapse's newest 100 hold five version tags, about ten
days of history. Once the release we need is older than the window it
is simply absent, and the app reports current forever. The failure is
silent and lands hardest on the apps furthest behind. Discovery now
falls back to PROBING exact tags, most-significant component first,
which has no window at all. Listing still runs first, so the common
case stays at one call; probing is bounded at 40 lookups. Same
reasoning the version ladder already uses, for the same reason.

Registry lookups were also throttled purely per-run, so an app
installed just after a window carried an empty available_digest until
the next one — up to CFG_UPDATER_REGISTRY_INTERVAL (6h) later. Empty
means update_available=false, which the UI renders as "up to date", so
a new app claimed to be current on no evidence. Seen live: seven apps
installed the evening after a 19:31 window all sat at
update_available=false, one of them two releases behind. Apps with no
prior registry answer are now looked up regardless of the throttle —
once each, and interval 0 still means manual-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:15:45 +01:00
librelad
741edfdeb1 config: carry newly-added app options into existing installs
An app's deployed config is written once, on first install, and never
touched again — dockerConfigSetupToContainer copies only when the file is
absent, precisely so an update can never overwrite values someone has
edited. Right default, unchosen consequence: an app that gains a CFG_
option in a new release has it on every fresh install and on no existing
one.

The failure is silent, which is the worst part. Nothing errors. The key
reads as empty and whatever depends on it quietly does something else.

Two halves, because there were two gaps. Per-app, when a config is set up,
options present in the template and missing from the deployed file are
appended with their comment blocks — the comment is the only explanation
of a new option that exists, and a bare key at the end of a documented file
is not actionable. And a sweep across every installed app after an update,
because an update redeploys LibrePortal itself and nothing else, so without
it a new option would reach an app only when someone next reinstalled it —
which, for an app that is working, may be never.

Existing values are never touched, and keys the deployed file has but the
template no longer does are left alone: a removed option is usually a
rename, and deleting someone's value is not recoverable. Deliberately not a
regenerate-from-template, which would place new keys in their proper
section and refresh the docs, but would put a whole-file rewrite of every
app config in the path of every app action — appending cannot lose a line.

Backfilled RANDOMIZED* defaults are generated in both paths. A placeholder
left in place would otherwise be a credential identical on every install
that took the upgrade.

The sweep is driven from the template directory, not the container one:
under rootless the container tree is drwxr-x--x and owned by the docker
user, so the manager can traverse it but not list it, and a glob there
expands to nothing — the sweep would report success having examined no apps.

Run against this install it found real drift beyond the test fixtures:
mattermost was missing CFG_MATTERMOST_ADMIN_PASSWORD, whose own comment
notes that without it the password-reset tool has nothing to write to, and
speedtest was missing its password key entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:13:14 +01:00
librelad
213c689cc1 stalwart: one primitive for probing the admin listener
stalwart_wait_http hardcoded the /healthz/ prefix and returned a yes/no,
so the admin-console check could not use it and grew its own copy of the
docker exec curl line. Extract stalwart_http_code <path> [max-time] and
build both on it: the wait loop keeps its probe-name signature and its
3s timeout, the console check keeps its 5s and gets the status code back
rather than a verdict, since 404 and no-reply-at-all need saying apart.

Probe commands are byte-identical to before; no behaviour change. The
upgrade verifier keeps its own copy on purpose — verifiers here are
self-contained (see nextcloud's, which inlines the occ idiom rather than
calling the install hook's wrapper) and should not drag a lifecycle file
they have no other use for into an upgrade run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:12:46 +01:00
librelad
27fea7aa17 feat(crowdsec): recover the bouncer key when the config lost it
Every install before the mirror target was corrected registered the bouncer but
never recorded its key, and cscli cannot show an existing bouncer's key — so
those installs had no route back to the value except re-registering, which
invalidates the key Traefik is already using.

The EXISTS branch now reads the key back from /etc/crowdsec/traefik_bouncer.key
when the config has none. That file is deliberately left owned by the manager at
0600 by libreportal-crowdsec, so this layer can read it without another
privileged round trip.

Restructured so both branches share one mirror, gated on the value actually
differing — a healthy reinstall now writes nothing instead of rewriting the same
key each time.

Exercised all six paths against the shipped block: fresh generation writes the
key; registered-with-empty-config recovers it; in-sync writes nothing; missing
and empty key files each explain what to do rather than failing silently; a cscli
error is unchanged. The masking added alongside holds throughout — the log shows
"Updated CFG_CROWDSEC_TRAEFIK_LAPI_KEY" with no value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:03:45 +01:00
librelad
c3b7d6ae35 fix(webui): use the app's real title in the Tools and Services headers
Both _titleBlock implementations title-cased the slug themselves instead of
calling getAppDisplayName, so the Tools tab read "Run app-specific actions for
Rocketchat" and Services read "the docker compose services that make up
Speedtest".

getAppDisplayName already resolves a slug to the app's declared title through
window.apps. Using it fixes four apps beyond Rocket.Chat:

    rocketchat           Rocketchat            -> Rocket.Chat
    speedtest            Speedtest             -> LibreSpeed
    ipinfo               Ipinfo                -> IPinfo
    libreportal_catalog  Libreportal Catalog   -> LibrePortal Catalog

The slug casing is kept as the fallback for the window.apps-not-loaded-yet case,
which is what the helper does internally anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:48:51 +01:00
librelad
5087a88f65 fix(crowdsec): mirror the bouncer key to a file that exists
crowdsec_install_host.sh wrote CFG_CROWDSEC_TRAEFIK_LAPI_KEY into
${configs_dir}security/security_crowdsec, but no such template ships in
configs/security/ (only security_logins and security_ssh), so
checkConfigFilesMissingFiles never created it, the -f guard always failed, and
the key was never mirrored — every install logged "Live config not present yet"
and the setting stayed empty. The key is declared in crowdsec.config, so point
the write there.

Switched the hand-rolled sed for updateConfigOption, which escapes the value,
routes the write through the user owning the containers tree, and re-sources so
the key is live in the same run. The old sed used | as its delimiter and would
have corrupted the file on a key containing one; verified the new path
round-trips a key with + / and | intact.

updateConfigOption logged "Updated <key> to <value>", and checkSuccess both
prints its message and appends it to the docker log — so mirroring the bouncer
key would have written it to disk in plaintext, as every admin password the auth
adapters persist already was. Credential-looking keys now log the name only;
everything else still logs its value, which is what makes that log useful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:48:38 +01:00
librelad
84b027feed chore(dev): vendor lp-shot, excluded from release tarballs
The WebUI screenshot helper CLAUDE.md already tells agents to use only
ever existed on the maintainer's box. Vendoring it means it survives a
machine rebuild and the setup steps are written down.

It does NOT ship: make_release.sh builds with `git archive`, which honours
export-ignore, so scripts/dev joins scripts/release and docs on that list.
Verified — the staged tarball has 1666 files and none under scripts/dev.

Keeping it out of releases is deliberate, not incidental. lp-shot signs
itself a session from the jwtSecret in frontend/.auth.json, which is fine
on a box where you already own that file, and has no business sitting in
a user's install where it would read as a backdoor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:43:35 +01:00
librelad
5c7372b8c2 grafana/prometheus: permission the directory, not the container's files
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>
2026-08-18 23:39:44 +01:00
librelad
6cc604f21f matrix: keep the secrets chown off Synapse's media store
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>
2026-08-18 23:29:08 +01:00
librelad
5b6ed924d2 fix(chat tools): wire list_users into the WebUI user-list modal
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>
2026-08-18 23:23:12 +01:00
librelad
77b50e5226 refactor(config): drop three more keys nothing reads
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>
2026-08-18 23:23:05 +01:00
librelad
ff25b08ee8 Make multi-instance actually install, and stop apps stealing each other's network rows
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>
2026-08-18 23:15:21 +01:00
librelad
f9ec4cc986 refactor(auth): drop the unread AUTH_PROFILE key
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>
2026-08-18 23:13:53 +01:00
librelad
af78ce1681 stalwart: make the mode switch finish the job itself
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>
2026-08-18 23:03:23 +01:00
librelad
88e9631b68 stalwart: choose private or public mail, and switch between them later
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>
2026-08-18 22:52:55 +01:00
librelad
9d6fd25c41 Point an instance's APP_NAME at its own slug
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>
2026-08-18 22:40:24 +01:00
librelad
861a51a22c Stop misreporting a reinstall's admin account and stale firewall rows
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>
2026-08-18 22:09:37 +01:00
librelad
133f54cd4f docs: correct the lp-shot auth note — it signs its own session
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>
2026-08-18 22:05:51 +01:00
librelad
2ea6340139 Prune unreadable dirs in the app-config scan
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>
2026-08-18 21:55:27 +01:00
librelad
4b6b05db81 fix(rocketchat): correct the roles call, satisfy the password policy, add enable
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>
2026-08-18 21:39:42 +01:00
librelad
71bc78df27 feat(rocketchat,stoat): user-management tools, sized to what each app supports
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>
2026-08-18 21:32:45 +01:00
librelad
5835fa09d7 fix(auth): only generate an admin password where something creates the account
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>
2026-08-18 21:31:05 +01:00