115 Commits

Author SHA1 Message Date
librelad
4aaf68c101 fix(source): regenerate files_source.sh, which had gone stale
files_validation.sh exists in the tree but was missing from the generated
source-array, so eager loading never sourced it. Found while diffing the repo
against the live install, where the array had been regenerated in place and
carried the entry the committed one lacked.

Regenerated with generate_arrays.sh rather than copied back from the install, so
the committed array is what the generator actually produces.
2026-08-20 22:15:20 +01:00
librelad
9c8f0782e1 feat(updater): build dates for off-Hub images, from the config blob
The unmaintained warning runs on one field — when upstream last rebuilt
the image — and off-Hub apps had no value for it. Hub answers in a single
call; the OCI API does not expose it at all, so an app on ghcr.io, quay.io
or lscr.io simply could not be assessed for staleness, which is the one
signal a user cannot work out for themselves.

It is in the image, just further down: manifest -> (if a multi-arch
index) a platform manifest -> config blob, whose "created" is the build
time. Three requests instead of Hub's one, once per registry window, and
only for the apps Hub cannot answer for — which is why Hub keeps its
cheap path rather than being routed through this.

Index and single-arch manifests are distinguished explicitly rather than
by position: in an index the first digest is a CHILD manifest, in an
image manifest it is the config itself, so reading "the first digest"
would silently fetch the wrong blob for one of the two shapes.

Live: stoat 2026-08-08, bookstack 2026-08-17, speedtest 2026-08-16,
invidious 2026-08-05 — all previously null. Hub unchanged, navidrome
still answered by the single-call path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:11:29 +01:00
librelad
d09b21eec1 feat(updater): probe any OCI registry, not just Docker Hub
Version discovery spoke only hub.docker.com, and every other registry
got a shrug: updaterTagExists returned "no" and updaterRegistryTags
returned nothing. Five apps live off Hub — stoat and wireguard on
ghcr.io, bookstack and speedtest on lscr.io, invidious on quay.io — and
for all of them the updater reported "up to date" having never asked.
That is the same dishonesty as a scan that never ran: an absence of
evidence rendered as a clean bill of health.

There was never a barrier, only unwritten code. The standard
Distribution API needs one extra step: request, read the
WWW-Authenticate challenge, fetch a token from the realm it names,
retry. ghcr.io, quay.io and lscr.io all answer anonymously for public
images — lscr.io by pointing its realm at ghcr.io, quay.io by not
challenging at all.

Docker Hub deliberately keeps its own path. hub.docker.com returns tags
NEWEST-first, so the 100 it pages are the 100 that matter, and it draws
on a different budget from the pull limit — registry-1.docker.io
manifest reads count against the anonymous 100/hour that the updater
needs for actual pulls, and a ladder probes a tag per rung.

Tag LISTING off Hub is a weaker signal and the comment says so: /v2/
tags/list is lexical, not newest-first, and large repos cap the page, so
the newest release can legitimately be absent. Probing backfills it,
which is why the probe fallback added earlier matters more off Hub than
on it.

Verified against all four registries: existence probing correct on eight
cases including true negatives; stoat climbs v0.15.0 -> v0.15.1 through
ghcr.io, and correctly reports nothing above v0.15.1 — the same answer
as before, but now because it looked. Hub unregressed: matrix still
resolves v1.158.0 -> v1.159.0 and nextcloud still ladders 31 -> 32 33 34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:38:56 +01:00
librelad
93ec260298 Provision a Stoat owner account, add create/reset user tools
Stoat shipped with no account and no way to make one from LibrePortal. It is
first-come-first-served, with invite_only=false, no captcha and no email
verification, so every install left a window between the API answering and
someone signing up in which anyone who could reach the port could take the
instance. The installer now claims the configured account as soon as the API
responds, and prints the credentials instead of "go and register".

Provisioning goes over HTTP, not Mongo: an account needs a login AND a
completed onboarding (accounts holds one, users the other) and passwords go
through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the
instance exactly as it was before this existed, which must not fail an
otherwise good install of sixteen containers.

Both obvious config defaults are rejected by Stoat, which is only visible as a
failed install, so both are chosen against its rules: example.com comes back
DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin"
comes back InvalidUsername (reserved) hence "administrator".

Two of the three missing adapter operations are now implemented:

- createUser: create, log in, complete onboarding. Without the last step an
  account can sign in and then sits on a pick-a-username screen forever.

- setPassword: previously excluded because hand-rolling argon2 risks writing a
  hash nothing can verify, locking the holder out with no error at the time.
  That objection is answered by refusing to hash at all — authifier already
  owns a reset flow, so this writes only its password_reset token to Mongo and
  lets PATCH /auth/account/reset_password do the hashing with the same code
  that verifies. Verified: reset by username and by email, new password logs
  in, token consumed.

