diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md new file mode 100644 index 0000000..f0237a4 --- /dev/null +++ b/docs/roadmap/storage-locations.md @@ -0,0 +1,180 @@ +# LibrePortal — Storage Locations (per-app data placement) + +**Status:** Proposal — **not built.** · **Audience:** us, future-self · **Scope:** register more than one filesystem root for live app data, choose one per app, move an app between them, and resolve the right one on restore/migrate · **Origin:** "add different locations to set up LibrePortal on, with control per app" (2026-08-24) + +--- + +## 0. The one idea + +Today LibrePortal has **three** relocatable roots (`--system-dir` / `--containers-dir` / `--backups-dir`), each chosen once at install and fixed afterwards. This adds a fourth degree of freedom, on top of the existing containers root: + +> **The containers root becomes a *list*. Every app declares which entry in that list holds its data. Everything else — install, compose, backup, restore, migrate — resolves the app's directory through one function instead of one variable.** + +Nextcloud's 4 TB of photos go on the spinning disk. Vaultwarden and the control plane stay on the NVMe. Jellyfin's library lives on the external HDD that isn't always plugged in — and when it isn't, LibrePortal refuses to start Jellyfin rather than silently rebuilding it empty on the bare mountpoint. + +## 1. Non-goals + +- ❌ Relocating the **system** root (configs/db/logs) per-app. It stays one place, chosen at install. Same for the WebUI's own container dir. +- ❌ A general volume manager. We don't format, partition, mount, or write `/etc/fstab`. The drive must already be mounted; we validate and use it. +- ❌ Striping/tiering/RAID-alikes. One app's data lives on exactly one location. No splitting an app across two. +- ❌ Per-*volume* placement inside an app (`./data` here, `./db` there). Location granularity is the app directory. Revisit only if a real need shows up. +- ❌ Making backup locations and storage locations the same registry. They overlap on disk but differ in trust, lifecycle and ownership (see §8.1). + +## 2. What already works in our favour + +The current code is closer to this than it looks: + +- **Compose volumes are relative.** Every shipped template uses `./data:/config`, and `dockerComposeUp` does `cd $containers_dir$app_name && docker compose …`. Change the `cd` target and the app comes up unchanged, with the same compose project name (derived from the directory *basename*, which doesn't change). The only absolute host paths in any template are `/etc/localtime`, `/dev/net/tun`, `/etc/ssl/certs` and the docker socket — none of them ours. +- **An app dir is self-describing.** `/.config` + `docker-compose.yml` + `.libreportal-manifest.json` is everything needed to identify and rebuild it. Discovery by scanning is viable, so we never have to trust a stale index. +- **The three-root split already exists** end-to-end: flag parsing, `libreportalValidatePaths`, the nesting/protected-path refusals, the baked `__CONTAINERS_DIR__` placeholders, and `paths.sh` as the single source of truth. We're generalising a design that's already there, not inventing one. +- **Backup locations are the exact template** for the registry UX: `configs/backup/locations//location.config`, `locationAdd`/`locationRemove`, `sourceBackupLocations`, a `libreportal backup location …` CLI, a WebUI *Locations* page, and `backupLocationLocalGuard` — which already implements the FAT/exFAT warning and the `REQUIRE_MOUNT` refusal we need verbatim. +- **`instance create` proves the model.** "An instance is just another app" — a cloned dir with its own slug and `CFG__*` namespace. Per-app placement inherits multi-instance support for free. + +## 3. The central constraint — root must not be told where to `chown` + +This decides the whole design, so it leads. + +The manager (`libreportal`) runs with a **scoped sudoers allowlist**: it may run the root-owned helpers in `/usr/local/lib/libreportal/` and a fixed system-binary set, and nothing else. No `sudo chown`, no `sudo tee`, no `sudo bash`. The helpers have the three roots **baked in at install** by `sed` (`__CONTAINERS_DIR__` …) precisely so that: + +> the manager cannot redirect a root `chown` by editing a config file. + +`libreportal-ownership` even re-checks its baked roots against a dangerous-path list as defence in depth. A naïve "storage locations live in a manager-writable config, and the helpers read it" **reopens that hole completely** — `chown -R dockerinstall /etc` is a full escalation. + +**The fix: a root-owned registry with an empty-directory admission rule.** + +1. The truth lives at `/usr/local/lib/libreportal/storage.roots`, **root:root 0644**. One record per line: `idpathdevfs_uuid`. Manager reads it; only root writes it. Every helper resolves an app dir through it instead of through a single baked constant. +2. Adding a location goes through a new helper, `libreportal-storage add `, which is in the sudoers allowlist — so the manager *can* call it, but it accepts a path only if **all** of these hold: + - absolute, and `realpath -e` returns the input unchanged (no symlink component, no `..`) + - not in the protected set (`/ /etc /usr /bin /sbin /lib* /boot /proc /sys /dev /run /var /tmp /root /home`), and not inside any user's home unless the install was made with `--allow-home` + - does not nest — in either direction — with the system/containers/backups roots or any already-registered location + - **is an empty directory** (tolerating only `lost+found`, or an existing `.libreportal-storage` marker whose install-id matches) + - is not the mountpoint of a filesystem holding someone else's data — implied by the emptiness rule, which is the point + + The emptiness rule is what makes this safe: **root only ever chowns a directory that contains nothing**, so acceptance can't hand away anything that already existed. Everything created underneath afterwards is ours by construction. +3. On acceptance the helper writes a root-owned `.libreportal-storage` marker (id + install id + created-at), `chown`s the root to the container owner, `chmod 0751`, and appends the record. `remove` refuses while any app dir still lives there. + +Consequence worth stating plainly: **`storage add` is a genuine privilege boundary crossing, not a config edit.** The WebUI can drive it (through the task system → CLI → helper, same as every other mutating action), but the *helper*, not the WebUI, is the gate. + +## 4. The resolution layer — one function, ~200 call sites + +The mechanical bulk of the work. Three new primitives in `scripts/source/paths.sh` (and mirrored inline in `init.sh`, per the existing keep-in-sync note): + +```bash +storageRoots # every enabled root, primary first +appDir # the app's directory — memoised +pathIsContainerData

