#!/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 } # 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 " " 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 [--name=NAME] [--allow-home]|probe [--allow-home]|fstab-add |remove |list|path |verify [id]}" >&2; exit 2 ;; esac