setAdmin is still NOT implemented, and the header now says so with evidence
rather than assertion. Stoat has no instance-level admin flag: the user
document holds only _id/username/discriminator and GET /users/@me adds only
relationship and online. Permissions are per-server bitfields on server_members.
A "make admin" button would invent a concept the app does not have.

Also fixed two things found while testing:

- post_start returned early when the public URL needed no settling, which
  skipped everything after it — so provisioning would have been silently
  missed on exactly the domain-backed installs that guessed the URL right.

- _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external
  resolver, in URLs compiled into the web client. Same fix as the APP_URL
  processor: prefer $local_ip_v4, since LibrePortal never forwards ports.

Verified end to end on a clean install: the owner account is created and
onboarded, the generated password logs in, both new tools run through
`libreportal app tool`, and a created account survives a password reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:37:32 +01:00
librelad
64ff5f508b feat(updater): verifiers for Matrix, Mattermost and Rocket.Chat
GATE 1 refuses to ladder an app that cannot prove a rung landed, and
only mastodon, nextcloud and stalwart could. None of those are installed
here, so the stepped upgrade — button or automatic — was unreachable for
every app on the box.

Three fixes.

_updaterPrimaryContainer assumed the container is "<app>-service". It is
a convention, not a rule: matrix names its anchor service matrix-synapse
and stoat names its api (container stoat-api). The verifier therefore
inspected a container that does not exist, saw no state, and could only
time out — on exactly the stateful apps that most need verifying. It now
reads the anchor service's container_name from the compose, buffering
per service block because container_name may sit either side of the
image line.

Added updaterVerifyHttpVersion: poll the app over its PUBLISHED port
from the host, pull the version from a JSON field or a response header,
and require agreement three polls running. Probed from the host rather
than `docker exec … curl` because half these images ship no curl at all
(mattermost is one), so exec-based probing is a coin flip on the
vendor's base image. Version comparison matches only the components both
sides state, since tags and self-reported builds rarely share precision:
v1.158.0 vs 1.158.0, 11.9 vs 11.9.1, 8.7.0 vs 8.7 all agree; 11.9 vs
11.10 does not.

Each app hook is then three facts. Verified live: all three confirm at
the version they are actually on, and all three REFUSE a version they
are not — which is the property that makes stepping them safe.

updaterUpgradeAuto now skips apps with no verifier instead of queueing a
task that GATE 1 will reject, which would otherwise mean a failure
notification every day for an app that was never eligible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:04:46 +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
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
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
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
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
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
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
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
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
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
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
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
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
librelad
d7af161206 feat(mattermost): user-management tools via mmctl local mode
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>
2026-08-18 21:27:42 +01:00
librelad
ca7fca5007 feat(matrix): user-management tools, and stop shipping a file named *.config
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>
2026-08-18 21:25:29 +01:00
librelad
a96a3a69a1 fix(linkding): declare the admin keys its auth adapter writes
linkding_auth.sh persists ADMIN_USER and ADMIN_PASSWORD when the first admin is
created, and keeps the password in step on later resets of that account, but
linkding.config declared neither — so both writes were no-ops and the WebUI
credentials card never had anything to show. Predates the slot work; it only
became visible once authPersistCfg started warning instead of failing silently.

Added empty rather than RANDOMIZED*, because unlike bookstack or nextcloud
nothing seeds a linkding account at install — the first user is created from the
WebUI. A generated password would name an account that does not exist, and the
card would display a password that cannot log in. Unslotted for the same reason:
the slot number marks a value the installer generates, and this one is written at
runtime by the tool.

No AUTH_PROFILE key: nothing reads it (it exists only in a comment in
auth_adapter.sh), and adding an unread key is what was just cleaned up elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:24:28 +01:00
librelad
4eb5fa6434 feat(matrix,stoat): run without a domain, on the LAN or a VPN
Both apps demanded a domain and Traefik. That was over-constrained: LibrePortal
ships WireGuard, Headscale and private ports, so LAN and VPN-only is a
first-class deployment here, and Rocket.Chat and Mattermost already prove chat
apps work fine on http://<lan-ip>:<port>.

The gate on Matrix rested on a mistake of mine: server_name being permanent.
server_name and public_baseurl are independent — the identity can be a domain
you own with no DNS behind it while clients reach the server on a LAN address,
so federation can be switched on later by adding DNS and TLS, with no rebuild
and no lost history. CFG_MATRIX_SERVER_NAME now exposes exactly that, and the
install warns when it falls back to the machine's IP.

What is genuinely lost without a domain is stated where it belongs, at install:
Matrix cannot federate and Element's mobile apps want HTTPS; Stoat cannot do
camera or microphone, because browsers gate getUserMedia on a secure context
and a VPN does not change that, the check being on the URL scheme.

