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>
This commit is contained in:
parent
089ac8827c
commit
c9779d6581
@ -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 <path>` (creates it `cowner:MANAGER`
|
||||
0750 — owner writes, manager traverses to confirm and review) and
|
||||
`restore-unstage <path>` (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_<idx>_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_<APP>_*`
|
||||
namespace and its compose identities (container names, Traefik routers,
|
||||
backup labels) would all need rewriting. `instance create` already solves
|
||||
|
||||
2
init.sh
2
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
|
||||
|
||||
@ -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: <path>" 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
# <path>" 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>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>}" >&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>|app-data-remove <app>|app-file <app> <relpath>|app-move <app> <dest-root>|app-adopt <app> <staged> <dest-root>|restore-stage <path>|restore-unstage <path>}" >&2; exit 2;;
|
||||
esac
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user