1367 Commits

Author SHA1 Message Date
librelad
046d69bad7 feat(updater): Upgrade button for cross-version moves
Puts the stepped engine behind the "34 available" chip so it is not
CLI-only. Wired end to end: updater_upgrade task type -> task-router ->
updaterUpgrade action -> `libreportal updater upgrade <app> [version]`,
with a label and icon in the tasks list.

The button always confirms, and the dialog states the plan and the
guarantee rather than asking "are you sure?" — which app, from which
version to which, that every step snapshots first and waits for the app
to confirm it is serving with no migration outstanding, that a failure
stops the ladder on the last version that verified, and that it can take
a long time because each release runs its own migration.

Both delegated dispatchers (per-app Updates tab and the fleet Overview
rows) learn the action, so the button works wherever the chip appears.

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

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

Per rung, and every part is load-bearing:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:04:33 +01:00
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
8a0c3641d0 fix(nextcloud,mastodon): track a moving stable line like every other app
Both templates were stale in different ways, and the new tag enumeration
surfaced it: nextcloud sat on 31-fpm-alpine with 34 out, mastodon on
v4.2.0 with v4.6 out.

  nextcloud  31-fpm-alpine -> 34-fpm-alpine
  mastodon   v4.2.0        -> v4.6

The mastodon one was the real problem: v4.2.0 is an EXACT patch pin, so
it never moved at all — no security patches, ever. v4.6 is a moving
minor-line tag (the same shape as stalwart's v0.16), so auto-update now
delivers patches within the line.

Deliberately NOT floated to :latest or :stable. Both projects require
stepped upgrades — Nextcloud in particular refuses to skip a major — so
a tag that crosses majors on its own would break the app on a routine
container recreate. A major-pinned, patch-moving tag is the correct
shape here, not a limitation.

Verified against the registry: all three pinned apps now report nothing
newer, and each tag still moves (v0.16 2026-08-10, 34-fpm-alpine
2026-08-03, v4.6 2026-08-06).

Templates only, so this changes NEW installs. Existing installs keep
their tag and will now be told a newer line exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:39:36 +01:00
librelad
7334557706 feat(updater): detect newer release lines, not just newer builds
The digest compare only ever asks about the tag already pinned, so it
answers "has my tag been rebuilt?" and can never answer "does a newer
version exist?". An app on v0.16 reports up to date forever while 0.17
ships. That is the gap between an app that updates and an app that is
current, and it silently affects every pinned app.

Adds tag enumeration for VERSIONED tags only (rolling tags already move
on their own): list the repo's tags, keep those sharing the current tag's
SHAPE, and pick the numerically greatest.

Shape matching is the whole safety story — v0.16 -> v#.# so it can never
"upgrade" you onto v0.16-alpine, 31-fpm-alpine onto 31-apache, or a date
tag onto a semver one. Comparison is component-wise numeric, so 0.10 > 0.9
and 1.0 > 0.99 (a string sort gets both wrong), with 10# forcing base ten
so an upstream "08" cannot be read as octal. 15 unit tests cover it.

Docker Hub only, deliberately: all three pinned apps live there, it needs
no auth, and the generic OCI tags/list wants a per-registry token dance.
Other registries stay quiet rather than guess. Throttled inside the
existing registry window and cached between windows so it cannot flicker.

Surfaced as INFORMATION, never an action: no button applies it, because a
version move can carry a data migration. `update_available` and the "up
to date" badge keep their exact meaning; the new state sits beside them
and points at the Version field.

Against the live registry: stalwart v0.16 is current, nextcloud is on
31-fpm-alpine with 34-fpm-alpine out, mastodon on v4.2.0 with v4.6.5 out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:30:08 +01:00
librelad
da97daf5f7 fix(apps): move Application Version to the Advanced tab
It sets the image tag, so a wrong value stops the app starting — that
belongs with the other expert settings, not beside feature toggles.
Tooltip now explains the split it participates in: automatic updates
apply rebuilds OF this version, changing it moves between releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:19:02 +01:00
librelad
fd8ac29621 feat(stalwart): auto-update patches, VERSION config for release moves
Reverses the manual default from 4ee2529, which was over-cautious once
the tag pin is taken into account.

Two things were conflated. Auto-update does not reinstall anything: it
snapshots, `compose pull`, `up -d` — the container is recreated from the
new image and the data volume is untouched. And because the image is
pinned to v0.16, the updater compares the digest of THAT tag, so auto
can only ever apply rebuilds of 0.16 (security/bug patches). It cannot
jump to 0.17. That is the safe half of updating, and there is no good
reason to withhold it.

Adds CFG_STALWART_VERSION=v0.16, which drives the image tag through the
existing #LIBREPORTAL|STALWART_VERSION_TAG| sentinel (verified: setting
it to v0.17 rewrites the image line). Moving between releases is now a
config change a user can make from the app's config page — the roadmap's
config-first version identity, used for real.

