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

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

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

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

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

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

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

393 lines
15 KiB
Bash
Executable File

#!/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