50 Commits

Author SHA1 Message Date
librelad
601ea03b1a Backup step: drop the timestamp list, and make both answers cards
The pre-password "Taken" list goes. It could only ever be a column of
timestamps, and the card directly above it already said how many snapshots
there were and how recent the newest was — so it answered a question that had
just been answered. The real choice now lives on Contents, where snapshots have
names. The times came out of the scan and verify payloads with it; carried but
unread is debt.

The read result is a card too, matching the folder's. They were a card and a
sentence sitting one above the other, looking like two different kinds of
thing. Shortened to the host and what it holds — "Change-Me · settings + 2
apps". No "continue to see what will happen": Next is right there and has just
become available, which says it better.

And a real bug, caught by asserting Next's state after a genuine read rather
than a simulated one: readBackup CLEARS the password field the moment it hands
the value to the host, so a gate that re-checked the source fields reported a
missing password about a repository it had already opened. Next stayed disabled
for good after a successful read. Both the button and validateStep now treat an
open backup as settling the question — they have to agree, because
enabled-but-refused is worse than either alone.

The test that caught it could not run at first: BACKUP was declared after the
block using it, so the whole eval died in the temporal dead zone and reported
as "the browser failed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:14:17 +01:00
librelad
6018250526 Choose which snapshot to restore, per app and for the settings
A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.

Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.

The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:

  - restoreInspect returns every snapshot per app and for the settings, not
    just the newest.
  - restoreFirstRunBulk reads an optional RESTORE_SNAPSHOT_CHOICE map instead
    of hardcoding "latest". An associative array rather than an argument,
    because the CLI wrapper pads argv to nine slots and a per-app map cannot
    survive it; the map reaches the host as base64 JSON, validated at the route
    against restic short ids and app names since both hit a command line.
  - backupRestoreSystemConfig takes a snapshot AND a host.

That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.

Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.

Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.

lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:44:21 +01:00
librelad
7fef102369 Put the found backups under the field they fill in
The results were rendered above the whole form, which made them read as a
separate step rather than as an answer about the Folder input. They now sit
under that field, inside its box, alongside the verdict.

What is shown depends on how many were found, because those are different
situations:

  one       it is the answer, not a choice — filled in, with its verdict. No
            card, because the card and the verdict said the same thing twice
  several   listed as buttons, most snapshots first; the field stays empty,
            since this is genuinely ambiguous and guessing would be worse
  none      says so — "we looked and there is nothing here" is information

And a bug: the single result filled in only the PLACEHOLDER, so the field was
still empty and pressing Check replied "give a full path, starting with /"
about the very backup shown directly above it. It fills the value in now, and
shows the verdict straight from the scan rather than making the user press
Check to be told what is already on screen. Never overwrites something already
typed — the user's own answer outranks anything we guessed.

_adoptSingleResult moved into renderFoundBackups: "show what we found, and if
there is exactly one, take it" is one behaviour, and splitting it meant the
test could only reach half of it.

Not done, deliberately: listing individual snapshots before the password. The
count is a directory listing, but each snapshot's identity — host, tags,
contents, when they are from — is in the encrypted object. Unlocked, all that
could be shown is a column of hex IDs and file timestamps, which is not
something anyone can choose between. After unlocking, the Contents step already
lists the settings snapshot and one entry per app, which is how people think
about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:27:16 +01:00
librelad
cf8a4b2c69 Find the backup before asking for its password
The Backup step opened with an empty box and /mnt/usb/libreportal-backups as
the placeholder — a path nobody has, presented as the shape of the answer.
Someone rebuilding a server was being asked to recall from memory the one thing
they came here because they had lost.

Two additions, and the point of both is that neither needs the repository
password. A restic repository keeps one file per snapshot under snapshots/, so
"is there a backup here, and how many" is a directory listing. Nothing is
decrypted — reading what is IN those snapshots is the next step, and that does
need the password.

restore scan looks where a backup actually is: this install's own backups root
(the disk often survives), every location the install already knows about, and
one level under each non-OS mount, a just-plugged-in drive being the other half
of "the system drive died". Bounded to named shapes and maxdepth 1, never a
filesystem walk — a scan nobody waits for is a scan nobody uses. Results are
buttons, most snapshots first, each showing its count and the age of its
newest snapshot; clicking one fills the path in.

restore verify <path> answers the same for a typed path. Its most useful answer
is the near-miss: pointing at the folder that CONTAINS the repositories rather
than at one of them, which it names and offers as a button rather than
explaining the distinction in prose.

A repository is recognised by config plus the snapshots, keys and data
directories together. config alone would match any folder with a file of that
name, and offering a stray directory as someone's backup is worse than finding
nothing.

The placeholder now comes from this machine — the first repository found, or
the install's own backups root — since a placeholder's job is to show the shape
of the answer and only a real one does that. The backups root is in the storage
feed for it.

