From ac4c11b5e9ed62f4012605bf9a0f1fa4c6cbefc0 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 24 Aug 2026 20:25:07 +0100 Subject: [PATCH] fix(storage): move the app->location index out of configs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "", 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 --- init.sh | 7 +- scripts/dev/lp-configs-guard-test | 44 +++ scripts/docker/command/run_privileged.sh | 7 + scripts/source/loading/scan_files.sh | 8 + scripts/source/paths.sh | 45 ++- scripts/storage/storage_checks.sh | 242 ++++++++++++++ scripts/storage/storage_locations.sh | 203 ++++++++++++ scripts/storage/storage_scan.sh | 148 +++++++++ scripts/system/libreportal-ownership | 6 +- scripts/system/libreportal-storage | 392 +++++++++++++++++++++++ 10 files changed, 1094 insertions(+), 8 deletions(-) create mode 100755 scripts/dev/lp-configs-guard-test create mode 100644 scripts/storage/storage_checks.sh create mode 100644 scripts/storage/storage_locations.sh create mode 100644 scripts/storage/storage_scan.sh create mode 100755 scripts/system/libreportal-storage diff --git a/init.sh b/init.sh index 8ce9345..0b4b2f7 100755 --- a/init.sh +++ b/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 diff --git a/scripts/dev/lp-configs-guard-test b/scripts/dev/lp-configs-guard-test new file mode 100755 index 0000000..cecd8d0 --- /dev/null +++ b/scripts/dev/lp-configs-guard-test @@ -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: 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 diff --git a/scripts/docker/command/run_privileged.sh b/scripts/docker/command/run_privileged.sh index a03952e..48a8455 100644 --- a/scripts/docker/command/run_privileged.sh +++ b/scripts/docker/command/run_privileged.sh @@ -166,6 +166,13 @@ _runRootHelper() { # app-perms|webui|taskdir|app-data-nobody } runOwnership() { _runRootHelper libreportal-ownership "$@"; } +# Storage locations — the ONLY writer of the root-owned storage registry: +# {add [--name=N] [--allow-home]|remove |list|path |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 } runResolv() { _runRootHelper libreportal-dns "$@"; } diff --git a/scripts/source/loading/scan_files.sh b/scripts/source/loading/scan_files.sh index c008bed..b792cc7 100755 --- a/scripts/source/loading/scan_files.sh +++ b/scripts/source/loading/scan_files.sh @@ -61,6 +61,14 @@ sourceScanFiles() sourceBackupLocations fi + # Same shape for storage locations: configs/storage/locations// + # 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" diff --git a/scripts/source/paths.sh b/scripts/source/paths.sh index ae620b4..51ff4cd 100644 --- a/scripts/source/paths.sh +++ b/scripts/source/paths.sh @@ -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 "". 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="" diff --git a/scripts/storage/storage_checks.sh b/scripts/storage/storage_checks.sh new file mode 100644 index 0000000..f97168a --- /dev/null +++ b/scripts/storage/storage_checks.sh @@ -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 +# +# \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 + + 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:-} $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" +} diff --git a/scripts/storage/storage_locations.sh b/scripts/storage/storage_locations.sh new file mode 100644 index 0000000..f1834af --- /dev/null +++ b/scripts/storage/storage_locations.sh @@ -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//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__* 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__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) +} diff --git a/scripts/storage/storage_scan.sh b/scripts/storage/storage_scan.sh new file mode 100644 index 0000000..cd6452e --- /dev/null +++ b/scripts/storage/storage_scan.sh @@ -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: +# +# +# role is one of: system | primary | storage: | backup: | 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) +} diff --git a/scripts/system/libreportal-ownership b/scripts/system/libreportal-ownership index 00afee0..923934f 100644 --- a/scripts/system/libreportal-ownership +++ b/scripts/system/libreportal-ownership @@ -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" diff --git a/scripts/system/libreportal-storage b/scripts/system/libreportal-storage new file mode 100755 index 0000000..349d538 --- /dev/null +++ b/scripts/system/libreportal-storage @@ -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 [name] validate, accept, mark, chown, append to the registry +# remove drop a location (refuses while app dirs remain) +# list print the registry (idpathdevuuid) +# verify [id] re-check marker + device of one/all locations +# path 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 \t\t 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 " " 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 [--name=NAME] [--allow-home]|probe [--allow-home]|remove |list|path |verify [id]}" >&2; exit 2 ;; +esac