LibrePortal/scripts/backup/files/backup_files.sh
librelad 3a1cd8464e fix(backup): make captured file staging readable by the backup user
The container capture preserved the app's ownership (e.g. www-data 0640), so
restic still hit permission denied on the staging copy. chown the staging tree
to the backup user after capture (modes unchanged, so the owner reads fine);
real ownership is reapplied from the descriptor on restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 18:23:03 +01:00

144 lines
6.1 KiB
Bash

#!/bin/bash
# Container-side file capture.
#
# Some apps store private data the backup user can't read from the host: in
# rooted Docker the files are owned by the container's real UIDs (e.g. Nextcloud
# data is www-data 0750), and in rootless they're owned by mapped sub-UIDs — in
# both cases restic, running as the unprivileged backup user, gets "permission
# denied" and the snapshot silently drops that data.
#
# So we read those paths the same way we dump databases: THROUGH the container.
# `docker exec <c> tar` runs in the container's namespace and sees every file as
# the app's own user, needing no host root and no host read access (works
# identically rooted and rootless). We extract the stream to a staging dir as
# PLAIN FILES (not a piped tar blob) so restic keeps full per-file dedup and
# per-file restore; the live path is then excluded from the snapshot. On restore
# we stream the staging copy back through a throwaway container that recreates
# the tree with the app's ownership in-namespace — again no host root.
#
# Declared per app as a compose label (multiple allowed):
#
# labels:
# libreportal.backup.files: "<container>:<container_path>:<host_subdir>:<uid>:<gid>"
#
# container service to exec/read through
# container_path path inside the container to capture (a bind-mount target)
# host_subdir app-dir-relative dir it maps to (excluded from the snapshot)
# uid:gid ownership to restore the files as (the app's runtime user)
#
# Example (Nextcloud):
# "nextcloud-service:/var/www/html:html:33:33"
# Staging lives at the app root (never inside an excluded path) so it rides in
# the snapshot alongside the DB dumps under .lp-backup/.
backup_files_stage_subdir=".lp-backup/files"
# Tiny image used as a throwaway, in-namespace extractor on restore.
backup_files_helper_image="busybox"
backupFilesDescriptors()
{
local app="$1"
local compose="$containers_dir$app/docker-compose.yml"
[[ -f "$compose" ]] || return 0
grep -E '^[[:space:]]*libreportal\.backup\.files[[:space:]]*:' "$compose" 2>/dev/null \
| sed -E 's/^[[:space:]]*libreportal\.backup\.files[[:space:]]*:[[:space:]]*//' \
| sed -E 's/[[:space:]]*#.*$//' \
| sed -E 's/^["'\'']//; s/["'\'']$//' \
| sed -E 's/[[:space:]]+$//'
}
backupFilesHasDescriptors()
{
local app="$1"
if [[ -n "$(backupFilesDescriptors "$app")" ]]; then return 0; fi
return 1
}
# Capture each declared path live, through its container, into staging. No host
# root, no host read perms.
backupFilesCapture()
{
local app="$1"
local app_dir="$containers_dir$app"
local desc container cpath subdir uid gid stage rc=0
backupFilesHasDescriptors "$app" || return 0
while IFS= read -r desc; do
[[ -z "$desc" ]] && continue
IFS=':' read -r container cpath subdir uid gid <<< "$desc"
[[ -z "$container" || -z "$cpath" || -z "$subdir" ]] && { isError "Bad backup.files descriptor: $desc"; rc=1; continue; }
stage="$app_dir/$backup_files_stage_subdir/$subdir"
isNotice "Capturing $subdir from $container — live, via container"
rm -rf "$stage" 2>/dev/null
mkdir -p "$stage"
# Read in the container's namespace, write the plain tree to staging.
if docker exec "$container" tar -C "$cpath" -cf - . 2>/dev/null | tar -xf - -C "$stage" 2>/dev/null; then
# The capture preserves the app's ownership (e.g. www-data, 0640),
# which the backup user still couldn't read. Hand the staging tree to
# the backup user so restic can read it; modes are unchanged, so the
# owner can now read everything. Real ownership is reapplied from the
# descriptor on restore.
chown -R "$docker_install_user":"$docker_install_user" "$stage" 2>/dev/null \
|| sudo chown -R "$docker_install_user":"$docker_install_user" "$stage" 2>/dev/null
isSuccessful "captured $subdir ($(du -sh "$stage" 2>/dev/null | cut -f1))"
else
isError "capture of $subdir from $container failed"
rc=1
fi
done < <(backupFilesDescriptors "$app")
return $rc
}
# Live paths the staging copies supersede — excluded from the snapshot.
backupFilesExcludePaths()
{
local app="$1"
local app_dir="$containers_dir$app"
local desc container cpath subdir uid gid
while IFS= read -r desc; do
[[ -z "$desc" ]] && continue
IFS=':' read -r container cpath subdir uid gid <<< "$desc"
[[ -n "$subdir" ]] && echo "$app_dir/$subdir"
done < <(backupFilesDescriptors "$app")
}
# Pre-start restore: rebuild each captured tree at its host path with the app's
# ownership, by extracting through a throwaway container running in-namespace.
# Runs after the snapshot is laid down (staging present, live path absent) and
# before the app starts. No host root.
restoreFilesRehydratePreStart()
{
local app="$1"
local app_dir="$containers_dir$app"
local desc container cpath subdir uid gid stage
backupFilesHasDescriptors "$app" || return 0
while IFS= read -r desc; do
[[ -z "$desc" ]] && continue
IFS=':' read -r container cpath subdir uid gid <<< "$desc"
stage="$app_dir/$backup_files_stage_subdir/$subdir"
[[ -d "$stage" ]] || { isNotice "No captured files for $subdir — skipping"; continue; }
uid="${uid:-0}"; gid="${gid:-0}"
isNotice "Restoring $subdir as $uid:$gid — via container"
# Helper runs as in-namespace root: it can clear/create the dir under the
# app dir, extract the streamed tree, and chown to the app's uid:gid
# (which maps to the right owner in rooted and rootless alike).
if tar -C "$stage" -cf - . 2>/dev/null | docker run --rm -i \
-v "$app_dir:/parent" "$backup_files_helper_image" \
sh -c "rm -rf '/parent/$subdir' && mkdir -p '/parent/$subdir' && tar -C '/parent/$subdir' -xf - && chown -R $uid:$gid '/parent/$subdir'" 2>/dev/null; then
isSuccessful "restored $subdir"
else
isError "restoring $subdir failed"
fi
done < <(backupFilesDescriptors "$app")
}