9 Commits

Author SHA1 Message Date
librelad
56cd6e7fa4 storage: choose which drive an app installs onto
The resolver already supported per-app placement — CFG_<APP>_STORAGE names a
location and appDir sends data, compose and config there — and 37 of 39 app
templates ship the field. What was missing was choosing AT INSTALL TIME. The
only routes were editing a config by hand before installing, or installing onto
the default disk and then `app move`ing it, which copies the data twice.

    libreportal app install <app> --storage=<location>

and the App Center's existing storage dropdown, which travels inside
config_variables. Both resolve to one answer in storageChoiceFor, so there is a
single code path.

Ordering is the whole difficulty, and getting it wrong is quiet. installApp
copies the app template into appDir(), sources it, and later applies the form
overrides. The choice has to be live before the copy (or the directory is
created on the wrong disk), written into the config before the source (or the
template's "default" wins and every later appDir in that process returns the
primary root), and folded into config_variables (or the override pass writes
"default" back). Miss any one and the directory and its config disagree — which
resolves correctly only until something sources the config.

Refuses an unknown or unmounted location, an existing directory, and an app
whose template marks the field **READONLY** (fixed to the primary root because
other apps reach it by literal path — storageMoveApp already refuses to move
those, and installing one elsewhere is the same violation from the other end).

Three shipped bugs found making this work:

  * updateConfigOption chose its write helper by comparing the path against
    $containers_dir — the PRIMARY root only — so an app on any other registered
    location took the manager branch and `sed -i` failed with exactly the
    permission error the comment above that code describes. `app move` writes
    the new location with `|| true`, so it reported a successful move while
    leaving the config naming the old disk.
  * storageLocationName resolved a location's name only from an in-scope
    CFG_STORAGE_LOC_<id>_NAME, falling back to the bare id. That name is the
    value CFG_<APP>_STORAGE is set to, so the generated dropdown offered
    "location-1" as both label and value — a choice that does not resolve. Read
    it from the location's config when the variable is not in scope.
  * storageSyncAllAppComments was written for "the regen path" and never wired
    into one. Every CFG_<APP>_STORAGE option list was frozen at install time, so
    adding a drive did not make it selectable anywhere. Called from the storage
    generator now, which runs exactly when those lists go stale — and extended
    to app TEMPLATES, since an app not installed yet is precisely the one whose
    install form needs to show which drives exist.

Verified on a live install with three locations: linkding and authelia on disk1,
ipinfo on disk2, fourteen on the default root, each config naming its own drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:22:11 +01:00
librelad
864059ab83 paths: recover the roots from the baked unit when nothing exported them
The three roots reach running code three ways — the CLI wrapper exports them,
the task-processor unit carries them as Environment=, and anything those start
inherits them. An @reboot crontab entry is started by none of the three: it runs
a script by absolute path, so paths.sh fell through to

    : "${LP_CONTAINERS_DIR:=/libreportal-containers}"

That entry is crontab_boot_app_reconcile.sh, which brings every installed app up
at boot. On a relocated install it therefore reconciled against the DEFAULT
root, and that does not fail — docker creates the bind-mount directories it does
not find, so every app comes back empty while the real data sits untouched on
the other disk. Nothing logs an error; the only symptom is opening an app and
finding it blank (storage-locations §10.1 calls this the top data-integrity
risk).

Verified on a case-2 install (apps on /mnt/lptest2): a bare environment resolved
containers_dir to /libreportal-containers/ where the real root was
/mnt/lptest2/libreportal-containers.

Recover them from the systemd unit, which is the authoritative baked record —
init.sh already reads it back the same way, libreportal-relocate rewrites it,
and it is root-owned, so this is not the manager reading a config it can edit.
An explicit environment still wins; with no unit the defaults are unchanged.

scripts/dev/lp-paths-roots-test pins all four cases; verified it fails when the
recovery is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 06:31:02 +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
49c463a996 feat(storage): a default location, so one choice places every future app
Per-app placement worked but had no default: a box with a big second disk
meant setting CFG_<APP>_STORAGE on every app individually. CFG_STORAGE_DEFAULT
fixes that, and the wizard asks for it in one line.

CFG_<APP>_STORAGE now has three states rather than two, and the third is
the point:

  <name>    this app goes there, whatever the default says
  primary   this app goes on the install-time root, explicitly
  default   no opinion — follow CFG_STORAGE_DEFAULT

Templates ship "default", so the setting reaches every app without
touching 37 configs, while an app that was deliberately placed keeps its
placement. "primary" is new, and needed: without it there was no way to
say "keep this one on the system disk" once the global default moved.

A default naming a location that has since been removed falls back to the
primary root rather than refusing — a disk that got unregistered must not
make apps un-installable.

The wizard asks only once a second drive is ticked; with nothing ticked
there is one possible answer and a control would be furniture. It sets a
default, not a placement, and the value stored is the location NAME, so it
survives the disk being remounted elsewhere.

scripts/dev/lp-storage-default-test covers all three states plus the
removed-location fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:31:13 +01:00
librelad
ac4c11b5e9 fix(storage): move the app->location index out of configs/
Follow-up to 928e244, which stopped configs/ subdirectories being sourced
without a .category marker. That closed the hole; this removes the thing
that fell into it.

storageIndexFile pointed at configs/storage/app_locations. The file's
requirements are only "manager-owned" and "not on a removable disk" —
configs/ satisfies both, which is why I put it there, and it was still
wrong: that tree carries a third property the file violates. sourceScanFiles
SOURCES what it finds under configs/, and sourcing means executing.

