529 Commits

Author SHA1 Message Date
librelad
60a2fc3b0b Add lp-task-names, a guard against unnamed task commands
Task titles come from one table whose final fallback returns the raw command
string, so a dispatched command with no matching row does not error — it just
renders as "libreportal instance remove bookstack_work" beside properly named
neighbours. That silence is why this kept being fixed and kept coming back.

The guard reads BOTH files as source — the command templates from
task-commands.js and the pattern table from tasks-format.js — so it fails on a
command added without a name rather than leaving it to be noticed in the UI.

Two checks, both from source rather than guessed from rendered text:

  1. Nothing falls through: a title equal to its command, or still starting with
     "libreportal ", means the raw fallback was reached.

  2. Every `libreportal app <verb>` verb has an actionMap entry. Without one the
     generic branch composes "<Verb> Application", which is how "Up Application"
     and "Down Application" shipped.

The second check reads the actionMap keys instead of pattern-matching the title,
which a first attempt did and which was wrong: "Reload Application" is both a
correct hand-written label and what the generic branch emits, so the rendered
text cannot distinguish them and the heuristic failed a title that was fine.

Verified by breaking it deliberately in both directions — adding a command with
no pattern, and deleting an actionMap verb. Each is caught, named, and pointed at
the file to edit; both files were restored byte-identical afterwards.

Lives in scripts/dev, which .gitattributes marks export-ignore, so it never ships
in a release tarball. Needs a node and borrows the running container's when the
host has none, the same constraint lp-shot works around for chromium.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:38:51 +01:00
librelad
9a74b841d9 Give every dispatched command a task name; finish instance removal
Task titles come from one declarative table in formatCommandForUser, whose final
fallback is the raw command string. Five of the sixteen command shapes the WebUI
dispatches had no row and hit that fallback, so they showed as
"libreportal instance remove bookstack_work" where a backup shows as
"Bookstack - Create Backup". Cross-checked every shape in task-commands.js
against the real formatter rather than fixing only the reported one:

  instance create  -> Bookstack - New Instance (work)
  instance remove  -> Bookstack · work - Remove Instance
  system status    -> LibrePortal - System Status
  system update    -> LibrePortal - System Update
  system reset     -> LibrePortal - Reset System

Instance create is named off the TYPE rather than the new slug: at create time
the instance is not in window.apps yet, so displayName() would fall back to
capitalising the slug and render "Bookstack_work".

Four more were not raw but read badly, because the generic
"<Action> Application" fallback has no entry for the compose verbs: up and down
rendered as "Up Application" and "Down Application". Now Start/Stop Containers,
Reload Application and Check Status. And per-service restart collapsed to
"Restart Application", losing the only thing that distinguished it — it now
names the service.

All seventeen shapes verified through the real formatter: zero fall through.

Instance removal also finishes properly now. It skipped the button lockout a
normal uninstall applies, so the instance's Config/Tools/Backups tabs stayed
live against an app being torn down underneath them; it now makes the same
disableAppButtons call executeUninstall does. And it bounced to the TYPE's
Config page, hiding the very task the click had just started — it now lands on
the removed instance's own Tasks tab.

Verified by driving the WebUI: creating an instance files a task titled
"Bookstack - New Instance (uitest)", removing it lands on
/app/bookstack_uitest/tasks, and disableAppButtons flips all five app tabs from
enabled to disabled. Test instance removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:27:27 +01:00
librelad
db4ef19698 fix(webui): make the backups filter placeholder readable
The snapshot filter's placeholder had no rule at all, so it fell through
to the browser default — a grey chosen for light backgrounds, rendering
near-invisible against a dark panel. "Filter by app, host, or backup id"
is the one hint telling you what the box accepts, so losing it costs the
control its explanation.