The found entries are buttons and had to own their geometry: .setup-app-card
carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it
collapsed to one cramped line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:04:15 +01:00
librelad
9bb9ed79a9 Storage defaults: hang them off the mount, not the app-data path
The advanced Storage step offered /mnt/disk/apps/libreportal-system as the
default home for LibrePortal's own tree. A registered location's path is where
APP DATA goes and is usually a subdirectory of the drive, so deriving anything
else from it nests that thing inside the app data — LibrePortal's own files
buried under it, on a path that reads as a mistake because it is one.

Both defaults now come off the location's mount point, which meant adding
"mount" to each entry in the storage feed; only the system block carried one.

  LibrePortal                    /mnt/disk/apps/libreportal-system
                              -> /mnt/disk/libreportal-system
  New apps, unregistered drive   /mnt/disk
                              -> /mnt/disk/libreportal-apps
  New apps, registered location  unchanged — it exists and may hold data, and
                                 proposing a different directory on the same
                                 drive would strand it

Names follow the layout the rest of the product uses (libreportal-system,
libreportal-containers, libreportal-backups) rather than a bare "apps", so a
drive shared with anything else stays legible.

collectStorage() no longer registers the drive picked for LibrePortal. A
storage location is somewhere app data lives; the system tree is not app data
and relocate creates that directory itself as root. Picking a drive there was
producing a location nobody asked for, on a mount chosen for something else.

Also in this change, from the Backup step:

  - The backend-specific fields are boxed under their own heading with a note,
    so choosing SFTP reveals "the SFTP part" rather than three more loose rows.

  - Fields had no vertical spacing. .setup-step gives its DIRECT children a
    16px gap, which is where every other step's fields get theirs; these sit a
    level deeper inside a .setup-section and inherited none of it, so each
    input ran into the next field's label.

  - Two field icons carried U+FE0F. Those codepoints have a text form and the
    selector only requests the emoji one, so they sat on a different baseline
    to the plain emoji beside them — the box measured perfectly centred while
    the glyph did not look it.

  - ?mode=restore&type=sftp makes the restore branch reachable by URL. Getting
    there previously took a click and a change event, so every screenshot and
    test had to drive the page before it could look at it.

Two test bugs fixed while doing it: a duplicate `const visible` in one scope
(a parse error, so the whole eval silently returned nothing), and a stub that
covered the POST but not the poll, leaving a 60s loop running that kept the
page from ever going network-idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:48:03 +01:00
librelad
42afc20ee0 Backup step: the wizard's own field layout, and a name that matches
Two things about the restore source step were wrong.

The progress bar said "Source" while the heading said "Where is your backup?",
which reads as two different steps. Every other step's section title is its step
name — Storage, Backups, Import — so this one is "Backup" in both places, with
the friendly question moved to the hint where the rest of the wizard puts it.
Contents and Rebuild got the same treatment.

And the fields used the Storage step's label-left rows. That layout suits a
column of dropdowns; a form of typed values in the middle of a wizard that
looks nothing like the rest of it just reads as unfinished. They now use the
same shape as Identity: a label with a tooltip, then an icon beside the input.
Every field has both, including the password.

On automating the relocate: it cannot be a WebUI action, and the reason is the
thing the privilege model rests on. Root helpers have their paths baked at
install so the manager cannot redirect a privileged operation by editing
something it owns; relocating re-bakes those paths, so a helper that did it
from a caller-supplied path would hand the manager the whole trust boundary.
Narrowing to "registry targets only" does not help either — the manager can add
to that registry by design. libreportal-relocate says this at the top and is
deliberately outside the manager's sudoers.

What was fixable is the part that actually annoyed — being handed a command
with no idea whether it worked:

  - Copy button, with a fallback that selects the text and names the keys,
    because clipboard access needs a secure context and a LAN install on
    http:// is not one.
  - The pending move persists to localStorage, not session state: it happens in
    a terminal minutes or days later, after the tab is gone.
  - A watcher on every page shows the outstanding command, polls for the move
    landing, and reloads to the homepage — whatever route you were on belonged
    to the old install.

"Landed" means the host REPORTS its system dir as the target, which is why
system_dir is now in storage.json. Deliberately not "the server restarted": an
ordinary container restart is indistinguishable, and announcing a relocation
that never happened is worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:10:27 +01:00
librelad
429ec3fa2d Contents step: show the two snapshot kinds as two things
A repository holds one system=config snapshot and one snapshot per app. They
are separate because they are used separately: the settings tree is small,
changes rarely and is meaningless per-app, while app data is large, changes at
its own rate, and has to be restorable, movable and ageable on its own — which
is what the per-app tag buys.

The step listed "Apps" and "Domains" as peer sections, which hid that entirely.
It read as though a backup held three kinds of thing, and gave no clue that the
domains come OUT of the system snapshot rather than being a third kind.

Now: a Settings section (one snapshot, dated, saying plainly that it carries
every backup repository with its credentials and is restored first because it
is what makes the others reachable) with the domains nested under it and each
one's DNS verdict; then an App data section, one snapshot each, dated and
sized. A repository with app data and no settings snapshot says so — the
consequence, that repositories and logins do not come back, is not something to
find out afterwards.

