The Storage step was a technical dump: every check's full sentence concatenated onto the card, so the fstab line the user is meant to act on was buried in prose nobody reads. The card now shows plain facts and at most two short flags — "Low on space · Won't be mounted after a reboot" — with everything else behind a Details button. The modal carries the technical spec (device, UUID, mount options, removable), every check with its full explanation, and the fstab offer. That needed the shell to stop joining checks into one string: the generator emits a record per check, plus the fstab line as its own field, so neither the card nor the modal has to parse anything back out of the other. The screenshot caught a bug this restructure introduced: summaries keyed on check id alone, so a PASSING check printed the failure wording next to a green tick — "This drive's format can't store file ownership" above "Filesystem: ext4". Now severity-aware. On writing /etc/fstab — §1 ruled it out and §6.3 now records why that reverses. The warning is useless to the audience this is for: "add this line to fstab" assumes SSH, root, an editor, and knowing what fstab is, and the likely outcome is a reboot where nothing starts. What makes it defensible is nofail + x-systemd.device-timeout, which mean a missing device can never block boot — without that pair it would stay a non-goal, because the failure being risked (an unbootable machine) is worse than the one being fixed. Enforced in the root helper: UUID never /dev/sdX, append inside a marked block, refuse a target or UUID already described, refuse the root filesystem, require a live mount, timestamped backup, and `findmnt --verify` before the file is installed — a file that doesn't parse never reaches /etc. Opt-in only. Verified against a real filesystem: entry added and verifies, the persistence warning then disappears on the next scan, and duplicate / root-fs / non-mountpoint / relative are each refused with the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
11 KiB
Bash
267 lines
11 KiB
Bash
#!/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
|
|
#
|
|
# <severity>\t<check>\t<message>
|
|
#
|
|
# 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"
|
|
}
|