feat(system): libreportal-relocate — move an installed LibrePortal's roots

Root-run command to move the system and/or containers root to another
disk after install, re-baking everything that carries those paths: the
nine root-owned helpers, the CLI wrapper, the systemd unit and the
WebUI's own compose bind-mounts.

Deliberately NOT in the manager's scoped sudoers, and symlinked into PATH
like the uninstaller. Moving a root re-bakes the very helpers the sudoers
allowlist trusts, so a helper that did it from a caller-supplied path
would hand the manager the entire trust boundary those helpers exist to
defend. A human with real root runs this; the WebUI can only print the
command, which is what the Storage step now does.

Copy-verify-then-leave, never move: the source tree is not removed at all
— the command tells you to delete it once you have confirmed the WebUI
works. An interrupted run therefore leaves a working install behind
rather than half of one, and the pre-relocation copies of every
root-owned file are kept under $lp_lib_dir/.relocate-<timestamp>/.

Admission mirrors libreportal-storage: absolute, no "..", not a protected
system path, not already in use, must be an empty directory, roots must
not nest, and space checked with 10% headroom.

One bug worth recording, caught on the first test run against a live
install: _validate_target was called inside $(...), and `die` runs `exit`
— which inside a command substitution kills only the subshell. Every
refusal silently became "proceed" and the relocation ran. No damage (the
copy steps were guarded on a now-empty variable, so the re-bake wrote
identical values and only the service bounced), but it is exactly the
difference between a refusal and an unintended relocation. It now sets a
global and returns, so `die` exits the script it is meant to.

footprint_version -> 7 for the new root-owned executable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-27 07:59:34 +01:00
parent 6fd6587714
commit af707be949
2 changed files with 274 additions and 2 deletions

15
init.sh
View File

@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal"
# `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump
# tells the updater the new release needs a root re-install (which re-bakes them).
# Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md.
footprint_version=6
footprint_version=7
footprint_marker="$lp_lib_dir/.footprint_version"
# Directories — three independently-relocatable roots (see scripts/source/paths.sh
@ -1168,6 +1168,16 @@ initRootHelpers()
rm -f "$helper_tmp"
done
# libreportal-relocate is installed the same way but deliberately NOT added to
# the scoped sudoers, and symlinked into PATH like the uninstaller: moving a
# root re-bakes the very helpers the sudoers allowlist trusts, so it has to be
# a human with real root, never the manager.
if [[ -f "$script_dir/scripts/system/libreportal-relocate" ]]; then
sudo install -m 0755 -o root -g root "$script_dir/scripts/system/libreportal-relocate" "$lp_lib_dir/libreportal-relocate"
sudo ln -sfn "$lp_lib_dir/libreportal-relocate" /usr/local/bin/libreportal-relocate
isSuccessful "Installed root-owned relocate command (libreportal-relocate)."
fi
# Install the release-signing PUBLIC key into the root-owned footprint, so the
# runtime updater verifies signatures against a key the manager can't swap.
if [[ -f "$script_dir/libreportal.pub" ]]; then
@ -1904,6 +1914,7 @@ runFullUninstall()
printf " ${BOLD}System integration${NC}\n"
printf " %-34s ${DIM}%s${NC}\n" "/usr/local/lib/libreportal/" "root-owned helpers + signing key"
printf " %-34s ${DIM}%s${NC}\n" "/usr/local/bin/libreportal" "CLI wrapper"
printf " %-34s ${DIM}%s${NC}\n" "/usr/local/bin/libreportal-relocate" "root-only relocate command"
printf " %-34s ${DIM}%s${NC}\n" "/etc/sudoers.d/$mgr" "scoped sudo grant"
printf " %-34s ${DIM}%s${NC}\n" "libreportal.service" "systemd task processor"
printf " %-34s ${DIM}%s${NC}\n" "/etc/sysctl.d/99-libreportal*" "rootless sysctl drop-ins"
@ -1961,7 +1972,7 @@ runFullUninstall()
fi
# 3. Remove the out-of-/docker footprint (see docs/architecture/system-footprint.md).
rm -f /usr/local/bin/libreportal /usr/local/bin/libreportal-uninstall
rm -f /usr/local/bin/libreportal /usr/local/bin/libreportal-uninstall /usr/local/bin/libreportal-relocate
rm -rf /usr/local/lib/libreportal
rm -f "/etc/sudoers.d/$mgr"
# Keep the sysctl drop-ins when preserving docker — removing + reloading them

View File