Written against --text-rgb rather than a fixed grey so it follows the
theme: white at 60% on nebula and dark-blue, dark at 60% on light, where
a hardcoded #ccc would have been unreadable the other way round. Firefox
dims placeholders on top of the colour, so opacity is pinned to 1 and the
alpha above owns the result. Slightly brighter than the task filter's
0.4, and in line with the shared .form-control rule's effective value,
because this string is instruction rather than decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:21:49 +01:00
librelad
7e828a2ec1 feat(webui): select and update several apps from the Updates tab
Updating one app and updating everything were both already there — a
per-row Update button and an Update all in the tab header. What was
missing is the middle: choosing three of eight. Both existing controls
are gated on update_available, so with a fully current fleet the tab
shows neither and reads as though it cannot update anything at all,
which is what prompted this.

Checkboxes appear only on rows that actually have something to apply.
Offering one on a current app would be a choice with no outcome, and
"selected 6, updated 2" is a worse answer than not offering the four.
The column is still reserved on every row, hidden — otherwise the icons
shift left and right as scans land and rows change state.

applySelected filters the chosen names against update_available rather
than trusting them: a selection can outlive the scan that justified it,
and asking to update an app with nothing to apply would spend a snapshot
to achieve nothing. Update all now routes through the same function, so
there is one path, one task, and the same per-app contract — snapshot
first, roll back on failure.

Two details that only show up when you use it. The checkbox sits inside
the row head, which is itself the expander target, so its handler has to
claim the click or picking an app also opens its detail panel. And the
count is refreshed in place instead of re-rendering the tab, because a
re-render rebuilds the checkboxes under the pointer mid-selection.

The bar holds its space at zero selected rather than appearing on the
first tick, which would shift the list down under the cursor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:17:37 +01:00
librelad
934193d901 copy: drop deployment trivia from app descriptions
App descriptions are read by people deciding whether they want the app,
not by people maintaining it. Several were spending their last clause on
facts the reader cannot act on and would not recognise — and in Stoat's
case actively talking the app down: "Heavy (16 containers) and does not
federate" is a maintainer's note, not a description.

Eight rewritten, all the same fault:

  stoat        "Heavy (16 containers) and does not federate", and LiveKit
               named as though the reader would know what it is
  vikunja      "Runs as a single container on SQLite, with no database sidecar"
  stalwart     "in a single container"
  gitea        "written in Go", plus "self-hosted Git service" twice in one line
  vaultwarden  "an alternative implementation of the Bitwarden server API
               written in Rust" — says what it is to a developer, not what it
               does for you
  speedtest    "implemented in Javascript"
  adguard      "resolving blocked domains to a local blackhole address"
  matrix       "Installs Synapse plus the Element web client"

Deliberately kept, because they change whether the app suits you rather
than merely describing how it is built: Rocket.Chat's free-edition user
cap, Mattermost's unlimited users, Navidrome's Subsonic compatibility
(it tells you which phone apps will work), Stalwart's protocol list, and
Gluetun's provider count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:07:59 +01:00
librelad
29fa8e8a60 Close Stoat registration by default, behind CFG_STOAT_INVITE_ONLY
Provisioning the owner account did not stop anyone else signing up. Stoat ships
open, with no captcha and no email verification, so a reachable instance still
accepted walk-in registrations.

Made a config option rather than hardcoded, because running an open community
server is legitimate — but defaulting CLOSED, which is the opposite of the other
registration toggle in the tree. Vaultwarden's SIGNUPS_ALLOWED defaults true for
a reason that does not apply here: it has to let you register to get in at all,
whereas Stoat's owner account is now created for you. Matrix's
ENABLE_REGISTRATION already defaults false for the same shape of app.

The section name is load-bearing and not guessable. invite_only under [features]
or [api.security.authifier] is accepted in silence and does nothing — the API
keeps reporting invite_only=false — so it goes under [api.registration], which
was found by testing all three against a running instance. Anything but an
explicit "false" closes registration, so a blank or misspelled value fails safe.

