LibrePortal/scripts/system/libreportal-storage
librelad fd0a0fd08c Storage step: say "not connected", and allow custom paths
A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.

Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.

A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.

libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.

Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.

lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:

  - It counted the cards that say "not connected" and asserted over those.
    Turn the feature off and the count is zero, every() over an empty list is
    true, and the block passed having checked nothing. The expectation now
    comes from the feed.

  - Both browser tests exited 0 whenever the page returned nothing. Under sudo,
    where chromium will not start, they reported PASS having asserted nothing.
    They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
    browser is the only thing that can have broken, and that is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:09:25 +01:00

501 lines
20 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
}
# Make a mount permanent by adding one line to /etc/fstab.
#
# fstab is the most dangerous file LibrePortal touches: a bad entry does not
# break an app, it can leave the machine unbootable and needing rescue media.
# Everything below exists to make that impossible:
#
# * nofail + x-systemd.device-timeout=10s — a missing device can then never
# block boot. This single pair is what makes writing fstab defensible at
# all; without it an unplugged drive strands the box at a systemd timeout
# or drops it to emergency mode.
# * UUID=, never /dev/sdX — device names reorder between boots.
# * append only, inside a marked block. Existing lines are never rewritten,
# so anything the user or another tool manages is untouched.
# * refuse when a line for that target or device already exists — we do not
# get to be the second opinion on a mount someone else configured.
# * a timestamped backup, and `findmnt --verify` before the new file is put
# in place. A file that does not verify is discarded, not installed.
#
# Opt-in only: nothing calls this unless a person asked for it.
fstab_add() {
local target="${1:-}"
[[ -n "$target" && "$target" == /* ]] \
|| { _err "fstab-add requires an absolute mount point"; return 2; }
target="${target%/}"
[[ "$target" == *..* ]] && { _err "invalid mount point"; return 2; }
command -v findmnt >/dev/null 2>&1 || { _err "findmnt unavailable"; return 1; }
# Refuse the root filesystem before anything else, so the reason given is the
# real one rather than a confusing "not a mount point".
[[ "$target" == "/" || -z "$target" ]] \
&& { _err "refusing to touch the root filesystem's fstab entry"; return 1; }
# Must currently be a real mount — we describe reality, we do not invent it.
local now_target
now_target=$(findmnt -no TARGET --target "$target" 2>/dev/null | tail -1)
if [[ "$now_target" != "$target" ]]; then
_err "'$target' is not a mount point right now. Mount it first, then add it."
return 1
fi
local uuid fstype
uuid=$(findmnt -no UUID --target "$target" 2>/dev/null | tail -1)
fstype=$(findmnt -no FSTYPE --target "$target" 2>/dev/null | tail -1)
if [[ -z "$uuid" ]]; then
_err "'$target' has no filesystem UUID, so no stable fstab entry can be written for it."
return 1
fi
[[ -z "$fstype" ]] && fstype="auto"
# Already described? Leave it alone — do not add a second opinion.
local existing
existing=$(grep -vE '^[[:space:]]*#' /etc/fstab 2>/dev/null \
| awk -v t="$target" -v u="UUID=$uuid" '$2==t || $1==u {print; exit}')
if [[ -n "$existing" ]]; then
_err "/etc/fstab already has an entry for this mount: $existing"
return 1
fi
local line="UUID=$uuid $target $fstype defaults,nofail,x-systemd.device-timeout=10s 0 2"
local stamp backup tmp
stamp=$(date +%Y%m%d-%H%M%S)
backup="/etc/fstab.libreportal-$stamp.bak"
cp -a /etc/fstab "$backup" || { _err "could not back up /etc/fstab"; return 1; }
tmp=$(mktemp) || return 1
cat /etc/fstab > "$tmp"
# Guarantee the file ends with a newline before appending, or the new entry
# would be glued onto whatever the last line was.
[[ -s "$tmp" && -n "$(tail -c1 "$tmp")" ]] && printf '\n' >> "$tmp"
{
printf '\n# Added by LibrePortal (%s) — storage location %s\n' "$stamp" "$target"
printf '# nofail: a missing device must never block boot.\n'
printf '%s\n' "$line"
} >> "$tmp"
# Verify BEFORE installing. findmnt --verify parses fstab and reports
# structural problems; a file that does not pass is thrown away.
if ! findmnt --verify --tab-file "$tmp" >/dev/null 2>&1; then
local why; why=$(findmnt --verify --tab-file "$tmp" 2>&1 | head -5)
rm -f "$tmp"
_err "the resulting /etc/fstab did not verify, so it was NOT installed: $why"
return 1
fi
install -m 0644 -o root -g root "$tmp" /etc/fstab || { rm -f "$tmp"; return 1; }
rm -f "$tmp"
# Let systemd pick up the new unit now, so the entry is live rather than
# only true after the next boot.
systemctl daemon-reload >/dev/null 2>&1 || true
echo "$line"
return 0
}
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
# Also match the name the listing prints, since that is what a person
# reading the table will type. Includes the "location-N" fallback the
# marker records when a location was added without one.
if [[ -f "${_path%/}/$MARKER" ]]; then
local _name
_name=$(sed -n 's/^name=//p' "${_path%/}/$MARKER" 2>/dev/null | head -1)
if [[ -n "$_name" && "$_name" == "$want" ]]; then
found_path="${_path%/}"; found_id="$_id"; break
fi
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:-}" ;;
fstab-add) fstab_add "${1:-}" ;;
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]|fstab-add <mountpoint>|remove <id|path>|list|path <id>|verify [id]}" >&2; exit 2 ;;
esac