diff --git a/scripts/backup/engine/engine_dispatch.sh b/scripts/backup/engine/engine_dispatch.sh index 8204e94..4edb7db 100644 --- a/scripts/backup/engine/engine_dispatch.sh +++ b/scripts/backup/engine/engine_dispatch.sh @@ -36,15 +36,18 @@ engineDispatch() # ---- Idx-scoped dispatchers ---------------------------------------------------- -engineInitLocation() { local i="$1"; engineDispatch "$(engineForLocation "$i")InitLocation" "$i"; } -engineEnsureLocationReady() { local i="$1"; engineDispatch "$(engineForLocation "$i")EnsureLocationReady" "$i"; } +# Local/removable-drive safety guard runs before init, readiness, and any backup +# write (see backupLocationLocalGuard) — refuses to write when a REQUIRE_MOUNT +# drive isn't mounted, so restic never fills the system disk. +engineInitLocation() { local i="$1"; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")InitLocation" "$i"; } +engineEnsureLocationReady() { local i="$1"; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")EnsureLocationReady" "$i"; } enginePasswordEnsure() { local i="$1"; engineDispatch "$(engineForLocation "$i")PasswordEnsure" "$i"; } engineLocationUri() { local i="$1"; engineDispatch "$(engineForLocation "$i")LocationUri" "$i"; } engineLocationStats() { local i="$1"; engineDispatch "$(engineForLocation "$i")LocationStats" "$i"; } engineEnvExport() { local i="$1"; engineDispatch "$(engineForLocation "$i")EnvExport" "$i"; } engineEnvUnset() { local i="$1"; engineDispatch "$(engineForLocation "${i:-1}")EnvUnset"; } -engineBackupApp() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")BackupAppToLocation" "$i" "$@"; } +engineBackupApp() { local i="$1"; shift; backupLocationLocalGuard "$i" || return 1; engineDispatch "$(engineForLocation "$i")BackupAppToLocation" "$i" "$@"; } engineRestoreSnapshot() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")RestoreSnapshot" "$i" "$@"; } engineSnapshotLatestId() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SnapshotLatestId" "$i" "$@"; } engineSnapshotsJson() { local i="$1"; shift; engineDispatch "$(engineForLocation "$i")SnapshotsJson" "$i" "$@"; } diff --git a/scripts/backup/locations/location_add.sh b/scripts/backup/locations/location_add.sh index ebb1b7a..4e96d76 100644 --- a/scripts/backup/locations/location_add.sh +++ b/scripts/backup/locations/location_add.sh @@ -37,6 +37,7 @@ locationAdd() echo "CFG_BACKUP_LOC_${idx}_TYPE=${type} # Type - Backend [local:Local / mounted path|sftp:SFTP|rest:REST|s3:S3|b2:Backblaze B2|gs:Google Cloud Storage|azure:Azure|rclone:rclone]" echo "CFG_BACKUP_LOC_${idx}_PATH_MODE=${default_path_mode} # Path Mode - Automatic uses the Default Backup Location from the Backup Engine config (one subfolder per location); Custom uses the path below [auto:Automatic|custom:Custom path]" echo "CFG_BACKUP_LOC_${idx}_PATH=${default_path} # Custom Path - Filesystem path on this server (used when Path Mode = Custom)" + echo "CFG_BACKUP_LOC_${idx}_REQUIRE_MOUNT=false # Require Mounted Drive - For an external/removable disk: refuse to back up unless the path is on a real mount, so an unplugged drive never silently fills the system disk [true:Yes|false:No]" echo "CFG_BACKUP_LOC_${idx}_URI= # URI Override - Custom restic URI (leave blank to build from the fields below) **ADVANCED**" echo "CFG_BACKUP_LOC_${idx}_SSH_USER= # SSH User - For sftp type" echo "CFG_BACKUP_LOC_${idx}_SSH_HOST= # SSH Host - For sftp type" diff --git a/scripts/backup/locations/location_paths.sh b/scripts/backup/locations/location_paths.sh index 422e882..781f4a8 100644 --- a/scripts/backup/locations/location_paths.sh +++ b/scripts/backup/locations/location_paths.sh @@ -75,3 +75,45 @@ backupLocationResolvedPath() 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 +}