Closing it broke LibrePortal's own tooling, which is the part worth noting: the
API answers MissingInvite to create_account too. So account creation now mints a
single-use invite and retries when it sees that. Reactive rather than reading the
config, so it follows the instance's actual state — someone who edits Revolt.toml
by hand gets the same behaviour. Stoat stamps the invite used/claimed_by as it
consumes it, and a failed create deletes it, so no reusable invite is left behind;
verified that the collection holds zero unused invites after two creations.

Verified end to end: a fresh install reports invite_only=true, a walk-in signup
is refused with MissingInvite, and the Create User Account tool still succeeds.
Flipping the config to false and reinstalling flips the API to open, and the
provisioning guard correctly reports "already has accounts" instead of trying to
claim a second owner. The value is baked into Revolt.toml at install, so changing
it needs a reinstall rather than a reload — now said in the config comment.

Unrelated flake seen once during testing and not reproduced: an install left
stoat-rabbit with no IP row, so the compose died on a literal IP_DATA_3. A
straight uninstall/reinstall allocated all 16 cleanly. Untouched here — it is in
the IP allocator, not this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:05:51 +01:00
librelad
a1290b47a3 fix(ports,config): stop losing columns in the port descriptor
Four faults, all in the same 12-column format, all silent.

The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.

The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".

Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.

Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.

Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:48:08 +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
701f014619 Hide zero-count filter chips on the Updates tab
An empty chip is a dead end: clicking it can only ever produce "Nothing matches
this filter". Hidden rather than disabled, because a greyed-out chip still
occupies the eye, and "no apps have CVEs" is better said by the row of chips
being short than by a control you are told not to press.

Two exceptions. `all` is never hidden — it is the way back. And the active chip
stays put even at zero: a rescan can empty the filter you are standing in, and
pulling it out from under the selection would leave the list filtered by a chip
that is no longer on screen.

Verified against the running WebUI: Updates sits at 0 here and its chip is gone,
leaving All 14 / Newer 3 / Security 11, with every remaining chip still
selecting correctly. Forcing the filter to `updates` re-shows that chip marked
active, so the zero-but-selected case renders as intended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:15:58 +01:00
librelad
e50adf8f97 Sort the Updates list by actionability, add a Newer filter
The list rendered in whatever order the updater emitted, so the handful of rows
that actually want a decision sat wherever they happened to land — on a long
list, below the fold, under a wall of "up to date".

Rows are now ranked by what you can DO about them rather than by how alarming
they look, since an app with an Update button is one click from resolved while
an app with only a Details link is not:

  0  update available, and the automatic attempt already failed
  1  update available
  2  a newer release line is published
  3  CVEs, but nothing to apply
  4  never scanned
  5  up to date

Auto-failed leads because that build was tried and rolled back and will NOT
retry itself — it is the only row in the update group that strictly needs a
person. Worst severity orders within a rank, then display name, so the ordering
is total and stable across repaints.

Filtering already existed (All / Updates / Security); this adds the one state
that was unreachable. `newer_version` is deliberately excluded from `updates` —
its action is Upgrade, inside the row detail, not the row's Update button — so
it had no chip of its own despite being the state most likely to need a
decision, because it never resolves on its own: automatic updates only track
the line you are already on. The chip excludes rows already counted under
`updates` so the totals partition the list rather than double-count.

Verified against the running WebUI. With real data the three newer-version rows
lead (critical, critical, high, then alphabetical), followed by the eight
remaining CVE rows and the three clean ones — 3 + 8 matching the Security count
of 11. Temporarily flagging two apps update-available confirmed ranks 0 and 1
take the top, with the auto-failed row above the plain one; that data file was
restored byte-identical afterwards. Every chip was exercised by clicking it:
All 14, Updates 0, Newer 3, Security 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:03:53 +01:00
librelad
acc91777c4 refactor(prometheus,stoat): prefix service names so instances are possible
Both apps were barred from multiple instances by the same rule: a
service or container name with no <app> prefix cannot be made unique per
instance, so a second copy would fight the first for it.