@ -0,0 +1,261 @@
#!/bin/bash
# LibrePortal relocate — move an installed LibrePortal's roots to another disk.
#
# sudo libreportal-relocate --system-dir=/mnt/ssd/libreportal-system
# sudo libreportal-relocate --containers-dir=/mnt/big/libreportal-containers
# sudo libreportal-relocate --system-dir=... --containers-dir=... --dry-run
#
# WHY THIS IS NOT A BUTTON IN THE WEBUI
#
# Moving a root means re-baking the paths inside the root-owned helpers in
# /usr/local/lib/libreportal/, the systemd unit, and the WebUI's own compose
# bind-mounts. Those helpers have their paths baked at install precisely so the
# manager cannot redirect a privileged operation by editing something it owns
# (see scripts/system/libreportal-storage and docs/roadmap/storage-locations.md
# §3). A helper that re-baked the other helpers from a caller-supplied path
# would hand the manager the entire trust boundary those helpers defend.
#
# So this is a root-run command, deliberately NOT in the manager's scoped
# sudoers — same category as libreportal-uninstall. A human with real root runs
# it; the WebUI can only tell you the command.
#
# ORDER OF OPERATIONS — the source is never removed until the destination is
# verified AND the rebaked footprint is in place, so an interrupted run leaves a
# working install behind rather than half a one.
set -u
[[ $EUID -eq 0 ]] || { echo "libreportal-relocate: must run as root" >&2; exit 1; }
LIB_DIR="/usr/local/lib/libreportal"
STORAGE_REGISTRY="$LIB_DIR/storage.roots"
MARKER=".libreportal-storage"
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; BOLD=$'\033[1m'; NC=$'\033[0m'
say() { echo "${BOLD}==${NC} $*"; }
ok() { echo " ${GREEN}✓${NC} $*"; }
warn() { echo " ${YELLOW}!${NC} $*"; }
die() { echo " ${RED}✗${NC} $*" >&2; exit 1; }
DRY=0
NEW_SYSTEM=""
NEW_CONTAINERS=""
for a in "$@"; do
case "$a" in
--system-dir=*) NEW_SYSTEM="${a#*=}" ;;
--containers-dir=*) NEW_CONTAINERS="${a#*=}" ;;
--dry-run) DRY=1 ;;
-h|--help)
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) die "unknown argument: $a" ;;
esac
done
[[ -n "$NEW_SYSTEM$NEW_CONTAINERS" ]] || die "nothing to do — pass --system-dir and/or --containers-dir"
# ---- current layout, read from the installed footprint ----------------------
CUR_SYSTEM=$(grep -m1 '^LP_SYSTEM_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
CUR_CONTAINERS=$(grep -m1 '^LP_CONTAINERS_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
[[ -n "$CUR_SYSTEM" && "$CUR_SYSTEM" != *"__"* ]] || die "cannot read the current system root from $LIB_DIR/libreportal — is LibrePortal installed?"
[[ -n "$CUR_CONTAINERS" && "$CUR_CONTAINERS" != *"__"* ]] || die "cannot read the current containers root"
MANAGER=$(grep -m1 '^CHECK_USER="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)
[[ -z "$MANAGER" || "$MANAGER" == *"__"* ]] && MANAGER="libreportal"
UNIT="/etc/systemd/system/libreportal.service"
[[ -f "$UNIT" ]] || UNIT="/lib/systemd/system/libreportal.service"
# ---- validation, before anything is touched ---------------------------------
# Validates, and on success leaves the normalised path in VALIDATED.
#
# Deliberately NOT called in a command substitution: `die` runs `exit`, and
# inside $( ) that only kills the subshell — the caller sails on with an empty
# variable and every refusal below silently becomes "proceed". That is exactly
# what happened the first time this was tested, and it is the difference between
# a refusal and an unintended relocation.
VALIDATED=""
_validate_target() {
local what="$1" cur="$2" new="$3"
VALIDATED=""
[[ "$new" == /* ]] || die "$what must be an absolute path (got '$new')"
[[ "$new" == *..* ]] && die "$what must not contain '..'"
new="${new%/}"
[[ "$new" == "$cur" ]] && die "$what is already $cur"
case "$new" in
/|/etc|/etc/*|/usr|/usr/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*|/boot|/boot/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/root|/root/*|/tmp|/tmp/*)
die "refusing $what '$new' — inside a protected system path" ;;
esac
# Same emptiness rule as libreportal-storage: we only ever take a directory
# that has nothing to lose.
if [[ -e "$new" ]]; then
[[ -d "$new" ]] || die "$what '$new' exists and is not a directory"
local e
shopt -s nullglob dotglob
for e in "$new"/*; do
case "${e##*/}" in lost+found|"$MARKER") continue ;; esac
shopt -u nullglob dotglob
die "$what '$new' is not empty — pick an empty directory"
done
shopt -u nullglob dotglob
fi
# Space, with headroom.
local need avail parent="${new%/*}"; [[ -z "$parent" ]] && parent="/"
need=$(du -sk "$cur" 2>/dev/null | awk '{print $1}')
avail=$(df -Pk "$parent" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$need" && -n "$avail" ]] && (( avail < need + need / 10 )); then
die "not enough space for $what: need ~$(( (need + need/10) / 1024 )) MiB, have $(( avail / 1024 )) MiB"
fi
VALIDATED="$new"
}
say "Checking"
if [[ -n "$NEW_SYSTEM" ]]; then
_validate_target "the system root" "$CUR_SYSTEM" "$NEW_SYSTEM"
NEW_SYSTEM="$VALIDATED"
fi
if [[ -n "$NEW_CONTAINERS" ]]; then
_validate_target "the containers root" "$CUR_CONTAINERS" "$NEW_CONTAINERS"
NEW_CONTAINERS="$VALIDATED"
fi
# The three roots must not nest.
FIN_SYSTEM="${NEW_SYSTEM:-$CUR_SYSTEM}"
FIN_CONTAINERS="${NEW_CONTAINERS:-$CUR_CONTAINERS}"
[[ "$FIN_SYSTEM" == "$FIN_CONTAINERS"/* || "$FIN_CONTAINERS" == "$FIN_SYSTEM"/* ]] \
&& die "the system and containers roots must not nest ($FIN_SYSTEM / $FIN_CONTAINERS)"
ok "system root: ${CUR_SYSTEM}${NEW_SYSTEM:+ -> $NEW_SYSTEM}"
ok "containers root: ${CUR_CONTAINERS}${NEW_CONTAINERS:+ -> $NEW_CONTAINERS}"
HELPER_SRC="$FIN_SYSTEM/install/scripts/system"
[[ -d "$CUR_SYSTEM/install/scripts/system" ]] \
|| die "cannot find the helper sources at $CUR_SYSTEM/install/scripts/system — a release install is required to re-bake them"
if (( DRY )); then
echo ""
say "Dry run — nothing was changed"
echo " would stop: libreportal.service, the WebUI container"
echo " would copy: $CUR_SYSTEM${NEW_SYSTEM:+ -> $NEW_SYSTEM}"
[[ -n "$NEW_CONTAINERS" ]] && echo " $CUR_CONTAINERS -> $NEW_CONTAINERS"
echo " would re-bake: $LIB_DIR/libreportal{,-ownership,-appcfg,-crowdsec,-storage,-svc,-dns,-ssh-access,-socket,-bininstall}"
echo " would rewrite: $UNIT"
echo " $FIN_CONTAINERS/libreportal/docker-compose.yml"
echo " would remove the old tree only after all of the above verified"
exit 0
fi
# ---- 1. stop -----------------------------------------------------------------
say "Stopping LibrePortal"
systemctl stop libreportal.service >/dev/null 2>&1 || true
ok "task processor stopped"
INSTALL_USER=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$CUR_SYSTEM/configs/general/general_docker_install" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
INSTALL_USER="${INSTALL_USER:-dockerinstall}"
_docker() {
local uid; uid=$(id -u "$INSTALL_USER" 2>/dev/null) || return 1
sudo -u "$INSTALL_USER" \
XDG_RUNTIME_DIR="/run/user/$uid" \
DOCKER_HOST="unix:///run/user/$uid/docker.sock" \
docker "$@" 2>/dev/null
}
if [[ -f "$CUR_CONTAINERS/libreportal/docker-compose.yml" ]]; then
( cd "$CUR_CONTAINERS/libreportal" && _docker compose down ) >/dev/null 2>&1 || true
ok "WebUI container stopped"
fi
# ---- 2. copy (never move: the source must survive a failure) -----------------
_copy_tree() {
local from="$1" to="$2" what="$3"
say "Copying $what"
mkdir -p "$to" || die "could not create $to"
cp -a --reflink=auto "$from/." "$to/" || die "copy of $what failed — nothing has been removed; the install is still at $from"
local a b
a=$(find "$from" -mindepth 1 2>/dev/null | wc -l)
b=$(find "$to" -mindepth 1 2>/dev/null | wc -l)
[[ "$a" == "$b" ]] || die "verification failed for $what ($a entries at source, $b at destination) — source left intact at $from"
ok "$what copied and verified ($a entries)"
}
[[ -n "$NEW_SYSTEM" ]] && _copy_tree "$CUR_SYSTEM" "$NEW_SYSTEM" "the system tree"
[[ -n "$NEW_CONTAINERS" ]] && _copy_tree "$CUR_CONTAINERS" "$NEW_CONTAINERS" "the containers tree"
# ---- 3. re-bake the root-owned footprint -------------------------------------
say "Re-baking the root-owned helpers"
BACKUP_DIR="/usr/local/lib/libreportal/.relocate-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
for h in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket \
libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec \
libreportal-storage; do
src="$HELPER_SRC/$h"
[[ -f "$src" ]] || { warn "$h has no source — left as-is"; continue; }
[[ -f "$LIB_DIR/$h" ]] && cp -a "$LIB_DIR/$h" "$BACKUP_DIR/$h"
tmp=$(mktemp)
sed -e "s/__MANAGER__/${MANAGER}/g" \
-e "s#__SYSTEM_DIR__#${FIN_SYSTEM}#g" \
-e "s#__CONTAINERS_DIR__#${FIN_CONTAINERS}#g" \
-e "s#__BACKUPS_DIR__#$(grep -m1 '^LP_BACKUPS_DIR="' "$LIB_DIR/libreportal" 2>/dev/null | cut -d'"' -f2)#g" \
"$src" > "$tmp"
if bash -n "$tmp" 2>/dev/null; then
install -m 0755 -o root -g root "$tmp" "$LIB_DIR/$h"
ok "$h"
else
rm -f "$tmp"; die "re-baked $h is malformed — aborting; originals are in $BACKUP_DIR"
fi
rm -f "$tmp"
done
# The CLI wrapper carries the roots too.
if [[ -f "$LIB_DIR/libreportal" ]]; then
cp -a "$LIB_DIR/libreportal" "$BACKUP_DIR/libreportal"
tmp=$(mktemp)
sed -e "s#^LP_SYSTEM_DIR=\".*\"#LP_SYSTEM_DIR=\"${FIN_SYSTEM}\"#" \
-e "s#^LP_CONTAINERS_DIR=\".*\"#LP_CONTAINERS_DIR=\"${FIN_CONTAINERS}\"#" \
"$LIB_DIR/libreportal" > "$tmp"
bash -n "$tmp" 2>/dev/null || { rm -f "$tmp"; die "re-baked CLI wrapper is malformed — originals in $BACKUP_DIR"; }
install -m 0755 -o root -g root "$tmp" "$LIB_DIR/libreportal"
rm -f "$tmp"
ok "CLI wrapper"
fi
# ---- 4. systemd unit + compose ----------------------------------------------
if [[ -f "$UNIT" ]]; then
cp -a "$UNIT" "$BACKUP_DIR/$(basename "$UNIT")"
sed -i -e "s#LP_SYSTEM_DIR=[^[:space:]\"]*#LP_SYSTEM_DIR=${FIN_SYSTEM}#g" \
-e "s#LP_CONTAINERS_DIR=[^[:space:]\"]*#LP_CONTAINERS_DIR=${FIN_CONTAINERS}#g" \
-e "s#${CUR_SYSTEM}#${FIN_SYSTEM}#g" \
-e "s#${CUR_CONTAINERS}#${FIN_CONTAINERS}#g" "$UNIT"
systemctl daemon-reload >/dev/null 2>&1 || true
ok "systemd unit"
fi
COMPOSE="$FIN_CONTAINERS/libreportal/docker-compose.yml"
if [[ -f "$COMPOSE" ]]; then
cp -a "$COMPOSE" "$BACKUP_DIR/docker-compose.yml"
sed -i -e "s#${CUR_SYSTEM}#${FIN_SYSTEM}#g" \
-e "s#${CUR_CONTAINERS}#${FIN_CONTAINERS}#g" "$COMPOSE"
ok "WebUI compose bind-mounts"
fi
# ---- 5. ownership on the new trees -------------------------------------------
say "Reconciling ownership"
"$LIB_DIR/libreportal-ownership" reconcile >/dev/null 2>&1 && ok "ownership reconciled" \
|| warn "ownership reconcile reported a problem — check 'libreportal system verify' after this"
# ---- 6. start, and only then remove the old trees -----------------------------
say "Starting LibrePortal"
systemctl start libreportal.service >/dev/null 2>&1 || warn "could not start libreportal.service"
if [[ -f "$COMPOSE" ]]; then
( cd "$FIN_CONTAINERS/libreportal" && _docker compose up -d ) >/dev/null 2>&1 \
&& ok "WebUI container started" || warn "WebUI container did not start — check 'libreportal webui logs'"
fi
echo ""
say "Done"
[[ -n "$NEW_SYSTEM" ]] && echo " LibrePortal now lives in $NEW_SYSTEM"
[[ -n "$NEW_CONTAINERS" ]] && echo " App data now lives in $NEW_CONTAINERS"
echo ""
echo " The previous copies were left in place on purpose — verify the WebUI"
echo " works, then remove them yourself:"
[[ -n "$NEW_SYSTEM" ]] && echo " rm -rf $CUR_SYSTEM"
[[ -n "$NEW_CONTAINERS" ]] && echo " rm -rf $CUR_CONTAINERS"
echo ""
echo " Pre-relocation copies of the root-owned files: $BACKUP_DIR"