#!/bin/bash # Fitness checks for a storage location — "will app data actually work here?" # # Deliberately separate from the ADMISSION checks in the libreportal-storage # root helper, which answer a different question ("is it safe for root to accept # this path?"). Admission is a security gate and it refuses. Fitness runs in the # manager, needs no privilege, and therefore can be run speculatively against a # disk the user has not chosen yet — which is what lets the setup wizard and # `libreportal storage scan` grade candidates before anything is committed. # # A fitness check REFUSES only when the location cannot work at all. It never # blocks something that merely needs care: a removable drive and a drive that # isn't in fstab are both supported setups (the media library on a USB disk is # half the point of the feature). Those warn — loudly and durably — because the # dangerous moment is start-up, not registration, and start-up is already gated # by the marker test in appDir/dockerComposeUp. # # Output: one record per line on stdout, so every caller shares one implementation # # \t\t # # severity is refuse | warn | info. storageCheckPath returns non-zero iff any # refusal was emitted. _storageEmit() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # Nearest existing ancestor — the candidate itself may not exist yet. _storageProbeDir() { local p="${1%/}" while [[ -n "$p" && "$p" != "/" && ! -d "$p" ]]; do p="${p%/*}"; done printf '%s' "${p:-/}" } # 1. Filesystem type. Stricter than the backup engine's equivalent, which only # warns: a backup repo on exFAT is merely lossy, but an app directory with no # POSIX ownership is broken from its first write under rootless. _storageCheckFsType() { local probe="$1" fstype command -v findmnt >/dev/null 2>&1 || { _storageEmit info fstype "findmnt unavailable — cannot identify the filesystem"; return 0; } fstype=$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | tail -1) case "$fstype" in vfat|exfat|ntfs|ntfs3|msdos|fuseblk) _storageEmit refuse fstype "$fstype cannot store file ownership or permissions, which app data requires. Reformat as ext4, xfs or btrfs." return 1 ;; "") _storageEmit info fstype "Filesystem type unknown." ;; *) _storageEmit info fstype "Filesystem: $fstype" ;; esac return 0 } # 2. Mount options. _storageCheckMountOpts() { local probe="$1" opts rc=0 command -v findmnt >/dev/null 2>&1 || return 0 opts=$(findmnt -no OPTIONS --target "$probe" 2>/dev/null | tail -1) [[ -z "$opts" ]] && return 0 if [[ ",$opts," == *,ro,* ]]; then _storageEmit refuse mount-ro "Mounted read-only — nothing can be written here." rc=1 fi if [[ ",$opts," == *,noexec,* ]]; then _storageEmit refuse mount-noexec "Mounted noexec; some apps execute helper binaries from their data directory." rc=1 fi [[ ",$opts," == *,nosuid,* ]] && _storageEmit info mount-nosuid "Mounted nosuid (harmless for app data)." return $rc } # 3/4/5. Ownership, sub-UID range and write/read-back. # # Delegated to the root helper's `probe` action, and it has to be: for a # CANDIDATE the directory is not ours yet — a fresh /mnt/disk is root-owned 0755 # — so an unprivileged probe can only ever report "cannot create a directory # here", which says nothing about whether the FILESYSTEM can hold app data. The # helper creates one uniquely-named directory as root, tests it, and removes it. # # This is the only check that catches NFS root_squash, which reports a perfectly # respectable nfs4 at check 1 and then silently refuses the chown. _storageCheckOwnership() { local probe="$1" out rc=0 if ! declare -f runStorage >/dev/null 2>&1; then _storageEmit info ownership "Cannot probe ownership without the storage helper." return 0 fi out=$(runStorage probe "$probe" 2>&1) || rc=1 if [[ -n "$out" ]]; then printf '%s\n' "$out" elif (( rc == 0 )); then _storageEmit info ownership "Ownership, sub-UID range and write/read-back all OK." fi return $rc } # 6. Reboot persistence. WARNS — never refuses. See the header, and §6.1 of # docs/roadmap/storage-locations.md. _storageCheckPersistence() { local probe="$1" target="" command -v findmnt >/dev/null 2>&1 || return 0 target=$(findmnt -no TARGET --target "$probe" 2>/dev/null | tail -1) [[ -z "$target" || "$target" == "/" ]] && return 0 if findmnt --fstab -no TARGET 2>/dev/null | grep -qx -- "$target"; then _storageEmit info persistence "Mounted from /etc/fstab — survives a reboot." return 0 fi if systemctl list-unit-files --type=mount 2>/dev/null | grep -q "$(systemd-escape -p --suffix=mount "$target" 2>/dev/null)"; then _storageEmit info persistence "Mounted by a systemd unit — survives a reboot." return 0 fi _storageEmit warn persistence "This drive is not listed in /etc/fstab, so the system will not mount it automatically after a reboot. Apps stored here will refuse to start until it is mounted again." # The exact line, as its own record: the UI offers to add it, and a person # reading the CLI can copy it. Kept separate from the prose so neither has # to be parsed out of the other. local line; line=$(storageFstabLine "$probe") [[ -n "$line" ]] && _storageEmit info fstab-line "$line" return 0 } # The /etc/fstab line that would make this mount permanent. # # UUID= rather than /dev/sdX because device names reorder across reboots, and a # stale one either mounts the wrong disk or nothing at all. # # nofail + x-systemd.device-timeout are what make this safe to add at all: a # missing device then cannot block boot. Without them an absent drive leaves the # machine sitting at a systemd timeout, or worse in emergency mode — a far worse # failure than the app-not-starting one we are trying to prevent. storageFstabLine() { local probe="$1" target src fstype uuid command -v findmnt >/dev/null 2>&1 || return 1 target=$(findmnt -no TARGET --target "$probe" 2>/dev/null | tail -1) [[ -z "$target" || "$target" == "/" ]] && return 1 src=$(findmnt -no SOURCE --target "$probe" 2>/dev/null | tail -1) fstype=$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | tail -1) uuid=$(findmnt -no UUID --target "$probe" 2>/dev/null | tail -1) local dev="${src:-}" [[ -n "$uuid" ]] && dev="UUID=$uuid" [[ -z "$dev" ]] && return 1 printf '%s %s %s defaults,nofail,x-systemd.device-timeout=10s 0 2' \ "$dev" "$target" "${fstype:-auto}" } # 7. Removable / hot-plug. WARNS — an external drive is a supported setup. _storageCheckRemovable() { local probe="$1" src name rm_flag hot_flag command -v findmnt >/dev/null 2>&1 || return 0 command -v lsblk >/dev/null 2>&1 || return 0 src=$(findmnt -no SOURCE --target "$probe" 2>/dev/null | tail -1) [[ -z "$src" || "$src" != /dev/* ]] && return 0 rm_flag=$(lsblk -no RM "$src" 2>/dev/null | head -1 | tr -d ' ') hot_flag=$(lsblk -no HOTPLUG "$src" 2>/dev/null | head -1 | tr -d ' ') if [[ "$rm_flag" == "1" || "$hot_flag" == "1" ]]; then _storageEmit warn removable "This is a removable drive. Apps stored here will refuse to start whenever it is not plugged in — which is deliberate: it stops them being rebuilt empty on the bare mount point." fi return 0 } # 8. Distinct device from the primary root — same disk buys nothing. _storageCheckDistinct() { local probe="$1" a b a=$(stat -c '%d' -- "$probe" 2>/dev/null) b=$(stat -c '%d' -- "$(primaryRoot)" 2>/dev/null) if [[ -n "$a" && "$a" == "$b" ]]; then _storageEmit warn same-device "This is the same filesystem as the primary location, so it adds no extra capacity or failure isolation." fi return 0 } # 9. Free space — per DEVICE, because locations can share a filesystem with each # other and with a backup repository (§6.2), so they draw on one pool. _storageCheckSpace() { local probe="$1" avail_kb pct avail_kb=$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {print $4}') [[ -z "$avail_kb" ]] && return 0 pct=$(df -Pk "$probe" 2>/dev/null | awk 'NR==2 {gsub("%","",$5); print $5}') if (( avail_kb < 1048576 )); then _storageEmit refuse space "Less than 1 GiB free — not enough for any app." return 1 fi if (( avail_kb < 5242880 )); then _storageEmit warn space "Only $((avail_kb / 1024)) MiB free." else _storageEmit info space "$((avail_kb / 1048576)) GiB free (${pct:-?}% of the device used)." fi return 0 } # 10. Encryption at rest — informational, never blocking. _storageCheckEncryption() { local probe="$1" src command -v findmnt >/dev/null 2>&1 || return 0 src=$(findmnt -no SOURCE --target "$probe" 2>/dev/null | tail -1) [[ -z "$src" ]] && return 0 if [[ "$src" == /dev/mapper/* ]] && command -v cryptsetup >/dev/null 2>&1 \ && cryptsetup status "${src##*/}" >/dev/null 2>&1; then _storageEmit info encryption "Encrypted at rest (LUKS/dm-crypt)." fi return 0 } # 11. Shares a device with a backup location — warn, never refuse (§6.2). _storageCheckSharedWithBackup() { local probe="$1" dev other_dev idx dev=$(stat -c '%d' -- "$probe" 2>/dev/null) [[ -z "$dev" ]] && return 0 declare -f resticEnabledLocations >/dev/null 2>&1 || return 0 declare -f backupLocationResolvedPath >/dev/null 2>&1 || return 0 while IFS= read -r idx; do [[ -z "$idx" ]] && continue local bpath; bpath=$(backupLocationResolvedPath "$idx" 2>/dev/null) [[ -z "$bpath" ]] && continue other_dev=$(stat -c '%d' -- "$(_storageProbeDir "$bpath")" 2>/dev/null) if [[ -n "$other_dev" && "$other_dev" == "$dev" ]]; then _storageEmit warn shared-fate "Shares this drive with a backup location. Snapshots here still protect against deletion, a bad update and ransomware — but not against this disk failing, since the data and its only copy would go together." return 0 fi done < <(resticEnabledLocations 2>/dev/null) return 0 } # Run every fitness check against a path. Returns non-zero if any check refused. storageCheckPath() { local path="$1" [[ -n "$path" ]] || { _storageEmit refuse path "No path given."; return 1; } local probe; probe=$(_storageProbeDir "$path") local rc=0 _storageCheckFsType "$probe" || rc=1 _storageCheckMountOpts "$probe" || rc=1 _storageCheckOwnership "$probe" || rc=1 _storageCheckSpace "$probe" || rc=1 _storageCheckPersistence "$probe" _storageCheckRemovable "$probe" _storageCheckDistinct "$probe" _storageCheckEncryption "$probe" _storageCheckSharedWithBackup "$probe" return $rc } # Same, for an already-registered location id. storageCheckLocation() { local id="$1" path path=$(storageLocationPath "$id") || { _storageEmit refuse unknown "No such storage location: $id"; return 1; } if ! storageRootAvailable "$path"; then _storageEmit refuse unmounted "'$path' has no LibrePortal marker — its drive is not mounted. Apps stored here will not start until it is." return 1 fi storageCheckPath "$path" }