The index is a TSV of "<slug><TAB><root>", which bash reads as a command
and its argument. Harmless while no slug matched a real executable. The
row for the app named `libreportal` armed it, because that IS the CLI on
PATH: sourcing ran `libreportal /libreportal-containers`, which re-entered
the scan, which sourced the file again — one process pair per level until
the host OOMed and took the desktop session with it.

It now lives at $system_dir/storage/app_locations, with a one-shot
migration so an install that already has an index keeps knowing where its
apps live rather than silently forgetting. libreportal-ownership
reconciles the new directory, and scan_files.sh gained a note that
configs/storage/ carries no .category on purpose.

scripts/dev/lp-configs-guard-test covers both ends: the index never lands
in configs/, a legacy one migrates, and a file of the exact detonating
shape placed in an unmarked configs/ subdirectory is not executed while a
marked category still loads.

Also wires sourceStorageLocations into the config scan beside
sourceBackupLocations — per-location configs sit at depth 3, below the
generic scan, and need their own walker like the backup ones do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:25:07 +01:00
librelad
8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00
librelad
db04f8f000 feat(storage): path primitives for multiple container roots
Phase 0 of docs/roadmap/storage-locations.md — the resolver layer. No
behaviour change yet: with no registry present, every function here
returns exactly what the old single-root code did, which is what makes
the ~200-site sweep that follows safe to land incrementally.

  primaryRoot / webuiDir     the install-time root, and the one tree that
                             never moves
  storageRoots               every registered root, primary first
  storageRootAvailable       marker present == drive mounted
  pathIsContainerData        replaces the `== "$containers_dir"*` idiom
                             that picks manager vs container-user elevation
  appDir / appDirSlash       THE resolver, memoised
  storageLocationPath/Name   name <-> path, via the root-owned registry
  storageIndexGet/Set        app -> location cache

appDir resolves discovery-first: whichever root actually holds
<slug>/<slug>.config wins, so a hand-move or half-finished migration
self-heals rather than corrupting.

The index exists because of a bug the unit test caught immediately.
Discovery cannot see an unmounted disk, so an installed app on an
unplugged drive looked identical to a brand-new app — and the fallback
handed back the PRIMARY root. Docker would then have created the bind
mounts there and booted the app empty on the wrong disk, which is the
precise failure the availability design exists to prevent. The index is
manager-owned (deliberately not on the removable disk: it must be
readable exactly when that disk is absent), consulted only when the scan
comes up empty, and rewritten by every successful scan so the disk stays
authoritative whenever it is actually present.

Availability is gated in appDir alone rather than at each caller: every
site reaches it by construction. It returns non-zero AND prints an
unusable sentinel path, so the many callers that will never check $?
still fail loudly on something harmless.

scripts/dev/lp-storage-test covers all of it against a throwaway tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:01:19 +01:00
librelad
b47e20133d feat(install): make the control-plane manager user configurable
sudo_user_name (the real manager — owns the install, runs the runtime, baked as
__MANAGER__ into the root helpers) was hardcoded to 'libreportal'. Make it
configurable, consistent with the relocatable roots:

- --manager-user=NAME flag + LP_MANAGER_USER env (default libreportal); resolved
  early in init.sh and in scripts/source/paths.sh (so the standalone processors
  get it too), validated as a real Linux username in libreportalValidatePaths.
- Baked everywhere it must be stable: the helpers + CLI wrapper (CHECK_USER now
  __MANAGER__, exports LP_MANAGER_USER) via the install-time sed; the systemd unit
  exports LP_MANAGER_USER=<manager>. User creation (initUsers), the sudoers
  drop-in, and ~35 call sites already used $sudo_user_name, so they follow.
- Fix the stray manager-name literals: install_crowdsec.sh chown, the
  check_install_type fallback. (Brand/identity strings like the backup
  engine:libreportal tag are left — they're not the username.)

Verified: resolves default/env/flag; wrapper bakes a custom name (admin) with no
placeholders left; validation rejects invalid usernames. The footprint paths
(/etc, /usr/local) stay fixed by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 17:47:05 +01:00
librelad
e4872ab511 refactor(paths): single source of truth for a relocatable, split layout (phase 1)
Introduce scripts/source/paths.sh as the canonical path resolver for three
independently-relocatable roots:
  LP_SYSTEM_DIR      manager-owned control plane (configs/logs/install/db/ssl/ssh/migrate)
  LP_CONTAINERS_DIR  container-user-owned live app data
  LP_BACKUPS_DIR     container-user-owned backup repos (own mount-able)

Roots come from the environment when set (install bakes them; CLI/app inherit
from init.sh), else default to /libreportal-*. A transitional compat default
keeps EXISTING installs (legacy single /docker tree, by config marker) on /docker
until a deliberate reinstall, so deploying this never strands a running box.

- init.sh derives the same vars inline (self-contained for the bare /root/init.sh
  reinstall case); paths.sh mirrors it for the standalone task/check processors,
  which now self-locate their scripts dir and source it.
- Replace functional /docker literals with the derived vars across runtime,
  install, backup, crontab, crowdsec/restic, headscale, and reinstall paths;
  clean the inert '== /docker/containers/*' guard fallbacks to the variable form.
- backend: CONTAINERS_DIR now from LP_CONTAINERS_DIR (compose env, filled at
  generation via a new CONTAINERS_DIR_TAG), legacy-safe default for un-recreated
  containers.
- backup default path falls back to the backups root; exclude paths.sh from the
  sourced-file arrays (bootstrap file, sourced explicitly).

The CLI-wrapper heredoc + root helpers still reference /docker; those get baked
in phase 3. No layout/ownership change yet (phase 2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-25 15:09:39 +01:00