`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>
104 lines
4.1 KiB
Bash
104 lines
4.1 KiB
Bash
#!/bin/bash
|
|
|
|
# System-config backup.
|
|
#
|
|
# Snapshots the system config tree (<system>/configs — global settings, WebUI
|
|
# credentials, and crucially the BACKUP-LOCATION credentials) to every enabled
|
|
# backup location, so a bare-metal restore is self-sufficient. Without this the
|
|
# location creds live only on the box: lose it and you can't even reach your own
|
|
# remote backups (chicken-and-egg). It is a lightweight, static-dir snapshot — no
|
|
# container quiescing or DB dumps (those are per-app concerns), so it does NOT go
|
|
# through backupAppStart. The install tree (code) is reproducible from the release
|
|
# and is deliberately NOT included; per-app data is handled by backupAppStart.
|
|
|
|
backupSystemConfig()
|
|
{
|
|
local source_path="${configs_dir%/}"
|
|
if [[ ! -d "$source_path" ]]; then
|
|
isNotice "System config dir not found ($source_path) — skipping system backup"
|
|
return 0
|
|
fi
|
|
if [[ -z "$(resticEnabledLocations)" ]]; then
|
|
isNotice "No backup locations enabled — skipping system config backup"
|
|
return 0
|
|
fi
|
|
|
|
isHeader "Backing up system config"
|
|
engineEnsureAllLocationsReady
|
|
|
|
local idx ok=0 fail=0
|
|
while IFS= read -r idx; do
|
|
[[ -z "$idx" ]] && continue
|
|
if engineBackupSystem "$idx" >/dev/null; then
|
|
ok=$((ok + 1))
|
|
else
|
|
fail=$((fail + 1))
|
|
fi
|
|
done < <(resticEnabledLocations)
|
|
|
|
if [[ $ok -eq 0 ]]; then
|
|
isError "System config backup failed on all locations"
|
|
return 1
|
|
fi
|
|
|
|
# Apply retention so system snapshots don't accumulate (respects append-only
|
|
# locations; bypasses backupAppStart's per-app forget, so do it here).
|
|
while IFS= read -r idx; do
|
|
[[ -z "$idx" ]] && continue
|
|
engineForgetSystem "$idx" >/dev/null 2>&1 || true
|
|
done < <(resticEnabledLocations)
|
|
|
|
if [[ $fail -gt 0 ]]; then
|
|
isNotice "System config backed up to $ok location(s), failed on $fail"
|
|
else
|
|
isSuccessful "System config backed up to $ok location(s)"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Restore the latest system-config snapshot from a location into a STAGING dir.
|
|
# Deliberately does NOT overwrite live config — recovering creds/settings is a
|
|
# review-then-copy step, never an automatic blast over a running control plane.
|
|
backupRestoreSystemConfig()
|
|
{
|
|
local idx="${1:-}"
|
|
[[ -z "$idx" ]] && idx=$(resticEnabledLocations | head -1)
|
|
if [[ -z "$idx" ]]; then
|
|
isError "No enabled backup location to restore the system config from"
|
|
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"
|
|
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
|
|
isError "System config restore failed"
|
|
return 1
|
|
fi
|
|
|
|
# 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
|
|
}
|