restoreInspect emits that shape now, assembled with jq against the discover
JSON rather than by hand-rolled string concatenation, and carries each
snapshot's date: a backup's age is what people actually judge it by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:51:24 +01:00
librelad
6a62c94cf8 Adoption: a new config file inherits its siblings' mode, not 0640
Third occurrence of the same defect, in a third directory. config-adopt gave a
file the destination did not already have 0640 — "these can hold secrets, so
default closed" — but the config tree is 0755 and the backup account reads all
of it. One 0640 file makes restic report permission denied, write an INCOMPLETE
snapshot and exit 3, so the entire system-config backup is reported failed, and
a first-run restore has nothing to restore from.

A new file now inherits from a sibling in the same directory, matching whatever
that tree's convention is rather than having a mode picked for it.

Since this has now happened three times for three unrelated reasons, the test
asserts the class rather than the instances: no file anywhere under configs/
may be unreadable by the backup account. Mutation-checked — chmod 0640 on any
one config makes it fail.

Also repaired the files the earlier buggy adoption runs clamped on this
install, and fixed lp-storage-step-test's hardcoded step index, which the new
Start step had shifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:07:12 +01:00
librelad
a361e38562 Wizard: New install or Restore from backup
The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:04:43 +01:00
librelad
5dd763713d Read a backup repository from the WebUI, and stop adoption breaking things
restore inspect answers "what would a restore from here bring?" without writing
anything: hosts, apps with sizes, and the domains — read out of the
system-config snapshot with engineDumpFile, the same way the preflight reads an
app manifest. Knowing a backup hands you six domains of which four point
elsewhere, before committing, is the difference between a rebuild and a
surprise.

restore connect is the WebUI entry point: creates the location from a base64
payload, redeems the repository password from the single-use secret channel,
inspects. Deliberately does not engineInitLocation — every other path that
creates a location initialises it because it is about to write there; this one
reads a repository that already exists. This is what unblocks the constraint
app_portable.sh records: a .lpapp could live in the WebUI because nothing
secret crosses from browser to host, and the repository restore could not. The
secret:<ref> channel is that missing piece.

A wrong password is the ordinary case and the user retries, so a failed connect
removes the location it just made. Otherwise every attempt left another
half-configured destination behind.

Three things found by using it:

  - locationRemove never worked. It unlinked as the container user, but
    configs/ is manager-owned, so it was always denied — and the result was
    never checked, so isSuccessful printed anyway and a "removed" location came
    back on the next listing. Now runInstallOp, and the directory is checked.

  - webuiSecretSweep had no callers. An abandoned flow left its repository
    password on disk forever. The sweep now runs in /api/setup/secret before
    each write, tied to the one event guaranteed to happen.

  - Adoption took the WebUI down. config-adopt chowned every adopted file to
    manager:manager 0640, and webui_logins is bind-mounted into the container,
    which then could not read its own credentials: exit 137 with no log line.
    It also clamped every parent directory it passed through, closing
    configs/webui and configs/backup to the container user.

    The fix is a principle, not a special case: a restore replaces the CONTENT
    of a config file and nothing else. The live install already knows who may
    read each one. Adoption preserves the destination's ownership and mode,
    defaults closed only for a file that did not exist, and never
    re-permissions a directory it passes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 04:33:07 +01:00
librelad
3fbc997a2d First-run restore: actually restore the system config, and check the domains
The installer's restore path printed "Settings restored" and had never restored
a setting. backupRestoreSystemConfig only STAGES — right in general, since
overwriting a running control plane's config should not be automatic — but
nothing ever adopted the staged tree. The backup locations, domains and logins
landed in $restore_dir/system-config and stayed there.

So adoption is its own step now (`restore adopt`), allowed only on a machine
with nothing on it yet. backup/locations/ is adopted as a subtree, since the
index is part of the path and that directory is the whole point: it holds every
repository and its credentials, which is what makes "one password unlocks the
rest" true. Deliberately NOT adopted: the container account and its generated
password, port allocations, docker/rootless wiring, and storage/locations —
those describe the old machine, and a registry of drives this box does not have
would make every placement lookup resolve to a phantom.

The guard failed in the shape this project keeps hitting. It globbed the
containers directory, but the manager can traverse that without listing it, so
the glob returned a literal '*', the loop skipped it, and the function returned
"first run" on a machine with three apps. It adopted over a live install in
testing. It now asks the container user for the listing and fails closed: an
unreadable directory means "in use", never "empty".

Two config modes were inverted, found because a restore cannot restore from a
snapshot that was never taken:

  - storage location configs were 0640 and hold no secrets. The backup runs as
    the container user, could not read them, and restic wrote an INCOMPLETE
    snapshot and exited 3 — so EVERY system-config backup failed once a second
    storage location existed. Now 0644, with the test asserting they stay
    secret-free so that mode remains defensible.

  - backup location configs were 0644 and hold the repository password; nobody
    could read them. They cannot simply be tightened, because the backup has to
    read the credentials it uses — so the directory carries the restriction
    (config-secure, manager:container 0750) and the file stays readable to the
    two accounts that belong.

Fixing that surfaced a third: config-adopt clamped existing parent directories
to manager:manager 0750, closing configs/backup to the container user and
breaking the credential read the directory fix had just preserved.

