LibrePortal/scripts/storage/storage_checks.sh
librelad ac4c11b5e9 fix(storage): move the app->location index out of configs/
Follow-up to 928e244, which stopped configs/ subdirectories being sourced
without a .category marker. That closed the hole; this removes the thing
that fell into it.

storageIndexFile pointed at configs/storage/app_locations. The file's
requirements are only "manager-owned" and "not on a removable disk" —
configs/ satisfies both, which is why I put it there, and it was still
wrong: that tree carries a third property the file violates. sourceScanFiles
SOURCES what it finds under configs/, and sourcing means executing.

The index is a TSV of "<slug><TAB><root>", which bash reads as a command
and its argument. Harmless while no slug matched a real executable. The
row for the app named `libreportal` armed it, because that IS the CLI on
PATH: sourcing ran `libreportal /libreportal-containers`, which re-entered
the scan, which sourced the file again — one process pair per level until
the host OOMed and took the desktop session with it.

It now lives at $system_dir/storage/app_locations, with a one-shot
migration so an install that already has an index keeps knowing where its
apps live rather than silently forgetting. libreportal-ownership
reconciles the new directory, and scan_files.sh gained a note that
configs/storage/ carries no .category on purpose.

scripts/dev/lp-configs-guard-test covers both ends: the index never lands
in configs/, a legacy one migrates, and a file of the exact detonating
shape placed in an unmarked configs/ subdirectory is not executed while a
marked category still loads.

Also wires sourceStorageLocations into the config scan beside
sourceBackupLocations — per-location configs sit at depth 3, below the
generic scan, and need their own walker like the backup ones do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:25:07 +01:00

243 lines
10 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
local src fstype
src=$(findmnt -no SOURCE --target "$probe" 2>/dev/null | tail -1)
fstype=$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | tail -1)
local uuid; uuid=$(findmnt -no UUID --target "$probe" 2>/dev/null | tail -1)
local line="${src:-<device>} $target ${fstype:-auto} defaults,nofail 0 2"
[[ -n "$uuid" ]] && line="UUID=$uuid $target ${fstype:-auto} defaults,nofail 0 2"
_storageEmit warn persistence "Not in /etc/fstab: after a reboot this drive will not be mounted, and apps stored here will not start until it is. To make it permanent, add: $line"
return 0
}
# 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"
}