#!/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"