backup: give borg and kopia the same ownership mapping as restic

The user-namespace prefix that lets an unprivileged restore put back a file's
original owner was only wired into restic. borg extract and kopia snapshot
restore run as the same backup user with the same lack of CAP_CHOWN, so both
lost <container-uid>:<backup-user> exactly the way restic did — an app whose
data comes back owned by the backup user cannot write it, which is how grafana
kept dying with "attempt to write a readonly database".

borg is quieter about it than restic: it does not print an "ignoring error"
line at all, so there was nothing to notice.

Move the prefix to engine_dispatch.sh as backupUsernsPrefix — it was never
restic-specific — and use it from all three engines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-28 06:20:31 +01:00
parent 17316b7fe2
commit 226ebe1717
5 changed files with 45 additions and 29 deletions

View File

@ -16,13 +16,22 @@ borgRestoreSnapshot()
runFileOp mkdir -p "$target_dir" runFileOp mkdir -p "$target_dir"
isNotice "Restoring $snapshot_id from $(resticLocationName "$idx")$target_dir" isNotice "Restoring $snapshot_id from $(resticLocationName "$idx")$target_dir"
# Same reason restic needs it: extraction runs as the backup user, which has
# no CAP_CHOWN, so without the namespace every <container-uid>:<backup-user>
# file comes back owned by the backup user and the app cannot write its own
# data. borg is quieter about it than restic — it does not even print an
# "ignoring error" line — so this went unnoticed for longer.
local ns_prefix=()
mapfile -t ns_prefix < <(backupUsernsPrefix)
local rc local rc
if [[ -n "$include_path" ]]; then if [[ -n "$include_path" ]]; then
local stripped="${include_path#/}" local stripped="${include_path#/}"
( cd "$target_dir" && runBackupOp borg extract "::$snapshot_id" "$stripped" ) ( cd "$target_dir" && runBackupOp "${ns_prefix[@]}" borg extract "::$snapshot_id" "$stripped" )
rc=$? rc=$?
else else
( cd "$target_dir" && runBackupOp borg extract "::$snapshot_id" ) ( cd "$target_dir" && runBackupOp "${ns_prefix[@]}" borg extract "::$snapshot_id" )
rc=$? rc=$?
fi fi
borgEnvUnset borgEnvUnset

View File