restore domains reports which restored domains point here, and the installer
offers to drop the strays. Three verdicts, not two: setupCheckDomainPointsHere
falls back to hostname -I, and comparing a public A record to a private 10.x
address would condemn every correctly-pointed domain on a LAN-only box, which
is the deployment this product targets. Unverifiable is never offered for
deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 04:18:43 +01:00
librelad
00b6926605 Storage step: the dropdown picks a drive, paths are their own section
Follow-up to fd0a0fd, which put a "Custom path…" entry in the drive dropdown.
That was the wrong shape: the dropdown answers "which disk", and an entry
meaning "actually, let me type a directory" sitting in the same list makes
typing one look like one of the normal answers. Picking a disk is the whole
question for most people.

The dropdown now offers drives and nothing else. Exact paths are a section
under it, advanced only — the same reason Metrics is advanced-only, since the
directory under a chosen drive is operator detail and the beginner path
deliberately does not get a wall of that. Beginners get the drive's default,
which is what they would have typed anyway.

Each field is prefilled from the selected drive and follows it when the drive
changes, so a path belonging to the old drive is never left behind. The
LibrePortal row appears only for a non-primary drive: relocating it onto the
drive it already sits on is not a move.

storageSystemChoice stays a drive, and the new storageSystemTarget holds the
relocate path. collectStorage() registers what the dropdowns point at, and a
system directory is not an app-data location — registering .../libreportal-system
as one would be wrong. Asserted directly.

The validator skips an untouched default: that is whatever the install already
uses, and second-guessing it would reject a legitimate layout.

lp-storage-custom-test -> lp-storage-step-test, and it no longer waits for a
drive to happen to be unplugged: it injects an unmounted candidate and
re-renders, so the offline assertions run everywhere rather than only on a
machine where ambient state obliges. That injection had its own trap worth
recording — renderStorage() rebuilds the selects, so a reference held across it
points at a detached node and setting .value on it succeeds while changing
nothing. Three assertions passed against a control no longer in the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:56:12 +01:00
librelad
fd0a0fd08c Storage step: say "not connected", and allow custom paths
A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.

Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.

A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.

libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.

Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.

lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:

  - It counted the cards that say "not connected" and asserted over those.
    Turn the feature off and the count is zero, every() over an empty list is
    true, and the block passed having checked nothing. The expectation now
    comes from the feed.

  - Both browser tests exited 0 whenever the page returned nothing. Under sudo,
    where chromium will not start, they reported PASS having asserted nothing.
    They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
    browser is the only thing that can have broken, and that is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:09:25 +01:00
librelad
99e81e9ab8 backup: LibrePortal Connect as a destination type, greyed out until it exists
promise.md names Connect as a paid service for "keeping off-site backups", with
two constraints that are load-bearing rather than marketing: it never sees your
data, and every hosted service has a free equivalent in the open code. Nothing
was implemented — no endpoint, no account, no client support.

The client half turns out to be almost entirely there, because a Connect
destination is not a new kind of thing: it is a restic REST repository whose
password never leaves the machine. So `connect` resolves exactly like `rest` in
resticLocationUri — it IS one. It is a separate TYPE only so the UI can tell it
apart from a REST server someone runs themselves, which are identical on disk.

Availability is data, not code: CFG_BACKUP_CONNECT_ENDPOINT (empty) is reported
through the locations feed as connect:{available,endpoint}, and the wizard
renders from that — the option present but disabled, its panel saying what it
will be and that SFTP and S3 do the same job today. The day the service exists,
setting that one value turns it on with no release. Verified both ways.

The device code is a credential, so it goes through the secret channel as a
reference rather than travelling in the wizard payload, which is base64'd into a
world-readable task file.

Design, and what the service still has to provide, in
docs/roadmap/connect-backup-destination.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:21:37 +01:00
librelad
d0735ea9ba docs: first-run restore generates new secrets over a backup holding the old ones
Restoring a real 13-app repository onto a fresh install: 11 came back working.
matrix failed because its install hook writes homeserver.yaml with the new DB
secret and the restore then lays the old one back over it; stoat failed because
rabbit's mnesia dir restores raw and the broker only honours
RABBITMQ_DEFAULT_PASS on an empty data dir.

Apps with a dump descriptor are unaffected — those clear the data dir so the
engine initialises with the current secret, then replay the dump. That is the
tell for what the general fix has to look like.

Not implemented: applying the restored system config on first run changes what a
restore does with credentials (including the WebUI login), which is a decision,
not a defect. Written up as §3.6 with a recommendation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:50:57 +01:00
librelad
95edf64558 docs: the multi-disk test matrix, and what it found
The failure shape worth naming: anything resolving a root at runtime works on a
default install and points at the wrong disk on a relocated one. Testing only
all-default or all-moved misses it, because in both the wrong answer is often
accidentally right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:46:52 +01:00
librelad
b3aff2833c docs: record what the relocated-restore failure actually cost
A restore onto a host laid out differently from the backup's source restored
nothing at all, silently, and that is the whole point of the feature. Now
verified end to end on a matrix case-4 install against a real 13-app repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:40:33 +01:00
librelad
17316b7fe2 backup: restore ownership through a multi-range userns map
grafana restored and then died with "attempt to write a readonly database",
repeatedly. Its database is recorded in the snapshot as 231543:1002 and landed
as 1002:1002 — the owner was lost, so grafana, running as 231543, could not
write it at mode 0640.