Net behaviour: patches land unattended inside the update window; a
version jump stays a deliberate decision, which is what pre-1.0 software
with a settling storage schema warrants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:13:47 +01:00
librelad
47b614f8b4 fix(apps): stop re-serialising the config section, which killed its listeners
Every themed dropdown on the app config page was dead: it rendered, but
clicking did nothing. Only that page — every other dropdown in the WebUI
worked.

renderAppDetail captured the config section's own innerHTML right after
displayConfigForm() had rendered it...

  const configHTML = document.getElementById('config-section')?.innerHTML;
  ...67 lines later...
  configSection.innerHTML = configHTML;

...and wrote the same string straight back. That is a no-op for the
markup and a catastrophe for behaviour: re-assigning innerHTML re-parses
the subtree, so every listener in it is destroyed.

custom-select.js had already wrapped each <select>, so the captured
string contained the .custom-select wrapper and the custom-select-native
class. The re-inserted copy therefore looked enhanced — which made the
enhancer correctly skip it as already-done — while having no click
handler at all. A dropdown that renders perfectly and does nothing.

The container is never wholesale-rewritten in that function (it updates
header/config/console individually), so the capture-and-restore had no
purpose. Both lines removed, with a comment stating that anything added
there must mutate the section rather than re-assign its innerHTML.

Diagnosed in a real browser rather than by reading: instrumenting
CustomSelect.build() showed the enhancer DID build a widget for the field
while the wrapper in the DOM was not the one it built, and patching the
innerHTML setter named apps-manager.js:785 as the writer. Verified live:
the dropdown opens, and picking an option sets the value (auto) and the
label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:09:25 +01:00
librelad
07daa7555a fix(webui): load mobile-menu.js; prune orphaned task queue entries
Both found in a user's console log.

1. ReferenceError: setupMobileMenu is not defined (dashboard.js:98)

   core/topbar/js/mobile-menu.js defines that global, and index.html
   never loaded it. dashboard.js called it unguarded as the FIRST line
   of setupEventListeners, so dashboard init threw every page load and
   took loadInstalledApps() with it — and the burger menu was dead on
   mobile. system-loader already guarded its own call with a typeof
   check, which is why this survived unnoticed.

   Loads the script (before dashboard.js) and guards the call, so
   optional nav chrome can never take down the page below it again.

