LibrePortal/scripts/system/libreportal-storage
librelad 7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00

491 lines
19 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
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