Restore runs as the backup user with no CAP_CHOWN, so it reinstates ownership
inside a user namespace. The prefix was

    unshare --map-root-user --map-users=SUB:SUB:N --map-groups=SUB:SUB:N

and unshare accepts ONE range per option, so the backup user's own GID was never
mapped — while app data is written as <container-uid>:<backup-user>. The group
half of every such chown referred to an unmapped id, lchown returned EINVAL, and
the file kept the restoring user's ownership. restic reports those as "ignoring
error ..." and still exits 0, so nothing failed: 1626 of one 13-app restore's
2086 failed chowns were grafana's, under a restore that reported success.

restic-userns-exec uses newuidmap/newgidmap, which write the multi-range maps
unshare cannot express:

    uid:  0       <- caller     inner root, or caps are dropped at exec
          SUB..   <- SUB..      identity, so restic can name the stored uid
    gid:  caller  <- caller     identity: the group half of app-data chowns
          SUB..   <- SUB..      identity

The caller's own UID is deliberately not identity-mapped — that slot is spent on
inner root — and a file stored as <caller>:<caller> lands owned by the caller
anyway, because that is who inner root is outside. So the one case this cannot
map is the one case needing no mapping. `unshare --map-auto --map-current-user`
is not a shortcut: it maps the subuid range to low inner ids while restic needs
identity. Tested.

