dockerDeleteData (uninstall) and the wipe-before-restore step in
restoreAppStart both did `runFileOp rm -rf $containers_dir$app_name`,
which runs as $CFG_DOCKER_INSTALL_USER (dockerinstall, uid 1002 on
rootless). That user owns app-template files but CANNOT remove
container sub-UID dirs created by the daemon's userns mapping —
postgres data at uid 232070, nextcloud html at uid 33, etc. The rm
therefore silently failed with
rm: cannot remove '/libreportal-containers/invidious/postgresdata':
Permission denied
while still reporting "<app> successfully uninstalled" — leaving the
sub-UID directory tree on disk to confuse the next install and leak
storage.
Fix: route the wipe through a new `app-data-remove` action in the
root-owned libreportal-ownership helper. Root can rm sub-UID files
unconditionally. The helper validates the app name (alphanumeric +
. _ -, no traversal), refuses the WebUI's own slot (libreportal), and
is idempotent when the dir is already gone.
Two callers updated:
- scripts/docker/app/uninstall/delete_data.sh
- scripts/restore/restore_app_start.sh
The helper itself ships root-owned at /usr/local/lib/libreportal/, so a
fresh install or release upgrade is needed to pick up the new action.
Bumped init.sh footprint_version 2 → 3 so the runtime updater
prompts a root re-install on the next release.
Signed-off-by: librelad <librelad@digitalangels.vip>
1823 lines
71 KiB
Bash
Executable File
1823 lines
71 KiB
Bash
Executable File
#!/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-docker-images On UNINSTALL: keep the rootless docker daemon, the
|
||
# docker-install user, and the image/build cache instead
|
||
# 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.
|
||
# --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/<user>
|
||
# (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-docker-images uninstall # keep docker layer
|
||
# ./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/DEVELOPMENT.md.
|
||
footprint_version=3
|
||
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/<idx>. 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)
|
||
# On uninstall: preserve the rootless docker layer (daemon +
|
||
# docker-install user + image/build cache) so the next reinstall's
|
||
# `docker build` is cache-fast. Honored in runFullUninstall.
|
||
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/<user>, 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/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" <<EOF
|
||
# Scoped least-privilege grant for the LibrePortal manager. Generated by init.sh.
|
||
Cmnd_Alias LP_HELPERS = ${lp_lib_dir}/libreportal-ownership, \\
|
||
${lp_lib_dir}/libreportal-dns, \\
|
||
${lp_lib_dir}/libreportal-ssh-access, \\
|
||
${lp_lib_dir}/libreportal-socket, \\
|
||
${lp_lib_dir}/libreportal-svc, \\
|
||
${lp_lib_dir}/libreportal-bininstall, \\
|
||
${lp_lib_dir}/libreportal-appcfg, \\
|
||
${lp_lib_dir}/libreportal-crowdsec
|
||
Cmnd_Alias LP_SYSTEM = /usr/bin/systemctl, /usr/sbin/ufw, /usr/local/bin/ufw-docker, \\
|
||
/usr/sbin/nft, /usr/sbin/sysctl, /sbin/sysctl, \\
|
||
/usr/bin/loginctl, /usr/sbin/service
|
||
${sudo_user_name} ALL=(${install_user}) NOPASSWD:SETENV: ALL
|
||
${sudo_user_name} ALL=(root) NOPASSWD: LP_HELPERS, LP_SYSTEM
|
||
EOF
|
||
if sudo visudo -cf "$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 <host>/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/<idx>/) — 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 <host>/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 <<EOF
|
||
#!/usr/bin/env bash
|
||
# LibrePortal uninstall command — generated by init.sh. Runs the engine's uninstall.
|
||
[[ \$EUID -eq 0 ]] || { echo "libreportal-uninstall must run as root (try: sudo)"; exit 1; }
|
||
_init="$script_dir/init.sh"; [[ -f "\$_init" ]] || _init="/root/init.sh"
|
||
[[ -f "\$_init" ]] || { echo "Cannot find init.sh to run the uninstall."; exit 1; }
|
||
exec bash "\$_init" "\$@" uninstall
|
||
EOF
|
||
sudo chmod 0755 "$lp_lib_dir/uninstall.sh"
|
||
sudo chown root:root "$lp_lib_dir/uninstall.sh"
|
||
sudo ln -sfn "$lp_lib_dir/uninstall.sh" /usr/local/bin/libreportal-uninstall
|
||
source $sudo_bashrc
|
||
}
|
||
|
||
initUpdateConfigs()
|
||
{
|
||
isHeader "Updating Configs"
|
||
|
||
initUpdateConfigOption "CFG_LIBREPORTAL_USER_PASS" "$param2" && isSuccessful "Updated Docker user password"
|
||
initUpdateConfigOption "CFG_GIT_USER" "$param3" && isSuccessful "Updated Git Username"
|
||
initUpdateConfigOption "CFG_GIT_KEY" "$param4" && isSuccessful "Updated Git Token"
|
||
initUpdateConfigOption "CFG_GIT_URL" "$param5" && isSuccessful "Updated Git URL"
|
||
initUpdateConfigOption "CFG_INSTALL_MODE" "$param7" && isSuccessful "Updated Installation Mode"
|
||
|
||
isHeader "Verifying Saved Configuration"
|
||
local cfg_file="${configs_dir}general/general_install"
|
||
if [[ ! -f "$cfg_file" ]]; then
|
||
isError "Expected $cfg_file is missing — install cannot proceed."
|
||
exit 1
|
||
fi
|
||
|
||
local saved_mode saved_user saved_url saved_key
|
||
saved_mode=$(grep -E '^CFG_INSTALL_MODE=' "$cfg_file" | sed -E 's/^[^=]+=([^[:space:]#]*).*/\1/')
|
||
saved_user=$(grep -E '^CFG_GIT_USER=' "$cfg_file" | sed -E 's/^[^=]+=([^[:space:]#]*).*/\1/')
|
||
saved_url=$(grep -E '^CFG_GIT_URL=' "$cfg_file" | sed -E 's/^[^=]+=([^[:space:]#]*).*/\1/')
|
||
saved_key=$(grep -E '^CFG_GIT_KEY=' "$cfg_file" | sed -E 's/^[^=]+=([^[:space:]#]*).*/\1/')
|
||
|
||
isNotice "Mode: $saved_mode"
|
||
isNotice "User: $saved_user"
|
||
isNotice "URL: $saved_url"
|
||
[[ -n "$saved_key" && "$saved_key" != "changeme" ]] \
|
||
&& isNotice "Token: [SET]" \
|
||
|| isNotice "Token: [EMPTY]"
|
||
|
||
if [[ "$saved_mode" == "git" ]]; then
|
||
local fail=0
|
||
if [[ -z "$saved_url" || "$saved_url" == "changeme" ]]; then
|
||
isError "CFG_GIT_URL didn't persist — installer will re-prompt."
|
||
fail=1
|
||
fi
|
||
if [[ "$saved_user" != "empty" && ( -z "$saved_user" || "$saved_user" == "changeme" ) ]]; then
|
||
isError "CFG_GIT_USER didn't persist — installer will re-prompt."
|
||
fail=1
|
||
fi
|
||
if (( fail )); then
|
||
isError "Aborting before handoff so you can fix this once instead of retyping every install run."
|
||
exit 1
|
||
fi
|
||
fi
|
||
isSuccessful "Configuration verified."
|
||
}
|
||
|
||
completeInitMessage()
|
||
{
|
||
isHeader "LibrePortal Initilization Complete"
|
||
|
||
# Run LibrePortal install as the libreportal user
|
||
isNotice "Starting LibrePortal installation as $sudo_user_name user..."
|
||
|
||
# Switch to libreportal user and run the install command
|
||
# LIBREPORTAL_INITIAL_INSTALL=1 tells the runtime entry (check_files.sh) to
|
||
# skip the routine update check on this very first run — we just installed
|
||
# the latest code from this very tree, and the update path would otherwise
|
||
# try to git-pull (no .git in the deployed install dir) or lpFetchRelease
|
||
# (no release manifest reachable yet), failing in ways that leave the
|
||
# WebUI / task-processor uninstalled.
|
||
if sudo -u "$sudo_user_name" LIBREPORTAL_SKIP_LOGO=1 LIBREPORTAL_INITIAL_INSTALL=1 bash -c "libreportal run install"; then
|
||
# Install done — tighten the manager's broad install-phase sudo down to
|
||
# the scoped runtime allowlist.
|
||
initScopedSudoers
|
||
else
|
||
echo ""
|
||
echo "⚠️ LibrePortal installation encountered issues."
|
||
echo " You can manually run the installation with:"
|
||
echo " sudo -u $sudo_user_name bash -c 'libreportal run install'"
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# FULL uninstall: permanently remove everything LibrePortal placed on the host.
|
||
# Runs as root (the entrypoint root-check below enforces it). Order matters —
|
||
# containers run AS the docker-install user and the rootless daemon is that user's
|
||
# systemd --user service, so stop those BEFORE removing the users. Self-contained:
|
||
# uses only init.sh's inline helpers, so it still works as it deletes /docker.
|
||
# Keep in sync with docs/FOOTPRINT.md.
|
||
# Discover where THIS box was actually installed — custom installs put the roots
|
||
# anywhere, and a bare `init.sh uninstall` has no LP_*_DIR in scope. The systemd
|
||
# unit is the authoritative baked record (Environment=LP_*_DIR + User=<manager>).
|
||
# 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-docker-images: keep the rootless docker layer (the daemon, the
|
||
# "$iuser" user, the image/build cache + the rootless sysctl drop-in) instead
|
||
# of tearing it down, so a following reinstall's `docker build` is cache-fast
|
||
# instead of from-scratch. 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-docker-images: KEEPING the rootless docker daemon, the '$iuser' user, 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/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
|