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>
393 lines
15 KiB
Bash
Executable File
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
|