Measured live, restoring grafana: failed chowns 1626 -> 12 (the 12 being the
caller's own files, correct), grafana.db back to 231543:1002, grafana up and
writing. Falls back to running the command plainly when there is no subuid
range, no newuidmap, or the namespace will not start.

scripts/dev/lp-userns-ownership-test pins all three ownership cases; verified
the old prefix fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:04:12 +01:00
librelad
1fa36abb63 backup: stop claiming restored files "are already owned correctly"
resticRestoreSnapshot forgives the un-mappable-uid lchown failures so a restore
is not aborted by them, and reported: "expected, they are already owned
correctly". That is true only for LibrePortal's own files, whose owner is the
backup user restic already runs as. It is false for container-owned data.

_resticUsernsPrefix maps the subuid range and root, but unshare takes one range
per option so the backup user's own GID is never mapped — and app data is
written as <container-uid>:<backup-user>. Every such chown fails with EINVAL and
the file falls back to <backup-user>:<backup-user>. Verified directly:
231543:231543 applies, 231543:1002 does not.

Observed on a 13-app restore: grafana's grafana.db is recorded as 231543:1002
and landed as 1002:1002, so grafana (running as 231543) could not write it at
mode 0640 and died with "attempt to write a readonly database" — under a restore
that reported success. 1626 of that run's 2086 failed chowns were grafana's.

This commit does not fix the mapping — that is the backup engine's ownership
handling rather than the first-run restore path, and the candidate fixes
(newuidmap multi-range maps, or restoring as root via a path-validated helper)
want a decision first. See docs/roadmap/first-run-restore.md §3.5. What it fixes
is the reporting: count the files and say plainly that container-owned data was
not reinstated and the app may fail to write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:42:53 +01:00
librelad
c9779d6581 restore: create staging as the principal that writes to it
`restore system` reported

    ✓ Success System config restored to: /libreportal-system/restore/system-config

for a directory that did not exist. Nothing had been written — on the step the
whole restore ordering depends on, since the system config carries every other
backup location's credentials.

Restore stages through $SYSTEM_DIR, which the manager owns, but the thing that
writes into the staging tree is restic, and runBackupOp runs it as the container
user. Both call sites created the directory as the wrong principal, in opposite
directions:

  backupRestoreSystemConfig  runFileOp mkdir  -> container user; denied on the
                             0751 manager-owned restore_dir, and unchecked
  storageRestoreAppTo        runInstallOp mkdir -> manager; restic could then
                             not create anything beneath it

Restic reports a permission denial as "ignoring error ..." and still exits 0, so
the callers' success checks were satisfied either way.

libreportal-ownership gains restore-stage (creates it cowner:MANAGER 0750 —
owner writes, manager traverses to confirm and review) and restore-unstage
(removes it; neither principal can, so staging trees simply accumulated). Both
confine the path to one component directly under the restore/migrate area.
footprint_version 8 -> 9.

backupRestoreSystemConfig now verifies the tree landed as the user that wrote
it, because the manager cannot read inside its own staging directory.

Verified on a live install: system config stages 57 real files, and the
relocation branch of storageRestoreAppTo ran for the first time — speedtest
restored from a snapshot taken at /libreportal-containers/speedtest into
/libreportal-alt/speedtest via stage-and-move, staging cleaned up afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:41:21 +01:00
librelad
5c8876428b test: pin the CLI argv path, and document the third silent truncation
scripts/dev/lp-cli-argv-test builds stubs from the real invocation line in
init.sh and the real LP_CLI_ARGS line in start.sh, then pushes thirteen app
names through them — so editing either file is what makes it fail. Verified
against both regressions: dropping "$@" from the wrapper, and reading "$@"
instead of "${@:10}" in start.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:22:18 +01:00
librelad
5b2d824aac docs: record the two silent no-ops the end-to-end restore run found
Both were checks whose failure mode was to not run, and both produced output
indistinguishable from a healthy report. Worth writing down as a shape rather
than as two bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:16:48 +01:00
librelad
ef60cce98a feat(app): portable .lpapp export and import — phase 4
`libreportal app export <app>` writes one app to a single file;
`libreportal app import <file>` installs it here. This is the thing the
original request described as "upload or navigate to the backup file" — a
restic repository is not a file, but the want behind the phrasing is real.

The format is deliberately boring: gzipped tar of the app directory with
its .libreportal-manifest.json at the root. That manifest already records
size, images, volumes, databases and storage location, so import reuses
the phase-3 checks for free — refusing an app this version no longer
ships, or one that will not fit, before unpacking anything.

Export stops the app first. A tar of a running Postgres is a corrupt
Postgres, and a file that looks fine until you restore it is worse than a
refusal. tar runs as the owning user with --numeric-owner so container
sub-UIDs survive the round trip instead of being remapped through this
machine's /etc/passwd.

Import re-runs the normal install pipeline after unpacking, because the
compose still carries the SOURCE machine's ports, IPs and domains — that
pipeline is what re-allocates them here, and migrateUrlRewrite fixes the
host-bound CFG_* fields.

Documented throughout as a courier format, not a backup: no history, no
retention, no encryption. Importing under a different name is refused
outright rather than half-working — the CFG_<APP>_* namespace and compose
identities would all need rewriting, and `instance create` already
answers "a second copy".

Fixes a bug this surfaced: _appDirIntended did an indirect expansion on
CFG_<SLUG>_STORAGE without checking <SLUG> can be a variable name, so a
hyphenated or mistyped app name emitted "invalid variable name" and then
reported the misleading "storage location is not mounted" for an app that
simply did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:08:41 +01:00
librelad
6c89e430e1 feat(restore): preflight report — phase 3
`restore preflight <loc_idx> <host>` reads every app's manifest out of its
own snapshot (engineDumpFile pulls a single file without restoring) and
checks it against this machine before anything is written:

  * an app this version no longer ships is skipped — restoring one
    produces a directory that can never start, and looks like success
    until someone opens it
  * an app that will not fit is skipped individually, because filling the
    disk part-way through takes the apps that already landed with it
  * a manifest naming a storage location this machine lacks falls back to
    the default, and says which app moved where

The installer's restore path runs it and asks once before continuing.

Two bugs found by running it against the live repository rather than
reading it:

The CLI dispatcher calls handlers with NO arguments, so `shift 4; "$@"`
inside one operates on an empty list. `restore first-run bulk` has always
had this — a bulk restore silently received zero apps. Fixed at the entry
point: start.sh now captures LP_CLI_ARGS from "$@", and both call sites
use it.

And the wrapper fills unset argv slots with the literal string "empty"
(${5:-empty} … ${9:-empty}), so a trailing slot arrives as a five-
character app name rather than a blank. Filtering on -n alone let five
phantom apps through and reported each as "no longer shipped". Both call
sites now drop the sentinel. That also caps any explicit list at five, so
preflight discovers the host's apps itself when given none.

Verified against the live repository: 13 apps discovered and checked.
Sizes read "?" there because those snapshots predate manifests carrying
size_bytes — the intended graceful fallback, not a refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:05:49 +01:00
librelad
3c2f8f0691 docs(roadmap): rewrite phases 3-4 for the installer-based restore
Phases 3 and 4 were written when the restore was going to be a WebUI
wizard branch. It runs in the installer now, so both change shape.

Reconciliation stops being a screen and becomes a preflight report. The
key realisation is that it can run BEFORE anything is written: every
app's snapshot carries its own .libreportal-manifest.json, and
engineDumpFile already pulls a single file out of a snapshot without
restoring it. So the installer can read all the manifests, compare them
to this machine, and print a verdict per app — skipping an app whose
template this version no longer ships, or whose data will not fit, rather
than restoring something unstartable.

Phase 4 is now the thing the original request actually described. 'Upload
or navigate to the backup file' does not match a restic repository, but
the want behind it is real: one file, one app, hand it around.
'libreportal app export/import' with a .lpapp (tar.gz of the app dir plus
its manifest) serves it directly, gets §3's reconciliation for free, and
gives the installer a third answer to 'where is your backup?'. Documented
as a courier format, explicitly not a backup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:57:26 +01:00
librelad
d44ebf0ca7 docs(roadmap): the restore password has no safe channel to the host yet
Phase 1 is built. Phase 2 hit a blocker worth deciding rather than
silently working around.

The WebUI cannot run restic, so a password typed in the browser has to
reach the host. Both existing channels leak it. The task command string —
which is what the Backup page ALREADY uses for this exact field — lands in
a task JSON under frontend/data/tasks at 0644, world-readable, and shows
in ps while the task runs. A file in that directory does not work either:
the container writes as dockerinstall, the manager runs as libreportal,
and at 0640 the manager cannot read it (verified on the live box).

So this is an existing product-wide weakness that the restore branch
happens to surface, not one the feature would introduce — and the restore
case is its sharpest form, since that password is the key to every backup
the user has.

Recommends a one-shot secret drop: the ownership helper already solves the
mirror-image case (_webui_bind_access chowns MANAGER:cowner 0640 so the
container can read manager-owned files), so the reverse is a small,
well-scoped addition — a directory owned cowner:MANAGER 0730 that the
container drops a 0640 file into, which the manager reads once and
unlinks. Worth doing because it also fixes the Backup page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:14:18 +01:00
librelad
69b62fda06 docs(roadmap): first-run New Install vs Restore from Backup
Proposal for making "I'm rebuilding my server" a first-class first-run
path. Recording it before building because the useful finding is how
little is actually missing.

The restore machinery is already there: locationAdd connects a repo,
restoreFirstRunDiscover reads it without touching live state,
migrateDiscoverHosts/Apps enumerate what is in it, backupRestoreSystemConfig
brings settings and credentials back, restoreFirstRunBulk restores many
apps from another host, migrateUrlRewrite fixes host-bound CFG_* fields,
and storageRestoreAppTo already handles a snapshot whose paths don't
match this machine. What is missing is the front door and one screen.

That screen is the reconciliation diff — a backup describes a machine
that no longer exists, and the failure mode worth designing against is
re-deciding those facts silently. Every difference is detectable before
anything is written: a storage location that doesn't exist here, an app
bigger than the target drive, domains pointing at the old host, ports
already taken, apps this version no longer ships.

Two things the doc is deliberately blunt about.

An encrypted repository cannot be opened with anything inside itself. The
location password lives in the system config, which is inside the backup,
so on a fresh machine the user must supply it by hand — that is what
encryption means, not a bug. It has to be asked for early, because a user
who never wrote it down does not have a backup and should learn that at
step 2 rather than step 5.

And "upload the backup file" doesn't match the engines: restic, borg and
kopia write to a repository, not a file. Local means navigating to a path;
remote means credentials. A genuine single-file import is a separate
feature (a portable per-app export built on the manifest) and shouldn't
be conflated with the backup engines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:01:47 +01:00
librelad
7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00
librelad
70ca195f30 docs(roadmap): mark what is built, and record the fork-bomb postmortem
The phasing table now reflects reality rather than intent: 0, 1, 2 and 4
are done, 5 and 6 are partial (the migrate "unknown location" prompt and
the Disks WebUI page are the gaps), 3 is untouched.

§10.8 records why the app->location index detonated, because the mistake
is easy to repeat and the blast radius was the whole machine. The file's
stated requirements — manager-owned, on the system disk — are both true
of configs/, which is why it looked like the right home. The property it
violated is that configs/ is SOURCED, so a TSV of "<slug> <path>" is a
script, and the row for the app named `libreportal` made it a
self-invoking one.

The generalisable rule is in there too: ask what a directory's contract
is, not just who owns it. configs/** is executed; containers/<app>/**.config
is executed. Both are load-bearing conventions a plain data file silently
breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:40:51 +01:00
librelad
48c78270a9 docs(roadmap): the Disks view — one row per filesystem
Resolves open question 1. The device becomes the organising concept and
the two registries become roles on it, so a user with one drive holding
both app data and snapshots stops seeing "bigdisk" twice meaning two
different things.

It extends a page that already exists rather than adding one:
/admin/system/storage is currently the Docker disk breakdown, and
webui_system_metrics.sh already builds a disks array from df. The view is
that array enriched with lsblk -J -e7 (model, transport, rotational,
removable — with -e7 dropping the ~15 snap loop devices a desktop box
carries) and joined against both registries.

The union is the part that matters more than the enrichment: a registered
drive that is currently unplugged does not appear in lsblk at all, and
that is exactly when someone opens this page. So rows come from the
registry first and attached hardware second — a missing device still
renders, marked not attached, naming the apps stranded on it. Rows key on
fs_uuid, since /dev/sdb1 reorders across reboots and would scramble the
table.

This also gives every standing badge from §6/§6.1/§6.2 a natural home,
because each is a property of the device rather than of either registry,
and it turns "type the same path into two different pages" into actions
on the row you are already looking at.

Two limitations written down rather than discovered later: ZFS datasets
report distinct st_dev but share a pool, so grouping by device
over-reports free space (btrfs subvolumes are fine); and the page stays
read-only about the system — it can show the fstab line to add, never
add it.

Phase 6 now builds this instead of a separate Storage page, which with
disks as the frame would have reintroduced the split this resolves.
Also folds in §10.5 — the dashboard gauge becomes a summary linking here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 03:54:22 +01:00
librelad
5491455290 docs(roadmap): storage and backup locations may share a drive
Resolves open question 2. One big disk holding both the app data and its
snapshots is what most people actually have, and two facts make it cheap:
reconcile() already chowns CONTAINERS_DIR and BACKUPS_DIR to the same
container user, so there is no permission negotiation; and sibling dirs
on one filesystem don't nest, so §3 admits them today unchanged.

The registries stay separate — that was always about trust and lifecycle,
not hardware — so §1's non-goal is reworded rather than dropped.

One hard rule survives: same drive yes, nested never. A storage location
containing a backup repo is a recursive-inclusion trap, and §3's nesting
refusal already covers both directions. What needs work is the error —
pointing storage at /mnt/bigdisk when /mnt/bigdisk/backups exists fails
the empty-dir rule, and the message must suggest a subdirectory rather
than saying "not empty", because that is the likely first attempt.

Shared fate gets the §6.1 treatment: durable badge on both locations and
a line in the backup summary, stated accurately rather than moralised —
a same-drive backup still covers accidental deletion, bad updates and
ransomware; what it doesn't survive is the disk dying. Also names the
compounding case, since nobody pictures it: a shared drive that is also
removable takes the apps and the restore path away at the same moment.

Checks: free space becomes per-device (two locations on one filesystem
draw from one pool, so a growing repo can starve the apps), plus a new
shared-device check that warns and never refuses.

Notes that this makes the naming collision worse and suggests the
resolution — a Disks view with one row per device showing which roles
LibrePortal has on it, registries separate underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 03:51:33 +01:00
librelad
5efdae0167 docs(roadmap): removable drives warn, never block
Reboot-persistence and hot-plug become loud durable warnings instead of
refusals, and the --allow-transient flag goes away — there is nothing
left for it to unlock.

The reasoning, recorded in §6.1 so it isn't relitigated: "not in fstab"
and "removable" describe a supported setup, not a broken one — the media
library on a USB disk is a reason to want this feature at all. Refusing
there fails an honest user on their first reasonable attempt and teaches
them to hunt for an override rather than read the warning. We can afford
to allow it because the dangerous moment is start-up, not registration,
and start-up is already gated: no marker, no location, appDir fails and
dockerComposeUp refuses. Blocking at registration buys nothing the
runtime doesn't already enforce.

So the obligation moves from prevent to inform, and "loud" has to mean
durable rather than a toast: the consequence in plain words plus a
ready-to-copy fstab line at registration, a standing badge on the
location for as long as it holds, and a start-up refusal that names the
cause instead of failing generically.

Fitness checks now refuse only when a location cannot work at all
(checks 1-5), never for merely needing care. Wizard cards get three
distinct states, with warned fully selectable and explicitly not
second-class.

Marked the §10.1 gate load-bearing: it is now the only thing between an
unmounted disk and an app rebuilding itself empty, so anything that
relaxes it has to revisit §6.1 as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 03:49:12 +01:00
librelad
bb56b1db4b docs(roadmap): fitness checks, first-run wizard step, config panels
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>
2026-08-24 03:35:01 +01:00
librelad
786d8ebfe8 docs(roadmap): drop STORAGE_PINNED, record the name-vs-path decision
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>
2026-08-24 03:14:04 +01:00
librelad
8ef04ef5ad docs(roadmap): tighten the storage-locations design
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>
2026-08-24 03:04:53 +01:00
librelad
11e8142d41 docs(roadmap): plan per-app storage locations
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>
2026-08-24 02:58:25 +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
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
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
c5df12201c docs(updater): mark P0–P3 shipped in the roadmap
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>
2026-07-18 00:00:49 +01:00
librelad
22d7bcf505 fix(updater): anchor app version to <slug>-service, not first image; revise design
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>
2026-07-17 22:57:12 +01:00
librelad
4b85c24e94 feat(trivy): add Trivy as a recommended security app
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>
2026-07-14 21:46:37 +01:00
librelad
6bf76d1774 docs(roadmap): design for per-app version updater + CVE scanning
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>
2026-07-14 21:30:22 +01:00
librelad
52be5968e9 docs: registry slice shipped — §8.4b bundle spec addendum + §8.7 status + publish guide
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>
2026-07-03 21:35:30 +01:00
librelad
0afd1de819 docs(roadmap): marketplace app design — dev-mode container + catalog + submissions
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>
2026-07-03 20:30:07 +01:00
librelad
d522a19cae docs(roadmap): App Files tab proposal + UID-access spike results
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>
2026-06-18 17:51:27 +01:00
librelad
9ca5e8922c docs(distribution): mark the hotfix product (Phases 1–5) built
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>
2026-05-31 21:26:31 +01:00
librelad
aced021aea docs(distribution): fold design-panel refinements into the artifact spec
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>
2026-05-31 17:01:35 +01:00
librelad
caee74bd76 feat(distribution): signed artifact-index fetch+verify primitive (Phase 1)
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>
2026-05-31 16:48:06 +01:00
librelad
30612a0d87 docs: organize docs/ into purpose folders with consistent naming
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>
2026-05-31 00:48:38 +01:00