Both now derive their URL from the port that was actually allocated. Since ports
are only assigned during compose-up, each writes a best guess before start and
corrects it afterwards, restarting only when the value really changed.

Three bugs found while proving it works end to end:

- The Synapse image writes /data as its UID/GID env, default 991, which under
  rootless is a host sub-UID owning nothing — so the generated signing key could
  not be moved by the install user. Both the generate container and the service
  now run as the same identity USER_TAG resolves to.

- Element's config.json is bind-mounted as a file, and docker silently creates a
  DIRECTORY when the source is missing. An early return left exactly that
  landmine, which then broke every later run. It is written first now, and a
  stale directory is cleared.

- A successful admin registration was reported as an error: checkSuccess read $?
  after an intervening [[ ]] test rather than the command's own status.

Verified with no domain and no Traefik installed: Synapse answers
/_matrix/client/versions and /health on http://<ip>:<port>, admin login returns
a token, and Element is configured against the corrected base_url.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:51:16 +01:00
librelad
4685320353 feat(secrets): real VAPID keypair for mastodon, slot-numbered DB passwords
VAPID: the two values are the halves of one P-256 keypair, not independent
secrets — the browser verifies that a push is signed by the private key matching
the public key it subscribed with. The RANDOMIZED* generators mint each
placeholder on its own, so they produced two unrelated strings and web push could
never have worked. Generate the pair in mastodon_install_post_setup the way stoat
already does, encoded as Mastodon's webpush gem expects: unpadded URL-safe base64
of the 32-byte private scalar and the 65-byte uncompressed public point, sliced
out of the SEC1 DER. Verified by rebuilding the key from the emitted private half
and re-deriving the public point — openssl accepts it and the point matches.

Generated once and never rotated (rotation would invalidate every subscription),
but a pair of the wrong shape is replaced, so an install carrying the old
unrelated strings heals itself on next install — their public half is 42 chars
where a real point is 87.

Slots: CFG_<APP>_DB_PASSWORD -> CFG_<APP>_DB_PASSWORD_1 and likewise for
DB_ROOT_PASSWORD, across mastodon, owncloud, mattermost, matrix, nextcloud and
bookstack, so a database credential is always a numbered slot and a second one is
just _2. Renaming a key means reconciliation drops the old and adds the new
holding its placeholder, so an existing install regenerates unless the value is
carried over first — documented, including that the old file survives as
.<app>.config.bak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:42:50 +01:00
librelad
9919eea138 stoat: add the ex-Revolt stack as the closest Discord equivalent
Sixteen containers: MongoDB, Valkey, RabbitMQ, MinIO and eleven Stoat services.
Servers, channels, roles and voice/video through LiveKit — the nearest thing in
the catalogue to Discord itself, at the price of being much the heaviest app in
it. Does not federate.

The compose service keys are deliberately kept identical to upstream's
(database, redis, api, autumn, ...) while container_name is prefixed stoat-.
Compose registers both on the network, so upstream's internal defaults keep
resolving and LibrePortal still gets the prefixed names its port, firewall and
backup layers key on.

Upstream's Caddy is kept as the internal path router and Traefik simply proxies
to it, which is upstream's own supported behind-a-reverse-proxy mode —
reimplementing eight path routes as Traefik labels would be a second copy to
keep in sync for nothing. The install hook is a non-interactive port of
generate_config.sh, and it never rewrites an existing secrets.env:
REVOLT__FILES__ENCRYPTION_KEY decrypts every file ever uploaded, so
regenerating it would orphan the whole media store.

LiveKit's UDP media range is published literally rather than through the port
table, because the firewall rebuild emits /tcp rules only and a range declared
there would produce a wrong rule rather than no rule. Voice falls back to TCP
7881 until the range is opened by hand; the post-install notice says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:28:46 +01:00
librelad
7ed1cddd5c stalwart: answer the setup wizard instead of handing it to the user
A new Stalwart drops you into a five-screen wizard — hostname, domain,
storage backend, directory, logging, DNS — before it will do anything.
LibrePortal already knows the two answers that matter and the rest have
sane defaults, so asking is asking a question we can answer ourselves.

v0.16 exposes those wizard fields as a `Bootstrap` singleton, so the whole
thing is one `update` applied through the Stalwart CLI. The CLI is not in
the server image (upstream split it into its own repo), but it publishes a
multi-arch container, so we borrow the server's network namespace and run
it there — nothing installed on the host, nothing to clean up, arm64 works.

Setup now also:

- generates DKIM keys (Ed25519 + RSA) with rotation left switched on, and
  requests a TLS certificate. That last one is easy to miss: Traefik only
  fronts the admin port, so 25/465/587/993 never see its certificate and
  clients would hit a self-signed one on 993.
