LibrePortal/scripts/backup/locations/location_paths.sh
librelad 61cebb5ab8 feat(backup): external/removable drive safety guards (phase 3b)
backupLocationLocalGuard (engine-agnostic, in location_paths.sh), wired into the
dispatcher before init, readiness, and every backup write (engineInitLocation /
engineEnsureLocationReady / engineBackupApp):

- Filesystem warning: the ownership model chowns the repo to the backup user, which
  needs POSIX permissions — warn (non-fatal) on FAT/exFAT/NTFS via findmnt FSTYPE.
- Mount-presence refusal: a location with CFG_BACKUP_LOC_<idx>_REQUIRE_MOUNT=true
  (an external/removable disk) is refused when its path isn't on a real mount
  (findmnt TARGET is '/' or unknown) — so an unplugged drive never silently fills
  the system disk. Opt-in; default false leaves on-disk locations unaffected.

New REQUIRE_MOUNT field documented in the location.config template (location_add.sh)
so it surfaces on the Locations page. Verified: REQUIRE_MOUNT+unmounted refuses;
default allows; non-local no-ops.

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

120 lines
4.0 KiB
Bash

#!/bin/bash
# Per-location directory layout helpers. Each backup location is one
# directory under configs/backup/locations/<idx>/ containing:
#
# location.config — sourced at startup, holds CFG_BACKUP_LOC_<idx>_*
# including PASSWORD (auto-randomized from
# RANDOMIZEDPASSWORD<N> on first install)
# ssh.key — private SSH key when AUTH=key (chmod 0600)
# kopia.config — kopia adapter's connection state (chmod 0600)
#
# Everything for one location is co-located here so add/remove operations
# are mkdir / rm of one directory.
backupLocationsDir()
{
echo "$configs_dir/backup/locations"
}
backupLocationDir()
{
local idx="$1"
echo "$(backupLocationsDir)/$idx"
}
backupLocationConfig()
{
local idx="$1"
echo "$(backupLocationDir "$idx")/location.config"
}
backupLocationSshKey()
{
local idx="$1"
echo "$(backupLocationDir "$idx")/ssh.key"
}
backupLocationKopiaConfig()
{
local idx="$1"
echo "$(backupLocationDir "$idx")/kopia.config"
}
# Owner used when chowning per-location files. Falls back to sudo_user_name
# when docker_install_user hasn't been resolved (CLI startup before
# checkInstallTypeRequirement runs).
backupLocationOwner()
{
echo "${docker_install_user:-${sudo_user_name:-libreportal}}"
}
backupLocationEnsureDir()
{
local idx="$1"
local dir
dir=$(backupLocationDir "$idx")
local owner
owner=$(backupLocationOwner)
runFileOp mkdir -p "$dir"
runFileOp chown "$owner":"$owner" "$dir"
runFileOp chmod 0700 "$dir"
}
backupLocationResolvedPath()
{
local idx="$1"
local mode
mode=$(resticLocationField "$idx" PATH_MODE)
if [[ "$mode" == "auto" ]]; then
# Base dir is the configurable Default Backup Location (Backup Engine
# config); each location gets its own numbered subfolder.
local base="${CFG_BACKUP_DEFAULT_PATH:-${backup_dir:-$docker_dir/backups}}"
echo "${base%/}/${idx}"
else
resticLocationField "$idx" PATH
fi
}
# Safety guard for LOCAL backup locations — especially external/removable drives.
# Engine-agnostic; call before init or any backup write. Returns non-zero only on
# the mount failure (so the caller refuses to write), warns (non-fatal) otherwise.
# - Filesystem warning: the ownership model chowns the repo to the backup user,
# which needs POSIX permissions. FAT/exFAT/NTFS can't, so warn.
# - Mount-presence refusal: when the location sets REQUIRE_MOUNT=true (an
# external/removable drive), refuse if the path isn't on a real mount — else
# restic would silently write to the underlying dir on the SYSTEM disk and
# fill it when the drive is unplugged.
backupLocationLocalGuard()
{
local idx="$1"
[[ -z "$idx" ]] && return 0
[[ "$(resticLocationType "$idx")" == "local" ]] || return 0
local base probe
base=$(backupLocationResolvedPath "$idx"); base="${base%/}"
[[ -z "$base" ]] && return 0
# Probe the nearest EXISTING ancestor (the repo dir may not exist yet).
probe="$base"
while [[ "$probe" != "/" && ! -d "$probe" ]]; do probe="$(dirname "$probe")"; done
if command -v findmnt >/dev/null 2>&1; then
local fstype
fstype=$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | tail -1)
case "$fstype" in
vfat|exfat|ntfs|ntfs3|msdos|fuseblk)
isNotice "Backup location $(resticLocationName "$idx"): '$probe' is $fstype — no POSIX ownership/permissions; the repo chown may fail. Prefer ext4/xfs/btrfs." ;;
esac
fi
if [[ "$(resticLocationField "$idx" REQUIRE_MOUNT)" == "true" ]]; then
local tgt=""
command -v findmnt >/dev/null 2>&1 && tgt=$(findmnt -no TARGET --target "$probe" 2>/dev/null | tail -1)
if [[ -z "$tgt" || "$tgt" == "/" ]]; then
isError "Backup location $(resticLocationName "$idx"): REQUIRE_MOUNT is set but '$base' is not on a mounted drive (target: ${tgt:-unknown}). Refusing to back up to the system disk — mount the drive and retry."
return 1
fi
fi
return 0
}