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:
parent
6fd6587714
commit
af707be949
15
init.sh
15
init.sh
@ -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
|
||||
|
||||
261
scripts/system/libreportal-relocate
Executable file
261
scripts/system/libreportal-relocate
Executable 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"
|
||||
Loading…
x
Reference in New Issue
Block a user