feat(install): custom, root-baked install locations (phase 3)

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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-25 15:29:34 +01:00
parent 492e62b6d0
commit 38e531ed6e

110
init.sh
View File

@ -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/<user>
# (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