diff --git a/init.sh b/init.sh index fed27f3..19671d4 100755 --- a/init.sh +++ b/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 diff --git a/scripts/system/libreportal-relocate b/scripts/system/libreportal-relocate new file mode 100755 index 0000000..dadce92 --- /dev/null +++ b/scripts/system/libreportal-relocate @@ -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"