# is this path under ANY container root? +``` + +- **`appDir`** builds a `slug → dir` map once per process by scanning each enabled root for `*/.config`, memoises it in an associative array, and falls back to the primary root for a slug that doesn't exist yet (fresh install). Discovery-first, config-second: if the map and `CFG__STORAGE` disagree, **the directory on disk wins** and we warn. That's what makes the system self-healing after a hand-move or a half-finished migration. +- **`pathIsContainerData`** replaces the `[[ "$p" == "$containers_dir"* ]]` idiom that decides manager-vs-container-user elevation. It appears in ~10 files (`create_folder.sh`, `create_touch.sh`, `copy_file.sh`, `copy_files.sh`, `copy_folder.sh`, `copy_folders.sh`, `move_file.sh`, `runCfgOp`, `tags_manager_update.sh`, `webui_atomic_write.sh`). **Every one is a silent-corruption bug if missed** — a file under an unrecognised root gets written as the manager, lands with the wrong owner, and the container fails to read it at a moment far removed from the cause. + +Scale of the sweep (measured, not estimated): + +| Pattern | Count | Action | +|---|---|---| +| `$containers_dir$app`-shaped, app-scoped | ~186 across ~90 files | → `$(appDir "$app")` | +| `$containers_dir/libreportal/…` (the WebUI's own tree) | 63 | → `webuiDir` helper, **pinned to the primary root** | +| `$containers_dir/{traefik,prometheus,grafana,adguard,…}` — one app reaching into another by literal name | ~29 | → `appDir `; those apps stay pinned in phase 1 (§8.3) | +| root-helper `$CONTAINERS_DIR/$app` | `libreportal-ownership`, `libreportal-appcfg` | → registry lookup inside the helper | +| the `== "$containers_dir"*` elevation test | ~10 | → `pathIsContainerData` | + +`sourceScanFiles app_configs` must also loop the roots. Keep the existing `-maxdepth 3` + prune rules exactly as they are — and remember `*.config` is a reserved extension anywhere under an app dir. + +## 5. Per-app configuration + +One new key in every app template, defaulting to the primary location: + +``` +# STORAGE = which storage location holds this app's data (see Storage on /admin) +CFG_BOOKSTACK_STORAGE=default +``` + +- The value is a **location name**, not a path. Names are what survive a migrate to a host whose disks are laid out differently; paths aren't. `default` always resolves to the install-time containers root. +- It renders as a dropdown on the app's config page for free — the WebUI config renderer already builds selects from the `[a:A|b:B]` comment convention; the option list is generated from the registry into the config comment on regen. +- Changing it in the config editor does **not** move data. It records intent; the move is `libreportal app move` (§6). The config page shows the current *resolved* directory next to the field, and flags a mismatch. +- `configBackfillMissingKeys` carries the new key into already-installed apps on the next update, so nothing needs a reinstall. +- **New compose tag** `#LIBREPORTAL|APP_DIR_TAG|APP_DIR_DATA`, filled in `dockerConfigSetupFileWithData` alongside the existing `CONTAINERS_DIR_TAG`, for the rare template that genuinely needs its own absolute host path. Most apps need nothing — relative volumes already do the right thing. + +## 6. Moving an installed app + +`libreportal app move ` — a task like any other, with progress rows in the WebUI. + +1. Resolve source and destination; refuse if they're the same, if the app isn't installed, or if the destination isn't mounted/writable. +2. **Space check** with headroom: `du -sb` of the source vs `df` of the destination, refuse under 110 %. +3. Compose **down** (data must be quiescent — a live copy of a running Postgres is a corrupt copy). +4. **Pre-move snapshot** to the first enabled backup location, reusing `migratePreBackupDestination`. Skippable with `--no-pre-backup`, on by default. +5. `runOwnership app-move ` — the copy runs as root inside the helper because app data contains rootless sub-UID files (postgres at uid 231141 etc.) that the manager can neither read nor recreate. + - same filesystem → `mv` (atomic, instant) + - across filesystems → `cp -a --reflink=auto` → verify → **only then** remove the source. Never `mv` semantics that could half-delete. +6. Update `CFG__STORAGE`, re-run `dockerComposeUpdateAndStartApp` (the `cd` target moves with `appDir`), bring it up, and health-check. +7. On any failure before step 6 the source is untouched; after step 6, roll back by pointing the config at the source and restarting. + +## 7. Restore and migrate — the interesting case + +This is the scenario that motivated the feature: *"we migrate another install, it checks the path in the config against our current locations, and if there's no match the user picks an existing one or sets up a new one."* + +**Add to `.libreportal-manifest.json`** (written into the app dir, so it rides inside the snapshot): + +```json +"storage": { "location": "bigdisk", "path": "/mnt/bigdisk/apps/nextcloud", "fs_uuid": "…" } +``` + +**On restore/migrate**, resolve in this order: exact location-name match → a location whose `fs_uuid` matches → **ask**. The "ask" is a real WebUI step in the migrate wizard — *"This app came from location `bigdisk` (`/mnt/bigdisk/apps`), which this host doesn't have. Restore it to: [ default ▾ ] or [ + add a location ]"* — with a CLI equivalent (`--storage=`) for unattended runs. + +**The blocker that must be fixed first.** Restore currently does: + +```bash +engineRestoreSnapshot "$idx" "$id" "/" "$containers_dir$app" +``` + +— restore to `/` with an include filter built from the **local** containers root. Restic reproduces the snapshot's absolute paths, so this only works when source and destination paths are byte-identical. That means: + +- relocating on restore is impossible today, and +- **this is already broken** for the shipped three-root feature: migrating from a host installed with `--containers-dir=/mnt/ssd/apps` onto a default host matches no include path and restores *nothing*, quietly. + +The fix is needed for this feature and is worth doing on its own: restore into `$restore_dir` staging with the snapshot's own path prefix, then move the tree into `appDir`. `resticRestoreAppLatest`, `restore_app_start.sh`, `migrate_apply.sh` and `migrate_preflight.sh` all take the source path from the snapshot/manifest rather than from the local `containers_dir`. + +Also worth noting: an app's restic snapshot paths change when it moves. Snapshot *history* stays intact and restorable (each snapshot knows its own path), but path-based filters in the snapshot browser need to accept either. + +## 8. Risks, in the order they'll bite + +**8.1 — An unplugged drive is the top data-integrity risk.** `crontab_boot_app_reconcile.sh` brings **every** installed app up at boot. If a location's drive isn't mounted yet — or at all — docker cheerfully creates the bind-mount directories on the bare mountpoint and the app boots **empty**. Plug the drive back in and there are now two divergent copies, with the good one hidden underneath the mount. Mitigation is mandatory, not optional: + +- every location carries `REQUIRE_MOUNT` (default **true** for anything not on the root filesystem), reusing `backupLocationLocalGuard`'s `findmnt --target` check plus the recorded `dev`/`fs_uuid` +- **`dockerComposeUp` refuses** to start an app whose location fails the guard — this is the real gate, since it also covers the manual and task-triggered paths, not just boot +- boot reconcile skips those apps with a loud notice and surfaces a dashboard warning rather than failing silently +- add a `.libreportal-storage` marker check: marker missing where a location is registered ⇒ almost certainly an unmounted drive, refuse + +**8.2 — Missing an elevation-test site** (§4) produces wrong-owner files that fail much later. Mitigate by making `containers_dir` itself the tripwire: once `appDir` lands, add a `scripts/dev/` linter that fails on any new `$containers_dir$app`-shaped concatenation, the same way `lp-task-names` guards task titles. + +**8.3 — Cross-app literal paths.** Traefik, Prometheus, Grafana, AdGuard, Gluetun, CrowdSec, Headscale are reached into by name from other apps' code. They're also small and infrastructural. **Pin them to the primary location** in phase 1 (`CFG__STORAGE=default`, not user-changeable) and relax per-app later once the call sites go through `appDir`. The WebUI's own dir is pinned permanently. + +**8.4 — Filesystem capability.** exFAT/NTFS/vfat can't hold POSIX ownership, so a rootless app dir on one is broken from the start — worse than for backups, where it's only a warning. For storage locations this must be a **refusal**, not a notice. Also check `noexec`/`nosuid` mount flags (some apps execute out of their data dir) and that the filesystem supports the sub-UID range rootless docker maps into. + +**8.5 — `du`/`df` reporting.** The dashboard's disk gauge reads `/` only. With apps spread over disks it needs one gauge per location, or the number is actively misleading. + +**8.6 — Uninstall/teardown.** `init.sh uninstall` prints and removes a fixed set of paths. It must enumerate the registry, and default to **leaving** external locations alone (with an explicit `--remove-storage` to wipe them) — an external drive is not ours to erase. + +**8.7 — Footprint version.** New root helper + changed baked helpers ⇒ **bump `footprint_version`** (currently 5), or existing installs update the manager-owned code and keep root helpers that don't know about the registry. + +## 9. Phasing + +Each phase is independently shippable and independently verifiable. Phase 0 carries almost all of the regression risk and none of the user-visible value — land and soak it alone. + +| Phase | Deliverable | Visible? | +|---|---|---| +| **0** | `appDir` / `storageRoots` / `pathIsContainerData` / `webuiDir`; sweep the ~200 call sites; helpers read a registry that contains exactly one root. Full install + backup + restore verified. | No | +| **1** | Root-owned registry + `libreportal-storage` helper + `libreportal storage {list,add,remove,check}`. Locations can exist; nothing uses them yet. | CLI only | +| **2** | `CFG__STORAGE`, install-time placement, per-location disk gauges. | Yes | +| **3** | `libreportal app move`. | Yes | +| **4** | Manifest `storage` block, staged restore + path rewrite (fixes §7's latent bug), the "unknown location" prompt in migrate. | Yes | +| **5** | WebUI **Storage** page mirroring *Backup → Locations*: add/remove, mount state, free space, apps per location, drag-to-move. | Yes | + +## 10. Open questions + +1. **Naming.** "Storage location" vs "backup location" in the same UI — is that confusing enough to want a different word (*volume*? *disk*? *pool*?) for one of them? +2. **Can a storage location double as a backup location?** Same drive, different subdirectory, two registries. Recommend keeping them separate but having the picker offer known paths. Backing an app up onto the same drive its data lives on should at least warn. +3. **Does the `libreportal` app itself ever get to move?** Currently pinned. If the primary root fills up, that's a reinstall — acceptable, or worth solving? +4. **Per-instance vs per-type placement.** Instances inherit the model for free, but should `instance create` offer a location up front (likely yes — "work Nextcloud on the big disk, family on the SSD" is a good demo)? +5. **`--storage-dir=` install flags** for pre-registering extra locations at install time, or is post-install `storage add` enough? +6. **Docs promise.** `docs/guide/install-and-use.md` currently states the roots are *"chosen at install and fixed afterward … part of the security model."* That stays true of the three roots; the guide needs a paragraph explaining that additional **containers** locations are addable at runtime, and why the empty-directory rule keeps that honest.