2. Endless 404s on /api/tasks/<id> for tasks that no longer exist

   queue.json is append-only from the enqueue side and nothing ever
   pruned it, so any task file removed afterwards left an id the WebUI
   re-fetched forever, one 404 per poll per orphan. Adds
   cleanupOrphanQueueEntries to the idle housekeeping pass: entries with
   no task file are dropped and logged. Self-heals existing strays.

   (Provoked by my own clean-up of two test tasks earlier in this
   session, but the gap is real and predates it.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:48:55 +01:00
librelad
5e06e77d7f fix(forms): one bad select no longer kills every dropdown after it
Reported: dropdowns dead on the app config page only, in a private window
(so not cache), with the served frontend confirmed identical to source.

The enhancer ran in a bare forEach with no try/catch anywhere, and
build() inserts its wrapper via `select.parentNode.insertBefore(...)`.
A detached select makes that a null deref, and one throw abandoned the
rest of the pass — every select AFTER it silently stayed native. The
MutationObserver callback had the same exposure for the remaining
mutation records in a batch.

The app config page is the one that can produce a detached select: it
builds its category panels in an async loop, so the observer can see a
node a later render already replaced. That matches "only app config".

Worse than losing the theme: if the throw landed after build() added
.custom-select-native, the select was left opacity:0 / pointer-events:
none behind a button with no listeners — a dropdown that looks right and
does nothing.

Now: detached/unconnected selects are skipped (they get enhanced when
their subtree is attached and the observer fires again), every
enhancement is individually guarded, a failed one is rolled back so it
can never be left invisible, and failures console.warn with the field
name instead of vanishing.

Verified with jsdom against the real file: detached nodes skipped without
throwing, a failure mid-pass leaves later selects working, and the
survivors open and set their value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:40:26 +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
8153d82282 fix(apps): don't serve a cached config form after a deploy
getFieldMappings/getConfigCategories fetched host-GENERATED files with
default caching, so a browser that had the page open before a release
kept rendering the previous release's config UI — a newly shipped field
(UPDATE_TYPE) simply never appeared, with nothing on screen to hint the
page was stale. Only a hard refresh fixed it.

Adds {cache:'no-store'} to both, plus the one configs.json read in this
file that was missing it while two others already had it. Cost is a
conditional request per config-page open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:22:43 +01:00
librelad
3325c53855 fix(install): keep the WebUI's bind-mount access after a config chown
The portal was found crash-looping with EACCES on /app/webui_logins.
configs/webui/* are bind-mounted into the container, which reads them
through its GROUP (files 0640, group = container owner). Two paths reset
that group to the manager and never gave it back:

  * init.sh setupConfigsFromRepo — `chown -R manager:manager` over the
    whole configs tree on every install/redeploy (the documented local-
    mode deploy), and
  * the runtime config reconcile — rewriting a live config replaces the
    file as the manager, so ANY release that merely adds a key to a
    webui_* config would break the portal.

Neither breaks anything immediately: the running container holds its
open files, so the failure only appears at the next restart, long after
the change that caused it. That is exactly how it surfaced here — a
deploy in the evening, a dead WebUI later.

init.sh gains restoreWebuiBindAccess (prefers the root ownership helper,
inline chown fallback for the first install, no-op when the container
user does not exist yet) called right after the chown; the reconcile
calls the existing reconcileWebuiDirOwnership when it is in scope.

Verified by reproducing the break (chown -R manager over configs), then
running the fixed deploy and force-recreating the container: group is
restored to the container owner and the portal serves on 3179.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:20:31 +01:00
librelad
23cef6f427 docs(roadmap): record the notification channel in the updater refinements
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:17:35 +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
7fae6bc308 fix(updater): stop a callee blanking the app name mid-update
First real end-to-end auto-update on a live install failed like this:

  Automatically updating trivy (a recovery snapshot is taken first)
  Snapshotting trivy before update…
  Pulling new image(s) for …
  Update of  failed — rolling back…
  Could not roll  back automatically

The app name went empty after the snapshot. Cause: bash is dynamically
scoped, so a callee assigning an undeclared variable writes the CALLER's
local of that name — and a `while read app` loop leaves it EMPTY at EOF.
webuiBackupAppStatus's dashboard generator runs at the end of every backup
and did exactly that to updaterApplyApp's `app`.

Nothing was damaged: the pull ran against an empty name, failed before
touching the image, and the rollback was a no-op on a nonexistent app.

Fixed both ends. The generator (and three gluetun loops with the same
latent leak) now declare `local app`. updaterApplyApp/updaterRollbackApp
hold the name in `_upd_app` so they no longer depend on every callee's
hygiene, and updaterApplyAll stops leaking its own loop var.

This is exactly the untested path the roadmap flagged: "apply/revert not
yet exercised end-to-end on a live install with a pending update."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:37:14 +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
48075d0551 feat(overview): open the board on Needs action by default
The board landed on "Everything", so the rows that want a decision were
mixed in with the healthy one-liners on arrival. Default to the "Needs
action" chip instead, falling back to "Everything" when nothing is
pending (that view would otherwise be empty). An explicit chip click
still sticks for the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:58:42 +01:00
librelad
98c919879a docs(adguard): tighten the long description by a third
Cut the 36-word blurb to 23 without losing either fact that matters: it's
DNS-based, and blocked domains resolve to a local blackhole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 20:57:56 +01:00
librelad
0b0fe3de40 fix(backup): pair tags and paths on one row in snapshot details
Tags and Paths were stacked full-width blocks, so a single tag chip and a
single path each burned a whole row and left the panel mostly empty. Wrap
them in a .bsm-blocks auto-fit grid that seats them side by side and falls
back to stacking under ~460px. The wrapper now owns the divider, so it
renders once for the pair instead of once per block, and is omitted
entirely when neither block has content.

Applied to both renderers of this markup: the global Backups view and the
per-app backup card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:43:27 +01:00
librelad
5db36ac3c7 docs(trivy): trim the long description to the one line that matters
The App Center blurb ran three sentences, two of which restated things
the Security view already makes obvious. Keep the what, drop the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:43:27 +01:00
librelad
48cb5d9380 fix(backup): name preserved env vars instead of relying on sudo -E
sudo-rs — the default sudo from Ubuntu 25.10, so on 26.04 — does not
implement bare -E. It does not reject it either: it warns to stderr
("preserving the entire environment is not supported, '-E' is ignored")
and runs the command with the environment DROPPED, leaving the exit
status untouched. Callers capture stderr, so the warning is invisible and
the backup engines simply never receive RESTIC_PASSWORD / BORG_PASSPHRASE
/ KOPIA_PASSWORD and cannot open the repository.

Name the nine vars explicitly via --preserve-env=<list>, which sudo-rs
and classic sudo (>=1.8.21, so Debian 10's 1.8.27) both honour, so this
needs no version gate. The list is cross-checked against every
RESTIC_/BORG_/KOPIA_ var the engine env scripts export.

The list lives in variables.sh with a literal fallback in runBackupOp,
because init.sh sources run_privileged.sh directly during install without
ever loading variables.sh — an unguarded empty list would silently
reproduce the same dropped-credential bug.

restoreFirstRunDiscover now goes through runBackupOp rather than issuing
its own sudo. It was the only backup-engine call bypassing that funnel,
which is why it missed this fix by construction; routing it back also
gives it the -H that keeps restic's cache under the backup user's HOME.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:15:29 +01:00
librelad
9b9b2054d3 fix(install): ensure ip_tables modules before rootless Docker setup
get.docker.com/rootless aborts when ip_tables/ip6_tables aren't loaded.
Ubuntu 24.04 and 26.04 ship both modules but don't autoload them on a
fresh box, so rootless setup died there — and because the caller captures
its output into $result, the reason never reached the console or the
error report. The install continued, reported success, and printed
credentials for a WebUI that was never running.

initPrerequires now modprobes both modules and persists them to
/etc/modules-load.d/libreportal-rootless.conf for subsequent boots,
failing with an actionable message when the kernel genuinely lacks them
(container/VM kernels without netfilter).

installDockerRootless gets its own guard, since it also runs outside
init.sh via start_docker / rootless_start_setup. It only attempts
modprobe when it can — the de-sudoed manager has no modprobe in the
LP_SYSTEM allowlist, matching how ubuntu.sh handles sudo-apt — and
returns non-zero rather than proceeding into a failure whose message
would be swallowed. Already-loaded modules are a clean no-op, so the
normal post-install re-run path is unaffected.

Uninstall removes the drop-in alongside the sysctl ones, and it's listed
in the footprint summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:08:32 +01:00
librelad
bdaec0c03b fix(install): resolve the 7-Zip package name per release
`apt install p7zip*` only still resolves on Debian 13 / Ubuntu 24.04+
because the renamed `7zip` package happens to declare `Provides: p7zip`.
That is an alias we don't control, so pick the real package name against
the freshly-updated lists instead: `7zip` where it exists, `p7zip-full`
otherwise. Match on a real package stanza rather than apt-cache's exit
status, which returns 0 with empty output for provided-only names.

Also in this path:
- apt -> apt-get for the scripted calls, so the "apt does not have a
  stable CLI interface" warning stops polluting the captured $result.
- drop a duplicated `pv` from the package list.
- move the package list below `apt-get update` so the 7-Zip probe reads
  current lists.

Debian 10 sysctl check now reads OS_TYPE/OS_VERSION from detectOS rather
than shelling out to lsb_release, which minimal images don't ship. This
also stops a non-Debian release numbered "10" from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:55:30 +01:00
librelad
f0d7106188 Merge claude/3 2026-08-01 10:52:16 +01:00
librelad
a23ed15637 feat(checks): accept Ubuntu 24.04 as a tested version
24.04 is the current LTS and was still hitting the untested-OS prompt
while the newer 26.04 did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:52:13 +01:00
librelad
4f5a803ee4 feat(checks): accept Ubuntu 26.04 as a tested version
Ubuntu 26.04 hit the "untested and may not be fully supported" prompt
and blocked non-interactive installs. Add it to the supported list.

Also escape the dots in the version alternation — unescaped they matched
any character, so e.g. "18X04" was treated as a tested release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:49:14 +01:00
librelad
728830f74d Merge claude/2 2026-07-21 20:10:12 +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
b9d9d88552 Merge claude/2 2026-07-21 19:58:19 +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
1af73143c3 Merge claude/1 2026-07-18 23:34:20 +01:00
librelad
d72f84a9cf fix(updater): honest CVE framing — key actionability on a real update
The Security triage implied 'fix available · update the app to clear these'
even when the app was already on the newest image — where updating does
nothing and the patch only lands when the maintainer rebuilds. That read as a
false to-do. Reframe honestly:

- Status line keyed on update_available, not Trivy's fixed_in: up to date =>
  'nothing to apply; clears when the maintainer ships a rebuilt image'; update
  available => 'updating may pull in patched packages'.
- Groups relabeled to describe reality: 'Patch released upstream' (hint: lands
  on rebuild / may be cleared by updating) and 'No patch yet'. Counts go
  neutral so a big number doesn't read as either alarm or all-clear.
- Row 'no fix yet' -> 'no patch'; use getAppDisplayName so it's 'Trivy' not
  'trivy'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:34:20 +01:00
librelad
59098667a1 Merge claude/2 2026-07-18 23:22:22 +01:00
librelad
71e1defa5f feat(updater): drop manual Check from Overview + Improvements tabs too
Consistency pass across the App Center Overview area: no tab has a top-level
manual "Check" button anymore — scans are automatic.

- Overview tab: remove the header "Check now"; the hero already reports last-scan
  time, and the unscanned sub-copy no longer points at a button that's gone.
- Improvements tab: remove the header "Check"; lead its body with the same
  auto-check line (right-side Check-now nudge) as the Updates tab.
- Remove the now-unused checkBtn helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:22:22 +01:00
librelad
6b93554040 Merge claude/1 2026-07-18 23:16:35 +01:00
librelad
c7c1601f59 feat(updater): triage the Security CVE list into actionable groups
A user rightly noted the Security section read as a wall of unrelated
dependency CVEs against an 'Up to date' app — no cue for what, if anything,
to do. Make it answer 'is this my problem, and will updating fix it?':

- Scanner (trivy_scan.sh): stop discarding Trivy's Class/Type/Status at the
  jq flatten — bind them onto each vuln so the UI can tell an OS package from
  the app's own bundled dependency, and a real fix from a won't-fix.
- Security section (updater-page.js): explain these are vulnerabilities in the
  packages bundled in the image (not the app version), tally 'N with a fix ·
  M no fix yet', then split the list into a 'Fix available' group (worst-first,
  each row tagged OS/dependency and showing installed -> fixed) and a dimmed
  'No fix yet' group. No fabricated 'this update fixes N' claim — fixed_in vs
  the image tag isn't a reliable join, so we only state fix availability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:16:35 +01:00
librelad
b875ee0525 Merge claude/2 2026-07-18 23:10:49 +01:00
librelad
1529776d5d feat(updater): mirror auto-check line to fleet Updates tab; in-banner Check-now
Extends the per-app Updates treatment to the fleet App Center → Overview →
Updates list, and folds the manual check into the status line so it's the one
canonical (secondary) affordance rather than a top-level tab button:

- updater-page.js: renderAutoCheckLine() now ends with a right-aligned "↻ Check
  now" button (data-updater-action="check" — both surfaces already wire it).
- overview-manager.js: drop the "Check"/"Check now" button from the Updates tab
  header (keep "Update all", only when updates exist); lead renderUpdates() with
  the auto-check line.
- overview.css: .updater-autocheck wraps on narrow widths; .updater-autocheck-btn
  sits right (margin-left:auto), smaller.
- app-tabbed-manager.js: friendlier no-data copy ("You're all caught up — no
  updates found for this app.").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:10:49 +01:00
librelad
3134afecd2 Merge claude/2 2026-07-18 22:38:05 +01:00
librelad
1de4d5d970 feat(updater): auto-check status line on the app Updates tab, drop manual Check
The per-app Updates tab pushed a manual "Check" button (header + empty state)
even though scans run automatically on CFG_UPDATER_SCAN_INTERVAL — so an app with
nothing to update read like an empty/actionable page. Replace the manual Check
with a calm status line inside the panel: "Checked automatically · last checked X
· next check ~Y", backup-schedule style. The genuine Apply/Roll back actions stay
(applying is still manual and safe). No auto-apply.

- webui_updater_scan.sh: stamp scan_interval_minutes alongside generated_at in
  updates.json so the display needs no separate config fetch (0 = auto off).
- updater-page.js: renderAutoCheckLine() + fmtRelFuture().
- app-tabbed-manager.js: drop the header/empty-state Check buttons; render the
  auto-check line; friendlier no-data copy.
- overview.css: style .updater-autocheck (green dot live / muted when off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 22:38:05 +01:00
librelad
dea4764bb6 Merge claude/1 2026-07-18 22:03:36 +01:00
librelad
f9c29df415 fix(overview): path deep-link for an expanded Updates row
The expanded-row deep-link pushed /overview/<tab>?app=<app> — wrong prefix
and a ?query the SPA's path-based router drops on a cold load, so the row
never reopened from a shared URL. Switch to /apps/overview/updates/<app>,
matching the Migrate/Backups sub-tab path pattern, and parse the app from
that path segment in _honorAppDeepLink so the row expands on cold load too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 22:03:36 +01:00
librelad
8ec608f82d Merge claude/1 2026-07-18 21:38:57 +01:00
librelad
7d4681b9a3 style(updater): give the Security CVE list a dark rounded container
The Security section in the per-app detail panel dropped the CVE list flush
against the panel edges (no side padding, no visible frame). Wrap it in an
inset dark rounded .updater-cve-box (side padding + border) so it reads as a
contained block matching the app rows, and make the scroll thumb more present
(wider, higher-contrast, padded track) so a long list clearly scrolls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 21:38:57 +01:00
librelad
7f96366cc1 Merge claude/1 2026-07-18 20:22:09 +01:00
librelad
27797ea119 style(overview): tighten Updates row height (~half)
Reduce .ov-row-head vertical padding (12px -> 5px) and shrink the app icon
tile (32px -> 24px) so each Updates row is roughly half as tall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:22:09 +01:00