Prometheus is now fully unblocked. node-exporter and cadvisor became
prometheus-node-exporter and prometheus-cadvisor — service key,
container_name and SERVICE_TAG default together — and prometheus.yml's
scrape targets follow them. The image references (prom/node-exporter,
gcr.io/cadvisor/cadvisor) and the job_name labels are deliberately
untouched: the first are upstream coordinates, the second are labels
dashboards and alert rules key off, and renaming either would break
something to fix nothing.

Stoat's sixteen services are now all stoat-prefixed. Its container_names
already were, so only the service keys and their depends_on references
moved. The subtlety is DNS: compose registers the service name as a
network alias, so renaming a key silently retires the name anything
else resolves it by — Revolt.toml reaches the broker as "rabbit", the
Caddyfile proxies to api, events, autumn, january and gifbox, and the
bucket job talks to http://minio:9000. Ten services already carried an
explicit alias that survives the rename; the six that did not
(database, redis, rabbit, createbuckets, crond, pushd) have been given
one holding their old short name. Every name that resolved before still
resolves. Verified by parsing the result: 16 services, every one keeping
its short alias, and no dangling depends_on in either app.

Stoat stays non-instanceable, but for one narrower reason now, recorded
in its config: LiveKit advertises its own tcp_port 7881 and UDP range
50000-50100 to clients from livekit.yml, so those cannot be randomised
per instance without generating that file per instance and allocating a
range for each.

Stoat's port descriptors name their parent service, so they follow the
rename too: caddy -> stoat-caddy, livekit -> stoat-livekit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:57:05 +01:00
librelad
3ebd4ee5bb fix(webui): tell a deliberate singleton from an un-reviewed app
The instances line had two states where there are three. An app with a
blocker cannot be instanced; an app whose flag is explicitly false was
reviewed and deliberately kept to one — the resolver clients point at,
the auth provider every router points at, the scanner the updater
resolves by a fixed container name; an app with no flag has simply never
been looked at.

Collapsing the middle case into the last reported a decision as an
oversight, and invited someone to "fix" it by flipping the flag. Those
apps now read "Not offered" and point at the reason recorded above the
flag in their config, rather than "not been opted in".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:50:11 +01:00
librelad
fd65b74d53 config: keep infrastructure apps out of multiple instances
The previous commit enabled everything that passed the two mechanical
checks. Passing them is necessary, not sufficient: several of those apps
are singletons by role, and an instance of them would validate, clone,
start, and then not make sense.

Eight are now off by design, each saying why:

  adguard              a resolver is what clients point at
  authelia             the forward-auth provider every Traefik router points at
  gluetun              a network provider — apps join it by container name
  headscale            the control server a tailnet is defined by
  libreportal_catalog  LibrePortal's own catalog, internal plumbing
  ollama               one endpoint, and gigabytes of models per copy
  trivy                the updater resolves the scanner by a FIXED container
                       name, trivy-service, so a second copy would run and
                       never be the one CVE scanning uses
  wireguard            one stable published UDP endpoint; peers are tied to it

And one that should never have been touched: crowdsec ships no
docker-compose.yml, so the audit — which required a compose to read
service names from — skipped it, while the enabling pass only required a
config and did not. It got an unaudited true. There is nothing for
`instance create` to clone, and one decision engine watching the whole
box is the point of it. Now false, with that stated.

23 apps instanceable, 15 not: 6 that cannot be, 9 that should not be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:47:40 +01:00
librelad
6813621fe9 config: enable multiple instances everywhere it is actually possible
Three apps were instanceable and the rest were silent, so the feature
looked far narrower than it is. Every app has now been checked against
the two rules instance_create.sh enforces, and the answer recorded in
its config rather than left unset.

32 apps are instance-safe and now say so. Six are not, and each says why
in its own words instead of being indistinguishable from an app nobody
had reviewed:

  pihole      a DNS server must own port 53
  unbound     a resolver must own its fixed 5335
  stalwart    a mail server must own 25/465/587/993
  traefik     must own 443, and one Traefik routes every other app
  prometheus  node-exporter and cadvisor carry no "prometheus" prefix
  stoat       pins 7881, and database/redis/rabbit/minio carry no prefix