@ -97,6 +97,29 @@ engineDumpFile() { local i="$1"; shift; engineDispatch "$(engineForL
# has no adapter on purpose. A missing adapter is therefore a quiet "no" rather # has no adapter on purpose. A missing adapter is therefore a quiet "no" rather
# than engineDispatch's error — callers fall back to restoring in place, which # than engineDispatch's error — callers fall back to restoring in place, which
# is correct for borg and merely conservative elsewhere. # is correct for borg and merely conservative elsewhere.
# Prefix that lets an unprivileged restore put back the ownership a file had
# when it was backed up. Engine-neutral: restic, borg and kopia all extract as
# the backup user, and all three lose <container-uid>:<backup-user> without it.
#
# The mapping itself lives in backup/engine/restic-userns-exec, because it needs
# three id ranges at once and `unshare` takes one per option. The checks here
# only decide whether to reach for it; the helper re-checks and falls back to
# running the command plainly if anything is missing.
backupUsernsPrefix()
{
local usr="${docker_install_user:-dockerinstall}"
local helper="${install_scripts_dir%/}/backup/engine/restic-userns-exec"
[[ -r "$helper" ]] || return 0
command -v unshare >/dev/null 2>&1 || return 0
command -v newuidmap >/dev/null 2>&1 || return 0
command -v newgidmap >/dev/null 2>&1 || return 0
# No subuid range (rooted mode, or a hand-rolled account) — nothing to map,
# so leave the call exactly as it was rather than guess.
grep -q "^${usr}:" /etc/subuid 2>/dev/null || return 0
grep -q "^${usr}:" /etc/subgid 2>/dev/null || return 0
printf '%s\n' bash "$helper"
}
engineSnapshotPaths() { engineSnapshotPaths() {
local i="$1"; shift local i="$1"; shift
local fn; fn="$(engineForLocation "$i")SnapshotPaths" local fn; fn="$(engineForLocation "$i")SnapshotPaths"

View File

@ -24,7 +24,13 @@ kopiaRestoreSnapshot()
final_target="$target_dir/${include_path#/}" final_target="$target_dir/${include_path#/}"
runFileOp mkdir -p "$final_target" runFileOp mkdir -p "$final_target"
fi fi
runBackupOp kopia snapshot restore "$snapshot_id" "$final_target" # Restore runs as the backup user with no CAP_CHOWN; without the namespace
# every <container-uid>:<backup-user> file comes back owned by the backup
# user and the app cannot write its own data. See restic-userns-exec.
local ns_prefix=()
mapfile -t ns_prefix < <(backupUsernsPrefix)
runBackupOp "${ns_prefix[@]}" kopia snapshot restore "$snapshot_id" "$final_target"
local rc=$? local rc=$?
kopiaEnvUnset kopiaEnvUnset
return $rc return $rc

View File

@ -25,28 +25,6 @@
# inner root, which IS that user on the host, so those files already land with # inner root, which IS that user on the host, so those files already land with
# exactly the right owner. resticRestoreErrorsAreBenign below is what keeps that # exactly the right owner. resticRestoreErrorsAreBenign below is what keeps that
# from being reported as a failed restore. # from being reported as a failed restore.
_resticUsernsPrefix()
{
local usr="${docker_install_user:-dockerinstall}"
# The mapping itself lives in restic-userns-exec, because it needs three id
# ranges at once and `unshare` takes one per option — see that file. The
# checks here only decide whether to reach for it at all; it re-checks and
# falls back to running the command plainly if anything is missing.
local helper="${install_scripts_dir%/}/backup/engine/restic-userns-exec"
[[ -r "$helper" ]] || return 0
command -v unshare >/dev/null 2>&1 || return 0
command -v newuidmap >/dev/null 2>&1 || return 0
command -v newgidmap >/dev/null 2>&1 || return 0
# No subuid range (rooted mode, or a hand-rolled account) — nothing to map,
# so leave the call exactly as it was rather than guess.
grep -q "^${usr}:" /etc/subuid 2>/dev/null || return 0
grep -q "^${usr}:" /etc/subgid 2>/dev/null || return 0
printf '%s\n' bash "$helper"
}
# True when every error restic reported is the expected "cannot map the backup # True when every error restic reported is the expected "cannot map the backup
# user's own uid" one described above. Anything else — a missing pack, a full # user's own uid" one described above. Anything else — a missing pack, a full
# disk, a permission problem on the target — must still fail the restore. # disk, a permission problem on the target — must still fail the restore.
@ -82,7 +60,7 @@ resticRestoreSnapshot()
isNotice "Restoring ${snapshot_id:0:8} from $(resticLocationName "$idx")$target_dir" isNotice "Restoring ${snapshot_id:0:8} from $(resticLocationName "$idx")$target_dir"
local ns_prefix=() local ns_prefix=()
mapfile -t ns_prefix < <(_resticUsernsPrefix) mapfile -t ns_prefix < <(backupUsernsPrefix)
# Output is captured (not streamed) so the benign-error check below can read # Output is captured (not streamed) so the benign-error check below can read
# it; it is echoed straight back afterwards, so the operator sees the same # it; it is echoed straight back afterwards, so the operator sees the same

View File

@ -254,6 +254,7 @@ declare -gA LP_FN_MAP=(
[backupSshKeyRefreshUi]="backup/locations/location_ssh.sh" [backupSshKeyRefreshUi]="backup/locations/location_ssh.sh"
[backupSshKeySet]="backup/locations/location_ssh.sh" [backupSshKeySet]="backup/locations/location_ssh.sh"
[backupSystemConfig]="backup/system/backup_system.sh" [backupSystemConfig]="backup/system/backup_system.sh"
[backupUsernsPrefix]="backup/engine/engine_dispatch.sh"
[backupVerifySnapshot]="backup/verify/backup_verify.sh" [backupVerifySnapshot]="backup/verify/backup_verify.sh"
[_bookstackArtisan]="bookstack/scripts/bookstack_auth.sh" [_bookstackArtisan]="bookstack/scripts/bookstack_auth.sh"
[bookstack_install_post_start]="bookstack/scripts/bookstack_install_hooks.sh" [bookstack_install_post_start]="bookstack/scripts/bookstack_install_hooks.sh"
@ -904,7 +905,6 @@ declare -gA LP_FN_MAP=(
[resticSnapshotPaths]="backup/engine/restic_snapshots.sh" [resticSnapshotPaths]="backup/engine/restic_snapshots.sh"
[resticSnapshotsJson]="backup/engine/restic_snapshots.sh" [resticSnapshotsJson]="backup/engine/restic_snapshots.sh"
[resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh" [resticSystemSnapshotsJson]="backup/engine/restic_snapshots.sh"
[_resticUsernsPrefix]="backup/engine/restic_restore.sh"
[restoreAppRunHook]="restore/restore_app_hooks.sh" [restoreAppRunHook]="restore/restore_app_hooks.sh"
[restoreAppStart]="restore/restore_app_start.sh" [restoreAppStart]="restore/restore_app_start.sh"
[restoreDbRehydratePreStart]="backup/db/backup_db.sh" [restoreDbRehydratePreStart]="backup/db/backup_db.sh"
@ -1493,6 +1493,7 @@ declare -gA LP_FN_ROOT=(
[backupSshKeyRefreshUi]="scripts" [backupSshKeyRefreshUi]="scripts"
[backupSshKeySet]="scripts" [backupSshKeySet]="scripts"
[backupSystemConfig]="scripts" [backupSystemConfig]="scripts"
[backupUsernsPrefix]="scripts"
[backupVerifySnapshot]="scripts" [backupVerifySnapshot]="scripts"
[_bookstackArtisan]="containers" [_bookstackArtisan]="containers"
[bookstack_install_post_start]="containers" [bookstack_install_post_start]="containers"
@ -2143,7 +2144,6 @@ declare -gA LP_FN_ROOT=(
[resticSnapshotPaths]="scripts" [resticSnapshotPaths]="scripts"
[resticSnapshotsJson]="scripts" [resticSnapshotsJson]="scripts"
[resticSystemSnapshotsJson]="scripts" [resticSystemSnapshotsJson]="scripts"
[_resticUsernsPrefix]="scripts"
[restoreAppRunHook]="scripts" [restoreAppRunHook]="scripts"
[restoreAppStart]="scripts" [restoreAppStart]="scripts"
[restoreDbRehydratePreStart]="scripts" [restoreDbRehydratePreStart]="scripts"
@ -2770,6 +2770,7 @@ backupSshKeyPublic() { unset -f backupSshKeyPublic; __lpAutoload "${install_scri
backupSshKeyRefreshUi() { unset -f backupSshKeyRefreshUi; __lpAutoload "${install_scripts_dir}backup/locations/location_ssh.sh"; backupSshKeyRefreshUi "$@"; } backupSshKeyRefreshUi() { unset -f backupSshKeyRefreshUi; __lpAutoload "${install_scripts_dir}backup/locations/location_ssh.sh"; backupSshKeyRefreshUi "$@"; }
backupSshKeySet() { unset -f backupSshKeySet; __lpAutoload "${install_scripts_dir}backup/locations/location_ssh.sh"; backupSshKeySet "$@"; } backupSshKeySet() { unset -f backupSshKeySet; __lpAutoload "${install_scripts_dir}backup/locations/location_ssh.sh"; backupSshKeySet "$@"; }
backupSystemConfig() { unset -f backupSystemConfig; __lpAutoload "${install_scripts_dir}backup/system/backup_system.sh"; backupSystemConfig "$@"; } backupSystemConfig() { unset -f backupSystemConfig; __lpAutoload "${install_scripts_dir}backup/system/backup_system.sh"; backupSystemConfig "$@"; }
backupUsernsPrefix() { unset -f backupUsernsPrefix; __lpAutoload "${install_scripts_dir}backup/engine/engine_dispatch.sh"; backupUsernsPrefix "$@"; }
backupVerifySnapshot() { unset -f backupVerifySnapshot; __lpAutoload "${install_scripts_dir}backup/verify/backup_verify.sh"; backupVerifySnapshot "$@"; } backupVerifySnapshot() { unset -f backupVerifySnapshot; __lpAutoload "${install_scripts_dir}backup/verify/backup_verify.sh"; backupVerifySnapshot "$@"; }
_bookstackArtisan() { unset -f _bookstackArtisan; __lpAutoload "${install_containers_dir}bookstack/scripts/bookstack_auth.sh"; _bookstackArtisan "$@"; } _bookstackArtisan() { unset -f _bookstackArtisan; __lpAutoload "${install_containers_dir}bookstack/scripts/bookstack_auth.sh"; _bookstackArtisan "$@"; }
bookstack_install_post_start() { unset -f bookstack_install_post_start; __lpAutoload "${install_containers_dir}bookstack/scripts/bookstack_install_hooks.sh"; bookstack_install_post_start "$@"; } bookstack_install_post_start() { unset -f bookstack_install_post_start; __lpAutoload "${install_containers_dir}bookstack/scripts/bookstack_install_hooks.sh"; bookstack_install_post_start "$@"; }
@ -3420,7 +3421,6 @@ resticSnapshotListFiles() { unset -f resticSnapshotListFiles; __lpAutoload "${in
resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; } resticSnapshotPaths() { unset -f resticSnapshotPaths; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotPaths "$@"; }
resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; } resticSnapshotsJson() { unset -f resticSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSnapshotsJson "$@"; }
resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; } resticSystemSnapshotsJson() { unset -f resticSystemSnapshotsJson; __lpAutoload "${install_scripts_dir}backup/engine/restic_snapshots.sh"; resticSystemSnapshotsJson "$@"; }
_resticUsernsPrefix() { unset -f _resticUsernsPrefix; __lpAutoload "${install_scripts_dir}backup/engine/restic_restore.sh"; _resticUsernsPrefix "$@"; }
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; } restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; } restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; } restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }