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>
This commit is contained in:
parent
928e244696
commit
ac4c11b5e9
7
init.sh
7
init.sh
@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
|
||||
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
|
||||
# tells the updater the new release needs a root re-install (which re-bakes them).
|
||||
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
|
||||
footprint_version=5
|
||||
footprint_version=6
|
||||
footprint_marker="$lp_lib_dir/.footprint_version"
|
||||
|
||||
# Directories — three independently-relocatable roots (see scripts/source/paths.sh
|
||||
@ -984,7 +984,8 @@ Cmnd_Alias LP_HELPERS = ${lp_lib_dir}/libreportal-ownership, \\
|
||||
${lp_lib_dir}/libreportal-svc, \\
|
||||
${lp_lib_dir}/libreportal-bininstall, \\
|
||||
${lp_lib_dir}/libreportal-appcfg, \\
|
||||
${lp_lib_dir}/libreportal-crowdsec
|
||||
${lp_lib_dir}/libreportal-crowdsec, \\
|
||||
${lp_lib_dir}/libreportal-storage
|
||||
Cmnd_Alias LP_SYSTEM = /usr/bin/systemctl, /usr/sbin/ufw, /usr/local/bin/ufw-docker, \\
|
||||
/usr/sbin/nft, /usr/sbin/sysctl, /sbin/sysctl, \\
|
||||
/usr/bin/loginctl, /usr/sbin/service
|
||||
@ -1024,7 +1025,7 @@ initRootHelpers()
|
||||
# sudo's (the trust boundary the scoped sudoers relies on).
|
||||
sudo install -d -m 0755 -o root -g root "$lp_lib_dir"
|
||||
local helper helper_src helper_dst helper_tmp
|
||||
for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec; do
|
||||
for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec libreportal-storage; do
|
||||
helper_src="$script_dir/scripts/system/$helper"
|
||||
helper_dst="$lp_lib_dir/$helper"
|
||||
if [[ ! -f "$helper_src" ]]; then
|
||||
|
||||
44
scripts/dev/lp-configs-guard-test
Executable file
44
scripts/dev/lp-configs-guard-test
Executable file
@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Regression test for the configs/ fork bomb: a data file in a configs/
|
||||
# subdirectory must never be sourced, and the index must not land there at all.
|
||||
R="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
B=$(mktemp -d); trap 'rm -rf "$B"' EXIT
|
||||
export LP_SYSTEM_DIR="$B/sys" LP_CONTAINERS_DIR="$B/primary" LP_BACKUPS_DIR="$B/bk"
|
||||
export LP_STORAGE_REGISTRY="$B/storage.roots"
|
||||
mkdir -p "$B/sys/configs/general" "$B/primary" "$B/sys/configs/storage"
|
||||
: > "$B/sys/configs/general/.category"
|
||||
echo 'CFG_REAL_OPTION=yes' > "$B/sys/configs/general/general_test"
|
||||
source "$R/scripts/source/paths.sh"
|
||||
fail=0
|
||||
|
||||
echo "--- 1. the index no longer lives under configs/ ---"
|
||||
idx=$(storageIndexFile)
|
||||
echo " index path: $idx"
|
||||
[[ "$idx" == "$B/sys/storage/app_locations" ]] && echo " ok outside configs/" || { echo " FAIL still in configs/"; fail=1; }
|
||||
|
||||
echo "--- 2. writing the index does not create configs/storage/app_locations ---"
|
||||
runInstallOp(){ "$@"; }; runInstallWrite(){ cat > "$1"; }
|
||||
storageIndexSet libreportal "$B/primary"
|
||||
[[ -f "$B/sys/configs/storage/app_locations" ]] && { echo " FAIL wrote into configs/"; fail=1; } || echo " ok nothing written into configs/"
|
||||
[[ -f "$idx" ]] && echo " ok index written to the system tree" || { echo " FAIL no index"; fail=1; }
|
||||
|
||||
echo "--- 3. legacy index is migrated, not abandoned ---"
|
||||
rm -rf "$B/sys/storage"
|
||||
mkdir -p "$B/sys/configs/storage"
|
||||
printf 'bookstack\t%s\n' "$B/primary" > "$B/sys/configs/storage/app_locations"
|
||||
got=$(storageIndexGet bookstack)
|
||||
[[ "$got" == "$B/primary" ]] && echo " ok legacy entry still resolves ($got)" || { echo " FAIL lost entry"; fail=1; }
|
||||
[[ -f "$B/sys/configs/storage/app_locations" ]] && { echo " FAIL legacy file left behind"; fail=1; } || echo " ok legacy file removed"
|
||||
|
||||
echo "--- 4. the actual bomb: a stray executable-looking file in configs/ ---"
|
||||
# Recreate the exact shape that detonated: <slug><TAB><path> where slug is a
|
||||
# real command on PATH. If the scan sources it, the marker file appears.
|
||||
mkdir -p "$B/sys/configs/storage"
|
||||
printf 'touch\t%s/DETONATED\n' "$B" > "$B/sys/configs/storage/app_locations"
|
||||
configs_dir="$B/sys/configs/"
|
||||
source "$R/scripts/source/loading/scan_files.sh"
|
||||
sourceScanFiles libreportal_configs >/dev/null 2>&1
|
||||
[[ -e "$B/DETONATED" ]] && { echo " FAIL the file was executed"; fail=1; } || echo " ok unmarked configs/ subdir not sourced"
|
||||
[[ "${CFG_REAL_OPTION:-}" == "yes" ]] && echo " ok marked category still loads" || { echo " FAIL real config stopped loading"; fail=1; }
|
||||
|
||||
echo; [[ $fail -eq 0 ]] && echo "ALL PASS" || echo "FAILURES"; exit $fail
|
||||
@ -166,6 +166,13 @@ _runRootHelper() {
|
||||
# app-perms|webui|taskdir|app-data-nobody <app>}
|
||||
runOwnership() { _runRootHelper libreportal-ownership "$@"; }
|
||||
|
||||
# Storage locations — the ONLY writer of the root-owned storage registry:
|
||||
# {add <path> [--name=N] [--allow-home]|remove <id|path>|list|path <id>|verify [id]}
|
||||
# `add` validates hard before it accepts (see the helper's header): root only
|
||||
# ever takes ownership of an empty directory, so acceptance can never hand away
|
||||
# anything that already existed.
|
||||
runStorage() { _runRootHelper libreportal-storage "$@"; }
|
||||
|
||||
# /etc/resolv.conf edits: {clear|add <ip>}
|
||||
runResolv() { _runRootHelper libreportal-dns "$@"; }
|
||||
|
||||
|
||||
@ -61,6 +61,14 @@ sourceScanFiles()
|
||||
sourceBackupLocations
|
||||
fi
|
||||
|
||||
# Same shape for storage locations: configs/storage/locations/<id>/
|
||||
# location.config sits at depth 3, below this scan's reach, and is loaded
|
||||
# by its own dedicated walker. Note storage/ carries no .category on
|
||||
# purpose — nothing under it should EVER be sourced by the generic scan.
|
||||
if declare -f sourceStorageLocations >/dev/null 2>&1; then
|
||||
sourceStorageLocations
|
||||
fi
|
||||
|
||||
# Specific for LibrePortal app container configs
|
||||
elif [ "$load_type" = "app_configs" ]; then
|
||||
local file_pattern="*.config"
|
||||
|
||||
@ -251,17 +251,53 @@ storageLocationName()
|
||||
# exact failure the whole availability design exists to prevent, so "not found
|
||||
# by the scan" must not silently mean "belongs on the primary root".
|
||||
#
|
||||
# Lives in the manager-owned configs tree (never on a removable disk — it has to
|
||||
# be readable precisely when that disk is gone). Self-heals: every successful
|
||||
# scan rewrites the entries it observed.
|
||||
# Location: the manager-owned SYSTEM tree, deliberately NOT under configs/.
|
||||
#
|
||||
# The requirements are only "manager-owned" and "not on a removable disk" (it has
|
||||
# to be readable precisely when that disk is gone). configs/ satisfies both and
|
||||
# was the first instinct — and it was wrong, because that tree carries a third
|
||||
# property this file violates: sourceScanFiles SOURCES what it finds there, and
|
||||
# sourcing means executing.
|
||||
#
|
||||
# This file is a TSV of "<slug><TAB><root>". Bash reads such a line as a command
|
||||
# and its argument. Harmless while no slug matched a real executable — and a fork
|
||||
# bomb the moment the row was for the app named `libreportal`, 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 died of OOM. It took out the desktop session with it.
|
||||
#
|
||||
# scan_files.sh now also requires a .category marker before sourcing anything in
|
||||
# a configs/ subdirectory, so the hole is closed from both ends. But a
|
||||
# machine-written data file has no business in the one tree whose contract is
|
||||
# "everything here is executed", so it lives here instead.
|
||||
storageIndexFile()
|
||||
{
|
||||
printf '%s' "${configs_dir%/}/storage/app_locations"
|
||||
printf '%s' "${system_dir%/}/storage/app_locations"
|
||||
}
|
||||
|
||||
# One-shot migration off the old configs/ path. Cheap (a -f test) and it runs
|
||||
# before any read, so an install that predates the move keeps its index instead
|
||||
# of silently forgetting where every app lives.
|
||||
_storageIndexMigrate()
|
||||
{
|
||||
local legacy="${configs_dir%/}/storage/app_locations"
|
||||
local current; current=$(storageIndexFile)
|
||||
[[ -f "$legacy" ]] || return 0
|
||||
local op=""
|
||||
declare -F runInstallOp >/dev/null 2>&1 && op="runInstallOp"
|
||||
if [[ ! -f "$current" ]]; then
|
||||
$op mkdir -p "${current%/*}" 2>/dev/null
|
||||
$op cp "$legacy" "$current" 2>/dev/null
|
||||
fi
|
||||
$op rm -f "$legacy" 2>/dev/null
|
||||
$op rmdir "${legacy%/*}" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
storageIndexGet()
|
||||
{
|
||||
local slug="$1" f s r
|
||||
_storageIndexMigrate
|
||||
f=$(storageIndexFile)
|
||||
[[ -r "$f" ]] || return 1
|
||||
while IFS=$'\t' read -r s r || [[ -n "$s" ]]; do
|
||||
@ -278,6 +314,7 @@ storageIndexSet()
|
||||
{
|
||||
local slug="$1" root="${2%/}" f tmp s r
|
||||
[[ -z "$slug" ]] && return 1
|
||||
_storageIndexMigrate
|
||||
f=$(storageIndexFile)
|
||||
|
||||
local cur=""
|
||||
|
||||
242
scripts/storage/storage_checks.sh
Normal file
242
scripts/storage/storage_checks.sh
Normal file
@ -0,0 +1,242 @@
|
||||
#!/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"
|
||||
}
|
||||
203
scripts/storage/storage_locations.sh
Normal file
203
scripts/storage/storage_locations.sh
Normal file
@ -0,0 +1,203 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Manager-side storage-location management.
|
||||
#
|
||||
# Division of labour, which is the whole security story:
|
||||
#
|
||||
# root helper (libreportal-storage) owns the REGISTRY — which paths exist and
|
||||
# may hold app data. Manager cannot write it.
|
||||
# here owns the per-location CONFIG — friendly
|
||||
# name, enabled flag, notes. Manager-owned,
|
||||
# and none of it can redirect a root chown.
|
||||
#
|
||||
# The per-location config mirrors the backup-location layout exactly
|
||||
# (configs/backup/locations/<idx>/location.config), so the WebUI config renderer,
|
||||
# the CLI and the sourcing path all work with no new machinery. Note the PATH is
|
||||
# deliberately NOT a field here: it is registry data, and changing it is
|
||||
# `storage add`/`remove`, not a config edit.
|
||||
|
||||
storageLocationsDir()
|
||||
{
|
||||
printf '%s' "${configs_dir%/}/storage/locations"
|
||||
}
|
||||
|
||||
storageLocationDir()
|
||||
{
|
||||
printf '%s' "$(storageLocationsDir)/$1"
|
||||
}
|
||||
|
||||
storageLocationConfig()
|
||||
{
|
||||
printf '%s' "$(storageLocationDir "$1")/location.config"
|
||||
}
|
||||
|
||||
# Source every per-location config so CFG_STORAGE_LOC_<id>_* are in the
|
||||
# environment. Called from the libreportal_configs scan path, alongside
|
||||
# sourceBackupLocations.
|
||||
sourceStorageLocations()
|
||||
{
|
||||
local dir cfg
|
||||
dir=$(storageLocationsDir)
|
||||
[[ -d "$dir" ]] || return 0
|
||||
local find_cmd=(find)
|
||||
if [[ ! -r "$dir" || ! -x "$dir" ]] && declare -f runFileOp >/dev/null 2>&1; then
|
||||
find_cmd=(runFileOp find)
|
||||
fi
|
||||
while IFS= read -r -d '' cfg; do
|
||||
[[ -f "$cfg" ]] && source "$cfg"
|
||||
done < <("${find_cmd[@]}" "$dir" -mindepth 2 -maxdepth 2 -name location.config -type f -print0 2>/dev/null)
|
||||
}
|
||||
|
||||
_storageWriteLocationConfig()
|
||||
{
|
||||
local id="$1" name="$2" path="$3"
|
||||
local dir cfg
|
||||
dir=$(storageLocationDir "$id")
|
||||
cfg=$(storageLocationConfig "$id")
|
||||
|
||||
runInstallOp mkdir -p "$dir"
|
||||
{
|
||||
echo "# Storage location $id — added $(date -Iseconds)."
|
||||
echo "# The PATH is not editable here: it lives in the root-owned registry"
|
||||
echo "# (/usr/local/lib/libreportal/storage.roots) so that editing config can"
|
||||
echo "# never redirect a privileged chown. Use 'libreportal storage add/remove'."
|
||||
echo "CFG_STORAGE_LOC_${id}_NAME=\"${name}\" # Name - Friendly label used by CFG_<APP>_STORAGE and shown in the UI"
|
||||
echo "CFG_STORAGE_LOC_${id}_ENABLED=true # Enabled - Offer this location when choosing where an app's data lives [true:Yes|false:No]"
|
||||
echo "CFG_STORAGE_LOC_${id}_PATH=\"${path}\" # Path - Where this location lives on disk (registry-owned; shown for reference) **READONLY**"
|
||||
echo "CFG_STORAGE_LOC_${id}_NOTES=\"\" # Notes - Free text, e.g. which physical disk this is"
|
||||
} | runInstallWrite "$cfg" >/dev/null
|
||||
runInstallOp chmod 0640 "$cfg"
|
||||
}
|
||||
|
||||
# Add a storage location: fitness first (so the user gets every reason at once),
|
||||
# then the root helper's admission checks, then the manager-side config.
|
||||
storageAdd()
|
||||
{
|
||||
local path="$1" name="$2" force="${3:-}"
|
||||
if [[ -z "$path" ]]; then
|
||||
isError "storageAdd requires a path"
|
||||
return 1
|
||||
fi
|
||||
path="${path%/}"
|
||||
|
||||
isHeader "Adding storage location $path"
|
||||
|
||||
# --- fitness (§6) --------------------------------------------------------
|
||||
local sev check msg refused=0
|
||||
while IFS=$'\t' read -r sev check msg; do
|
||||
[[ -z "$sev" ]] && continue
|
||||
case "$sev" in
|
||||
refuse) isError "$check: $msg"; refused=1 ;;
|
||||
warn) isNotice "$check: $msg" ;;
|
||||
info) isNotice "$check: $msg" ;;
|
||||
esac
|
||||
done < <(storageCheckPath "$path")
|
||||
|
||||
if (( refused )) && [[ "$force" != "--force" ]]; then
|
||||
isError "Refusing to add '$path' — the failures above mean app data cannot work there."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- admission (root helper, §3) ----------------------------------------
|
||||
local id
|
||||
if ! id=$(runStorage add "$path" ${name:+--name="$name"} 2>&1); then
|
||||
isError "$id"
|
||||
return 1
|
||||
fi
|
||||
id="${id##*$'\n'}"
|
||||
if [[ ! "$id" =~ ^[0-9]+$ ]]; then
|
||||
isError "Unexpected response from the storage helper: $id"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_storageWriteLocationConfig "$id" "${name:-location-$id}" "$path"
|
||||
source "$(storageLocationConfig "$id")" 2>/dev/null
|
||||
|
||||
# Long-lived processes (the task processor) hold a memoised root list.
|
||||
storageCacheReset
|
||||
|
||||
isSuccessful "Storage location $id '${name:-location-$id}' added at $path"
|
||||
_storageRefreshWebui
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
# Regenerate the WebUI's storage data when that generator exists.
|
||||
#
|
||||
# `declare -f X >/dev/null && X` as a function's LAST statement makes the whole
|
||||
# function return 1 whenever X is absent — the caller then reads a successful
|
||||
# operation as a failure. Same trap _appCallHook documents in app_install.sh.
|
||||
_storageRefreshWebui()
|
||||
{
|
||||
if declare -f webuiGenerateStorageLocations >/dev/null 2>&1; then
|
||||
webuiGenerateStorageLocations || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove a location. The helper refuses while app directories remain, and also
|
||||
# when the drive is absent (we would be dropping the only record of where those
|
||||
# apps live).
|
||||
storageRemove()
|
||||
{
|
||||
local want="$1"
|
||||
[[ -n "$want" ]] || { isError "storageRemove requires an id or path"; return 1; }
|
||||
|
||||
local id
|
||||
if ! id=$(runStorage remove "$want" 2>&1); then
|
||||
isError "$id"
|
||||
return 1
|
||||
fi
|
||||
id="${id##*$'\n'}"
|
||||
|
||||
local dir; dir=$(storageLocationDir "$id")
|
||||
[[ -d "$dir" ]] && runInstallOp rm -rf "$dir"
|
||||
storageCacheReset
|
||||
isSuccessful "Storage location $id removed"
|
||||
_storageRefreshWebui
|
||||
return 0
|
||||
}
|
||||
|
||||
# Human-readable listing: id, name, path, state, apps, free space.
|
||||
storageList()
|
||||
{
|
||||
local id path state name_var name apps free
|
||||
isHeader "Storage locations"
|
||||
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "ID" "NAME" "PATH" "STATE" "APPS" "FREE"
|
||||
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "0" "default" "$(primaryRoot)" "ok" \
|
||||
"$(storageAppsOnRoot "$(primaryRoot)" | wc -l | tr -d ' ')" \
|
||||
"$(_storageFreeHuman "$(primaryRoot)")"
|
||||
|
||||
while IFS=$'\t' read -r id state path; do
|
||||
[[ -z "$id" ]] && continue
|
||||
name_var="CFG_STORAGE_LOC_${id}_NAME"
|
||||
name="${!name_var:-location-$id}"
|
||||
if [[ "$state" == "ok" ]]; then
|
||||
apps=$(storageAppsOnRoot "$path" | wc -l | tr -d ' ')
|
||||
free=$(_storageFreeHuman "$path")
|
||||
else
|
||||
apps="?"; free="-"
|
||||
fi
|
||||
printf '%-4s %-16s %-34s %-12s %-5s %s\n' "$id" "$name" "$path" "$state" "$apps" "$free"
|
||||
done < <(runStorage verify 2>/dev/null)
|
||||
}
|
||||
|
||||
_storageFreeHuman()
|
||||
{
|
||||
local p="$1" kb
|
||||
kb=$(df -Pk "$p" 2>/dev/null | awk 'NR==2 {print $4}')
|
||||
[[ -z "$kb" ]] && { printf '-'; return; }
|
||||
if (( kb >= 1048576 )); then printf '%s GiB' "$((kb / 1048576))"
|
||||
else printf '%s MiB' "$((kb / 1024))"; fi
|
||||
}
|
||||
|
||||
# Apps living on one specific root.
|
||||
storageAppsOnRoot()
|
||||
{
|
||||
local root="${1%/}" d
|
||||
storageRootAvailable "$root" || return 0
|
||||
local scan_op=""
|
||||
declare -F runFileOp >/dev/null 2>&1 && scan_op="runFileOp"
|
||||
while IFS= read -r d; do
|
||||
[[ -n "$d" ]] && printf '%s\n' "${d##*/}"
|
||||
done < <($scan_op find "$root" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
|
||||
}
|
||||
148
scripts/storage/storage_scan.sh
Normal file
148
scripts/storage/storage_scan.sh
Normal file
@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Candidate discovery — "what else could hold app data on this box?"
|
||||
#
|
||||
# Shared by the setup wizard's Storage step, `libreportal storage scan`, and the
|
||||
# Disks view, so all three show the same answer.
|
||||
#
|
||||
# Filtering has to be aggressive rather than optional: a desktop-class install
|
||||
# carries a dozen-plus snap loop mounts, and without pruning them the real answer
|
||||
# is invisible. `lsblk -e7` drops loop devices at the source; the rest are pruned
|
||||
# by mount point and filesystem type below.
|
||||
|
||||
# Filesystem types that are never a candidate.
|
||||
_STORAGE_SKIP_FSTYPES="squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|proc|sysfs|cgroup|cgroup2|configfs|debugfs|tracefs|securityfs|pstore|efivarfs|autofs|binfmt_misc|fusectl|mqueue|hugetlbfs|bpf|nsfs|iso9660|udf"
|
||||
|
||||
# Mount points that are never a candidate.
|
||||
_storageSkipTarget()
|
||||
{
|
||||
local t="$1"
|
||||
case "$t" in
|
||||
""|/boot|/boot/*|/efi|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/snap|/snap/*|/var/snap/*|/tmp|/var/lib/docker/*)
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Emit one TSV record per candidate filesystem:
|
||||
# <target> <source> <fstype> <size> <avail> <uuid> <removable> <role>
|
||||
#
|
||||
# role is one of: system | primary | storage:<id> | backup:<idx> | free
|
||||
# — the thing that lets the Disks view show what LibrePortal does with a device
|
||||
# without every caller re-deriving it.
|
||||
storageScanCandidates()
|
||||
{
|
||||
command -v findmnt >/dev/null 2>&1 || return 0
|
||||
|
||||
local target source fstype uuid size avail rm_flag role dev
|
||||
local primary_dev backup_devs=""
|
||||
primary_dev=$(stat -c '%d' -- "$(primaryRoot)" 2>/dev/null)
|
||||
|
||||
# Registered storage roots, by device
|
||||
local -A storage_dev=()
|
||||
local _id _path _rest
|
||||
if [[ -r "$lp_storage_registry" ]]; then
|
||||
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
||||
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
||||
dev=$(stat -c '%d' -- "${_path%/}" 2>/dev/null) || continue
|
||||
[[ -n "$dev" ]] && storage_dev["$dev"]="$_id"
|
||||
done < "$lp_storage_registry"
|
||||
fi
|
||||
|
||||
# Enabled backup locations, by device
|
||||
local -A backup_dev=()
|
||||
if declare -f resticEnabledLocations >/dev/null 2>&1 \
|
||||
&& declare -f backupLocationResolvedPath >/dev/null 2>&1; then
|
||||
local idx bpath probe
|
||||
while IFS= read -r idx; do
|
||||
[[ -z "$idx" ]] && continue
|
||||
bpath=$(backupLocationResolvedPath "$idx" 2>/dev/null); bpath="${bpath%/}"
|
||||
[[ -z "$bpath" ]] && continue
|
||||
probe="$bpath"
|
||||
while [[ -n "$probe" && "$probe" != "/" && ! -d "$probe" ]]; do probe="${probe%/*}"; done
|
||||
dev=$(stat -c '%d' -- "${probe:-/}" 2>/dev/null) || continue
|
||||
[[ -n "$dev" ]] && backup_dev["$dev"]="$idx"
|
||||
done < <(resticEnabledLocations 2>/dev/null)
|
||||
fi
|
||||
|
||||
local system_dev; system_dev=$(stat -c '%d' -- / 2>/dev/null)
|
||||
|
||||
# findmnt -P emits KEY="value" pairs: the only output form that survives a
|
||||
# mount point containing spaces. -r/-n is space-separated and silently
|
||||
# collapses every field into the first variable.
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
target=""; source=""; fstype=""; size=""; avail=""; uuid=""
|
||||
local kv key val
|
||||
for kv in TARGET SOURCE FSTYPE SIZE AVAIL UUID; do
|
||||
val="${line#*${kv}=\"}"
|
||||
[[ "$val" == "$line" ]] && continue
|
||||
val="${val%%\"*}"
|
||||
case "$kv" in
|
||||
TARGET) target="$val" ;;
|
||||
SOURCE) source="$val" ;;
|
||||
FSTYPE) fstype="$val" ;;
|
||||
SIZE) size="$val" ;;
|
||||
AVAIL) avail="$val" ;;
|
||||
UUID) uuid="$val" ;;
|
||||
esac
|
||||
done
|
||||
[[ -z "$target" ]] && continue
|
||||
_storageSkipTarget "$target" && continue
|
||||
[[ "$fstype" =~ ^($_STORAGE_SKIP_FSTYPES)$ ]] && continue
|
||||
|
||||
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
|
||||
|
||||
rm_flag=0
|
||||
if [[ "$source" == /dev/* ]] && command -v lsblk >/dev/null 2>&1; then
|
||||
local r h
|
||||
r=$(lsblk -no RM "$source" 2>/dev/null | head -1 | tr -d ' ')
|
||||
h=$(lsblk -no HOTPLUG "$source" 2>/dev/null | head -1 | tr -d ' ')
|
||||
[[ "$r" == "1" || "$h" == "1" ]] && rm_flag=1
|
||||
fi
|
||||
|
||||
# Most specific role wins — a device can hold more than one.
|
||||
if [[ -n "$dev" && -n "${storage_dev[$dev]:-}" ]]; then
|
||||
role="storage:${storage_dev[$dev]}"
|
||||
elif [[ -n "$dev" && "$dev" == "$primary_dev" ]]; then
|
||||
role="primary"
|
||||
elif [[ -n "$dev" && -n "${backup_dev[$dev]:-}" ]]; then
|
||||
role="backup:${backup_dev[$dev]}"
|
||||
elif [[ -n "$dev" && "$dev" == "$system_dev" ]]; then
|
||||
role="system"
|
||||
else
|
||||
role="free"
|
||||
fi
|
||||
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$target" "$source" "$fstype" "$size" "$avail" "${uuid:-}" "$rm_flag" "$role"
|
||||
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL,UUID 2>/dev/null)
|
||||
}
|
||||
|
||||
# Human-readable form of the above, with a fitness verdict per candidate.
|
||||
storageScan()
|
||||
{
|
||||
local target source fstype size avail uuid rm_flag role
|
||||
isHeader "Storage candidates"
|
||||
printf '%-26s %-10s %-8s %-8s %-12s %s\n' "MOUNT" "FS" "SIZE" "FREE" "ROLE" "VERDICT"
|
||||
|
||||
while IFS=$'\t' read -r target source fstype size avail uuid rm_flag role; do
|
||||
[[ -z "$target" ]] && continue
|
||||
local verdict="usable" sev check msg
|
||||
local refusals="" warnings=""
|
||||
while IFS=$'\t' read -r sev check msg; do
|
||||
case "$sev" in
|
||||
refuse) refusals+="${refusals:+; }$check" ;;
|
||||
warn) warnings+="${warnings:+; }$check" ;;
|
||||
esac
|
||||
done < <(storageCheckPath "$target" 2>/dev/null)
|
||||
if [[ -n "$refusals" ]]; then
|
||||
verdict="unusable ($refusals)"
|
||||
elif [[ -n "$warnings" ]]; then
|
||||
verdict="usable, note: $warnings"
|
||||
fi
|
||||
printf '%-26s %-10s %-8s %-8s %-12s %s\n' \
|
||||
"$target" "$fstype" "$size" "$avail" "$role" "$verdict"
|
||||
done < <(storageScanCandidates)
|
||||
}
|
||||
@ -54,6 +54,10 @@ SSL_DIR="$SYSTEM_DIR/ssl"
|
||||
SSH_DIR="$SYSTEM_DIR/ssh"
|
||||
RESTORE_DIR="$SYSTEM_DIR/restore"
|
||||
MIGRATE_DIR="$SYSTEM_DIR/migrate"
|
||||
# Storage-location bookkeeping the manager writes (the app -> root index).
|
||||
# Deliberately NOT under configs/: that tree is SOURCED, and a data file there
|
||||
# is executed. See storageIndexFile in scripts/source/paths.sh.
|
||||
STORAGE_DIR="$SYSTEM_DIR/storage"
|
||||
DB_PATH="$SYSTEM_DIR/database.db"
|
||||
WEBUI_DIR="$CONTAINERS_DIR/libreportal"
|
||||
TASK_DIR="$WEBUI_DIR/frontend/data/tasks"
|
||||
@ -122,7 +126,7 @@ reconcile() {
|
||||
chown "$MANAGER:$MANAGER" "$SYSTEM_DIR"
|
||||
local p
|
||||
for p in "$CONFIGS_DIR" "$LOGS_DIR" "$INSTALL_DIR" "$SSL_DIR" "$SSH_DIR" \
|
||||
"$RESTORE_DIR" "$MIGRATE_DIR" "$DB_PATH"; do
|
||||
"$RESTORE_DIR" "$MIGRATE_DIR" "$STORAGE_DIR" "$DB_PATH"; do
|
||||
[[ -e "$p" ]] && chown -R "$MANAGER:$MANAGER" "$p"
|
||||
done
|
||||
[[ -f "$DB_PATH" ]] && chmod o+r "$DB_PATH"
|
||||
|
||||
392
scripts/system/libreportal-storage
Executable file
392
scripts/system/libreportal-storage
Executable file
@ -0,0 +1,392 @@
|
||||
#!/bin/bash
|
||||
# LibrePortal storage-location helper — the ONLY writer of the root-owned
|
||||
# storage registry, and the only thing that may hand a new directory to the
|
||||
# container user.
|
||||
#
|
||||
# Why this exists: with storage locations, the set of paths root will chown is no
|
||||
# longer fixed at install. If that set came from a manager-writable config, the
|
||||
# manager could aim a root `chown -R dockerinstall` at /etc and own the box. So
|
||||
# the registry lives root:root here, this script is its only writer, and every
|
||||
# candidate must clear the admission rules below before it is accepted.
|
||||
#
|
||||
# The rule that makes it safe:
|
||||
#
|
||||
# root only ever chowns a directory that is EMPTY.
|
||||
#
|
||||
# An empty directory contains nothing to give away, so acceptance cannot transfer
|
||||
# anything that already existed. Everything created underneath afterwards is ours
|
||||
# by construction. The one relaxation — a directory already carrying OUR marker,
|
||||
# so a drive full of app data can be adopted — costs nothing: writing that marker
|
||||
# requires write access you would have had to already possess.
|
||||
#
|
||||
# Self-contained ON PURPOSE: it must NOT source any manager-owned code (incl.
|
||||
# paths.sh), or it would re-open the very escalation it exists to close. init.sh
|
||||
# bakes the roots and the manager name into the installed copy.
|
||||
#
|
||||
# Actions:
|
||||
# add <path> [name] validate, accept, mark, chown, append to the registry
|
||||
# remove <id|path> drop a location (refuses while app dirs remain)
|
||||
# list print the registry (id<TAB>path<TAB>dev<TAB>uuid)
|
||||
# verify [id] re-check marker + device of one/all locations
|
||||
# path <id> print one location's path
|
||||
|
||||
set -u
|
||||
|
||||
[[ $EUID -eq 0 ]] || { echo "libreportal-storage: must run as root" >&2; exit 1; }
|
||||
|
||||
# Baked by init.sh at install (placeholders replaced). An unbaked copy still
|
||||
# contains the "__" sentinel, which no real absolute path does.
|
||||
MANAGER="__MANAGER__"
|
||||
SYSTEM_DIR="__SYSTEM_DIR__"
|
||||
CONTAINERS_DIR="__CONTAINERS_DIR__"
|
||||
BACKUPS_DIR="__BACKUPS_DIR__"
|
||||
[[ "$MANAGER" == *"__"* || -z "$MANAGER" ]] && MANAGER="libreportal"
|
||||
[[ "$SYSTEM_DIR" == *"__"* || -z "$SYSTEM_DIR" ]] && SYSTEM_DIR="/libreportal-system"
|
||||
[[ "$CONTAINERS_DIR" == *"__"* || -z "$CONTAINERS_DIR" ]] && CONTAINERS_DIR="/libreportal-containers"
|
||||
[[ "$BACKUPS_DIR" == *"__"* || -z "$BACKUPS_DIR" ]] && BACKUPS_DIR="/libreportal-backups"
|
||||
|
||||
LIB_DIR="/usr/local/lib/libreportal"
|
||||
REGISTRY="$LIB_DIR/storage.roots"
|
||||
MARKER=".libreportal-storage"
|
||||
DB_CFG="$SYSTEM_DIR/configs/general/general_docker_install"
|
||||
|
||||
# Paths that must never become a storage location, whatever the caller says.
|
||||
# /home is excluded here and only reachable with --allow-home (see _protected).
|
||||
PROTECTED=(/ /etc /usr /bin /sbin /lib /lib32 /lib64 /libx32 /boot /proc /sys
|
||||
/dev /run /var /tmp /root /home /srv/../ /media/../)
|
||||
|
||||
_err() { echo "libreportal-storage: $*" >&2; }
|
||||
|
||||
_mode() {
|
||||
local m
|
||||
m=$(grep -h '^CFG_DOCKER_INSTALL_TYPE=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
|
||||
echo "${m:-rootless}"
|
||||
}
|
||||
|
||||
_container_owner() {
|
||||
local appusr=""
|
||||
if [[ "$(_mode)" == "rootless" ]]; then
|
||||
appusr=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$DB_CFG" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
|
||||
if [[ -n "$appusr" ]] && id -u "$appusr" >/dev/null 2>&1; then echo "$appusr"; return; fi
|
||||
echo "dockerinstall"; return
|
||||
fi
|
||||
echo "$MANAGER"
|
||||
}
|
||||
|
||||
_install_id() {
|
||||
# Stable per-install identity, so an adopted drive can say which install
|
||||
# wrote it. Derived from the machine id; never a secret.
|
||||
local mid=""
|
||||
[[ -r /etc/machine-id ]] && mid=$(cat /etc/machine-id 2>/dev/null)
|
||||
[[ -z "$mid" && -r /var/lib/dbus/machine-id ]] && mid=$(cat /var/lib/dbus/machine-id 2>/dev/null)
|
||||
echo "${mid:-unknown}" | cut -c1-16
|
||||
}
|
||||
|
||||
_protected() {
|
||||
local d="$1" allow_home="$2" p
|
||||
for p in "${PROTECTED[@]}"; do
|
||||
[[ "$p" == */../ ]] && continue
|
||||
if [[ "$d" == "$p" ]]; then
|
||||
[[ "$allow_home" == "1" && "$p" == "/home" ]] && continue
|
||||
return 0
|
||||
fi
|
||||
# Inside a protected tree. /home is special: allowed with --allow-home,
|
||||
# matching init.sh's existing --allow-home for the install-time roots.
|
||||
if [[ "$d" == "$p"/* ]]; then
|
||||
[[ "$allow_home" == "1" && "$p" == "/home" ]] && continue
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Refuse a candidate that nests with any root we already know about, in EITHER
|
||||
# direction. A storage location containing a backup repo (or the reverse) is a
|
||||
# recursive-inclusion trap: the backup engine would walk a tree holding its own
|
||||
# repository.
|
||||
_nests() {
|
||||
local d="$1" other
|
||||
local -a known=("$SYSTEM_DIR" "$CONTAINERS_DIR" "$BACKUPS_DIR")
|
||||
local _id _path _rest
|
||||
if [[ -r "$REGISTRY" ]]; then
|
||||
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
||||
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
||||
known+=("${_path%/}")
|
||||
done < "$REGISTRY"
|
||||
fi
|
||||
for other in "${known[@]}"; do
|
||||
other="${other%/}"
|
||||
[[ -z "$other" ]] && continue
|
||||
if [[ "$d" == "$other" || "$d" == "$other"/* || "$other" == "$d"/* ]]; then
|
||||
echo "$other"; return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Empty means: nothing but lost+found (a filesystem's own artefact) and our own
|
||||
# marker. Anything else and we refuse — see the header.
|
||||
_is_empty_enough() {
|
||||
local d="$1" e
|
||||
shopt -s nullglob dotglob
|
||||
for e in "$d"/*; do
|
||||
e="${e##*/}"
|
||||
[[ "$e" == "lost+found" || "$e" == "$MARKER" ]] && continue
|
||||
shopt -u nullglob dotglob
|
||||
return 1
|
||||
done
|
||||
shopt -u nullglob dotglob
|
||||
return 0
|
||||
}
|
||||
|
||||
_has_marker() { [[ -f "$1/$MARKER" ]]; }
|
||||
|
||||
_next_id() {
|
||||
local max=0 _id _rest
|
||||
if [[ -r "$REGISTRY" ]]; then
|
||||
while IFS=$'\t' read -r _id _rest || [[ -n "$_id" ]]; do
|
||||
[[ "$_id" =~ ^[0-9]+$ ]] || continue
|
||||
(( _id > max )) && max=$_id
|
||||
done < "$REGISTRY"
|
||||
fi
|
||||
echo $(( max + 1 ))
|
||||
}
|
||||
|
||||
_dev_of() { stat -c '%d' -- "$1" 2>/dev/null || echo 0; }
|
||||
_uuid_of() { findmnt -no UUID --target "$1" 2>/dev/null | tail -1; }
|
||||
|
||||
# Probe whether a filesystem can actually hold app data: POSIX ownership, the
|
||||
# high sub-UIDs rootless containers map into, and a write that reads back.
|
||||
#
|
||||
# Runs as root because it must: 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 the
|
||||
# filesystem. The probe creates one uniquely-named directory, tests it, and
|
||||
# removes it; the path is validated by the same protected-path rules as `add`
|
||||
# before anything is created.
|
||||
#
|
||||
# Prints one <severity>\t<check>\t<message> record per finding, matching the
|
||||
# manager-side checks. Exit non-zero if anything refused.
|
||||
probe() {
|
||||
local raw="${1:-}" allow_home=0
|
||||
[[ "${2:-}" == "--allow-home" ]] && allow_home=1
|
||||
[[ -n "$raw" ]] || { _err "probe requires a path"; return 2; }
|
||||
[[ "$raw" == /* ]] || { _err "path must be absolute"; return 2; }
|
||||
|
||||
local d
|
||||
d=$(realpath -e -- "$raw" 2>/dev/null) || { echo -e "refuse\tpath\tNo such directory: $raw"; return 1; }
|
||||
d="${d%/}"
|
||||
if _protected "$d" "$allow_home"; then
|
||||
echo -e "refuse\tprotected\tInside a protected system path."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local t="$d/.lp-storage-probe.$$"
|
||||
local rc=0
|
||||
if ! mkdir -p "$t" 2>/dev/null; then
|
||||
echo -e "refuse\twritable\tCannot create a directory here."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cowner; cowner=$(_container_owner)
|
||||
if ! chown "$cowner:$cowner" "$t" 2>/dev/null; then
|
||||
echo -e "refuse\townership\tCannot set file ownership here (an NFS export with root_squash, or a filesystem without POSIX ownership)."
|
||||
rc=1
|
||||
fi
|
||||
|
||||
if (( rc == 0 )) && ! chown 165536:165536 "$t" 2>/dev/null; then
|
||||
echo -e "refuse\tsubuid\tCannot store the high UIDs rootless containers use (tried 165536)."
|
||||
rc=1
|
||||
fi
|
||||
|
||||
if (( rc == 0 )); then
|
||||
if ! echo libreportal > "$t/probe" 2>/dev/null; then
|
||||
echo -e "refuse\twrite\tWrite failed."
|
||||
rc=1
|
||||
else
|
||||
sync -f "$t/probe" 2>/dev/null || true
|
||||
if [[ "$(cat "$t/probe" 2>/dev/null)" != "libreportal" ]]; then
|
||||
echo -e "refuse\treadback\tWrote a file but read back different content — the device may be failing."
|
||||
rc=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf -- "$t"
|
||||
return $rc
|
||||
}
|
||||
|
||||
add() {
|
||||
local raw="" name="" allow_home=0 a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--allow-home) allow_home=1 ;;
|
||||
--name=*) name="${a#--name=}" ;;
|
||||
-*) _err "unknown option $a"; return 2 ;;
|
||||
*) [[ -z "$raw" ]] && raw="$a" || name="$a" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$raw" ]] || { _err "add requires a path"; return 2; }
|
||||
|
||||
# --- absolute, and free of symlinks/.. ---------------------------------
|
||||
[[ "$raw" == /* ]] || { _err "path must be absolute (got '$raw')"; return 1; }
|
||||
local d
|
||||
d=$(realpath -e -- "$raw" 2>/dev/null) || { _err "no such directory: $raw"; return 1; }
|
||||
d="${d%/}"
|
||||
if [[ "$d" != "${raw%/}" ]]; then
|
||||
_err "refusing '$raw' — it resolves to '$d' (symlinked or non-canonical). Register the real path."
|
||||
return 1
|
||||
fi
|
||||
[[ -d "$d" ]] || { _err "not a directory: $d"; return 1; }
|
||||
|
||||
# --- protected system paths --------------------------------------------
|
||||
if _protected "$d" "$allow_home"; then
|
||||
_err "refusing '$d' — inside a protected system path."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- no nesting with any root we know ----------------------------------
|
||||
local clash
|
||||
if clash=$(_nests "$d"); then
|
||||
# Re-adding the same path is idempotent when it already carries our marker.
|
||||
if [[ "$clash" == "$d" ]] && _has_marker "$d"; then
|
||||
local existing
|
||||
existing=$(awk -F'\t' -v p="$d" '$2==p{print $1}' "$REGISTRY" 2>/dev/null | head -1)
|
||||
[[ -n "$existing" ]] && { echo "$existing"; return 0; }
|
||||
fi
|
||||
_err "refusing '$d' — it nests with '$clash'. Use a sibling directory (e.g. '$d/apps') instead."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- the parent must not be manager-writable ---------------------------
|
||||
# Closes the validate-then-chown race: if the manager can rename or replace
|
||||
# the directory between the checks below and the chown, the checks prove
|
||||
# nothing. /mnt, /srv, /media are root-owned, which is the intended home.
|
||||
local parent="${d%/*}"; [[ -z "$parent" ]] && parent="/"
|
||||
if [[ -w "$parent" ]] && sudo -u "$MANAGER" test -w "$parent" 2>/dev/null; then
|
||||
_err "refusing '$d' — its parent '$parent' is writable by $MANAGER, which would make the safety checks racy. Use a location under a root-owned parent such as /mnt or /srv."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# --- empty, or already ours --------------------------------------------
|
||||
if ! _is_empty_enough "$d"; then
|
||||
if _has_marker "$d"; then
|
||||
: # adopt: it is already a LibrePortal storage location
|
||||
else
|
||||
_err "refusing '$d' — it is not empty. Root only ever takes ownership of an empty directory. Create an empty subdirectory (e.g. '$d/apps') and register that."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- accept -------------------------------------------------------------
|
||||
local id
|
||||
id=$(_next_id)
|
||||
local cowner; cowner=$(_container_owner)
|
||||
|
||||
umask 022
|
||||
mkdir -p "$LIB_DIR"
|
||||
{
|
||||
echo "# LibrePortal storage location. Managed by libreportal-storage; do not edit."
|
||||
echo "location_id=$id"
|
||||
echo "install_id=$(_install_id)"
|
||||
echo "created=$(date -Iseconds)"
|
||||
echo "name=${name:-location-$id}"
|
||||
} > "$d/$MARKER"
|
||||
chown root:root "$d/$MARKER"
|
||||
chmod 0644 "$d/$MARKER"
|
||||
|
||||
chown "$cowner:$cowner" "$d"
|
||||
chmod 0751 "$d"
|
||||
|
||||
printf '%s\t%s\t%s\t%s\n' "$id" "$d" "$(_dev_of "$d")" "$(_uuid_of "$d")" >> "$REGISTRY"
|
||||
chown root:root "$REGISTRY"
|
||||
chmod 0644 "$REGISTRY"
|
||||
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
remove() {
|
||||
local want="${1:-}"
|
||||
[[ -n "$want" ]] || { _err "remove requires an id or path"; return 2; }
|
||||
[[ -r "$REGISTRY" ]] || { _err "no storage registry"; return 1; }
|
||||
|
||||
local _id _path _dev _uuid found_path="" found_id=""
|
||||
while IFS=$'\t' read -r _id _path _dev _uuid || [[ -n "$_id" ]]; do
|
||||
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
||||
if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then
|
||||
found_path="${_path%/}"; found_id="$_id"; break
|
||||
fi
|
||||
done < "$REGISTRY"
|
||||
[[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }
|
||||
|
||||
# Refuse while app data remains. A location whose drive is absent cannot be
|
||||
# proven empty, so refuse that too rather than dropping the only record of
|
||||
# where those apps live.
|
||||
if [[ ! -f "$found_path/$MARKER" ]]; then
|
||||
_err "refusing to remove location $found_id — '$found_path' has no marker, so its drive is probably not mounted. Mount it first, or the apps on it would be orphaned."
|
||||
return 1
|
||||
fi
|
||||
local e leftovers=0
|
||||
shopt -s nullglob
|
||||
for e in "$found_path"/*/; do
|
||||
[[ -d "$e" ]] && leftovers=$((leftovers + 1))
|
||||
done
|
||||
shopt -u nullglob
|
||||
if (( leftovers > 0 )); then
|
||||
_err "refusing to remove location $found_id — '$found_path' still holds $leftovers app director$( ((leftovers==1)) && echo y || echo ies). Move or uninstall them first."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp; tmp=$(mktemp)
|
||||
awk -F'\t' -v id="$found_id" '$1!=id' "$REGISTRY" > "$tmp"
|
||||
cat "$tmp" > "$REGISTRY"
|
||||
rm -f "$tmp"
|
||||
chown root:root "$REGISTRY"; chmod 0644 "$REGISTRY"
|
||||
rm -f "$found_path/$MARKER"
|
||||
echo "$found_id"
|
||||
}
|
||||
|
||||
list() {
|
||||
[[ -r "$REGISTRY" ]] || return 0
|
||||
cat "$REGISTRY"
|
||||
}
|
||||
|
||||
path() {
|
||||
local want="${1:-}"
|
||||
[[ -r "$REGISTRY" ]] || return 1
|
||||
awk -F'\t' -v id="$want" '$1==id{print $2; found=1} END{exit !found}' "$REGISTRY"
|
||||
}
|
||||
|
||||
# Re-check a location: marker present (i.e. drive mounted) and still the same
|
||||
# filesystem it was registered on. Prints "<id> <state> <path>" per location.
|
||||
verify() {
|
||||
local only="${1:-}"
|
||||
[[ -r "$REGISTRY" ]] || return 0
|
||||
local _id _path _dev _uuid state now_uuid
|
||||
while IFS=$'\t' read -r _id _path _dev _uuid || [[ -n "$_id" ]]; do
|
||||
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
||||
[[ -n "$only" && "$only" != "$_id" ]] && continue
|
||||
if [[ ! -f "${_path%/}/$MARKER" ]]; then
|
||||
state="unmounted"
|
||||
else
|
||||
now_uuid=$(_uuid_of "${_path%/}")
|
||||
if [[ -n "$_uuid" && -n "$now_uuid" && "$_uuid" != "$now_uuid" ]]; then
|
||||
state="different-device"
|
||||
else
|
||||
state="ok"
|
||||
fi
|
||||
fi
|
||||
printf '%s\t%s\t%s\n' "$_id" "$state" "${_path%/}"
|
||||
done < "$REGISTRY"
|
||||
}
|
||||
|
||||
action="${1:-}"; shift 2>/dev/null || true
|
||||
case "$action" in
|
||||
add) add "$@" ;;
|
||||
probe) probe "${1:-}" "${2:-}" ;;
|
||||
remove) remove "${1:-}" ;;
|
||||
list) list ;;
|
||||
path) path "${1:-}" ;;
|
||||
verify) verify "${1:-}" ;;
|
||||
*) echo "usage: libreportal-storage {add <path> [--name=NAME] [--allow-home]|probe <path> [--allow-home]|remove <id|path>|list|path <id>|verify [id]}" >&2; exit 2 ;;
|
||||
esac
|
||||
Loading…
x
Reference in New Issue
Block a user