From 38e531ed6e5d61a4ff4853a4800579a608c69de6 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 25 May 2026 15:29:34 +0100 Subject: [PATCH] feat(install): custom, root-baked install locations (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the three roots selectable at install and bake them into the CLI wrapper (the last /docker-hardcoded consumer). - init.sh: --system-dir= / --containers-dir= / --backups-dir= flags (=form keeps the single-token shift logic), plus --allow-home; LP_*_DIR env also honored. Re-derives paths after flag parsing. - libreportalValidatePaths (run only in the install flow): each root must be a non-root absolute path outside protected system trees; the three must not nest (except the legacy /docker compat layout); a containers/backups root inside a human home is refused unless --allow-home (rootless o+x traversal = privacy trade-off). The root helpers re-check at runtime (defence in depth). - CLI wrapper: a baked bootstrap (the same __ROOT__ placeholder mechanism as the helpers) exports LP_*_DIR and derives docker_dir/configs_dir/script_dir; every /docker literal in the heredoc now resolves from those at runtime. init.sh seds the placeholders into the root-owned wrapper after writing it. The scoped sudoers needs no change (it references only the fixed helper paths + system binaries, never a data root). Custom locations verified end-to-end: generate+bake the wrapper with /mnt/* roots → syntax OK, no placeholders left, paths resolve. Live box untouched (wrapper/helpers only change on reinstall). Phase 3b (external-drive guards) + phase 4 (verify) follow. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- init.sh | 110 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 14 deletions(-) diff --git a/init.sh b/init.sh index 9bea5a9..677b5fd 100755 --- a/init.sh +++ b/init.sh @@ -15,6 +15,15 @@ # of tearing them down — so a following reinstall rebuilds # the WebUI image from cache (fast) instead of from # scratch. (No effect on install.) +# --system-dir=PATH Install root for the control plane (configs/logs/install/ +# db). Default /libreportal-system. (Also: LP_SYSTEM_DIR.) +# --containers-dir=PATH Root for live app data. Default /libreportal-containers. +# (Also: LP_CONTAINERS_DIR.) Put on its own disk if wanted. +# --backups-dir=PATH Root for backup repos. Default /libreportal-backups. +# (Also: LP_BACKUPS_DIR.) Point at a separate disk/mount. +# --allow-home Permit a containers/backups root inside /home/ +# (needs rootless o+x traversal of that home — a privacy +# trade-off; refused without this flag). # # Examples: # ./init.sh --random-password --local init @@ -97,6 +106,7 @@ init_unattended_mode=false init_skip_os_update=false init_skip_prereqs=false init_skip_docker_images=false +init_allow_home=false install_param="init" sudo_user_name=libreportal @@ -152,6 +162,46 @@ libreportalDerivePaths() { } libreportalDerivePaths +# Validate the chosen roots before anything is created/baked. Called from the +# install flow only (NOT at source time — the CLI sources init.sh too). Aborts on +# an unsafe choice; the root helpers also re-check at runtime (defence in depth). +libreportalValidatePaths() { + local pair name d + for pair in "system:$LP_SYSTEM_DIR" "containers:$LP_CONTAINERS_DIR" "backups:$LP_BACKUPS_DIR"; do + name="${pair%%:*}"; d="${pair#*:}" + case "$d" in + ""|/) isError "The $name root must be a non-root absolute path (got '$d')."; exit 1 ;; + /usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*|/boot|/boot/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/root|/root/*) + isError "Refusing $name root '$d' — inside a protected system path."; exit 1 ;; + /*) ;; # absolute, allowed + *) isError "The $name root must be an absolute path (got '$d')."; exit 1 ;; + esac + done + # The three must not nest inside one another — EXCEPT the legacy single-tree + # compat layout (/docker + /docker/{containers,backups}), which nests by design. + if [[ "$LP_SYSTEM_DIR" != "/docker" ]]; then + local a b + for a in "$LP_SYSTEM_DIR" "$LP_CONTAINERS_DIR" "$LP_BACKUPS_DIR"; do + for b in "$LP_SYSTEM_DIR" "$LP_CONTAINERS_DIR" "$LP_BACKUPS_DIR"; do + [[ "$a" == "$b" ]] && continue + if [[ "$a" == "$b"/* ]]; then + isError "Roots must not nest: '$a' is inside '$b'."; exit 1 + fi + done + done + fi + # containers/backups inside a human home need the rootless user to traverse it + # (o+x up the chain) — a privacy trade-off. Require an explicit opt-in. + for pair in "containers:$LP_CONTAINERS_DIR" "backups:$LP_BACKUPS_DIR"; do + name="${pair%%:*}"; d="${pair#*:}" + if [[ "$d" == /home/* && "$init_allow_home" != "true" ]]; then + isError "The $name root '$d' is inside a user home; the rootless container user would need o+x traversal of that home (a privacy trade-off)." + isNotice "Re-run with --allow-home to accept, or choose a dedicated path/mount." + exit 1 + fi + done +} + # Parse flags init_shift_count=0 for ((i=1; i<=$#; i++)); do @@ -183,9 +233,20 @@ for ((i=1; i<=$#; i++)); do init_skip_docker_images=true ((init_shift_count++)) ;; + # Relocatable roots (=form keeps the single-token shift logic). Validated + # by libreportalValidatePaths before any folder is created. Can also be set + # via the LP_*_DIR environment. + --system-dir=*) LP_SYSTEM_DIR="${!i#*=}"; ((init_shift_count++)) ;; + --containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; ((init_shift_count++)) ;; + --backups-dir=*) LP_BACKUPS_DIR="${!i#*=}"; ((init_shift_count++)) ;; + --allow-home) init_allow_home=true; ((init_shift_count++)) ;; esac done +# Re-derive after flags (flags/env set LP_*_DIR; libreportalDerivePaths keeps any +# value already set and only fills the rest). +libreportalDerivePaths + # Shift parsed flags to get positional parameters if [ $init_shift_count -gt 0 ]; then for ((i=1; i<=init_shift_count; i++)); do @@ -1057,6 +1118,18 @@ if [ "$CURRENT_USER" != "$CHECK_USER" ]; then exit 1 fi +# --- Relocatable roots (baked into this wrapper at install by init.sh) -------- +# An unbaked copy keeps the "__" sentinel, which no real absolute path has. +# Exported so start.sh (and paths.sh) inherit them authoritatively. +LP_SYSTEM_DIR="__SYSTEM_DIR__"; [[ "$LP_SYSTEM_DIR" == *"__"* ]] && LP_SYSTEM_DIR="/libreportal-system" +LP_CONTAINERS_DIR="__CONTAINERS_DIR__"; [[ "$LP_CONTAINERS_DIR" == *"__"* ]] && LP_CONTAINERS_DIR="/libreportal-containers" +LP_BACKUPS_DIR="__BACKUPS_DIR__"; [[ "$LP_BACKUPS_DIR" == *"__"* ]] && LP_BACKUPS_DIR="/libreportal-backups" +export LP_SYSTEM_DIR LP_CONTAINERS_DIR LP_BACKUPS_DIR +docker_dir="$LP_SYSTEM_DIR" +configs_dir="$LP_SYSTEM_DIR/configs" +script_dir="$LP_SYSTEM_DIR/install" +install_scripts_dir="$script_dir/scripts" + command1="${1:-empty}" command2="${2:-empty}" command3="${3:-empty}" @@ -1082,7 +1155,7 @@ reset_git_config() { # Helper function to load config files in the libreportal command commandReloadConfigs() { # Load new structure config files only - for category_dir in /docker/configs/*; do + for category_dir in "$configs_dir"/*; do if [ -d "$category_dir" ] && [ -f "$category_dir/.category" ]; then for config_file in "$category_dir"/*; do local should_load=true @@ -1104,7 +1177,7 @@ commandReloadConfigs() { commandUpdateConfigOption() { local config_option="$1" local config_value="$2" - for category_dir in /docker/configs/*; do + for category_dir in "$configs_dir"/*; do if [ -d "$category_dir" ] && [ -f "$category_dir/.category" ]; then for config_file in "$category_dir"/*; do if [ -f "$config_file" ] && [[ ! "$config_file" =~ \.category$ ]]; then @@ -1254,8 +1327,8 @@ setup_repo() { } sync_configs_from_install() { - local src="/docker/install/configs" - local dst="/docker/configs" + local src="$script_dir/configs" + local dst="$configs_dir" if [ ! -d "$src" ]; then echo "ERROR: $src missing — clone broken." return 1 @@ -1266,7 +1339,7 @@ sync_configs_from_install() { echo "ERROR: Failed to sync configs from $src to $dst." return 1 fi - chown -R libreportal:libreportal "$dst" + chown -R "$CHECK_USER:$CHECK_USER" "$dst" if [ ! -f "$dst/general/general_install" ]; then echo "ERROR: $dst/general/general_install missing after sync." return 1 @@ -1279,12 +1352,12 @@ sync_configs_from_install() { } clone_repo() { - rm -rf /docker/install + rm -rf "$script_dir" local clone_url if [ "$CFG_GIT_USER" != "empty" ]; then for clone_url in "$AUTH_HTTPS_REPO_URL" "$AUTH_HTTP_REPO_URL"; do - if git clone -q "$clone_url" "/docker/install" 2>/dev/null; then - sudo cp -f /docker/install/init.sh /root/ + if git clone -q "$clone_url" "$script_dir" 2>/dev/null; then + sudo cp -f "$script_dir/init.sh" /root/ sync_configs_from_install || return 1 echo "SUCCESS: Clone complete. Run 'libreportal run' to continue." return 0 @@ -1294,8 +1367,8 @@ clone_repo() { return 1 fi for clone_url in "https://${CLEAN_GIT_URL}.git" "http://${CLEAN_GIT_URL}.git"; do - if git clone -q "$clone_url" "/docker/install" 2>/dev/null; then - sudo cp -f /docker/install/init.sh /root/ + if git clone -q "$clone_url" "$script_dir" 2>/dev/null; then + sudo cp -f "$script_dir/init.sh" /root/ sync_configs_from_install || return 1 echo "SUCCESS: Clone complete. Run 'libreportal run' to continue." return 0 @@ -1316,18 +1389,24 @@ clone_and_install() { clone_repo; } -cd /docker/ +cd "$docker_dir/" 2>/dev/null || cd / if [[ $command1 == "reset" ]]; then clone_and_install -elif [ -f "/docker/install/start.sh" ]; then - chmod 0755 /docker/install/* - cd /docker/install +elif [ -f "$script_dir/start.sh" ]; then + chmod 0755 "$script_dir"/* + cd "$script_dir" ./start.sh "$command1" "$command2" "$command3" "$command4" "$command5" "$command6" "$command7" "$command8" "$command9" else clone_and_install fi # LibrePortal Command End EOF + # Bake the three roots into the (root-owned) wrapper, same as the helpers. + sudo sed -i \ + -e "s#__SYSTEM_DIR__#${LP_SYSTEM_DIR}#g" \ + -e "s#__CONTAINERS_DIR__#${LP_CONTAINERS_DIR}#g" \ + -e "s#__BACKUPS_DIR__#${LP_BACKUPS_DIR}#g" \ + "$command_script" sudo chmod +x $command_script sudo chown root:root $command_script # Put it on $PATH via a symlink (replaces any older real file at this path). @@ -1546,6 +1625,9 @@ if [[ $EUID -ne 0 ]]; then exit 1 else if [[ "$param1" == "init" ]]; then + # Validate the chosen install roots before creating/baking anything. + libreportalValidatePaths + # Always check existing config first initCheckConfigs