diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md index 10489c0..257819d 100644 --- a/docs/roadmap/first-run-restore.md +++ b/docs/roadmap/first-run-restore.md @@ -144,6 +144,62 @@ Three changes, because one would not have been enough: `scripts/dev/lp-cli-argv-test` builds stubs from the real lines in `init.sh` and `start.sh` and pushes thirteen app names through them. +### 3.3 — Staging: created by one principal, written by another + +The worst of the set, because it sat on the step everything else depends on. + +`restore system` printed + +``` + ✓ Success System config restored to: /libreportal-system/restore/system-config +``` + +for a directory that did not exist. Nothing had been written. + +Restore stages through `$SYSTEM_DIR`, which the **manager** owns — but the thing +writing 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: + +| Call site | Created by | Written by | Result | +|---|---|---|---| +| `backupRestoreSystemConfig` | `runFileOp` → container user | container user | `mkdir` denied on the 0751 manager-owned `restore_dir`, unchecked | +| `storageRestoreAppTo` | `runInstallOp` → manager | container user | restic could not create anything beneath it | + +And restic reports a permission denial as `ignoring error …` and **still exits +0**, so the caller's success check was satisfied either way. Same family as +§3.1: the check had no failure path. + +Root has to bridge that, the way `webui-bind` already bridges the mirror case. +`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, since the manager +cannot delete the container user's files inside and the container user cannot +unlink the entry from the manager's directory, so staging simply accumulated). +Both confine the path to a single component directly under the restore/migrate +area. `footprint_version` 8 → 9. + +`backupRestoreSystemConfig` now also verifies the tree landed **as the user that +wrote it**, since the manager cannot read inside its own staging directory. + +### 3.4 — What the run finally proved + +With all of the above fixed, on a live machine: + +- 13 of 13 apps restored from the repository and came up healthy, databases + included (`bookstack`, `matrix` ×3, `mattermost` ×2, `nextcloud` ×4, + `rocketchat` ×2, `stoat` ×10, …) +- `restore system` staged 57 real files +- the **relocation** branch of `storageRestoreAppTo` ran for the first time: + `speedtest`, moved to a second storage location, restored from a snapshot + taken at `/libreportal-containers/speedtest` into `/libreportal-alt/speedtest` + via stage-and-move, with the staging tree cleaned up afterwards + +One genuine environment collision remains and is **not** a LibrePortal defect: +stoat's livekit publishes a fixed UDP range (50000–50100) that it advertises to +clients and so cannot be re-rolled, and a desktop's `kdeconnectd` held 50016. +Worth a fixed-range preflight check of its own; see §9. + ## 4. The password problem, stated plainly **An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC__PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means. @@ -252,6 +308,12 @@ change — and the docs should say so plainly so nobody uses it as their backup. exists, so the third answer to "where is your backup?" is a small addition — but a single app file is a thin thing to rebuild a *server* from, and offering it beside a repository may imply more than it delivers. +7. **A published range that cannot be re-rolled** — livekit advertises + 50000–50100 to clients, so the port allocator's randomisation does not apply. + Nothing checks such a range against the host before compose-up, and the + result is an app that comes back part-started. A preflight that compares + fixed published ranges against `ss` output would catch it; the open question + is what to *do* about it, since the app cannot simply be moved elsewhere. 6. **Import under a different name** is refused today: the app's `CFG__*` namespace and its compose identities (container names, Traefik routers, backup labels) would all need rewriting. `instance create` already solves diff --git a/init.sh b/init.sh index 7a93152..8bcb9d3 100755 --- a/init.sh +++ b/init.sh @@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal" # `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump # tells the updater the new release needs a root re-install (which re-bakes them). # Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md. -footprint_version=8 +footprint_version=9 footprint_marker="$lp_lib_dir/.footprint_version" # Directories — three independently-relocatable roots (see scripts/source/paths.sh diff --git a/scripts/backup/system/backup_system.sh b/scripts/backup/system/backup_system.sh index a4dd271..cda7274 100644 --- a/scripts/backup/system/backup_system.sh +++ b/scripts/backup/system/backup_system.sh @@ -68,15 +68,36 @@ backupRestoreSystemConfig() return 1 fi + # Root has to make this, because the manager owns restore_dir but restic + # writes into the staging tree as the container user. `runFileOp mkdir` + # here was denied every time, unchecked — and restic reports a permission + # denial as "ignoring error …" and still exits 0, so this reported + # "System config restored to: " for a path that was never created. + # That is the step the whole restore ordering depends on for credentials. local staging="${restore_dir%/}/system-config" - runFileOp mkdir -p "$staging" + if ! runOwnership restore-stage "$staging"; then + isError "Could not create the staging directory at $staging" + return 1 + fi isHeader "Restoring system config (to staging — live config is untouched)" - if engineRestoreSystemLatest "$idx" "$staging"; then - isSuccessful "System config restored to: $staging" - isNotice "Review it, then copy what you need into ${configs_dir} (backup-location creds, logins, settings). Live config was NOT overwritten." - return 0 + if ! engineRestoreSystemLatest "$idx" "$staging"; then + isError "System config restore failed" + return 1 fi - isError "System config restore failed" - return 1 + + # Restic reports a permission denial as "ignoring error …" and still exits + # 0, so its status alone is not evidence that anything landed. Look for the + # tree as the user that wrote it — the staging dir is the container user's, + # and the manager can traverse it but not read inside. + local landed + landed=$(runFileOp find "$staging" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null) + if [[ -z "$landed" ]]; then + isError "System config restore wrote nothing to $staging" + return 1 + fi + + isSuccessful "System config restored to: $staging" + isNotice "Review it, then copy what you need into ${configs_dir} (backup-location creds, logins, settings). Live config was NOT overwritten." + return 0 } diff --git a/scripts/storage/storage_restore_path.sh b/scripts/storage/storage_restore_path.sh index 35e24c8..001e823 100644 --- a/scripts/storage/storage_restore_path.sh +++ b/scripts/storage/storage_restore_path.sh @@ -71,11 +71,17 @@ storageRestoreAppTo() isNotice "This snapshot was taken at '$src'; restoring to '$dest'." + # Created by root and handed to the container user: restic writes here via + # runBackupOp, so a manager-owned staging dir meant every file failed with + # "mkdir … permission denied" while restic still exited 0. local stage="${restore_dir%/}/relocate-$app.$$" - runInstallOp mkdir -p "$stage" + if ! runOwnership restore-stage "$stage"; then + isError "Could not create staging at $stage" + return 1 + fi if ! engineRestoreSnapshot "$idx" "$snapshot_id" "$stage" "$src"; then isError "Restore into staging failed." - runInstallOp rm -rf "$stage" + runOwnership restore-unstage "$stage" return 1 fi @@ -91,7 +97,7 @@ storageRestoreAppTo() return 1 fi - runInstallOp rm -rf "$stage" + runOwnership restore-unstage "$stage" isSuccessful "Restored $app to $dest" return 0 } diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership index d259978..f1d7c62 100644 --- a/scripts/system/libreportal-ownership +++ b/scripts/system/libreportal-ownership @@ -383,6 +383,73 @@ app_file() { [[ -e "$d/$rel" ]] && chown "$cowner:$cowner" "$d/$rel" } +# Create a staging directory under the restore/migrate area, owned by the +# container user. +# +# 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. So whoever created the directory, the other one could not +# write to it, and both existing call sites created it as the wrong principal: +# +# backupRestoreSystemConfig runFileOp mkdir -> container user, denied on a +# 0751 manager-owned restore_dir, and the failure +# was never checked. restic then wrote nothing, +# exited 0 ("ignoring error ... permission denied"), +# and the CLI reported "System config restored to: +# " for a path that did not exist. +# storageRestoreAppTo runInstallOp mkdir -> manager, so restic could +# not create anything beneath it. +# +# Root has to bridge that, the same way webui-bind already bridges the mirror +# case. The path is confined to the restore/migrate area and the name to a +# single component, so this cannot be pointed anywhere else. +# Shared gate for the staging actions: exactly one component directly below the +# restore or migrate area, no traversal, no arbitrary path. +_restore_stage_ok() { + local path="${1:-}" + [[ -n "$path" && "$path" == /* && "$path" != *..* ]] \ + || { echo "libreportal-ownership: invalid staging path" >&2; return 1; } + path="${path%/}" + local parent="${path%/*}" leaf="${path##*/}" + [[ "$parent" == "$RESTORE_DIR" || "$parent" == "$MIGRATE_DIR" ]] \ + || { echo "libreportal-ownership: staging must sit directly under the restore/migrate area" >&2; return 1; } + [[ "$leaf" =~ ^[A-Za-z0-9._-]+$ && "$leaf" != "." && "$leaf" != ".." ]] \ + || { echo "libreportal-ownership: invalid staging name" >&2; return 1; } + return 0 +} + +# Remove a staging tree. Root's job for the same reason creating it was: the +# tree belongs to the container user but sits in a directory the manager owns, +# so neither of them can unlink it — the manager cannot delete the container +# user's files inside, and the container user cannot remove the entry from the +# manager's directory. Left to itself the staging dir simply accumulated. +restore_unstage() { + local path="${1:-}" + _restore_stage_ok "$path" || return 1 + path="${path%/}" + [[ -e "$path" ]] || return 0 + rm -rf -- "$path" +} + +restore_stage() { + local path="${1:-}" + _restore_stage_ok "$path" || return 1 + path="${path%/}" + local parent="${path%/*}" + + local mode cowner; mode="$(_mode)"; cowner="$(_container_owner "$mode")" + mkdir -p -- "$parent" || return 1 + chown "$MANAGER:$MANAGER" -- "$parent" 2>/dev/null + chmod 0751 -- "$parent" 2>/dev/null + mkdir -p -- "$path" || return 1 + # Owner writes (restic, as the container user), group traverses (the + # manager, which has to confirm the tree landed and is the account a human + # reviews it from). Same shape as webui-bind, mirrored. + chown "$cowner:$MANAGER" -- "$path" || return 1 + chmod 0750 -- "$path" || return 1 + return 0 +} + action="${1:-}"; shift 2>/dev/null || true case "$action" in reconcile) reconcile "${1:-}";; @@ -399,5 +466,7 @@ case "$action" in app-file) app_file "${1:-}" "${2:-}";; app-move) app_move "${1:-}" "${2:-}";; app-adopt) app_adopt "${1:-}" "${2:-}" "${3:-}";; - *) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody |app-data-remove |app-file |app-move |app-adopt }" >&2; exit 2;; + restore-stage) restore_stage "${1:-}";; + restore-unstage) restore_unstage "${1:-}";; + *) echo "usage: libreportal-ownership {reconcile [mode]|traversal|containers-top|backups-top|db-own|app-perms|webui|webui-bind|taskdir|app-data-nobody |app-data-remove |app-file |app-move |app-adopt |restore-stage |restore-unstage }" >&2; exit 2;; esac