The first four are genuinely one-per-host: the port is not arbitrary, it
is the protocol. The last two are compose-identity problems and could be
fixed by prefixing those service names, which is a change to make
deliberately rather than in passing.

Recorded as an explicit false with a reason, not left unset, so the next
person reads a decision instead of an absence. The audit was verified not
to pass anything vacuously: every app resolves at least one service name,
so no app reached "eligible" merely because nothing was found to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:38:34 +01:00
librelad
964021ba5e fix(webui): move the "no instances" reason into a tooltip, and say more
The reason spent a full line of every non-instanceable app's page on a
sentence only someone asking "why not" wants. It now shows a short
"Not available" / "Not enabled" with a hover-and-focus target carrying
the technical detail.

It also says more than it could before. The old text knew about one
blocker — a pinned host port — and its own comment conceded the rest was
"left unexplained rather than guessed at, since the remaining blocker
lives in the compose, which the frontend never sees". It does see it:
apps.json carries each app's service list, so the second rule
instance_create.sh enforces (a service or container name with no <app>
prefix, which cannot be made unique per instance) is reproducible here.
Prometheus now says node-exporter and cadvisor rather than nothing.

And it separates two states that read identically before. An app with no
blocker at all is not "unavailable", it is simply not opted in — a
different sentence with a different fix, and the one Matrix was hitting.

Keyboard reachable via tabindex, with aria-label duplicating the tooltip
since screen readers do not reliably surface a title attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:29:29 +01:00
librelad
1b3d68d9f7 Highlight the hovered app row on the Updates tab
The row head is the click target that expands a row and already set
cursor:pointer, but nothing changed under the pointer — so a clickable row
looked identical to a static one right up until it opened, and the only visual
state was post-click.

The tint is painted by the row rather than the head. .ov-row's `padding: 0`
loses to .updater-row's `padding: 12px 15px` on equal specificity, so the head
is only 34px tall inside a 60px row; filling the head left a floating band with
13px of untinted row above and below it. Painting the row covers the full
height and picks up its 11px radius for free.

Keyed on `:has(> .ov-row-head:hover)` rather than `.ov-row:hover` so the
highlight tracks what is actually clickable: on an expanded row, drifting into
the detail body stops indicating a click target, because there isn't one there.
The head-level rule stays as the fallback for engines without :has() — it still
marks the row, just inset — and the pair is mutually exclusive so the two tints
never stack. :has() is already relied on elsewhere in the WebUI CSS.

Verified against the running WebUI by driving Chrome over CDP and dispatching a
real mouseMoved, since lp-shot cannot hover: the hovered row goes from
rgba(255,255,255,.035) to .086, its neighbour stays at .035, and the head fill
reads transparent so nothing double-tints. Confirmed visually in the capture
too. ov-row-head is emitted only by renderUpdates(), so no other tab changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:28:30 +01:00
librelad
b8c23f0dc9 fix(stoat): name compose services, not containers, in the port descriptors
The Ports page showed no service selected for Stoat, and the app's URL
never appeared anywhere, so there was no way to learn which port it is
on.

Field 1 of a PORT descriptor is the parent SERVICE:
db_list_installed_app matches it against each compose service name to
attach the app's access URL. Stoat named containers instead —
"stoat-caddy" and "stoat-livekit", whose services are "caddy" and
"livekit". It is the only app in the catalogue that did; rocketchat says
rocketchat-service, matrix says matrix-synapse, and both are real
service names. Nothing matched, so no URL was ever associated and the
Ports page had nothing to select.

The port itself was always allocated correctly — Caddy publishes 4210 —
which is why the app worked for anyone who already knew the number.

The live config had also lost the 11th field (the Traefik subdomain,
"stoat") that the template carries, so restore that at the same time.