- creates postmaster@<domain>. The generated zone points DMARC and TLS-RPT
  reports there and nothing was creating it, so those reports bounced.
- prints the record set read back from the server rather than composed
  here, so it includes the real DKIM public keys, MTA-STS, TLS-RPT and the
  SRV records clients autoconfigure from. This hook used to tell the user
  to go and fetch DKIM themselves; by that point the keys exist.

Optionally hands DNS to a provider API (Cloudflare/DigitalOcean/DeSEC),
which keeps the whole record set in sync and makes DKIM rotation safe to
leave on. Off by default: the token can write to your zone and lives in
the mail server's database.

Re-running is safe — provisioning is skipped once config.json exists, and
the plans use upsert so they reconcile rather than duplicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:05:03 +01:00
librelad
0eda3104c7 stalwart: report a missing admin console without failing the upgrade
A failed verify makes the engine abort and restore, and a restore cannot
put back a bundle that was never downloaded — it would roll a working
mail server back a version to fix a missing web page, then hit the same
empty GitHub fetch next time. So the console check now warns loudly and
returns 0; readiness stays the only gate.

Renamed to stalwart_upgrade_check_admin_ui so the name cannot be read as
part of the gate, and bounded its poll to a 60s grace window (capped by
the caller's deadline) — the upgrade result is already decided by then,
so there is no reason to hold the run open on a web asset. The unreach-
able-probe branch is advisory for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:59:02 +01:00
librelad
4c80ea018d stalwart: check the admin console, not just readiness
Stalwart v0.16 does not ship the WebUI in its Docker image — the admin
console is fetched from GitHub on first start. With no outbound HTTPS at
that moment the fetch fails silently: /healthz/ready still answers 200
because the mail server genuinely is serving, so both the installer and
the upgrade verifier reported success while /admin and /account 404'd
with nothing to explain why.

Install hook now probes /admin after the port-25 and PTR checks and, on
404, names the GitHub download as the cause rather than emitting a
generic failure. Upgrade verifier treats stable readiness as necessary
but not sufficient and confirms /admin before returning 0; the console
is polled under the same deadline because the bundle download runs
behind the server coming up, and failing on the first 404 would abort an
upgrade that was seconds from finishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:54:32 +01:00
librelad
42995eb373 fix(webui): prune icons for apps that no longer exist
Removing Focalboard from the catalogue left its icon still being served:
the sync only ever ADDS, so every app ever dropped leaves a file behind
that the portal keeps offering for something that is gone. Same shape as
the task queue that only ever appended.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:14:55 +01:00
librelad
f596b36a73 chore: remove Focalboard from the catalogue
Retired last commit, deleted now at the maintainer's call. Mattermost
ended support in 2023, the community repo is asking for maintainers, and
the image had not been rebuilt in 1042 days — the staleness signal's
worst case after speedtest. Vikunja covers the same ground and is
actively developed.

Self-contained: every reference lived inside containers/focalboard/ plus
its generated manifest entries, so nothing else needed touching. Git
keeps the history.

Anyone with it already installed keeps a running container and their
data — removing the template only stops NEW installs. Their app will now
report as unknown in the App Center rather than offering an update,
which is the honest state for software with no upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:13:00 +01:00
librelad
cdf76040f1 feat(vikunja): add Vikunja; retire Focalboard rather than swap it
Focalboard is the one audit finding with no successor to follow.
Mattermost ended support in 2023, the community repo is openly asking
for maintainers, and the image has not been rebuilt in 1042 days.

Adds Vikunja as the replacement for NEW installs: lists, kanban, table
and gantt — the same job Focalboard did — from a project rebuilt 14 days
ago. One container on SQLite, no database sidecar, following the
catalogue conventions (tag sentinels throughout, category/title +
backup.db/backup.files labels, traefik block, gluetun markers).

VIKUNJA_SERVICE_PUBLICURL is wired to the existing APP_URL_TAG rather
than a hand-built URL. It is not optional for this app — get it wrong
and creating the first account fails with a bare "unauthorized" — and
APP_URL_TAG already resolves to https://<domain> behind Traefik or
http://<host>:<assigned-port> otherwise, so the port is never guessed.
(First attempt invented a PORT_DATA_1 tag that does not exist; checking
what the processors actually emit found the real mechanism, which
bookstack already uses.)

Focalboard is RETIRED, not deleted. Replacing an app in place would stand
still for anyone already running it — their data does not move to Vikunja
— so it keeps working, and instead:
  * the description says plainly that it is unmaintained, why, and what
    to use instead
  * UPDATE_TYPE drops to manual, because there is nothing to update TO
    and auto-pulling a 2.8-year-old tag is pure churn

Icon is a drawn placeholder, not the upstream trademark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 04:04:23 +01:00
librelad
6b44b7dd59 fix(nextcloud): copy nginx.conf on install, so the web container can start
Found by actually installing it. The compose bind-mounts
./resources/nginx.conf into the web container, but nothing ever copied
that file into the container tree, so Docker created a DIRECTORY in its
place and nginx died with:

  error mounting ".../resources/nginx.conf" to rootfs at
  "/etc/nginx/nginx.conf": not a directory

Worse than a hard failure: the app still recorded as installed. Three of
four containers came up, the DB and the app itself were fine, and only
the web front end was missing — a quiet, partial install.

Apps needing a resource file declare the copy in a hook (authelia does
exactly this); Nextcloud simply never had one. Adds
nextcloud_install_post_compose — after the compose file is written,
before permissions and `up` — which repairs any stub directory left by a
previous attempt and then copies the file.

The stub repair matters: without it the copy lands INSIDE the directory
(resources/nginx.conf/nginx.conf) and the mount fails identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:18:55 +01:00
librelad
0679fd65b2 feat(updater): stepped upgrade engine — climbs a ladder, verifying each rung
Ties the ladder and the verifiers together behind a new verb:

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

Per rung, and every part is load-bearing:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:04:33 +01:00
librelad
598f74c26b feat(updater): per-app upgrade verifiers — the safety half of stepping
Stepping 31 -> 32 -> 33 is arithmetic. Knowing 32 FINISHED before
touching 33 is the whole safety story, and it is invisible from outside
the app: Nextcloud runs its migration on boot and sits in maintenance
mode — or fails halfway — while Docker reports the container perfectly
healthy. Advance a rung there and a migration has been skipped on live
data.

Contract:  <app>_upgrade_verify <app> <expected-tag> <deadline>  -> 0

Returns 0 ONLY on positive confirmation that the app serves at the
expected version with nothing outstanding. Unhealthy, indeterminate and
timed-out all return non-zero — uncertainty is a failure, not a maybe,
because the alternative gambles with data.

  nextcloud  `occ status`: installed, NOT in maintenance, no pending DB
             upgrade, and the running major matches the tag. Maintenance
             mid-migration is expected and simply keeps waiting.
  mastodon   /health serving, ZERO "down" rows in db:migrate:status, and
             the version from /api/v1/instance matching. /health alone is
             insufficient — Puma answers before migrations finish.
  stalwart   /healthz/ready (per its documented probes), required to hold
             stable rather than flash once. Weaker by design: the probes
             confirm serving but report no version, and the file says so
             rather than implying more.

updaterVerifyGeneric (running + healthy + no restart during a settle
window) is the fallback for everything else, and is explicitly NOT
sufficient to justify climbing a rung — the engine will refuse to ladder
an app with no declared verifier.

9 tests drive the dangerous states directly: maintenance mode, pending DB
upgrade, and a wrong major all correctly REFUSE to verify; clean states
pass. Those three negatives are the ones that would have corrupted data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:58:23 +01:00
librelad
913cacaff0 feat(updater): version ladder for apps that cannot skip a release
Foundation for stepped upgrades. Answers one question only — WHICH
versions, in WHICH order — with no side effects, so it can be tested
exhaustively. Applying the rungs is a separate job.

Nextcloud refuses to skip a major ("Updates between multiple major
versions and downgrades are unsupported") and will not start; databases
behave the same way about their data directory. For those apps 31 -> 34
is three upgrades, each with a migration that must finish before the
next begins.

Built by PROBING each candidate rung, not by enumerating tags — because
enumeration is provably unsafe here. Docker Hub pages at 100 ordered by
recency, and the first real-registry run proved the danger: it produced
v4.2 -> v4.4 -> v4.5 -> v4.6 for mastodon, silently skipping v4.3, which
exists (HTTP 200) but had fallen off the newest-100 listing. Skipping a
rung is the precise failure this file exists to prevent, so the ladder is
now built by incrementing and probing: v4.2 -> v4.3 -> v4.4 -> v4.5 ->
v4.6, 4 steps.

Guarantees: same shape only (never 31-fpm-alpine onto 31-apache),
strictly ascending, never a downgrade, rolling tags refused outright, and
a version upstream never published is stepped over only because the probe
said so. If a continuous path to the target cannot be constructed it
returns 1 and prints nothing — refusing to guess, because a wrong ladder
means a skipped migration.

20 unit tests, including the exact listing-truncation case above and the
numeric ordering that would otherwise drive an app backwards (0.9 vs
0.10). Real registry: nextcloud 3 steps, mastodon 4, stalwart current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:52:53 +01:00
librelad
4ee25292d5 feat(stalwart): add Stalwart Mail Server as a catalog app
One container providing SMTP/IMAP/POP3/JMAP plus CalDAV/CardDAV, an admin
UI and spam filtering — chosen over mailcow (owns its own installer, which
is what killed the earlier attempt now sitting in scripts/unused/) and
over Mailu (~7 containers) because a single image with a single data dir
is the only shape that fits the existing conventions cleanly: one anchor
service the updater can version, one path the backup engine can snapshot.

Mail-specific departures from the usual app template, each deliberate:

* Ports are FIXED, not random. Other mail servers connect to :25 by
  number and clients expect 465/587/993 — a randomised external port
  would silently make the server unreachable. Only the admin UI takes a
  random port, since that one really is just a browser behind Traefik.
  143/995/4190/443 ship disabled; the port processor comments them out.

* UPDATE_TYPE=manual and the image pinned to v0.16, not :latest.
  Stalwart is pre-1.0 and has said the storage schema is still being
  finalised, so an unattended minor bump could carry a data migration on
  the message store. This is the one app where the auto default is wrong.

* BACKUP_STRATEGY=stop-snapshot-start. The message store is written
  continuously; a live copy can land mid-transaction. Seconds of queued
  delivery (senders retry) buys a consistent snapshot.

* The install hook checks outbound port 25 and reverse DNS, then prints
  the MX/SPF/DMARC records with real values. A mail server whose
  container started is not a working mail server, and every remaining
  requirement lives at the registrar or the VPS provider.

Admin credentials are seeded via STALWART_RECOVERY_ADMIN from the app
config rather than left to Stalwart's first-run random password, which
would otherwise exist only in the container log.

Icon is a drawn placeholder, not the upstream trademark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:02:26 +01:00
librelad
f221177b12 feat(notify): outbound alerts for failed background tasks
The missing piece of hands-off updates/backups: when a task fails while
nobody has the WebUI open, LibrePortal now says so — email (via the
existing Mail settings), ntfy, Gotify, Discord, Slack, Telegram, or
Pushover, configured under Settings → Notifications.

One hook, everywhere: the task processor reports every terminal task to
`libreportal notify task <id>` (detached, never load-bearing — hard curl
timeouts, failures ignored). The POLICY lives in the notify command, not
the daemon: CFG_NOTIFY_EVENTS = failures (default) | all | off, and
cancelled tasks never notify. Failure copy is task-aware — a failed
update says the app was already rolled back and won't be retried, so the
reader knows the box is safe before opening the WebUI.

`libreportal notify test` sends to every enabled channel with per-channel
results. Verified against a local mock endpoint: all webhook payloads,
JSON escaping (quotes/newlines), the events policy, and fail-fast on
dead endpoints (8ms, exit nonzero).

The v0.1.0 per-app NOTIFY_* field-mapping scaffolding (never wired to a
sender) stays as-is; this global channel is the system it was waiting on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:15:53 +01:00
librelad
66c79f997e feat(updater): install window, honest Check-now, failed-auto surfacing
Four fixes that make the auto-updater a trustworthy background system:

* CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after the
  05:00 backup cron; HH:MM-HH:MM wraps midnight, 'always' = any time).
  Gates only the enqueue — scans keep running all day, so the Updates
  page stays current and pending updates visibly wait for the window.
  Malformed values fail closed and are rejected by the WebUI validator.

* "Check now" actually checks: an explicit `updater check` sets
  UPDATER_REGISTRY_FORCE=1. The flag existed but nothing ever set it,
  so the button silently reused the 6h digest cache and could not find
  a build the user knew had shipped. Force also overrides interval 0,
  which now means "manual-only" as documented in the roadmap.

* Registry stamp moved from /tmp to <system>/logs: the task processor
  runs under PrivateTmp, so daemon and CLI each kept a separate 6h
  clock and the daemon's reset on every service restart.

* A failed automatic attempt is no longer invisible: the scan emits
  auto_attempted_digest (the one-shot no-retry stamp), and when it
  matches the available build the UI stops promising an install that
  will never come — per-app detail explains, the fleet row gets an
  "auto failed" chip, and the Overview board counts it as needing you.

Also corrects the CFG_TIMEZONE label: it sets the containers' TZ only;
scheduled tasks follow the host clock (timedatectl), and the old
"Timezone for scheduled tasks" wording promised a knob that never
existed. The window + auto_window display state plainly WHEN updates
land, answering "how does the user know when the next update happens".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:06:27 +01:00
librelad
1c3af5a533 fix(install): log why compose up failed instead of just "exit 1"
navidrome's install died on 2026-08-01 and left no explanation: the log
had only "Started container for navidrome (exit 1, up_app.sh:130)". The
compose output was captured into `result` and never read, and stderr was
not captured at all — so the one thing that says WHY (image pull EOF,
port clash, missing external network) was thrown away at the moment it
mattered.

Capture stderr and print the tail of the output on failure, before
checkSuccess (which can exit). Both the rootless and rooted call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:20:31 +01:00
librelad
cdeb2d1658 feat(updater): per-app UPDATE_TYPE, automatic by default
Adds the decision half of the app updater. Detection (P2) and the
snapshot-first apply/revert (P3) were already real, but nothing ever
pressed the button — every update waited for a click.

  CFG_<APP>_UPDATE_TYPE=auto|manual   per app, default auto (33 templates)
  CFG_UPDATER_AUTO=true|false         master switch, default true

updaterAppPolicy resolves the two the way backupResolveStrategy already
resolves backup strategy: the global switch can only make things more
manual. updaterApplyAuto runs at the end of `updater check` and enqueues
the ordinary updater_apply task for each auto app that has an update —
never applies inline, so an automatic update is the same code path, task
log, History entry and Roll back button as a manual one.

Safety: each attempt stamps its target digest under generated/auto/, so a
build that fails is rolled back and then left alone rather than retried on
every scan; in-flight updater tasks are skipped so scans can't stack.

Tracked end to end: updates.json carries each app's resolved update_type,
History entries carry trigger=manual|auto. The WebUI says whether updates
install themselves, chips only the apps that opted out, labels automatic
history, and — since an auto app's pending update needs no decision — keeps
it off the Overview board's "Needs action" view.

Also fixes artifactApplyAuto enqueueing without --detach: it runs inside
the single-threaded task processor's own poll, so following the new task in
the foreground waits for a task that cannot start until it returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:22:04 +01:00
librelad
2b77c8dfdd fix(install): sweep stranded containers before the daemon can resurrect them
A fresh install creates an empty containers root but leaves the rootless
daemon's own container state untouched. Restarting the daemon then runs its
container-restore pass, which resurrects the previous install's containers —
and Docker materialises each missing bind-mount source first, creating an empty
DIRECTORY even where the mount is a file.

That is the trigger behind the <app>.config stub directories: at 19:38:59 the
daemon re-created every missing mount source for a container built 40 minutes
earlier, runc then failed with "not a directory: Are you trying to mount a
directory onto a file", and the abandoned stubs collided with the install's own
copies 18 seconds later.

Add dockerRemoveStrandedContainers, run right after the rootless daemon
restart: remove containers whose compose project directory no longer exists, so
the next restart has nothing to resurrect. Scoped to project directories under
the LibrePortal containers root, so unrelated containers on the host are never
touched, and gated on the daemon answering.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-21 20:10:12 +01:00
librelad
3f6a9b395a fix(install): repair Docker bind-mount stub dirs where a config file belongs
Docker materialises a missing bind-mount source as an empty directory when a
container starts. The WebUI compose mounts ./libreportal.config as a file, so a
container start before the config landed left a directory at that path — and it
was self-perpetuating:

  - copyFolder's tar extract aborted the whole source copy with
    "libreportal/libreportal.config: Cannot open: File exists" (exit 2)
  - dockerConfigSetupToContainer guards on [ ! -f ], which a directory fails, so
    copyFile dropped the real config INSIDE the stub
  - the closing -e / -r sanity checks both pass on a directory

The installer then reported success while libreportal-service crash-looped on
EISDIR reading /app/libreportal.config, leaving the WebUI unreachable.

Add repairStubDirForFile: promote a same-named file out of the stub, drop the
directory, and report if the path still isn't a regular file. Call it before the
WebUI source copy and before the per-app config copy (covers every app, not just
the WebUI), and tighten the closing existence check from -e to -f so a stub can
never pass validation again.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-21 19:58:19 +01:00
librelad
2d1e4aa98f feat(health): self-healing control-plane watchdog + crash-loop failure cap
An offline trivy install crash-looped (server FATALs when it can't fetch the
vuln DB), and on rootless docker the restart storm churned the shared network's
port-forwarder until the WebUI's own published host port was torn down — the
WebUI stayed healthy INSIDE its container but was unreachable from the host, with
nothing detecting or healing it.

Three fixes, in the house self-healing style (mirrors the network-drift trio):

1. Control-plane health checker wired into the existing task-processor idle poll
   (maybeRegenPoll), no new daemon. dockerHealthScan (read-only) detects daemon
   down, a WebUI running-but-host-port-unreachable (the port-forward corruption),
   and crash-looping containers. webuiSystemHealthCheck writes
   frontend/data/system/health_status.json + self-dispatches a heal — the user
   can't click a button on a dead WebUI, so the poll drives the fix. Frontend
   health-notifier surfaces a topbar badge + dashboard banner + details panel.

2. Failure cap, enforced centrally by dockerHealthHeal (task-gated): stops
   crash-loopers (removing the churn), restarts the WebUI to re-publish a lost
   port forward, and — only if that fails — recycles the rootless daemon and
   restarts the core container. Caps every app immediately, no template churn.

3. Trivy no longer crash-loops offline: the server runs in a shell retry-loop so
   the container stays Up and quietly retries on a backoff instead of exiting
   FATAL. Verified: container stays Up across repeated DB-download failures.
   Core WebUI compose gains restart: unless-stopped so it self-recovers after a
   reboot / daemon recycle instead of staying down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 22:03:02 +01:00
librelad
abd8e0b68b feat(updater): surface Trivy CVE scanner state (installed / DB updating / ready)
Trivy runs as a server whose vulnerability DB downloads on first boot; until it
lands no scan can produce results. Previously the updater generator wrote an
empty-but-valid cves.json the moment the file was missing, so installing Trivy
painted a green "no known vulnerabilities" all-clear that was actually a lie —
the DB hadn't even downloaded, and the Updates/Security view gave no signal.

Add an honest scanner state the WebUI branches on:
- containers/trivy/scripts/trivy_scan.sh — trivyScannerState (absent |
  db_updating | ready) via `trivy version -f json`, trivyDbUpdatedAt, and
  trivyScanImageCves (per-image scan normalized to {id,severity,package,
  installed,fixed_in,url}, deduped). All degrade safely on error.
- webui_updater_scan.sh — stamp cves.json with scanner.state; only run real
  per-image scans once the DB is ready. Always rewritten so state tracks live.
- updater-page.js — Security tab shows a loading box while the DB updates, an
  install nudge when absent, and the genuine 🎉 only when ready+empty; Overview
  CVE card sub + hint reflect the state.
- overview-manager.js — fleet Security row surfaces the "building CVE database"
  pending state instead of silently omitting.
- function_manifest.sh — regenerated for the new trivy_scan.sh functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:47:07 +01:00
librelad
e85fba3b09 auto: session-start commit — 4 file(s) at 2026-07-13 15:49:56 2026-07-13 15:49:56 +01:00
librelad
abf3a65c88 refactor(catalog): move sources into a config file (CFG_CATALOG_1..9), generate from it
Per the intended model: catalog sources should live in the config place (like
domains), and registry_catalog.json should be a purely GENERATED artifact
derived from them — not the source of truth. Replaces the earlier
$docker_dir/catalog/sources.json store.

- New configs/general/general_catalogs — CFG_CATALOG_1..9, one catalog base URL
  per slot ("url" or "url|channel"), domains-style. Official stays pinned as
  source #1 (derived from CFG_RELEASE_BASE_URL, not listed here). Slot N → source
  idx N+1 (stable id for the Add picker / `app add --source`).
- catalog_sources.sh now reads/writes those CFG vars (via updateConfigOption)
  instead of a JSON file; dropped catalogSourcesFile + the enable/disable toggle
  (presence = enabled; remove = clear the slot).
- configUpdateBatch regenerates registry_catalog.json when a CFG_CATALOG_* key
  changed — so pressing Save in the WebUI rebuilds the browse data.
- webuiRegistryCatalogScan is unchanged (still iterates catalogEnabledSources).

Verified: CFG_CATALOG_1/2 → sources at idx 2/3, empty slots skipped, url|channel
parsed, official pinned at idx 1.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:01:22 +01:00
librelad
2c4752adab Merge branch 'main' into claude/1
# Conflicts:
#	scripts/source/files/arrays/function_manifest.sh
2026-07-06 21:00:02 +01:00
librelad
ba4dbc6e02 fix(tasks): stop the lazy-autoload stub from looping forever + add processor failsafes
Root cause of the "task loops eternally" bug: the lazy-autoload stub was

    fn() { source "$file"; fn "$@"; }

If the source fails — the real case: an app-install/deploy rsync briefly
removes-then-replaces a generator file while a setup task calls it — `fn` is
never redefined, so `fn "$@"` re-invokes the *stub*, which sources the (still
missing) file, which re-invokes the stub… A single setup finalize recursed
12,050 levels, flooding the task log and taking 82s before it happened to
recover when the file reappeared. A permanently-missing file would never
recover.

Fix (root cause): drop the stub before sourcing —

    fn() { unset -f fn; source "$file"; fn "$@"; }

so a failed source degrades to one "command not found" (rc 127) instead of
unbounded recursion. Regenerated function_manifest.sh (975 stubs, reformat
only — no function-set change).

Failsafes on the task processor (defence in depth, per request):
- FUNCNEST cap (TASK_FUNCNEST_MAX, default 1000) inside the task's eval
  subshell — any runaway recursion now aborts in milliseconds instead of
  spamming the log until the stack/disk gives out.
- Wall-clock cap (TASK_MAX_RUNTIME_SECS, default 7200s, 0=off) — the heartbeat
  watcher TERM→KILLs a task's process group once exceeded and marks it failed
  (distinct from a user cancel via a .timeout marker). Generous so real long
  installs/backups/migrations finish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-06 20:56:07 +01:00