#!/bin/bash # # LibrePortal Initialization Script # # Usage: ./init.sh [OPTIONS] init [password] [git_user] [git_token] [git_url] [unattended] [install_mode] # # OPTIONS: # --random-password Generate a random password automatically # --local Use local folder installation automatically # --unattended Run in unattended mode (skip confirmations) # --skip-os-update Skip operating system update # --skip-prereqs Skip installing prerequisite apps # --skip-rootless On UNINSTALL: keep the entire rootless layer (the # (also: --skip-docker-images) # docker-install user, the rootless dockerd, the sysctl # drop-ins, and the image/build cache) instead of tearing # them down. A following reinstall then skips the slow # `dockerd-rootless-setuptool.sh install` step entirely # and rebuilds containers from the existing image cache, # which on local dev iteration cuts reinstall time from # minutes to seconds. (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. # --manager-user=NAME Control-plane manager user (owns the install, runs the # runtime). Default libreportal. (Also: LP_MANAGER_USER.) # --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 # ./init.sh --random-password --local --unattended init # ./init.sh --random-password --local --skip-os-update --skip-prereqs init # ./init.sh --unattended --skip-rootless uninstall # keep rootless layer for fast reinstall # ./init.sh init mypassword myuser mytoken https://github.com/user/repo.git # RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' isSuccessful() { echo -e "${GREEN}✓ Success${NC} $1"; } isError() { echo -e "${RED}✗ Error${NC} $1"; } isNotice() { echo -e "${YELLOW}! Notice${NC} $1"; } isQuestion() { echo -e -n "${BLUE}❯ Question${NC} $1 "; } displayLibrePortalLogo() { local hbar; hbar=$(printf '═%.0s' $(seq 1 50)) printf '\n╔%s╗\n' "$hbar" printf '║%6s%s%8s║\n' '' '╦ ┬┌┐ ┬─┐┌─┐ ╭─╮ ╔═╗┌─┐┬─┐┌┬┐┌─┐┬' '' printf '║%6s%s%8s║\n' '' '║ │├┴┐├┬┘├┤ │◉│ ╠═╝│ │├┬┘ │ ├─┤│' '' printf '║%6s%s%6s║\n' '' '╩═╝┴└─┘┴└─└─┘ ╨─╨ ╩ └─┘┴└─ ┴ ┴ ┴┴─┘' '' printf '╚%s╝\n\n' "$hbar" } init_action="$1" for arg in "$@"; do case "$arg" in --*) ;; *) init_action="$arg"; break ;; esac done if [[ "${BASH_SOURCE[0]}" == "$0" ]] \ && [[ "$init_action" == "init" ]] \ && [[ "$LIBREPORTAL_SKIP_LOGO" != "1" ]]; then displayLibrePortalLogo fi unset init_action checkSuccess() { if [ $? -eq 0 ]; then isSuccessful "$1" else isError "$1" exit 1 fi } isHeader() { local title="$1" local width=52 local inner=$((width - 2)) local title_len=${#title} local total_pad=$((inner - title_len)) if (( total_pad < 0 )); then total_pad=0; fi local left_pad=$((total_pad / 2)) local right_pad=$((total_pad - left_pad)) local hbar hbar=$(printf '═%.0s' $(seq 1 "$inner")) printf '\n╔%s╗\n' "$hbar" printf '║%*s%s%*s║\n' "$left_pad" '' "$title" "$right_pad" '' printf '╚%s╝\n\n' "$hbar" } # Original parameters for backward compatibility param1="$1" # init to start script param2="$2" # password param3="$3" # git user param4="$4" # git token param5="$5" # git url param6="$6" # unattended param7="$7" # install mode (git/local) # Parse command line arguments init_random_password=false init_local_install=false 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" # Control-plane manager user — configurable via --manager-user= / LP_MANAGER_USER # (default libreportal). Resolved early here (sudo_bashrc needs it); re-resolved # after flag parsing in libreportalDerivePaths, and baked into the helpers/unit/ # wrapper at install (the __MANAGER__ placeholder). sudo_user_name="${LP_MANAGER_USER:-libreportal}" sshd_config="/etc/ssh/sshd_config" sudo_bashrc="/home/$sudo_user_name/.bashrc" hosts_file="/etc/hosts" hostname_file="/etc/hostname" # All LibrePortal executables installed outside /docker live together here # (root-owned). The user-facing CLI is symlinked into $PATH from /usr/local/bin. lp_lib_dir="/usr/local/lib/libreportal" command_script="$lp_lib_dir/libreportal" command_symlink="/usr/local/bin/libreportal" # Version of the ROOT-OWNED footprint (helpers, CLI wrapper, uninstall launcher, # systemd unit, sudoers). BUMP THIS whenever you change any of those — a plain # `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=4 footprint_marker="$lp_lib_dir/.footprint_version" # Directories — three independently-relocatable roots (see scripts/source/paths.sh # for the canonical description). Defaults below; overridden by --system-dir / # --containers-dir / --backups-dir (parsed in the flag loop) via the LP_*_DIR # vars. init.sh derives inline (kept in sync with paths.sh) because the bare # /root/init.sh reinstall copy has no scripts/ alongside to source. # LP_SYSTEM_DIR — manager-owned control plane (configs/logs/install/db/…) # LP_CONTAINERS_DIR — container-user-owned live app data # LP_BACKUPS_DIR — container-user-owned backup repos (own mount-able) libreportalDerivePaths() { # Transitional compat: an existing install (legacy single /docker tree, # identified by its config marker) keeps using /docker until a deliberate # reinstall — so deploying new code never strands a running box. if [[ -z "${LP_SYSTEM_DIR:-}" ]]; then if [[ ! -e /libreportal-system && -f /docker/configs/general/general_docker_install ]]; then LP_SYSTEM_DIR=/docker : "${LP_CONTAINERS_DIR:=/docker/containers}" : "${LP_BACKUPS_DIR:=/docker/backups}" else LP_SYSTEM_DIR=/libreportal-system fi fi : "${LP_CONTAINERS_DIR:=/libreportal-containers}" : "${LP_BACKUPS_DIR:=/libreportal-backups}" docker_dir="$LP_SYSTEM_DIR" system_dir="$LP_SYSTEM_DIR" configs_dir="$LP_SYSTEM_DIR/configs/" logs_dir="$LP_SYSTEM_DIR/logs/" ssl_dir="$LP_SYSTEM_DIR/ssl/" ssh_dir="$LP_SYSTEM_DIR/ssh/" wireguard_dir="$LP_SYSTEM_DIR/wireguard/" migrate_dir="$LP_SYSTEM_DIR/migrate" restore_dir="$LP_SYSTEM_DIR/restore" script_dir="$LP_SYSTEM_DIR/install" install_configs_dir="$script_dir/configs/" install_containers_dir="$script_dir/containers/" install_scripts_dir="$script_dir/scripts/" containers_dir="$LP_CONTAINERS_DIR/" backup_dir="$LP_BACKUPS_DIR" # Pre-update install snapshots (configs + logs zipped before the legacy # git-update reset_git → checkout cycle). Distinct from restic's per- # location subdirs which live at $backup_dir/. Used by # gitPerformUpdate / gitCleanInstallBackups / use_git_backup / config_git_check. backup_install_dir="$backup_dir/install" # Control-plane manager user (configurable; default libreportal). sudo_user_name="${LP_MANAGER_USER:-${sudo_user_name:-libreportal}}" sudo_bashrc="/home/$sudo_user_name/.bashrc" } 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 # Manager username: must be a valid Linux username (it becomes a real account, # a sudoers drop-in name, and the baked __MANAGER__ in the root helpers). if [[ ! "$sudo_user_name" =~ ^[a-z_][a-z0-9_-]*$ ]]; then isError "Invalid manager user '$sudo_user_name' — use lowercase letters, digits, '_' or '-' (must start with a letter or '_')." exit 1 fi 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 case "${!i}" in --random-password) init_random_password=true ((init_shift_count++)) ;; --local) init_local_install=true ((init_shift_count++)) ;; --unattended) init_unattended_mode=true ((init_shift_count++)) ;; --skip-os-update) init_skip_os_update=true ((init_shift_count++)) ;; --skip-prereqs) init_skip_prereqs=true ((init_shift_count++)) ;; --skip-docker-images|--skip-rootless) # On uninstall: preserve the rootless docker layer (daemon + # docker-install user + sysctl drop-ins + image/build cache) # so the next reinstall's `docker build` is cache-fast AND # the slow `dockerd-rootless-setuptool.sh install` step is # skipped. Honored in runFullUninstall. # # Two spellings: --skip-docker-images is the original name; # --skip-rootless is the clearer alias (the flag keeps a lot # more than just images). Both set the same flag, so passing # either or both has the same effect. 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++)) ;; --manager-user=*) LP_MANAGER_USER="${!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 shift done # Reset positional parameters after shifting flags param1="$1" # init to start script param2="$2" # password param3="$3" # git user param4="$4" # git token param5="$5" # git url param6="$6" # unattended param7="$7" # install mode (git/local) fi # Apply flag logic if [ "$init_random_password" = true ]; then param2=$(openssl rand -base64 12 | tr -d '\n') isSuccessful "Generated password: $param2" fi if [ "$init_local_install" = true ]; then param7="local" fi # Auto-detect installation mode based on provided parameters. Only when init.sh # is EXECUTED directly (install time) — start.sh sources init.sh for its function # definitions at runtime (Model A, as the manager), and this block must not run # then (it would print a spurious "Auto-detected ..." and rewrite the config). if [[ "${BASH_SOURCE[0]}" == "$0" && "$param1" != "uninstall" && -z "$param7" ]]; then # A reinstall that doesn't re-pass the git args must not silently # downgrade an existing git install to local (that disables the updater # and blanks the saved creds). Honor a git URL already saved from a # prior install — only fall back to local when there's no git history. saved_git_url=$(grep -E '^CFG_GIT_URL=' "${configs_dir}general/general_install" 2>/dev/null \ | sed -E 's/^[^=]+=([^[:space:]#]*).*/\1/') if [[ -n "$param3" || -n "$param4" || -n "$param5" ]]; then # Git parameters provided, set to git mode param7="git" isSuccessful "Auto-detected Git installation mode (git args provided)" elif [[ -n "$saved_git_url" && "$saved_git_url" != "changeme" && "$saved_git_url" != "empty" ]]; then # No git args this run, but a prior git install is on disk — keep it param7="git" isSuccessful "Auto-detected Git installation mode (existing git config detected)" else # No git parameters and no prior git config, set to local mode param7="local" isSuccessful "Auto-detected Local installation mode" fi fi initUpdateConfigOption() { local config_option="$1" local config_value="$2" local config_file="" 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 if grep -q "^$config_option=" "$config_file"; then # Use SOH (\x01) as the s-command delimiter — it can't appear # in text-based config values or comments, so no field needs # to escape the delimiter. The replacement field DOES need # `&` (whole-match insertion) and `\` (escape char) neutralised # in BOTH the value AND the comment — comments like # `# Installation Mode - ...[release|git|local]` previously # broke the `|`-delimited form with "unknown option to `s'". local DELIM=$'\x01' local escaped_value=$(printf '%s' "$config_value" | sed -e 's/[\\&]/\\&/g') local original_line=$(grep "^$config_option=" "$config_file") local comment_part=$(echo "$original_line" | sed -n "s|^$config_option=[^#]*\(#.*\)|\1|p") local escaped_comment=$(printf '%s' "$comment_part" | sed -e 's/[\\&]/\\&/g') if [[ -n "$comment_part" ]]; then sed -i "s${DELIM}^$config_option=.*${DELIM}$config_option=$escaped_value $escaped_comment${DELIM}" "$config_file" else sed -i "s${DELIM}^$config_option=.*${DELIM}$config_option=$escaped_value${DELIM}" "$config_file" fi source "$config_file" return 0 fi fi done fi done return 1 } if [[ "${BASH_SOURCE[0]}" == "$0" && -n "$param7" && -f "${configs_dir}general/general_install" ]]; then initUpdateConfigOption "CFG_INSTALL_MODE" "$param7" fi if [ "$init_unattended_mode" = true ]; then param6="true" fi if [ "$init_skip_os_update" = true ]; then isNotice "Skipping operating system update" fi if [ "$init_skip_prereqs" = true ]; then isNotice "Skipping prerequisite apps installation" fi # Get script directory for local installation (only if needed) if [ "$init_local_install" = true ]; then init_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" isNotice "Using script directory for local installation: $init_script_dir" fi detectLocalLibrePortal() { local check_dir # If --local flag is used, check script directory, otherwise check current directory if [ "$init_local_install" = true ]; then check_dir="$init_script_dir" echo "Checking script directory for LibrePortal structure..." else check_dir="$(pwd)" echo "Checking current directory for LibrePortal structure..." fi local required_dirs=("configs" "scripts" "containers") local required_files=("init.sh" "start.sh") local missing_count=0 # Check for required directories for dir in "${required_dirs[@]}"; do if [[ ! -d "$check_dir/$dir" ]]; then echo " Missing directory: $dir" ((missing_count++)) fi done # Check for required files for file in "${required_files[@]}"; do if [[ ! -f "$check_dir/$file" ]]; then echo " Missing file: $file" ((missing_count++)) fi done if [[ $missing_count -eq 0 ]]; then echo "Valid LibrePortal structure detected in $check_dir" echo "" return 0 else isNotice "Warning: $missing_count required items missing. Not a valid LibrePortal source." return 1 fi } copyFilesFromLocal() { local source_dir # If --local flag is used, use script directory, otherwise use current directory if [ "$init_local_install" = true ]; then source_dir="$init_script_dir" else source_dir="$(pwd)" fi isHeader "Copying from Local Directory" # Remove existing install directory sudo rm -rf "$script_dir" # Create install directory sudo mkdir -p "$script_dir" isNotice "Copying files from $source_dir to $script_dir..." # Copy all files while preserving structure if sudo cp -r "$source_dir"/* "$script_dir/" 2>/dev/null; then sudo cp -f "$script_dir/init.sh" /root/ setupConfigsFromRepo sudo chown -R $sudo_user_name:$sudo_user_name "$script_dir" isSuccessful "Files copied from local directory to '$script_dir'." else isError "Failed to copy files from local directory." exit 1 fi } resetConfigVars() { param2="" param3="" param4="" param5="" param7="" GIT_USER="" GIT_TOKEN="" GIT_URL="" INSTALL_MODE="" } initCheckConfigs() { # Check if any config files exist (new structure) or old config files local config_found=false # Check for new structure. Skip subdirectories (per-location nested backup # configs are loaded by their own helper) and the .category markers. if [ -d "$configs_dir" ]; then 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 source "$config_file" config_found=true fi done fi done fi if [ "$config_found" = false ]; then isNotice "Configuration files do not exist, skipping init check" return fi get_cfg() { local var_name="$1" local var_value="${!var_name}" echo "$var_value" } [[ -z "$param2" ]] && param2=$(get_cfg CFG_LIBREPORTAL_USER_PASS) [[ -z "$param3" ]] && param3=$(get_cfg CFG_GIT_USER) [[ -z "$param4" ]] && param4=$(get_cfg CFG_GIT_KEY) [[ -z "$param5" ]] && param5=$(get_cfg CFG_GIT_URL) [[ -z "$param7" ]] && param7=$(get_cfg CFG_INSTALL_MODE) [[ "$param2" == "changeme" ]] && param2="" [[ "$param3" == "changeme" ]] && param3="" [[ "$param4" == "changeme" ]] && param4="" [[ "$param5" == "changeme" ]] && param5="" [[ "$param7" == "changeme" ]] && param7="" } validateUnattended() { if [[ -z "$param2" ]]; then isError "Password is required in unattended mode" exit 1 fi # If install mode is not specified, default to git if [[ -z "$param7" ]]; then param7="git" fi # For git installation, validate git parameters if [[ "$param7" == "git" ]]; then if [[ -z "$param5" ]]; then isError "Git repository URL is required in unattended mode" exit 1 fi if [[ -z "$param3" ]]; then param3="empty" param4="empty" fi if [[ "$param3" != "empty" && -z "$param4" ]]; then param4="empty" fi fi } initInputQuestions() { ### PASSWORD if [[ -z "$param2" ]]; then while true; do echo "" echo "LibrePortal User Password" read -p "Use custom (c) or randomized (r) password? (c/r): " choice case "$choice" in c) read -p "Enter custom password: " param2 [[ -z "$param2" ]] && echo "Password cannot be empty." || break ;; r) param2=$(openssl rand -base64 12) isSuccessful "Generated password: $param2" break ;; *) echo "Invalid option. Enter c or r." ;; esac done fi ### INSTALLATION METHOD if [[ -z "$param7" ]]; then echo "" echo "Installation Method:" # Check if local installation is available if detectLocalLibrePortal; then echo "1) Install from Git repository" echo "2) Install from local folder (current directory)" echo "" while true; do read -p "Choose option [1-2]: " install_choice case "$install_choice" in 1) param7="git" echo "Using Git repository installation." break ;; 2) param7="local" echo "Using local folder installation." break ;; *) echo "Invalid option. Choose 1 or 2." ;; esac done else echo "Local installation not available - missing required files/directories." echo "Defaulting to Git repository installation." param7="git" fi fi ### GIT USER (only for git installation) if [[ "$param7" == "git" && -z "$param3" ]]; then echo "" echo "Git Authentication Method:" echo "1) Login required (username + token)" echo "2) Authenticationless (public repos / SSH keys)" echo "" while true; do read -p "Choose option [1-2]: " git_auth_choice case "$git_auth_choice" in 1) read -p "Enter Git username: " param3 if [[ -z "$param3" ]]; then echo "Username cannot be empty for login authentication." else break fi ;; 2) param3="empty" param4="empty" echo "Using authenticationless Git access." break ;; *) echo "Invalid option. Choose 1 or 2." ;; esac done fi ### GIT TOKEN (only for git installation) if [[ "$param7" == "git" && "$param3" != "empty" && -z "$param4" ]]; then read -p "Enter Git token: " param4 [[ -z "$param4" ]] && param4="empty" fi ### GIT URL (only for git installation) if [[ "$param7" == "git" && -z "$param5" ]]; then while true; do echo "" read -p "Enter Git repository URL: " param5 [[ -z "$param5" ]] && echo "Git repository URL is required." || { # Validate and fix Git URL param5=$(validateAndFixGitUrl "$param5") echo "Using Git URL: $param5" break } done elif [[ "$param7" == "git" ]]; then # Also validate URL if provided as parameter param5=$(validateAndFixGitUrl "$param5") echo "Using Git URL: $param5" fi } cleanGitUrl() { local url="$1" url="${url#"${url%%[![:space:]]*}"}" url="${url%"${url##*[![:space:]]}"}" url="${url#https://}" url="${url#http://}" while [[ "$url" == */ ]]; do url="${url%/}"; done url="${url%.git}" echo "$url" } validateAndFixGitUrl() { local url="$1" url="${url#"${url%%[![:space:]]*}"}" url="${url%"${url##*[![:space:]]}"}" echo "$url" } initReloadConfigs() { 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 source "$config_file" fi done fi done # Check old config files (backward compatibility) for config_file in "$configs_dir"/config_*; do if [ -f "$config_file" ]; then source "$config_file" fi done } writeConfig() { # Defer config writes until the repo is cloned and /docker/configs is # populated by setupConfigsFromRepo. On a fresh install the file # below doesn't exist yet — initUpdateConfigs at the end of init.sh # is what actually persists everything. if [[ ! -f "${configs_dir}general/general_install" ]]; then return 0 fi [[ -n "$param2" ]] && initUpdateConfigOption "CFG_LIBREPORTAL_USER_PASS" "$param2" [[ -n "$param3" ]] && initUpdateConfigOption "CFG_GIT_USER" "$param3" [[ -n "$param4" ]] && initUpdateConfigOption "CFG_GIT_KEY" "$param4" [[ -n "$param5" ]] && initUpdateConfigOption "CFG_GIT_URL" "$param5" [[ -n "$param7" ]] && initUpdateConfigOption "CFG_INSTALL_MODE" "$param7" } initDisplayConfig() { GIT_USER="$param3" GIT_TOKEN="$param4" GIT_URL="$param5" INSTALL_MODE="$param7" isHeader "Configuration Summary" isNotice "LibrePortal User Password: [HIDDEN]" if [[ "$INSTALL_MODE" == "local" ]]; then isNotice "Git Username: [NOT APPLICABLE]" isNotice "Git Token: [NOT APPLICABLE]" else [[ "$GIT_USER" == "empty" ]] \ && isNotice "Git Username: [DISABLED]" \ || isNotice "Git Username: $GIT_USER" [[ "$GIT_TOKEN" == "empty" ]] \ && isNotice "Git Token: [DISABLED]" \ || isNotice "Git Token: [HIDDEN]" fi [[ "$INSTALL_MODE" == "local" ]] \ && isNotice "Installation Mode: Local Folder" \ || isNotice "Installation Mode: Git Repository" if [[ "$INSTALL_MODE" != "local" ]]; then isNotice "Git URL: $GIT_URL" fi echo "" if [[ "$param6" == "true" || "$param6" == "1" ]]; then isNotice "Unattended mode enabled — auto-accepting configuration" writeConfig fi if [[ "$INSTALL_MODE" != "local" ]]; then read -p "Are these details correct? (y/n): " confirm else confirm=y fi if [[ "$confirm" != "y" ]]; then echo "Restarting configuration.." resetConfigVars initCheckConfigs initInputQuestions initDisplayConfig else writeConfig isSuccessful "Configuration saved." fi } initOS() { isHeader "Updating Operating System" apt-get install sudo -y sudo apt-get update sudo apt-get dist-upgrade -y echo "" isSuccessful "OS Updated" } initPrerequires() { isHeader "Installing Prerequired Apps" # apache2-utils → htpasswd, used by hashPassword for fast local bcrypt. sudo apt-get install git zip curl sshpass dos2unix dnsutils apt-transport-https ca-certificates software-properties-common uidmap adduser apache2-utils restic -y TARGET_PATH="/usr/local/bin" CONFIG_FILE="$HOME/.bashrc" if ! echo "$PATH" | grep -q "$TARGET_PATH"; then echo "Adding $TARGET_PATH to PATH..." echo "export PATH=\$PATH:$TARGET_PATH" >> "$CONFIG_FILE" source "$CONFIG_FILE" echo "PATH updated successfully!" else echo "$TARGET_PATH is already in PATH." fi isSuccessful "Prerequisite apps installed." } initDocker() { isHeader "Installing Docker" if command -v docker &> /dev/null; then isSuccessful "Docker is already installed." else curl -fsSL https://get.docker.com | sh systemctl start docker systemctl enable docker isSuccessful "Docker has been installed successfully." fi } initUsers() { isHeader "Creating User Accounts" if id "$sudo_user_name" &>/dev/null; then isSuccessful "User $sudo_user_name already exists." else # No -G sudo: the manager's privileges come from the scoped /etc/sudoers.d # drop-in below (user-specific), not blanket sudo-group membership. sudo useradd -s /bin/bash -d "/home/$sudo_user_name" -m "$sudo_user_name" 2>/dev/null isNotice "Setting password for $sudo_user_name user." echo "$sudo_user_name:$param2" | sudo chpasswd sudo usermod -aG docker "$sudo_user_name" sudo systemctl restart docker isSuccessful "User $sudo_user_name created successfully." fi # Drop a stale cron spool from a prior install. userdel doesn't remove # /var/spool/cron/crontabs/, so if the manager's uid was recycled the # leftover (owned by the dead uid) can't be replaced by the new user in the # sticky spool dir → "crontab: rename: Operation not permitted". Remove it # only when it's owned by a different uid (a current-uid spool is valid), plus # the legacy easydocker artifact, so the manager-run crontab setup writes a # clean, correctly-owned spool. local spool_dir="/var/spool/cron/crontabs" local mgr_uid; mgr_uid=$(id -u "$sudo_user_name" 2>/dev/null) if [[ -f "$spool_dir/$sudo_user_name" \ && "$(stat -c %u "$spool_dir/$sudo_user_name" 2>/dev/null)" != "$mgr_uid" ]]; then sudo rm -f "$spool_dir/$sudo_user_name" isNotice "Removed a stale cron spool for $sudo_user_name (recycled uid)." fi sudo rm -f "$spool_dir/easydocker" # /home/$sudo_user_name may be owned by a stale uid from a previous install # (e.g. the EasyDocker rename): useradd doesn't reclaim an existing home dir, # so files inside — incl. restic's cache dir under ~/.cache/restic — end up # unreadable by the new manager. restic then logs `mkdir: permission denied` # every backup (non-fatal but slows them). Same recycled-uid pattern as the # cron spool above. Idempotent: no-op when ownership already matches. if [[ -d "/home/$sudo_user_name" \ && "$(stat -c %u "/home/$sudo_user_name" 2>/dev/null)" != "$mgr_uid" ]]; then sudo chown -R "$sudo_user_name":"$sudo_user_name" "/home/$sudo_user_name" isNotice "Reclaimed /home/$sudo_user_name from a stale uid (recycled or rename)." fi # Install-phase sudo: the heavy install runs AS this user (see the handoff in # completeInitMessage) and needs BROAD root — useradd for the docker-install # user, rootless setup, apt, sysctl, etc. So grant a temporary validated # NOPASSWD: ALL drop-in now (never appended to /etc/sudoers — a malformed main # file locks out sudo entirely); completeInitMessage calls initScopedSudoers # to tighten it to the scoped RUNTIME allowlist once the install succeeds. local sudoers_dropin="/etc/sudoers.d/${sudo_user_name}" local sudoers_tmp sudoers_tmp=$(mktemp) printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$sudo_user_name" > "$sudoers_tmp" if sudo visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then sudo install -m 0440 -o root -g root "$sudoers_tmp" "$sudoers_dropin" isSuccessful "Configured install-phase sudo for $sudo_user_name (tightened after install)." else isError "Refusing to install an invalid sudoers drop-in for $sudo_user_name." fi rm -f "$sudoers_tmp" } # Tighten the manager's sudo from the install-phase NOPASSWD: ALL down to the # scoped RUNTIME allowlist. Called AFTER the (manager-run) install phase, which # needs the broad root this deliberately withholds. The runtime then reaches root # ONLY via: the unprivileged docker-install user (data plane, rootless-confined), # the root-owned /usr/local/lib/libreportal/ helpers (each a fixed, self-validated # op the manager can't modify), and a fixed system-binary set. Excluded: # bash/su + tee/cp/chmod/chown/sed/mv/rm/install (each root-equivalent). Also # clears legacy broad grants (a NOPASSWD: ALL in the main /etc/sudoers, sudo-group # membership). See docs/architecture/system-footprint.md. initScopedSudoers() { local sudoers_dropin="/etc/sudoers.d/${sudo_user_name}" local install_user install_user=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$configs_dir/general/general_docker_install" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') install_user="${install_user:-${CFG_DOCKER_INSTALL_USER:-dockerinstall}}" local sudoers_tmp sudoers_tmp=$(mktemp) cat > "$sudoers_tmp" </dev/null 2>&1; then sudo install -m 0440 -o root -g root "$sudoers_tmp" "$sudoers_dropin" # Clear legacy broad grants from older installs. if sudo grep -qE "^${sudo_user_name}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD:[[:space:]]+ALL" /etc/sudoers 2>/dev/null; then local main_tmp; main_tmp=$(mktemp) sudo grep -vE "^${sudo_user_name}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD:[[:space:]]+ALL$" /etc/sudoers > "$main_tmp" sudo visudo -cf "$main_tmp" >/dev/null 2>&1 && sudo cp "$main_tmp" /etc/sudoers rm -f "$main_tmp" fi sudo gpasswd -d "$sudo_user_name" sudo >/dev/null 2>&1 || true isSuccessful "Tightened $sudo_user_name sudo to the scoped runtime allowlist." else isError "Invalid scoped sudoers — left install-phase sudo in place for $sudo_user_name." fi rm -f "$sudoers_tmp" } # Install the root-owned privilege helpers. Under Model A the runtime runs AS the # manager, so the genuine-root operations it can't drop (the /docker ownership # model, /etc/resolv.conf edits, host SSH access) need root — but granting the # manager blanket `sudo chown/chmod/tee/sed/cp` would be root-equivalent. Each # helper does a FIXED, self-validated set of operations and lives root:root 0755 # where the manager can't edit it, so the scoped sudoers can allow each wholesale. # The manager name is baked in here (the manager can't change it); the sed is a # no-op on helpers without the placeholder. initRootHelpers() { # One root-owned home for every LibrePortal executable installed outside # /docker (the helpers here; the CLI wrapper lands here too, see the command # setup). Root-owned 0755 = the manager can't tamper with the helpers it # sudo's (the trust boundary the scoped sudoers relies on). sudo install -d -m 0755 -o root -g root "$lp_lib_dir" local helper helper_src helper_dst helper_tmp for helper in libreportal-ownership libreportal-dns libreportal-ssh-access libreportal-socket libreportal-svc libreportal-bininstall libreportal-appcfg libreportal-crowdsec; do helper_src="$script_dir/scripts/system/$helper" helper_dst="$lp_lib_dir/$helper" if [[ ! -f "$helper_src" ]]; then isError "Root helper source missing ($helper_src) — skipping." continue fi helper_tmp=$(mktemp) # Bake the manager name + the three relocatable roots into the installed # (root-owned, manager-immutable) helper. This is the trust boundary: the # helpers operate on FIXED paths chosen at install by root, never read from # manager-writable config. '#' delimiter since the values are paths. sed -e "s/__MANAGER__/${sudo_user_name}/g" \ -e "s#__SYSTEM_DIR__#${LP_SYSTEM_DIR}#g" \ -e "s#__CONTAINERS_DIR__#${LP_CONTAINERS_DIR}#g" \ -e "s#__BACKUPS_DIR__#${LP_BACKUPS_DIR}#g" \ "$helper_src" > "$helper_tmp" if bash -n "$helper_tmp" 2>/dev/null; then sudo install -m 0755 -o root -g root "$helper_tmp" "$helper_dst" isSuccessful "Installed root-owned helper ($helper)." else isError "Refusing to install a malformed root helper ($helper)." fi rm -f "$helper_tmp" done # 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 sudo install -m 0644 -o root -g root "$script_dir/libreportal.pub" "$lp_lib_dir/libreportal.pub" fi # Record the footprint version now installed (root-owned, world-readable) so the # manager-run updater can tell when a release bumps it and a root re-install is # needed. Written here because this re-runs on every install/reinstall. echo "$footprint_version" | sudo tee "$footprint_marker" >/dev/null sudo chmod 0644 "$footprint_marker" } initFolders() { isHeader "LibrePortal Folder Creation" folders=("$docker_dir" "$containers_dir" "$ssl_dir" "$ssh_dir" "$wireguard_dir" "$logs_dir" "$configs_dir" "$backup_dir" "$backup_install_dir" "$restore_dir" "$migrate_dir" "$script_dir") for folder in "${folders[@]}"; do if [ ! -d "$folder" ]; then sudo mkdir "$folder" sudo chown $sudo_user_name:$sudo_user_name "$folder" sudo chmod 750 "$folder" isSuccessful "Folder '$folder' created." fi done isSuccessful "All folders have been created." } # Establish the rootless container layer — the docker-install user and ownership # of /docker/containers — during the ROOT phase, BEFORE the manager-run install # boots. That boot scans app configs under /docker/containers AS the container # user (runFileOp), so if the dir is still manager-owned (as initFolders leaves # it) the scan errors with "Permission denied". Handing the dir to the container # user here means the scan reads a dir it owns. Rootless-only — rooted keeps # containers manager/root-owned, which the manager reads fine. Idempotent: the # later rootless setup finds the user existing and just (re)asserts its password # + daemon config. Runs after initGIT (config present) + initFolders (dir present). initContainerLayer() { local cfg="$configs_dir/general/general_docker_install" local dtype duser dtype=$(grep -h '^CFG_DOCKER_INSTALL_TYPE=' "$cfg" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') [[ "$dtype" != "rootless" ]] && return 0 duser=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$cfg" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') duser="${duser:-dockerinstall}" isHeader "Container Layer Setup" if id "$duser" &>/dev/null; then isSuccessful "Container user '$duser' already exists." else # -m + the system login.defs SUB_UID/GID defaults assign its subordinate # uid/gid ranges (needed for rootless). The later rootless setup sees it # existing and configures the daemon/linger/password. sudo useradd -m -s /bin/bash -d "/home/$duser" "$duser" 2>/dev/null isSuccessful "Created container user '$duser'." fi # The system root is manager-owned and initFolders makes it 750; give it the # rootless traversal bit (o+x → 751) so the container user can reach the few # bind-mount sources it must read there (configs/webui/*). The container + backup # roots are SEPARATE roots now, so the container user no longer traverses the # system tree to reach its own data. [[ -d "$docker_dir" ]] && sudo chmod o+x "$docker_dir" # Hand the container + backup roots to the container user — it owns per-app data # in rootless, and restic (which runs AS that user) writes the backup repos. # 751: owner full; the manager (other) can traverse in to known paths. local d for d in "$containers_dir" "$backup_dir"; do if [[ -d "$d" ]]; then sudo chown "$duser:$duser" "$d" sudo chmod 751 "$d" fi done isSuccessful "containers/ + backups/ handed to '$duser' (system root traversable)." } setupConfigsFromRepo() { isNotice "Setting up configuration files from repository..." local src="$script_dir/configs" local dst="${configs_dir%/}" if [[ ! -d "$src" ]]; then isError "Source configs directory missing: $src" isError "The clone in $script_dir didn't include a configs/ tree — aborting." exit 1 fi sudo mkdir -p "$dst" # No-clobber: only seed config files that don't already exist. On a fresh # install this copies the whole template; on a re-run/deploy it preserves the # user's live values (a plain cp -a here silently reset e.g. the Docker # install type back to the template default). New *keys* in existing files # are added separately by the add-only reconcile pass. if ! sudo cp -an "$src"/. "$dst"/; then isError "Failed to copy configs from $src to $dst — aborting." exit 1 fi sudo chown -R "$sudo_user_name":"$sudo_user_name" "$dst" if [[ ! -f "$dst/general/general_install" ]]; then isError "Configs were copied but $dst/general/general_install is missing." isError "Repository layout may have changed — fix and re-run." exit 1 fi isSuccessful "Configuration files copied from repository." isNotice "Applying initial configuration values..." [[ -n "$param2" ]] && initUpdateConfigOption "CFG_LIBREPORTAL_USER_PASS" "$param2" [[ -n "$param3" ]] && initUpdateConfigOption "CFG_GIT_USER" "$param3" [[ -n "$param4" ]] && initUpdateConfigOption "CFG_GIT_KEY" "$param4" [[ -n "$param5" ]] && initUpdateConfigOption "CFG_GIT_URL" "$param5" [[ -n "$param7" ]] && initUpdateConfigOption "CFG_INSTALL_MODE" "$param7" isSuccessful "Configuration setup complete." } initGIT() { isHeader "Git Clone / Update" # Handle local installation if [[ "$param7" == "local" ]]; then isNotice "Using local folder installation." copyFilesFromLocal return fi # Handle release installation — a verified tarball, not a clone. The bootstrap # (install.sh) usually staged + verified the code already (LP_ALREADY_FETCHED); # otherwise fetch it via the sourced helper (only possible when run from a # populated install tree — a bare /root reinstall must go through install.sh). if [[ "$param7" == "release" ]]; then if [[ "${LP_ALREADY_FETCHED:-0}" == "1" ]]; then isNotice "Using the release staged + verified by the bootstrap installer." elif [[ -f "$script_dir/scripts/source/fetch.sh" ]]; then source "$script_dir/scripts/docker/command/run_privileged.sh" source "$script_dir/scripts/source/fetch.sh" lpFetchRelease || { isError "Release fetch failed."; exit 1; } else isError "Release-mode (re)install must be run via install.sh: curl -fsSL /install.sh | sudo bash" exit 1 fi sudo cp -f "$script_dir/init.sh" /root/ 2>/dev/null || true setupConfigsFromRepo sudo chown -R "$sudo_user_name":"$sudo_user_name" "$script_dir" return fi GIT_USER="$param3" GIT_TOKEN="$param4" GIT_URL="$param5" if [[ -z "$GIT_URL" ]]; then isError "Git URL is empty. Please provide a valid Git repository URL." exit 1 fi local clean_url clean_url=$(cleanGitUrl "$GIT_URL") if [[ -z "$clean_url" ]]; then isError "Could not normalize Git URL: $GIT_URL" exit 1 fi local auth="" if [[ -n "$GIT_USER" && "$GIT_USER" != "empty" ]]; then auth="${GIT_USER}:${GIT_TOKEN}@" fi isNotice "Cloning $clean_url into $script_dir ..." sudo rm -rf "$script_dir" local scheme cloned=false for scheme in https http; do if sudo -u "$sudo_user_name" git clone -q "${scheme}://${auth}${clean_url}.git" "$script_dir" 2>/dev/null; then cloned=true isSuccessful "Cloned via ${scheme^^}." break fi done if ! $cloned; then isError "Failed to clone $clean_url over HTTPS or HTTP." isError "Check the URL, credentials, and that the server is reachable." exit 1 fi sudo cp -f "$script_dir/init.sh" /root/ setupConfigsFromRepo sudo chown -R "$sudo_user_name":"$sudo_user_name" "$script_dir" } initLibrePortalCommand() { isHeader "Custom Command Setup" if ! grep -q "LibrePortal Command Start" $sudo_bashrc; then isNotice "Command maker not found. Removing old LibrePortal command." sed -i '/^libreportal() {$/,/^}$/d' $sudo_bashrc else isNotice "Command maker found. Removing old LibrePortal command." sed -i '/# LibrePortal Command Start/,/# LibrePortal Command End/d' $sudo_bashrc fi isNotice "Custom command 'libreportal' is not installed. Installing..." # The CLI wrapper lives alongside the root helpers in $lp_lib_dir; a symlink # in /usr/local/bin keeps `libreportal` on $PATH. sudo install -d -m 0755 -o root -g root "$lp_lib_dir" sudo rm -rf $command_script sudo tee -a "$command_script" >/dev/null <<'EOF' #!/usr/bin/env bash # LibrePortal Command Start # LibrePortal Command Version 1.4 # Manager user baked at install (the __MANAGER__ placeholder); unbaked keeps "__". CHECK_USER="__MANAGER__"; [[ "$CHECK_USER" == *"__"* ]] && CHECK_USER="libreportal" LP_MANAGER_USER="$CHECK_USER"; export LP_MANAGER_USER CURRENT_USER=$(whoami) # Check if the script is run by the specified user if [ "$CURRENT_USER" != "$CHECK_USER" ]; then echo "Script is NOT able to run from this user." echo "This script should ONLY be run as: $CHECK_USER" 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}" command4="${4:-empty}" command5="${5:-empty}" command6="${6:-empty}" command7="${7:-empty}" command8="${8:-empty}" command9="${9:-empty}" path="$PWD" reset_git_config() { echo "" echo "Resetting Git configuration for re-entry..." # Use dynamic config update commandUpdateConfigOption "CFG_GIT_USER" "changeme" commandUpdateConfigOption "CFG_GIT_KEY" "changeme" commandUpdateConfigOption "CFG_GIT_URL" "changeme" } # Helper function to load config files in the libreportal command commandReloadConfigs() { # Load new structure config files only. Skip subdirectories (e.g. the # per-location nested backup configs/backup/locations//) — those are # loaded by their own sourceBackupLocations helper. Skip the .category # marker files too. Matches the guard initReloadConfigs / commandUpdateConfigOption # already use. 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 source "$config_file" fi done fi done } # Helper function to update config files in the libreportal command commandUpdateConfigOption() { local config_option="$1" local config_value="$2" 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 if grep -q "^$config_option=" "$config_file"; then # Use SOH (\x01) as the s-command delimiter — it can't appear # in text-based config values or comments, so no field needs # to escape the delimiter. The replacement field DOES need # `&` (whole-match insertion) and `\` (escape char) neutralised # in BOTH the value AND the comment — comments like # `# Installation Mode - ...[release|git|local]` previously # broke the `|`-delimited form with "unknown option to `s'". local DELIM=$'\x01' local escaped_value=$(printf '%s' "$config_value" | sed -e 's/[\\&]/\\&/g') # Extract the comment part first (everything after the first #) local original_line=$(grep "^$config_option=" "$config_file") local comment_part=$(echo "$original_line" | sed -n "s|^$config_option=[^#]*\(#.*\)|\1|p") local escaped_comment=$(printf '%s' "$comment_part" | sed -e 's/[\\&]/\\&/g') if [[ -n "$comment_part" ]]; then sed -i "s${DELIM}^$config_option=.*${DELIM}$config_option=$escaped_value $escaped_comment${DELIM}" "$config_file" else sed -i "s${DELIM}^$config_option=.*${DELIM}$config_option=$escaped_value${DELIM}" "$config_file" fi source "$config_file" return 0 fi fi done fi done } update_config_values() { # Reload all config files to get current values commandReloadConfigs if [[ "$CFG_INSTALL_MODE" == "local" ]]; then [[ "$CFG_GIT_USER" != "empty" ]] && commandUpdateConfigOption "CFG_GIT_USER" "empty" [[ "$CFG_GIT_KEY" != "empty" ]] && commandUpdateConfigOption "CFG_GIT_KEY" "empty" [[ "$CFG_GIT_URL" != "empty" ]] && commandUpdateConfigOption "CFG_GIT_URL" "empty" commandReloadConfigs return 0 fi CFG_GIT_USER="${CFG_GIT_USER:-changeme}" if [[ -z "$CFG_GIT_USER" ]] || [[ "$CFG_GIT_USER" == "changeme" ]]; then while true; do echo "Please enter your Git username (Press Enter to set to 'empty'):" read -r NEW_GIT_USER if [[ -n "$NEW_GIT_USER" || "$NEW_GIT_USER" == "" ]]; then if [[ -z "$NEW_GIT_USER" ]]; then NEW_GIT_USER="empty" fi commandUpdateConfigOption "CFG_GIT_USER" "$NEW_GIT_USER" commandReloadConfigs echo "Updating Git Username to '$NEW_GIT_USER'" break fi done fi CFG_GIT_KEY="${CFG_GIT_KEY:-changeme}" # If Git user is disabled, force token to empty and skip prompt if [[ "$CFG_GIT_USER" == "empty" ]]; then commandUpdateConfigOption "CFG_GIT_KEY" "empty" commandReloadConfigs echo "Git authentication disabled; skipping Git token." else # Only prompt if token is unset or changeme if [[ -z "$CFG_GIT_KEY" || "$CFG_GIT_KEY" == "changeme" ]]; then while true; do echo "Please enter your Git access token (Press Enter to set to 'empty'):" read -rs NEW_GIT_KEY echo "" if [[ -z "$NEW_GIT_KEY" ]]; then NEW_GIT_KEY="empty" fi commandUpdateConfigOption "CFG_GIT_KEY" "$NEW_GIT_KEY" commandReloadConfigs echo "Git token updated." break done fi fi CFG_GIT_URL="${CFG_GIT_URL:-changeme}" if [[ -z "$CFG_GIT_URL" ]] || [[ "$CFG_GIT_URL" == "changeme" ]]; then while true; do echo "Please enter your Git repository URL:" read -rs NEW_GIT_URL if [[ -n "$NEW_GIT_URL" ]]; then commandUpdateConfigOption "CFG_GIT_URL" "$NEW_GIT_URL" commandReloadConfigs echo "Updating Git URL" break fi echo "Error: Git URL cannot be empty" done fi } commandCleanGitUrl() { local url="$1" url="${url#"${url%%[![:space:]]*}"}" url="${url%"${url##*[![:space:]]}"}" url="${url#https://}" url="${url#http://}" while [[ "$url" == */ ]]; do url="${url%/}"; done url="${url%.git}" echo "$url" } setup_repo() { while true; do update_config_values commandReloadConfigs CLEAN_GIT_URL=$(commandCleanGitUrl "$CFG_GIT_URL") local auth="" if [[ "$CFG_GIT_USER" != "empty" && -n "$CFG_GIT_USER" ]]; then auth="${CFG_GIT_USER}:${CFG_GIT_KEY}@" fi AUTH_HTTPS_REPO_URL="https://${auth}${CLEAN_GIT_URL}.git" AUTH_HTTP_REPO_URL="http://${auth}${CLEAN_GIT_URL}.git" echo "" echo "Configuration Summary:" echo "" if [[ "$CFG_INSTALL_MODE" == "local" ]]; then echo "Git Username: [NOT APPLICABLE]" echo "Git Token: [NOT APPLICABLE]" echo "Git URL: [NOT APPLICABLE]" else echo "Git Username: $CFG_GIT_USER" echo "Git Token: [HIDDEN]" echo "Git URL: $CLEAN_GIT_URL" fi echo "" read -p "Are these details correct? (y/n): " confirm_config if [[ "$confirm_config" == "y" ]]; then echo "Configuration confirmed." break else reset_git_config fi done } sync_configs_from_install() { local src="$script_dir/configs" local dst="$configs_dir" if [ ! -d "$src" ]; then echo "ERROR: $src missing — clone broken." return 1 fi mkdir -p "$dst" # No-clobber: preserve the user's live config values; only add new files. if ! cp -an "$src"/. "$dst"/; then echo "ERROR: Failed to sync configs from $src to $dst." return 1 fi 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 fi [ -n "$CFG_GIT_USER" ] && commandUpdateConfigOption "CFG_GIT_USER" "$CFG_GIT_USER" [ -n "$CFG_GIT_KEY" ] && commandUpdateConfigOption "CFG_GIT_KEY" "$CFG_GIT_KEY" [ -n "$CFG_GIT_URL" ] && commandUpdateConfigOption "CFG_GIT_URL" "$CFG_GIT_URL" commandUpdateConfigOption "CFG_INSTALL_MODE" "git" echo "SUCCESS: Configs synced and credentials re-applied." } clone_repo() { 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" "$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 fi done echo "ERROR: Authentication failed. Please check your credentials." return 1 fi for clone_url in "https://${CLEAN_GIT_URL}.git" "http://${CLEAN_GIT_URL}.git"; do 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 fi done echo "ERROR: Anonymous clone failed." return 1 } clone_and_install() { commandReloadConfigs if [[ "$CFG_INSTALL_MODE" == "local" ]]; then echo "NOTICE: Local install detected — no Git remote to clone." return 0 fi if [[ "$CFG_INSTALL_MODE" == "release" ]]; then if [ -f "$install_scripts_dir/source/fetch.sh" ] && [ -f "$install_scripts_dir/docker/command/run_privileged.sh" ]; then source "$install_scripts_dir/docker/command/run_privileged.sh" source "$install_scripts_dir/source/fetch.sh" if lpFetchRelease; then echo "SUCCESS: Re-fetched the verified release. Run 'libreportal run' to continue." else echo "ERROR: release re-fetch failed." fi else echo "NOTICE: Release install — reinstall via the bootstrap: curl -fsSL /install.sh | sudo bash" fi return 0 fi update_config_values; setup_repo; clone_repo; } cd "$docker_dir/" 2>/dev/null || cd / if [[ $command1 == "reset" ]]; then clone_and_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 manager name + three roots into the (root-owned) wrapper, same as # the helpers. sudo sed -i \ -e "s/__MANAGER__/${sudo_user_name}/g" \ -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). sudo ln -sfn "$command_script" "$command_symlink" # Generate the uninstall command at the fixed footprint + put it on $PATH as # `libreportal-uninstall` (same idea as the CLI wrapper above — generated by # init.sh, not a separate repo file). It just runs the engine's uninstall, so # users never type a data path. $script_dir is baked in; /root/init.sh is a # fallback if the install tree is already gone. sudo tee "$lp_lib_dir/uninstall.sh" >/dev/null <). # Sets LP_*_DIR + sudo_user_name so the following libreportalDerivePaths resolves # the real locations; silently no-ops on a legacy unit (then the derive defaults / # /docker compat shim apply). libreportalReadBakedRoots() { local unit=/etc/systemd/system/libreportal.service [[ -f "$unit" ]] || return 0 local s c b m s=$(grep -oE 'LP_SYSTEM_DIR=\S+' "$unit" | head -1 | cut -d= -f2) c=$(grep -oE 'LP_CONTAINERS_DIR=\S+' "$unit" | head -1 | cut -d= -f2) b=$(grep -oE 'LP_BACKUPS_DIR=\S+' "$unit" | head -1 | cut -d= -f2) m=$(grep -oE '^User=\S+' "$unit" | head -1 | cut -d= -f2) [[ -n "$s" ]] && LP_SYSTEM_DIR="$s" [[ -n "$c" ]] && LP_CONTAINERS_DIR="$c" [[ -n "$b" ]] && LP_BACKUPS_DIR="$b" [[ -n "$m" ]] && { LP_MANAGER_USER="$m"; sudo_user_name="$m"; } } runFullUninstall() { # Resolve the ACTUAL install roots/manager for this box before removing anything. libreportalReadBakedRoots libreportalDerivePaths local mgr="${sudo_user_name:-libreportal}" local iuser iuser=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "${configs_dir}general/general_docker_install" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}') iuser="${iuser:-dockerinstall}" # --skip-rootless (alias: --skip-docker-images): keep the entire rootless # layer (the daemon, the "$iuser" user, the image/build cache, and the # rootless sysctl drop-ins) instead of tearing it down, so a following # reinstall skips the slow `dockerd-rootless-setuptool.sh install` AND # rebuilds from the existing image cache. Everything else (control plane, # manager, footprint, /docker) is still removed. local keep_docker="${init_skip_docker_images:-false}" isHeader "LibrePortal — FULL Uninstall" echo "" printf " ${RED}${BOLD}⚠ PERMANENT — there is no undo.${NC} Everything below is wiped:\n" echo "" printf " ${BOLD}Filesystem${NC}\n" printf " %-34s ${DIM}%s${NC}\n" "$docker_dir" "system root — configs, database, install tree" printf " %-34s ${DIM}%s${NC}\n" "$containers_dir" "live app data" printf " %-34s ${DIM}%s${NC}\n" "$backup_dir" "backup repos" echo "" printf " ${BOLD}Users${NC}\n" printf " %-34s ${DIM}%s${NC}\n" "$mgr" "manager — /home/$mgr + scoped sudoers + cron spool" printf " %-34s ${DIM}%s${NC}\n" "$iuser" "rootless docker user — /home/$iuser + image cache" echo "" 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" "/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" echo "" printf " ${BOLD}Containers + binaries${NC}\n" printf " ${DIM}%s${NC}\n" "all containers + images + the rootless docker daemon" printf " ${DIM}%s${NC}\n" "restic, kopia, ufw-docker (only the ones LibrePortal installed)" echo "" printf " ${GREEN}Left in place:${NC} ${DIM}docker engine, docker-compose, apt-installed deps, and your SSH config (so you can't get locked out).${NC}\n" echo "" if [[ "$keep_docker" == "true" ]]; then isNotice "--skip-rootless: KEEPING the rootless docker daemon, the '$iuser' user, sysctl drop-ins, and the image/build cache (for a faster reinstall)." echo "" fi if [[ "$init_unattended_mode" == true ]]; then isNotice "Unattended mode — proceeding without the DELETE LIBREPORTAL prompt." else isQuestion "Type exactly DELETE LIBREPORTAL to confirm:" local confirm; read -r confirm if [[ "$confirm" != "DELETE LIBREPORTAL" ]]; then isNotice "Aborted — nothing was removed." return 0 fi fi isHeader "Tearing down LibrePortal" # 1. Stop + remove the task-processor service. systemctl disable --now libreportal.service >/dev/null 2>&1 rm -f /etc/systemd/system/libreportal.service systemctl daemon-reload >/dev/null 2>&1 isSuccessful "Stopped + removed the task-processor service" # 2. Best-effort graceful container removal, then tear down the rootless # user's session (stops the rootless dockerd + any survivors). local uid; uid=$(id -u "$iuser" 2>/dev/null) if [[ -n "$uid" ]]; then # Remove stale containers either way — they bind-mount the about-to-be- # wiped /docker. The images + build cache live in the user's home and are # untouched by `docker rm`. sudo -u "$iuser" env XDG_RUNTIME_DIR="/run/user/$uid" \ DOCKER_HOST="unix:///run/user/$uid/docker.sock" \ bash -c 'docker ps -aq | xargs -r docker rm -f' >/dev/null 2>&1 || true if [[ "$keep_docker" == "true" ]]; then isSuccessful "Removed containers; kept the rootless docker daemon + images for '$iuser'" else sudo -u "$iuser" env XDG_RUNTIME_DIR="/run/user/$uid" \ dockerd-rootless-setuptool.sh uninstall >/dev/null 2>&1 || true loginctl disable-linger "$iuser" >/dev/null 2>&1 || true loginctl terminate-user "$iuser" >/dev/null 2>&1 || true pkill -9 -u "$iuser" >/dev/null 2>&1 || true isSuccessful "Stopped containers + rootless docker for '$iuser'" fi 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 -rf /usr/local/lib/libreportal rm -f "/etc/sudoers.d/$mgr" # Keep the sysctl drop-ins when preserving docker — removing + reloading them # would reset ip_unprivileged_port_start etc. out from under the still-running # rootless daemon. (The reinstall re-adds them idempotently.) if [[ "$keep_docker" != "true" ]]; then rm -f /etc/sysctl.d/99-libreportal-hardening.conf /etc/sysctl.d/99-libreportal-rootless.conf sysctl --system >/dev/null 2>&1 fi rm -f /usr/local/bin/restic /usr/local/bin/kopia /usr/local/bin/ufw-docker rm -f /root/init.sh isSuccessful "Removed the system-integration footprint" # 4. Remove all app data — the three roots (on a legacy single-tree install the # container/backup roots are subdirs of the system root, so this is safe and # idempotent either way). rm -rf "$docker_dir" "$containers_dir" "$backup_dir" isSuccessful "Removed $docker_dir, $containers_dir, $backup_dir" # 5. Remove the LibrePortal users + their subuid/subgid ranges + home dirs. # Terminate each user's session/linger and kill its processes first, or # `userdel -r` leaves the home behind ("user currently used"); rm -rf the # home afterwards as a backstop. local u local users_to_remove=("$mgr") [[ "$keep_docker" == "true" ]] || users_to_remove+=("$iuser") for u in "${users_to_remove[@]}"; do loginctl disable-linger "$u" >/dev/null 2>&1 || true loginctl terminate-user "$u" >/dev/null 2>&1 || true pkill -9 -u "$u" >/dev/null 2>&1 || true userdel -r "$u" >/dev/null 2>&1 || true [[ -n "$u" ]] && rm -rf "/home/$u" # userdel does NOT remove the cron spool; a leftover (owned by this # now-removed uid) blocks the next install's user from replacing it in the # sticky spool dir → "crontab: rename: Operation not permitted". [[ -n "$u" ]] && rm -f "/var/spool/cron/crontabs/$u" done rm -f /var/spool/cron/crontabs/easydocker # legacy pre-rename artifact if [[ "$keep_docker" == "true" ]]; then sed -i "/^${mgr}:/d" /etc/subuid /etc/subgid 2>/dev/null || true isSuccessful "Removed user '$mgr' (+ home); kept '$iuser' + its docker image store" else sed -i "/^${mgr}:/d;/^${iuser}:/d" /etc/subuid /etc/subgid 2>/dev/null || true isSuccessful "Removed users '$mgr' + '$iuser' (+ home dirs)" fi isHeader "LibrePortal uninstalled" isNotice "Left in place: docker engine, docker-compose, apt deps, SSH config." } # Only run the installer entrypoint (root check + init flow) when init.sh is # EXECUTED directly. When it's SOURCED — start.sh loads init.sh for its function # defs at runtime, and under Model A start.sh runs as the manager, not root — the # defs above are all that's wanted and this root check must NOT fire. [[ "${BASH_SOURCE[0]}" != "${0}" ]] && return 0 2>/dev/null if [[ $EUID -ne 0 ]]; then echo "This script must be run as root." exit 1 else if [[ "$param1" == "init" ]]; then # Validate the chosen install roots before creating/baking anything. libreportalValidatePaths # Always check existing config first initCheckConfigs if [[ "$param6" == "true" || "$param6" == "1" ]]; then # Validate unattended params validateUnattended initDisplayConfig # in unattended mode, it will auto-accept else # Interactive mode initInputQuestions initDisplayConfig fi # Common steps (run in both interactive and unattended after confirmation) if [ "$init_skip_os_update" != true ]; then initOS fi if [ "$init_skip_prereqs" != true ]; then initPrerequires fi initDocker initUsers initFolders initGIT initRootHelpers initLibrePortalCommand initUpdateConfigs initContainerLayer completeInitMessage elif [[ "$param1" == "uninstall" ]]; then runFullUninstall fi fi