Adds the three surfaces the plan was missing, and the check suite that
gates them.
§6 splits validation in two, which is the point: admission (§3) is a
security question answered by the root helper and it refuses; fitness is
"will app data actually work here", needs no privilege, and therefore
can be run speculatively against a disk the user hasn't chosen — which is
what lets the wizard grade candidates before anything is committed. Ten
checks, of which two are worth calling out: a real chown probe, because
NFS root_squash reports a healthy nfs4 and then can't chown; and reboot
persistence, because hand-mount → register → install Nextcloud → reboot
is §10.1's disaster on day one. Checks recur on the crontab rather than
only gating at add time — a location that passed in March can be full,
read-only or unplugged by June.
§7 adds the setup-wizard Storage step. It follows the Metrics precedent
for a conditional step but with a better trigger: only appear if the box
has somewhere else to put things, so the one-disk case is untouched.
Candidate detection has to filter hard — this dev box has ~15 snap loop
devices to drop before the real answer shows. Refused candidates render
greyed with the reason rather than vanishing.
§5.2 puts the resolved path in the field's comment for hand-recovery,
written only on change (install, move, or a regen that finds it stale) so
it isn't churn on a user-editable file in the container-owned tree. It's
a breadcrumb: nothing reads it to decide anything, and the WebUI panel
composes its description from appDir so the editor is right even when
the comment is stale.
Phasing regrouped to 7 phases; §10.4 folded into §6; open question 5
resolved (build the flag — unattended installs have no wizard).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning an app to the primary location isn't a fact about its storage,
it's a statement about whether the field is editable — so it belongs in
the comment, where the renderer's other display metadata already lives.
webui_generate_configs.sh already parses **ADVANCED** out of a field's
comment; **READONLY** parses the same way and needs no new key, no
engine-side list, and no special case in appDir or the migrate path. It
also has uses beyond this feature: crowdsec.config:72 warns that editing
a field does nothing, next to an editable input.
Also records why CFG_<APP>_STORAGE holds a location name rather than a
path, since it's the kind of decision that gets relitigated: mount paths
move (relabel, /media/<user>/…, a different session) and a name means
re-registering once instead of rewriting every app config; and migrate's
central question — "this host has no such location, where should it go?"
— is a registry lookup, not a string comparison. The config stores intent,
the manifest stores fact. Unlike backup locations there's no case for a
custom-path override: root only touches registered roots, so a path
outside the registry is unusable rather than merely discouraged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three refinements that make the plan smaller rather than bigger, plus
an honest accounting of what stays awkward.
The .libreportal-storage marker now does three jobs instead of one:
admission, mount detection and migrate provenance. Marker-lives-on-the-
drive replaces the findmnt/REQUIRE_MOUNT/fs_uuid guard family with a
single file read, and it covers cases those miss — a LUKS volume that
was never unlocked, a dropped network mount. It also unblocks adopting
a drive that already holds app data, which strict emptiness forbade;
that relaxation is free, because planting a marker requires write
access you'd have to already have.
appDir becomes the one place availability is checked, so an absent
drive fails centrally instead of needing a guard at 200 callers. And
the sweep verifies itself: retire containers_dir to a sentinel path so
survivors fail loudly on something that cannot exist.
Pinning infra apps becomes CFG_<APP>_STORAGE_PINNED, mirroring
MULTI_INSTANCE, rather than a hardcoded list in the engine.
New §9 records the four things that don't get a clean answer: the
TOCTOU window and the parent-writability restriction that closes it,
two registries that will look like one, a 90-file diff that only
process makes safe, and large moves simply being slow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proposal for turning the single containers root into a registry of
locations, with each app choosing one — so bulk data can live on a
second disk or an external drive while the control plane stays put.
Records what the codebase already gives us for free (relative compose
volumes, self-describing app dirs, the backup-locations pattern), the
~200 call sites that must move behind an appDir() resolver, and the
one constraint that shapes everything: the root helpers bake their
paths at install precisely so the manager cannot redirect a root
chown, so the location registry has to be root-owned with an
empty-directory admission rule.
Also notes a latent bug found while checking: restore builds its
include path from the LOCAL containers root, so migrating between
hosts with different --containers-dir values silently restores nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Only two of the four keys flagged as unused actually were. gitea and invidious
ADMIN_PASSWORD are written by their auth adapters through authPersistCfg, which
builds the name as CFG_${app^^}_${key} from a parameter — invisible to a literal
grep, which is why the earlier pass called them dead. They stay.
Worse, the slot rename broke that write path for five apps: adguard, bookstack,
gitea, invidious and nextcloud all persist ADMIN_PASSWORD, and the config now
holds ADMIN_PASSWORD_1. updateConfigOption only rewrites a key that already
exists, so the write became a no-op — the app's password would really change
while the config and the WebUI kept showing the old one.
authPersistCfg now falls back to the numbered slot when the bare key is absent,
so adapters never need to know how a credential is numbered and adding a slot
can't silently disconnect the adapter that writes it. When neither name exists
it warns and returns non-zero instead of failing silently, which surfaces a
pre-existing case: linkding's adapter persists ADMIN_USER and ADMIN_PASSWORD but
its config declares neither, and never did.
Deleted the two that really are dead: CFG_TRAEFIK_ADMIN_PASSWORD_1 (its adapter
uses CFG_TRAEFIK_USER/CFG_TRAEFIK_PASS from the system config) and
CFG_GLUETUN_CONTROL_SERVER_API_KEY_1, plus their WebUI field mappings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes the convention uniform: if a config key holds a generated value, its name
ends in a slot number. 42 keys across the catalog, up from the 9 database ones
done previously — admin passwords, app keys, tokens, HMAC and auth secrets,
generated usernames and database names. An app needing a second credential of a
kind now just adds _2; nothing is registered anywhere, since the tag name is
derived from the key by tags_processor_app_config_values.
Keys holding an operator-chosen value (CFG_NEXTCLOUD_ADMIN_USER=admin) keep their
names — the slot number is what marks a value as generated.
The rename would have silently cost seven keys their WebUI field mapping. The
frontend resolver matches a mapping key against a config key by equality, _suffix
or prefix_ (apps-manager.js findMatchingCFGKey), so the generic "ADMIN_PASSWORD"
entry stops matching CFG_GITEA_ADMIN_PASSWORD_1 — it neither ends with
_ADMIN_PASSWORD nor starts with ADMIN_PASSWORD_. Rather than loosen the matcher
(PORT_1 relies on its numeric suffix being part of the name), add explicit
entries. Did the same for eight keys that were already unmapped before this
change, so all 42 now render with a label and, where appropriate, masked: the
only one typed as text is Mastodon's VAPID public key, which is public by design.
Verified by simulating the resolver against every app config, and by running each
app in the catalog through fill -> hook -> templating: every secret tag
substitutes, no RANDOMIZED placeholder survives, every compose still parses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VAPID: the two values are the halves of one P-256 keypair, not independent
secrets — the browser verifies that a push is signed by the private key matching
the public key it subscribed with. The RANDOMIZED* generators mint each
placeholder on its own, so they produced two unrelated strings and web push could
never have worked. Generate the pair in mastodon_install_post_setup the way stoat
already does, encoded as Mastodon's webpush gem expects: unpadded URL-safe base64
of the 32-byte private scalar and the 65-byte uncompressed public point, sliced
out of the SEC1 DER. Verified by rebuilding the key from the emitted private half
and re-deriving the public point — openssl accepts it and the point matches.
Generated once and never rotated (rotation would invalidate every subscription),
but a pair of the wrong shape is replaced, so an install carrying the old
unrelated strings heals itself on next install — their public half is 42 chars
where a real point is 87.
Slots: CFG_<APP>_DB_PASSWORD -> CFG_<APP>_DB_PASSWORD_1 and likewise for
DB_ROOT_PASSWORD, across mastodon, owncloud, mattermost, matrix, nextcloud and
bookstack, so a database credential is always a numbered slot and a second one is
just _2. Renaming a key means reconciliation drops the old and adds the new
holding its placeholder, so an existing install regenerates unless the value is
carried over first — documented, including that the old file survives as
.<app>.config.bak.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five apps (mastodon, owncloud, mattermost, matrix, stoat) took their generated
secrets from the compose-side generator tags PASSWORD_TAG_<n>/RANDOM_TAG_<n>/
HEX_TAG_<n>/VAPID_TAG_<n>. Those mint a fresh secret on every templating run, so
a reinstall handed the app a new database password while its data volume kept the
one initdb was given, and the app came back up unable to open its own database.
Move them to <app>.config as RANDOMIZED* placeholders, reaching the compose via
the #LIBREPORTAL|<APP>_<KEY>_TAG| mechanism tags_processor_app_config_values
already provides. No new handler: the tag name is derived from the config key, so
this is a config line plus a tag per secret. Generation is unchanged — still
random on first install; the value is now remembered instead of re-rolled.
Also fixes two things this exposed:
- The RANDOMIZED* replacers matched unanchored. `sort -u` orders slots lexically
(1, 10, 11, 2), so slot 1's pattern rewrote the prefix inside slot 10's
placeholder and slots 10+ ended up holding slot 1's secret with a digit glued
on — derivable, and invisible because the values weren't byte-identical.
Anchoring with \b makes match order irrelevant. Verified at 20 slots across
all four placeholder types: 64 keys, 64 distinct values, no prefix collisions.
- generateRandomPassword drew from base64 without constraining the mix; measured
over 2000 draws, 1 in 40 contained no digit at all. Retry until the result has
both a digit and a letter, bounded so a pathological length can't spin.
owncloud gains a fix in passing: its compose seeded the admin account from
PASSWORD_TAG_2 while the WebUI displayed CFG_OWNCLOUD_ADMIN_PASSWORD, which was
generated separately and never used. Both now read the same value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tag manager reads `#LIBREPORTAL|<TAG>|<VALUE>` and takes <VALUE> as the
current literal to search for on that line, so it must equal the string in the
line body. Mastodon used `unconfigured` as the annotation value against bodies
like `PASSWORD_TAG_1_DATA` — nothing matched, nothing was ever substituted, and
the placeholder shipped as the live database password, SECRET_KEY_BASE, OTP
secret and VAPID keypair.
Nothing caught it either: `unconfigured` doesn't match `_DATA`, so
tagsManagerGetTagState reported the tags as configured, and the stale-tag gate
in dockerComposeUp (which tests the annotation value against
`^[A-Z][A-Z0-9_]*_DATA(_[0-9]+)?$`) let the app start.
Adopt the convention every other app already uses — body placeholder identical
to the annotation value, `<KIND>_DATA_<n>`. All 11 tags now substitute, the app
and postgres services agree on the same generated credentials, and an unfilled
tag is visible to the pre-start gate.
Existing 0.1.0 installs keep their literal credentials until re-installed, and
their Postgres was initialised with them, so a plain re-install desynchronises
the compose from the volume. Document both recovery paths in upgrade notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
P0 anchor, P1 config-first pin, P2 registry detection + display, and P3 honest
pinned apply/revert are all built and deployed. Records the detection nuance
(digest-compare fully covers rolling apps; versioned newer-version enumeration
is a P5 add) and the remaining deferred work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The scan read the app's "current image" via `grep -m1 image:` — the first
image line. For apps that declare a companion service first this mislabels the
app: ollama lists `ollama-webui` (open-webui) before `ollama-service`
(ollama/ollama), so an app named ollama reported open-webui's version.
Add `updaterPrimaryImage`: resolve the version anchor from the app's
`<slug>-service` image (the universal primary-service naming convention,
33/33 apps; underscores→hyphens for slugs like libreportal_catalog). Falls back
to the first image line off-convention. Used by both the version and CVE loops.
Verified: only ollama changes (→ ollama/ollama:latest); nextcloud, mastodon,
jitsi, gitea, vaultwarden anchors unchanged.
Also revise docs/roadmap/app-version-updater-and-cve.md to the config-first
direction agreed this session:
- CFG_<APP>_VERSION ADOPTED (was rejected): the #LIBREPORTAL tagging system
makes the config the source (compose tag derived from it), not a second one.
- Two version TYPES — versioned (real tag, version picker) vs rolling (floating
channel, digest-freshness); digest is the uniform detection engine for both,
version numbers are display enrichment. Answers "why not just compare numbers"
(most upstreams publish none; no universal latest-version API).
- Multi-service anchor = <slug>-service, not first line; lock-step sets (jitsi)
= one channel → several image lines; sidecars tracked-by-digest, not headline.
- Phases updated: P0 anchor (done) → P1 config-first pin → P2 detection+display.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
New containers/trivy app (aquasec/trivy in server mode, private API port,
docker socket + cache volume) in the security,recommended categories, plus
placement in the setup wizard recommended step, the server-side install
tier after crowdsec, and the CLI first-install prompt. The updater's CVE
scan (design doc P4) will gate on this app being installed; §5 of the
design doc updated to record the app-based decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Fills the gap updates-and-distribution.md always deferred: how versions
are actually detected, pinned, and reverted, and how cves.json gets real
data. Core decisions: floating tag stays the channel while the live
compose pins the digest (making detection possible and rollback honest),
and CVE scanning runs as a LibrePortal-orchestrated ephemeral Trivy
container (no host binary, no always-on scanner app).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Roadmap: Phase 6 entry (app bundles + Browse-&-Add, built 2026-07-03),
the app-bundle format addendum (meta object, tarball contract, collision
policy, trust-then-quarantine ordering), deferred bullet trimmed to what
actually remains (community quarantine, taps, theme/component, canary
countersign) with a pointer to marketplace-website.md for the website
layer. development.md gains the 'Publish an app to the marketplace
catalog' section (make_app.sh flow + the app add loop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The marketplace system ships as containers/libreportal-marketplace (a normal
app definition, hidden behind CFG_DEV_MODE via a new CFG_<APP>_DEV_ONLY
convention): it serves the signed catalog tree plus a client-rendered browse
UI over the same index.json every box verifies. The official instance is our
own install of this app; self-hosting a marketplace = installing it.
Community submissions stay PR -> review -> sign (phase 2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Design note for a per-app Files tab scoped to LibrePortal-managed files
(not system files): four file buckets (hidden/view-only/editable/lever),
the advanced/dev mode as the single escalation lever (not per-file flags),
and the hard rule that the flag is UX-only while the locked-down task CLI
stays the security boundary (jail + secret allowlist).
Includes the live UID-access spike: the manager owns and can write the
config tree (/libreportal-system/configs) directly, but the container tree
(/libreportal-containers/<app>) is dockerinstall-owned — readable, not
writable — so config edits need no helper while compose-class edits do.
webui_logins is manager-readable, so secret-hiding must live in the CLI
allowlist, not in perms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Update §8.7 + the banner + §1 TODOs to reflect that Phases 2–5 shipped today
(apply/revert pipeline, severity-split auto-apply, the WebUI Improvements stream
+ per-app chip, and make_hotfix.sh). Only the registry/marketplace stays
deferred (demand-gated by design).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The four-lens design panel finished (marketplace-first ranked top) and
confirmed the format; graft in the strongest refinements it surfaced so
the spec is genuinely "done":
- Publishers MAP trust anchor: `publisher` is now a key into an index-root
`publishers` map ({display, role, key}) the team-signed index vouches
for, not an inline {name,trust}. An artifact's claimed trust is honored
only if the publisher's role permits AND its sig verifies against that
key — so a community key can never self-certify as official. This is the
load-bearing trust mechanism for the marketplace seam.
- Two-tier reversibility: a per-op `undo` array (precise revert) plus the
snapshot (dirty-op fallback).
- All-or-nothing dry-precheck-all before any snapshot; unknown op rejects
the whole artifact at validation.
- Canonical-bytes signing rule (sign the exact artifact bytes, never
re-serialize on the box) + warrant-canary countersigning index_serial.
- Op vocabulary grown to the full set (set-data-file as the bridge to
bundles; set/unset-compose-env; ensure-compose-up/restart-service).
- Envelope gains version/supersedes/reversible + richer applies_when
(image_match/requires/conflicts).
- CFG_HOTFIX_AUTO + staged rollout / randomized delay / recall-via-supersedes.
- Flag the VERIFIED existing bug: updaterRecordHistory silently skips the
audit entry when jq is absent (cli_updater_commands.sh:154-168) — Phase 2
must make it fail-closed; "nothing silent" depends on it.
- Phases re-sequenced (P2 heart, P3 auto-apply, P4 WebUI, P5 make_hotfix.sh,
deferred registry).
Spec-only change — no code; the Phase 1 read primitive is unaffected (it's
a generic verified fetch; publisher/envelope internals are Phase 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Build the read side of the unified distribution primitive from
docs/roadmap/updates-and-distribution.md: one team-signed catalog
(index.json) on the same channel as latest.json, listing type-tagged
artifact envelopes. A hotfix is the first artifact type; apps/themes/
components are future envelope rows through the SAME pipe — the
marketplace seam is just the `type` + `payload.kind` fields.
Phase 1 is fetch + verify + parse only (NO mutation; the snapshot →
ops → rollback → History apply verb is Phase 2):
- Factor `lpVerifyMinisig` out of `lpFetchRelease` (scripts/source/
fetch.sh) — one trust anchor (the root-owned footprint key) now
shared by releases and the index; refactor `lpFetchRelease` to use
it (behaviour-preserving, still fail-closed).
- scripts/source/artifacts.sh: `lpFetchIndex` — download →
verify-before-parse → `valid_until` freshness (anti-withholding) →
`index_serial` monotonic high-water (anti-rollback, TUF-lite) → emit
verified JSON. Trust core is jq-free; parsing accessors prefer jq
with a grep fallback.
- `libreportal artifact index` (scripts/cli/commands/artifact/) —
read-only front door that fetches, verifies and lists. Runs directly
like `updater check` (no task; no mutation).
- Regenerate the source arrays + lazy-load function manifest for the
new files.
Doc: promote the format from vision to spec (§8) — 3 layers
(INDEX/ENVELOPE/PIPELINE), the bounded declarative op vocabulary (no
run-script, ever), the apply pipeline mapped onto existing functions,
the marketplace seam, and resolutions for all five open forks.
Self-tested 12/12: trust core fails closed (real key + no minisign →
refuse), happy path, stale-refused, rollback-refused, signature-refused,
jq + grep parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- docs: remove the docs/README.md index and docs/CONTRIBUTING.md pointer
(duplicate filenames); the canonical contributing guide stays at
docs/contributing/contributing.md. Clean tree, no name collisions.
- scripts/system/*: 6 helper headers + host_access.sh said the helpers
install to /usr/local/sbin, but init.sh installs all of them to
/usr/local/lib/libreportal/ (verified via initRootHelpers + the sudoers
Cmnd_Alias). Corrected. The only remaining /usr/local/sbin is the legit
PATH export in the task processor.
- frontend kernel: drop migration-era comments that are now false post-
modularization (feature-registry 'passive/phase 0/unused', lifecycle
'ctx.services lands with Phase 2', manifest 'scan generator lands') —
describe current behaviour instead.
Comment-only edits to scripts/system/* — no footprint_version bump (no
behavioural change; bumping would force needless reinstalls).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Audited every doc against the code. Three fixes:
- system-footprint.md: add the libreportal-crowdsec root helper row
(init.sh installs 8 helpers; the table listed 7). appcfg row clarified
to 'CrowdSec-bouncer' config since the new helper does the host install.
- .gitattributes: add 'site export-ignore' — development.md documents the
website as never-shipping, but the rule was missing, so site/ was landing
in release tarballs. No runtime refs to site/; hosting lives in the Infra repo.
- promise.md: fix LICENSE link (../../LICENSE) after the docs/ reshuffle.
Everything else (install-and-use, development, contributing) verified current:
all install/uninstall/update flags, release scripts, fetch fns, footprint_version,
service name, and config keys check out against the code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Sort docs/ into guide/ contributing/ architecture/ roadmap/ and rename
to consistent kebab-case (USER->guide/install-and-use, FOOTPRINT->
architecture/system-footprint, frontend-modularization->architecture/
webui-architecture, etc.). Add a docs/README.md index and a docs/
CONTRIBUTING.md pointer so the forge still surfaces the contributing
guide. Fix every reference (README, init.sh comments, frontend code
comments, and the USER<->DEVELOPMENT cross-links). History preserved
via git mv. Root stays README.md + CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The modularization shipped (2026-05-30), so the design doc was stale and
internally contradictory: it described a features/ tree (real tree is
components/), a shell-generator/Node route that were never built, and a
'partially implemented' status. Replace the 59KB design exploration with
a short, accurate description of the component-module system as it exists
(components/<id> pages, core/ subsystems, the kernel: feature-registry/
services/lifecycle/spa, static manifest discovery, mount/unmount contract,
eager global CSS). Fix one stale features/ path in a spa.js comment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Captures the brainstorm on hotfixes, the updater reframe to
'Updates & Improvements', and registry-not-marketplace distribution:
one signed/declarative/reversible primitive behind hotfixes, app
installs, and themes. Vision/TODO doc with open forks, not a spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add an Implementation status section: kernel + manifest routing, all pages
migrated to feature modules, folder auto-discovery via /api/features/list
(supersedes the §3 shell-regen generator), DI seam, and the shared token
layer are shipped + verified. God-file decomposition and the base.css/CSS
split remain as the large internal refactors still to do.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Synthesized architecture for turning the no-build vanilla-JS WebUI into a
scan-and-manifest feature system mirroring the backend container scan:
self-contained features/<id>/ folders, a navigation kernel, uniform
mount/unmount lifecycle, DI service context replacing ~80 window globals,
per-feature CSS, god-file decomposition, and a strangler migration roadmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Three closeouts in one pass:
1. DEVELOPMENT.md — consolidated hook-conventions table covering all 8 per-app
hook types (tools / update-specifics / compose-tags / webui-refresh / the two
traefik markers / the two network-provider hooks). One place to look instead
of inferring from the codebase.
2. Nextcloud APCu wired alongside Redis: appUpdateSpecifics_nextcloud now sets
memcache.local=\OC\Memcache\APCu too (was deferred from the fpm switch). APCu
= cheap in-process cache; the fpm-alpine image ships the extension. CLI mode
may emit a harmless "no memory cache" notice on `occ` runs — Nextcloud is
graceful, the FPM worker still uses APCu fine.
3. Container-side file-capture rollout to 3 confident cases:
- bookstack: lscr.io/linuxserver/bookstack with PUID=1000 → /config (1000:1000)
- gitea: gitea/gitea with USER_UID=1000 → /data (1000:1000)
- owncloud: owncloud/server (Apache/PHP) → /mnt/data (33:33, www-data)
Snapshots are now complete for these (the dir's excluded from the raw restic
pass and captured live through the container as a tar → libreportal-owned
staging, same proven pattern as Nextcloud). Less-evidenced candidates left
for live verification: linkding, mastodon, jellyfin, trilium, focalboard,
invidious, vaultwarden, headscale-service — each needs its in-container uid
confirmed before labeling (wrong uid won't break backup, but restore would
chown to the wrong owner).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- Add .backup-system-card { margin-top: 20px } — the card stands alone below the
two-column cards row (which has no bottom margin), so it was butting against it.
- Add a server-stack icon to the card header (matches the nebula stroke-icon style).
- DEVELOPMENT.md: document the dashboard "System config" card + its last-backup
status (tag system=config → `system` in the dashboard JSON), the CLI/auto paths,
and that the libreportal app is excluded from the per-app grid.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
(a) Docs: reserve tools/ scripts/ resources/ as LibrePortal folder names (apps must
not bind-mount to them); document resources/ as the home for nest-able data AND for
.sh payloads that execute on load (vs scripts/ for sourced functions); document the
backup model (what's captured vs reproducible).
(b) System-config backup so a bare-metal restore is self-sufficient — this is why
the system root is its own tree. New scripts/backup/system/backup_system.sh:
- backupSystemConfig snapshots <system>/configs (global settings, WebUI creds, and
the BACKUP-LOCATION creds — otherwise the keys to reach your own backups live only
on the box) to every enabled location. Lightweight static-dir snapshot — it does
NOT go through backupAppStart (no containers to quiesce / DBs to dump).
- restic adapter resticBackupSystemToLocation (tag system=config) + dispatcher
engineBackupSystem; restore via resticRestoreSystemLatest / engineRestoreSystemLatest
+ backupRestoreSystemConfig (restores to a STAGING dir — never auto-overwrites
live config).
- backupAllApps runs it after the app loop.
WebUI exclusion: backupAllApps skips the 'libreportal' app — its frontend + generated
JSON regenerate, and its only state (the login) is in the system config now captured
above. Nothing in its data dir warrants a snapshot.
Verified with stubs: app loop skips libreportal + invokes the system backup; the
system backup dispatches to both locations; backup/restore function names pair with
the dispatcher. NOTE: restic-only (the sole live engine adapter); end-to-end repo
round-trip still needs a live box before being relied on.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The rename was justified partly by an anticipated second `libreportal-regen`
unit — which we then decided not to create (the poll rides the existing task
processor). What's left is cosmetic, and it isn't worth a footprint_version bump
(which forces a root re-install on every existing box) plus the dual-name
migration cruft.
Reverting also means the rename was the ONLY footprint change in the regen work,
so the whole regen system now ships as a plain manager-owned code deploy — no
root re-install needed. footprint_version stays 2.
Kept only the accurate FOOTPRINT.md note that the service also drives the poll.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The single systemd unit is the task processor (and now also drives the periodic
regen poll), so name it for what it does instead of the ambiguous bare
"libreportal.service" — clearer now that the runtime has more than one concern.
- svc helper: SERVICE_NAME=libreportal-taskprocessor.service; _drop_legacy()
stops/removes the pre-rename unit on install (idempotent migration) so an
upgraded box never runs two processors.
- init.sh: read baked roots from the new unit (fall back to the old name);
uninstall removes both names; bump footprint_version 2 -> 3 (root-owned unit
changed, so a manager-run update flags "root re-install needed").
- check_webui_systemd: accept either name during the transition.
- docs/FOOTPRINT.md: new unit name + uninstall command.
No sudoers change — it allows /usr/bin/systemctl generically, not a named unit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add `lpRegen` (scripts/webui/webui_regen.sh) — one entry point that rebuilds the
file-derived artifacts whose sources changed, so callers don't have to know which
generator owns what. Self-heal is a cheap `find -newer` mtime compare (no watcher
/ daemon): a stage runs only when a source is newer than its artifact, or --force.
- `libreportal regen [all|webui|arrays] [--force]` CLI command (new category).
- Task processor idle tick runs a throttled `regen webui` poll, so an app dropped
in out-of-band (drag-drop / marketplace) appears on its own — no manual command,
no inotify (works on the relocatable/external-drive roots where inotify can't).
- make_release.sh guards against shipping stale source arrays (regenerate; abort
if the committed tree was out of date), killing the "forgot generate_arrays" bug
class at the build boundary.
- Document the front door in DEVELOPMENT.md.
webui scope rebuilds from containers/<app>/{*.config,tools/*.tools.json}; arrays
scope from scripts/** (a dev/build concern — a no-op on a normal install). Gate
logic verified in a sandbox (clean/config-newer/tools-newer/force/missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Each app now carries everything under containers/<app>/: Tools-tab actions in
tools/ (declaration <app>.tools.json + function <app>_<tool_id>.sh) and logic
helpers in scripts/ (e.g. <app>_auth.sh). The container scan live-sources every
.sh under the app (maxdepth 3, prunes only resources/) and webui_tools.sh
auto-merges the .tools.json, so an app is a true drop-in — no central edit, no
array regen.
- Empty the central webui_tools.sh heredoc; all 34 tools across 11 apps now
come from per-app declarations (verified byte-identical to the old output).
- Retire the orphaned mattermost tool scripts to scripts/unused (there is no
containers/mattermost; its install fn already lived in unused).
- Update the dispatch comment/error path, the auth-adapter doc, and
DEVELOPMENT.md to the new convention.
- Regenerate static arrays (files_app.sh no longer lists app/containers/*).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
getlibreportal (downloads host) + weblibreportal (website) — including the website
Eleventy source and the publish tool functions — now live in the separate
LibrePortal-Infra repo (Webstar/LibrePortal-Infra). They're the project's own
outward-facing hosting, not something users install, so the base stays clean.
Removed from base: containers/{getlibreportal,weblibreportal}, the
scripts/app/containers/<app>/<app>_publish.sh tool functions, and their entries in
webui_tools.sh; regenerated the sourced-file arrays; dropped the dead .gitignore
docroot lines. scripts/release/make_release.sh stays here (it builds the base
release). docs/DEVELOPMENT.md now points publishing at LibrePortal-Infra.
LibrePortal-Infra overlays onto an install and picks up releases/catalogue from the
base tree — see its README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The sha256 only proves a download is intact; a compromised host could swap the
tarball + its checksum. Add minisign signatures, which prove authenticity (the host
can't forge them without the offline secret key). Ships INACTIVE behind a REPLACE_ME
placeholder, so installs work until a real key is generated; then it's REQUIRED.
- make_release.sh: signs the tarball when LP_MINISIGN_SECKEY is set -> <tarball>.minisig.
- libreportal.pub: the public key (placeholder), ships in the tarball and is installed
to the ROOT-OWNED footprint (/usr/local/lib/libreportal/libreportal.pub) by init.sh
-> the manager can't swap it to accept forged updates. footprint_version -> 2.
- install.sh: LP_MINISIGN_PUBKEY constant; once non-placeholder, downloads + verifies
the .minisig (minisign -P) and REFUSES on invalid/missing (auto-installs minisign if
needed). --no-verify-signature is a dev-only escape hatch.
- fetch.sh (update path): verifies against the footprint .pub (minisign -p), refuses on
invalid/missing.
- docs/DEVELOPMENT.md: keygen (minisign -G), paste pubkey into libreportal.pub +
install.sh, keep the secret key offline, sign builds via LP_MINISIGN_SECKEY, bump
footprint_version on key rotation.
Verified end-to-end with a real throwaway key: good signature accepted; tampered,
wrong-key, and missing-signature all refused; placeholder skips (sha256 still enforced).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A manager-run 'update apply' refreshes code/apps/WebUI but CANNOT rewrite the
root-owned footprint (helpers/wrapper/uninstall/unit/sudoers) — that immutability
is the de-sudo boundary. Previously a release that changed those would silently
leave them stale. Make it explicit:
- init.sh: footprint_version=1 constant, baked at install into
/usr/local/lib/libreportal/.footprint_version (root:root 0644) by initRootHelpers.
Bump it whenever a root component changes.
- make_release.sh: publishes footprint_version in latest.json.
- fetch.sh: lpInstalledFootprintVersion (marker) + lpReleaseLatestFootprint (manifest).
- check_update.sh: 'update apply' REFUSES when the release's footprint_version
exceeds the installed one, directing to a root re-install (which fetches +
re-bakes everything atomically). No half-applied updates.
- webui_system_update.sh: badge sets footprint_update_needed + clears can_update so
the WebUI won't offer a one-click apply for a footprint-bumping release.
- docs/DEVELOPMENT.md: the bump rule + the footprint exception explained.
Verified: manifest carries footprint_version; drift decision correct both ways
(no marker/older -> needs re-install; equal -> no drift).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The docs were telling users to run /libreportal-system/install/uninstall.sh — a
hardcoded data path, wrong for any custom --system-dir, contradicting the whole
relocatable design.
Fix it the way the CLI already works: install uninstall.sh to the FIXED footprint
(/usr/local/lib/libreportal/uninstall.sh) and symlink it onto $PATH as
'libreportal-uninstall' (initLibrePortalCommand). It self-resolves the real data
roots from the systemd unit, so the command is the same everywhere regardless of
where data lives. Teardown removes the new symlink; FOOTPRINT.md lists it.
Docs now say 'sudo libreportal-uninstall' — no data path. (Dev-from-clone still
uses ./uninstall.sh / ./init.sh uninstall.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Tidy the repo root (README + LICENSE stay there per convention; everything else
moves):
- CONTRIBUTING.md, PROMISE.md, FOOTPRINT.md -> docs/ (alongside USER.md/DEVELOPMENT.md)
- update the references: README links, the website site.json raw URLs, init.sh's
'see FOOTPRINT.md' comments -> docs/FOOTPRINT.md; drop the now-redundant
CONTRIBUTING.md export-ignore (docs/ is already export-ignored).
Refresh FOOTPRINT.md: it claimed 'everything lives under /docker', which is no
longer true. Now describes the three relocatable roots (system/containers/backups)
and makes explicit that the roots + manager name are baked into the helpers/unit/
wrapper at install (the privilege boundary) while this out-of-root footprint stays
fixed by design. Uninstall sketch + sudoers/unit rows updated for the configurable
manager. CONTRIBUTING/PROMISE were already current — left as-is.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Two guides covering what wasn't written down:
- USER.md: install (the install.sh one-liner), placing the three roots on separate
disks/external drives, channels, updating, backups (REQUIRE_MOUNT), uninstall.
- DEVELOPMENT.md: the install-mode/roots/users model + key files; running a dev copy
(local/git); cutting stable/edge releases (bump VERSION -> make_release.sh ->
dist/<channel>/{tarball,.sha256,latest.json} -> publish); testing a release
locally via LP_RELEASE_BASE_URL + python3 -m http.server (incl. checksum-refusal);
how release updates work; conventions.
README Quick start updated to the release flow + a docs pointer. docs/ is
export-ignored so it doesn't bloat release tarballs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>