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>
243 lines
10 KiB
Bash
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"
|
|
}
|