Not a Caddy problem: Stoat's Caddy is its internal path router, doing
prefix stripping and Location rewrites across api, events, autumn,
january, gifbox and web plus file-serving /.well-known/stoat. Traefik is
the edge in front of it — the same split Nextcloud uses with its nginx
sidecar — and Traefik cannot serve that .well-known file at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:14:21 +01:00
librelad
419105c906 fix(webui): spell out the auto-update window as am/pm
"06:00-08:00" is unambiguous on paper and ambiguous at a glance — the
strip said updates install "during 06:00–08:00" and left the reader
working out whether that meant morning or night. The window now renders
as 6am–8am. The config stays 24-hour so there is still one canonical way
to type it; only the display spells it out, and anything that is not a
plain HH:MM-HH:MM passes through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:02:46 +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
c3494f7d19 Make CFG_SEARXNG_THEME actually apply
The hook substituted `simple_style: auto`, a line that only exists in SearXNG's
full bundled settings.yml. The file generated here is the minimal
`use_default_settings: true` form with no ui: block at all, so the sed matched
nothing and the theme setting had never taken effect on any install.

It could not have worked even with the right pattern: the entrypoint chowns
settings.yml to searxng:searxng (uid 977) mode 644 on first start, so the
host-side docker user cannot write to it. The edit now runs inside the
container via docker exec, targeting the real key path
ui.theme_args.simple_style.

Three shapes are handled so the hook stays correct on repeat installs and
alongside hand edits: substitute in place when simple_style already exists,
nest theme_args inside an existing ui: block rather than appending a second one
(a duplicate YAML key SearXNG refuses to load), and otherwise append the whole
block. All three were exercised against the running container and produce valid
YAML with exactly one ui: block. awk rather than `sed a\` for the nesting case,
since busybox sed does not expand \n in appended text.

The value is validated against auto|light|dark|black before being written.
SearXNG checks it at startup and exits on anything else, so an unrecognised
CFG_SEARXNG_THEME would have taken the app down instead of merely looking
wrong; it is now reported and the default left alone.

Verified end to end on a base install and a --local instance: both come up,
serve 200, and report Dark as the selected style on /preferences, each with its
own settings.yml and secret_key. The instance's cloned hook correctly reads
CFG_SEARXNG_PROBE_THEME and targets its own container, since the container name
is built from $app_name. Both test installs were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:58:08 +01:00
librelad
1fb8fdee20 Grant searxng DAC_OVERRIDE so it can write its own config
SearXNG never started on rootless Docker — base install or instance, it
restart-looped from the first boot and the web UI was never reachable.

Cause is a specific capability combination, not a general permissions problem.
The image's entrypoint runs as root and, on first start, chowns the mounted
config dir to searxng:searxng (uid 977) using CHOWN, then immediately copies
settings.yml into the directory it has just given away. The compose dropped ALL
capabilities and added back only CHOWN, SETGID and SETUID — so the chown
succeeded and the write did not, because root cannot write to a 0755 directory
owned by another uid without DAC_OVERRIDE:

  cp: can't create '/etc/searxng/settings.yml': Permission denied
  !!! "/etc/searxng/settings.yml" is not a valid file, exiting...

Isolated the cause by running the image against fresh directories: it fails
with the shipped cap set and succeeds with DAC_OVERRIDE added, everything else
identical. Granting CHOWN without DAC_OVERRIDE is the exact breaking pair; all
other capabilities stay dropped.

Verified end to end: base install and a --local instance both come up and serve
200 on their own random ports, each with its own settings.yml and a distinct
secret_key. This also completes the instance verification that was blocked
earlier — searxng could not be runtime-tested while the app could not boot.
Both test installs were removed afterwards.

Separate pre-existing bug, not fixed here: the install hook applies the theme
with sed s/simple_style: auto/.../ but the generated settings.yml contains no
style keys at all, so the substitution matches nothing and CFG_SEARXNG_THEME
has never had any effect. Fixing it means inserting a ui: block rather than
replacing a line, which is a different mechanism and wants its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:25:31 +01:00
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
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
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
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
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
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
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
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
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