A full uninstall removes the primary containers root and everything in it, but app data on an ADDITIONAL storage location survives — along with each location's .libreportal-storage marker and directories owned by a uid that stops mapping to a user once the container account is removed. Either policy is defensible on its own. Deleting someone's data off a separate disk unasked is worse than leaving it, so leaving it is right. But doing one silently and the other silently is the single option nobody can predict, and the teardown gives no hint that the data is still there. List them, with a note that nothing there was deleted and that a reinstall can adopt the location again. Found tearing the test install down: /libreportal-containers went, while two app directories on one test disk and one on another stayed, unmentioned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2413 lines
97 KiB
Bash
Executable File
2413 lines
97 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-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/<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-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=9
|
||
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
|
||
|
||
# Interactive disk picker, in the shape an OS installer asks it.
|
||
#
|
||
# --system-dir/--containers-dir have existed for a while and nobody running the
|
||
# curl|bash installer ever learned they existed, so a box with a 4 TB disk beside
|
||
# a small system SSD quietly put everything on the SSD.
|
||
#
|
||
# Two roots, asked independently, because they are genuinely different choices:
|
||
# the control plane is ~20 MB and never grows, while app data is what becomes
|
||
# terabytes. On a normal desktop only the second matters; on an SBC with a small
|
||
# eMMC and a USB SSD you want both moved. Backups stay on --backups-dir, which
|
||
# is a scripted-install concern rather than a first-run one.
|
||
#
|
||
# Deliberately narrow:
|
||
# * one disk list, two short questions. Three questions nobody can answer at
|
||
# that moment is worse than a flag they never see.
|
||
# * a SUBDIRECTORY on the chosen disk, never its mount point — that keeps the
|
||
# "root only ever takes an empty directory" rule intact and leaves anything
|
||
# already on the disk alone.
|
||
# * candidates exclude anything on the same filesystem as /, since placing
|
||
# data there gains nothing.
|
||
#
|
||
# Skipped when unattended, with no TTY, when the matching flag was already
|
||
# passed, or when the scan finds nothing else — a prompt with one possible
|
||
# answer is not a question. Self-contained (findmnt only): scripts/ is not
|
||
# necessarily loadable this early.
|
||
# Say something when another drive is attached but not usable yet. Deliberately
|
||
# only a notice: mounting or formatting someone's disk is not a thing an
|
||
# installer should do without being asked, and `storage add` does it properly
|
||
# later with the empty-directory admission rule and the fitness checks.
|
||
_initMentionUnmounted()
|
||
{
|
||
command -v lsblk >/dev/null 2>&1 || return 0
|
||
|
||
# "Has no mount point" is not the same as "is free". The disk holding the
|
||
# root filesystem has no mount point of its own — its PARTITION does — so a
|
||
# naive check offers the user the disk they booted from. Mark anything
|
||
# mounted or claimed as in use, then propagate that up the parent chain.
|
||
declare -A _used=() _child=() _type=() _size=() _fs=() _parent=()
|
||
local line kv val name type size mnt fstype pkname
|
||
# -P, not -r: an unmounted device has an EMPTY mount point, and in raw output
|
||
# that is just a run of spaces, so `read` collapses it and every column after
|
||
# it shifts left. An LVM member then reads as a mount point and is offered as
|
||
# free space. Key="value" pairs cannot slide.
|
||
while IFS= read -r line; do
|
||
[[ -z "$line" ]] && continue
|
||
name=""; type=""; size=""; mnt=""; fstype=""; pkname=""
|
||
for kv in NAME TYPE SIZE MOUNTPOINT FSTYPE PKNAME; do
|
||
val="${line#*${kv}=\"}"
|
||
[[ "$val" == "$line" ]] && continue
|
||
val="${val%%\"*}"
|
||
case "$kv" in
|
||
NAME) name="$val" ;; TYPE) type="$val" ;; SIZE) size="$val" ;;
|
||
MOUNTPOINT) mnt="$val" ;; FSTYPE) fstype="$val" ;; PKNAME) pkname="$val" ;;
|
||
esac
|
||
done
|
||
[[ -z "$name" ]] && continue
|
||
_type[$name]="$type"; _size[$name]="$size"; _fs[$name]="$fstype"
|
||
_parent[$name]="$pkname"
|
||
[[ -n "$pkname" ]] && _child[$pkname]=1
|
||
[[ -n "$mnt" ]] && _used[$name]=1
|
||
case "$fstype" in swap|LVM2_member|crypto_LUKS|linux_raid_member) _used[$name]=1 ;; esac
|
||
done < <(lsblk -Pno NAME,TYPE,SIZE,MOUNTPOINT,FSTYPE,PKNAME 2>/dev/null)
|
||
|
||
# Anything holding something in use is itself in use, all the way up.
|
||
local n p
|
||
for n in "${!_used[@]}"; do
|
||
p="${_parent[$n]:-}"
|
||
while [[ -n "$p" ]]; do _used[$p]=1; p="${_parent[$p]:-}"; done
|
||
done
|
||
|
||
local -a spare=()
|
||
for n in "${!_type[@]}"; do
|
||
[[ -n "${_used[$n]:-}" ]] && continue
|
||
case "$n" in loop*|sr*|zram*|ram*|fd*) continue ;; esac
|
||
# A disk with partitions is described by those partitions; offering both
|
||
# would list the same space twice.
|
||
[[ "${_type[$n]}" == "disk" && -n "${_child[$n]:-}" ]] && continue
|
||
[[ "${_type[$n]}" == "disk" || "${_type[$n]}" == "part" ]] || continue
|
||
# Skip the small ones — EFI and boot stubs are not app-data drives.
|
||
case "${_size[$n]}" in *G|*T) ;; *) continue ;; esac
|
||
spare+=("/dev/$n ${_size[$n]}${_fs[$n]:+ ${_fs[$n]}}${_fs[$n]:+}")
|
||
[[ -z "${_fs[$n]}" ]] && spare[-1]="${spare[-1]} (no filesystem yet)"
|
||
done
|
||
|
||
(( ${#spare[@]} )) || return 0
|
||
|
||
echo ""
|
||
isNotice "Another drive is attached but not mounted, so it cannot be used yet:"
|
||
local s
|
||
for s in "${spare[@]}"; do printf ' %s\n' "$s"; done
|
||
echo ""
|
||
echo " Mount it and run this installer again to install onto it, or add it"
|
||
echo " afterwards with: libreportal storage add /path/to/mountpoint"
|
||
echo ""
|
||
return 0
|
||
}
|
||
|
||
initPickRoots()
|
||
{
|
||
[[ "$init_unattended_mode" == true ]] && return 0
|
||
[[ -t 0 && -t 1 ]] || return 0
|
||
command -v findmnt >/dev/null 2>&1 || return 0
|
||
# Nothing left to ask?
|
||
[[ -n "${LP_SYSTEM_DIR_EXPLICIT:-}" && -n "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]] && return 0
|
||
|
||
local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null)
|
||
local -a paths=() labels=()
|
||
local line kv val target source fstype size avail dev
|
||
|
||
while IFS= read -r line; do
|
||
[[ -z "$line" ]] && continue
|
||
target=""; source=""; fstype=""; size=""; avail=""
|
||
for kv in TARGET SOURCE FSTYPE SIZE AVAIL; do
|
||
val="${line#*${kv}=\"}"
|
||
[[ "$val" == "$line" ]] && continue
|
||
val="${val%%\"*}"
|
||
case "$kv" in
|
||
TARGET) target="$val" ;; SOURCE) source="$val" ;;
|
||
FSTYPE) fstype="$val" ;; SIZE) size="$val" ;; AVAIL) avail="$val" ;;
|
||
esac
|
||
done
|
||
[[ -z "$target" ]] && continue
|
||
case "$target" in
|
||
/|/boot|/boot/*|/efi|/proc*|/sys*|/dev*|/run*|/snap*|/var/snap/*|/tmp) continue ;;
|
||
esac
|
||
case "$fstype" in
|
||
squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|iso9660|udf|vfat|exfat|ntfs|ntfs3|msdos|fuseblk|"") continue ;;
|
||
esac
|
||
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
|
||
[[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue
|
||
paths+=("$target")
|
||
labels+=("$(printf '%-24s %-8s %s free' "$target" "${size:-?}" "${avail:-?}")")
|
||
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL 2>/dev/null)
|
||
|
||
# Nothing mounted to offer. That is not the same as "no other disk": a
|
||
# brand-new drive is usually unmounted, sometimes unformatted, and this
|
||
# prompt is the only place the installer ever mentions that app data can
|
||
# live somewhere else. Returning in silence taught those users the feature
|
||
# does not exist.
|
||
if (( ${#paths[@]} == 0 )); then
|
||
_initMentionUnmounted
|
||
return 0
|
||
fi
|
||
|
||
|
||
local root_size root_avail
|
||
root_size=$(findmnt -no SIZE --target / 2>/dev/null | tail -1)
|
||
root_avail=$(findmnt -no AVAIL --target / 2>/dev/null | tail -1)
|
||
|
||
echo ""
|
||
isHeader "Where should LibrePortal keep things?"
|
||
echo ""
|
||
printf ' %s %-24s %-8s %s free\n' "1)" "This disk (default)" "${root_size:-?}" "${root_avail:-?}"
|
||
local i
|
||
for i in "${!paths[@]}"; do
|
||
printf ' %s %s\n' "$((i + 2)))" "${labels[$i]}"
|
||
done
|
||
echo ""
|
||
|
||
# Ask for one root. Echoes the chosen mount point on STDOUT, or nothing for
|
||
# "this disk". Everything the human reads goes to stderr — stdout is the
|
||
# return channel here, and prompting on it makes the prompt part of the
|
||
# answer.
|
||
_initAskDisk() {
|
||
local prompt="$1" choice=""
|
||
echo "$prompt" >&2
|
||
isQuestion "Choose [1]:" >&2
|
||
read -r choice
|
||
echo "" >&2
|
||
[[ -z "$choice" || "$choice" == "1" ]] && return 0
|
||
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 2 || choice > ${#paths[@]} + 1 )); then
|
||
isNotice "Not a listed option — using this disk." >&2
|
||
return 0
|
||
fi
|
||
printf '%s' "${paths[$((choice - 2))]}"
|
||
}
|
||
|
||
local chosen
|
||
if [[ -z "${LP_SYSTEM_DIR_EXPLICIT:-}" ]]; then
|
||
chosen=$(_initAskDisk "LibrePortal — settings, database, logs. Around 20 MB, and it stays small.")
|
||
if [[ -n "$chosen" ]]; then
|
||
LP_SYSTEM_DIR="${chosen%/}/libreportal-system"
|
||
isSuccessful "LibrePortal will live in $LP_SYSTEM_DIR"
|
||
fi
|
||
fi
|
||
|
||
if [[ -z "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]]; then
|
||
chosen=$(_initAskDisk "App data — everything your apps store. This is the one that grows.")
|
||
if [[ -n "$chosen" ]]; then
|
||
LP_CONTAINERS_DIR="${chosen%/}/libreportal-containers"
|
||
isSuccessful "App data will live in $LP_CONTAINERS_DIR"
|
||
fi
|
||
fi
|
||
|
||
unset -f _initAskDisk
|
||
return 0
|
||
}
|
||
|
||
# First question of the whole installer: is this a new server, or a replacement
|
||
# for one?
|
||
#
|
||
# The two answers are genuinely different installs, not a setting — a restore
|
||
# skips the setup wizard entirely, because identity, domains and app choices all
|
||
# come out of the backup rather than being asked for twice.
|
||
#
|
||
# It lives HERE, in the installer, rather than in the WebUI wizard, and that is
|
||
# what makes it tractable: the repository password is typed at a root terminal
|
||
# and used by restic in the same process. Asking in the browser would mean
|
||
# moving that password across the container/manager boundary, where the only
|
||
# available channels are a world-readable task file or a command line visible in
|
||
# ps (see docs/roadmap/first-run-restore.md §4.1).
|
||
init_mode="new"
|
||
initPickMode()
|
||
{
|
||
[[ "$init_unattended_mode" == true ]] && return 0
|
||
[[ -t 0 && -t 1 ]] || return 0
|
||
|
||
echo ""
|
||
isHeader "What are we doing?"
|
||
echo ""
|
||
printf ' 1) %s\n' "New setup — install LibrePortal fresh on this machine"
|
||
printf ' 2) %s\n' "Restore from backup — rebuild a server from an existing backup"
|
||
echo ""
|
||
local choice=""
|
||
isQuestion "Choose [1]:"
|
||
read -r choice
|
||
echo ""
|
||
case "$choice" in
|
||
2) init_mode="restore"
|
||
isSuccessful "Restore mode — LibrePortal will be installed, then filled from your backup."
|
||
echo "" ;;
|
||
*) init_mode="new" ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# The restore half, run after the platform is installed and the WebUI is up.
|
||
#
|
||
# Order matters and mirrors what the CLI already enforces: the system config
|
||
# comes back FIRST, because it carries every other backup location's
|
||
# credentials — so one password the user remembers unlocks the rest — and only
|
||
# then are apps restored.
|
||
initRestoreFromBackup()
|
||
{
|
||
[[ "$init_mode" == "restore" ]] || return 0
|
||
|
||
local as_manager=(sudo -u "$sudo_user_name")
|
||
|
||
echo ""
|
||
isHeader "Restore from backup"
|
||
echo ""
|
||
echo " Your backups live in a repository — a folder on a disk, or a remote"
|
||
echo " server. Not a single file, so point us at the repository itself."
|
||
echo ""
|
||
|
||
local btype="" bpath="" bpass=""
|
||
printf ' 1) %s\n' "A folder on this machine or a plugged-in disk"
|
||
printf ' 2) %s\n' "An SFTP server"
|
||
echo ""
|
||
isQuestion "Where is it? [1]:"
|
||
read -r btype
|
||
echo ""
|
||
|
||
local loc_type="local"
|
||
local ssh_user="" ssh_host="" ssh_path=""
|
||
if [[ "$btype" == "2" ]]; then
|
||
loc_type="sftp"
|
||
isQuestion "SSH user:"; read -r ssh_user; echo ""
|
||
isQuestion "SSH host:"; read -r ssh_host; echo ""
|
||
isQuestion "Remote path to the repository:"; read -r ssh_path; echo ""
|
||
[[ -n "$ssh_user" && -n "$ssh_host" && -n "$ssh_path" ]] || {
|
||
isError "SFTP needs a user, a host and a path — skipping the restore."
|
||
return 1
|
||
}
|
||
else
|
||
isQuestion "Path to the repository:"; read -r bpath; echo ""
|
||
if [[ -z "$bpath" || ! -d "$bpath" ]]; then
|
||
isError "'$bpath' is not a directory — skipping the restore."
|
||
isNotice "You can do this later with: libreportal backup location add …"
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
# -s: the repository password is the key to every backup the user has, so it
|
||
# is never echoed and never lands in a command line or a log.
|
||
isQuestion "Repository password:"
|
||
read -rs bpass
|
||
echo ""
|
||
echo ""
|
||
if [[ -z "$bpass" ]]; then
|
||
isError "No password given — an encrypted repository cannot be opened without it."
|
||
return 1
|
||
fi
|
||
|
||
# --- connect ---------------------------------------------------------------
|
||
local idx
|
||
idx=$("${as_manager[@]}" libreportal backup location add restore-source "$loc_type" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||
if [[ -z "$idx" ]]; then
|
||
isError "Could not create a backup location — skipping the restore."
|
||
return 1
|
||
fi
|
||
|
||
local cfg="$configs_dir/backup/locations/$idx/location.config"
|
||
if [[ ! -f "$cfg" ]]; then
|
||
isError "Backup location $idx has no config at $cfg — skipping the restore."
|
||
return 1
|
||
fi
|
||
# Written straight into the (0640, manager-owned) location config rather than
|
||
# passed as an argument, so the password never appears in ps or a task file.
|
||
_initSetCfg() { sed -i "s|^$1=.*|$1=$2|" "$cfg"; }
|
||
if [[ "$loc_type" == "sftp" ]]; then
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_USER" "$ssh_user"
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_HOST" "$ssh_host"
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_SSH_PATH" "$ssh_path"
|
||
else
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_PATH_MODE" "custom"
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_PATH" "$bpath"
|
||
fi
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_PASSWORD" "$bpass"
|
||
_initSetCfg "CFG_BACKUP_LOC_${idx}_ENABLED" "true"
|
||
unset -f _initSetCfg
|
||
bpass=""
|
||
chown "$sudo_user_name":"$sudo_user_name" "$cfg" 2>/dev/null
|
||
chmod 0640 "$cfg" 2>/dev/null
|
||
|
||
# --- discover ---------------------------------------------------------------
|
||
isNotice "Reading the repository…"
|
||
local snaps
|
||
snaps=$("${as_manager[@]}" libreportal restore first-run discover "$idx" 2>/dev/null)
|
||
if [[ -z "$snaps" || "$snaps" == "null" ]]; then
|
||
isError "Could not read any snapshots. Wrong password, or not a LibrePortal repository."
|
||
isNotice "The location was kept as '$idx' — fix it on the Backup page and restore from there."
|
||
return 1
|
||
fi
|
||
|
||
local hosts
|
||
hosts=$(printf '%s' "$snaps" | grep -o '"hostname":"[^"]*"' | cut -d'"' -f4 | sort -u)
|
||
[[ -z "$hosts" ]] && { isError "No hosts found in that repository."; return 1; }
|
||
|
||
local host_count; host_count=$(printf '%s\n' "$hosts" | grep -c .)
|
||
local source_host
|
||
if (( host_count == 1 )); then
|
||
source_host="$hosts"
|
||
isSuccessful "Found backups from '$source_host'"
|
||
else
|
||
echo ""
|
||
echo " Backups were found from more than one machine:"
|
||
local i=1 h
|
||
while IFS= read -r h; do printf ' %s) %s\n' "$i" "$h"; i=$((i+1)); done <<< "$hosts"
|
||
echo ""
|
||
isQuestion "Which one? [1]:"
|
||
local hc; read -r hc; echo ""
|
||
[[ "$hc" =~ ^[0-9]+$ ]] || hc=1
|
||
source_host=$(printf '%s\n' "$hosts" | sed -n "${hc}p")
|
||
[[ -z "$source_host" ]] && source_host=$(printf '%s\n' "$hosts" | head -1)
|
||
isSuccessful "Restoring from '$source_host'"
|
||
fi
|
||
|
||
# --- system config first ----------------------------------------------------
|
||
isNotice "Restoring settings and credentials…"
|
||
"${as_manager[@]}" libreportal restore system "$idx" >/dev/null 2>&1 \
|
||
&& isSuccessful "Settings restored" \
|
||
|| isNotice "System config could not be restored — apps will still be attempted."
|
||
|
||
# --- apps -------------------------------------------------------------------
|
||
local apps
|
||
apps=$(printf '%s' "$snaps" \
|
||
| grep -o '"app=[^"]*"' | sed 's/"app=\(.*\)"/\1/' | sort -u)
|
||
if [[ -z "$apps" ]]; then
|
||
isNotice "No app snapshots found for '$source_host' — nothing else to restore."
|
||
return 0
|
||
fi
|
||
|
||
# Preflight before anything is written: each app's manifest is read out of
|
||
# its own snapshot and checked against this machine, so an app this version
|
||
# no longer ships, or one that will not fit, is skipped rather than restored
|
||
# into something that cannot start.
|
||
echo ""
|
||
# No app list: preflight discovers the host's apps itself, so nothing is
|
||
# truncated by the CLI wrapper's nine-slot argv.
|
||
"${as_manager[@]}" libreportal restore preflight "$idx" "$source_host"
|
||
echo ""
|
||
|
||
isQuestion "Continue? [Y/n]:"
|
||
local yn; read -r yn; echo ""
|
||
case "$yn" in
|
||
[nN]*) isNotice "Skipped. Restore any time from the Backup page."; return 0 ;;
|
||
esac
|
||
|
||
isNotice "Restoring — this takes a while."
|
||
# No app list, deliberately: bulk discovers the host's apps and re-applies
|
||
# the preflight itself. Passing the list here is what let a 13-app restore
|
||
# arrive as four and still report success.
|
||
"${as_manager[@]}" libreportal restore first-run bulk "$idx" "$source_host"
|
||
|
||
# The wizard would ask for identity, domains and apps that the backup has
|
||
# already answered, so mark it done rather than asking twice.
|
||
local lock="$containers_dir/libreportal/frontend/data/.setup_complete"
|
||
[[ -d "$(dirname "$lock")" ]] && { : > "$lock"; chown "$CFG_DOCKER_INSTALL_USER":"$CFG_DOCKER_INSTALL_USER" "$lock" 2>/dev/null; chmod 0644 "$lock" 2>/dev/null; }
|
||
|
||
isSuccessful "Restore complete"
|
||
return 0
|
||
}
|
||
|
||
# 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#*=}"; LP_SYSTEM_DIR_EXPLICIT=1; ((init_shift_count++)) ;;
|
||
--containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; LP_CONTAINERS_DIR_EXPLICIT=1; ((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"
|
||
|
||
# Refuse to copy a directory onto itself. Everything below deletes the
|
||
# destination BEFORE reading the source, so when the two resolve to the same
|
||
# path the rm wipes the source too and the copy fails with the tree already
|
||
# gone — taking the install down and stopping short of the root-helper bake
|
||
# and the sudoers tightening, leaving install-phase sudo in place.
|
||
# Running `sudo ./init.sh init` from inside the install tree is enough to hit
|
||
# it, which is an easy mistake when re-running to re-bake the footprint.
|
||
local _src_real _dst_real
|
||
_src_real="$(readlink -f "$source_dir" 2>/dev/null || echo "$source_dir")"
|
||
_dst_real="$(readlink -f "$script_dir" 2>/dev/null || echo "$script_dir")"
|
||
if [[ "$_src_real" == "$_dst_real" ]]; then
|
||
isError "Source and destination are the same directory ($_dst_real)."
|
||
isNotice "Run init.sh from your source checkout, not from inside the install tree."
|
||
exit 1
|
||
fi
|
||
|
||
# Validate the source BEFORE destroying the destination, for the same reason:
|
||
# a bad source used to be discovered only after the install tree was gone.
|
||
# detectLocalLibrePortal derives the directory the same way source_dir is
|
||
# derived above, so it checks exactly what is about to be copied.
|
||
if ! detectLocalLibrePortal >/dev/null 2>&1; then
|
||
isError "$source_dir is not a valid LibrePortal source — refusing to replace $script_dir."
|
||
exit 1
|
||
fi
|
||
|
||
# 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"
|
||
}
|
||
|
||
# Rootless Docker needs ip_tables/ip6_tables present before its installer runs.
|
||
# Idempotent: safe to re-run, and a no-op once the modules are already live.
|
||
# Returns non-zero if the modules genuinely can't be loaded, so the caller can
|
||
# stop rather than let rootless setup fail later with a swallowed message.
|
||
initIptablesModules()
|
||
{
|
||
local modules_conf="/etc/modules-load.d/libreportal-rootless.conf"
|
||
local mod missing=()
|
||
|
||
for mod in ip_tables ip6_tables; do
|
||
# Already built in (or loaded) — nothing to do for this one.
|
||
if lsmod 2>/dev/null | grep -q "^${mod}\b"; then
|
||
continue
|
||
fi
|
||
if ! sudo modprobe "$mod" 2>/dev/null; then
|
||
missing+=("$mod")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||
isError "Could not load required kernel module(s): ${missing[*]}"
|
||
isNotice "Rootless Docker cannot be installed without these. On a VPS this usually"
|
||
isNotice "means a container/VM kernel without netfilter — check with your provider."
|
||
return 1
|
||
fi
|
||
|
||
# Persist across reboots. Written unconditionally so an edited/truncated file
|
||
# gets repaired, but only when the content would actually change.
|
||
local desired="# LibrePortal: rootless Docker requires the legacy iptables modules
|
||
ip_tables
|
||
ip6_tables"
|
||
if [[ "$(cat "$modules_conf" 2>/dev/null)" != "$desired" ]]; then
|
||
echo "$desired" | sudo tee "$modules_conf" >/dev/null
|
||
isSuccessful "Persisted iptables modules to $modules_conf"
|
||
else
|
||
isSuccessful "iptables modules already loaded and persisted."
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
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
|
||
|
||
# get.docker.com/rootless aborts when the legacy ip_tables modules aren't
|
||
# loaded. Ubuntu 24.04/26.04 ship them but don't autoload them on a fresh
|
||
# box, so rootless setup dies here with a message that never surfaces (the
|
||
# caller captures its output). Load them now and persist for later boots.
|
||
initIptablesModules
|
||
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/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" <<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, \\
|
||
${lp_lib_dir}/libreportal-storage
|
||
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 libreportal-storage; 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
|
||
|
||
# libreportal-relocate is installed the same way but deliberately NOT added to
|
||
# the scoped sudoers, and symlinked into PATH like the uninstaller: moving a
|
||
# root re-bakes the very helpers the sudoers allowlist trusts, so it has to be
|
||
# a human with real root, never the manager.
|
||
if [[ -f "$script_dir/scripts/system/libreportal-relocate" ]]; then
|
||
sudo install -m 0755 -o root -g root "$script_dir/scripts/system/libreportal-relocate" "$lp_lib_dir/libreportal-relocate"
|
||
sudo ln -sfn "$lp_lib_dir/libreportal-relocate" /usr/local/bin/libreportal-relocate
|
||
isSuccessful "Installed root-owned relocate command (libreportal-relocate)."
|
||
fi
|
||
|
||
# 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"
|
||
# Grouped by root, each root immediately followed by its own children
|
||
# (alphabetical). Order also matters: mkdir below has no -p, so every
|
||
# parent must be listed before its children.
|
||
folders=(
|
||
"$docker_dir" "$configs_dir" "$script_dir" "$logs_dir" "$migrate_dir" "$restore_dir" "$ssh_dir" "$ssl_dir" "$wireguard_dir"
|
||
"$containers_dir"
|
||
"$backup_dir" "$backup_install_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)."
|
||
}
|
||
|
||
# Give the WebUI container back its read access to the configs/webui bind
|
||
# mounts after anything chowns the configs tree. Prefers the root helper (it
|
||
# knows the install mode and therefore the right container owner); falls back to
|
||
# an inline chown for the first install, where the helper is not baked yet.
|
||
# Safe to call when the container user does not exist — it simply does nothing.
|
||
restoreWebuiBindAccess()
|
||
{
|
||
local helper="/usr/local/lib/libreportal/libreportal-ownership"
|
||
if [[ -x "$helper" ]]; then
|
||
sudo "$helper" webui-bind 2>/dev/null && return 0
|
||
fi
|
||
local wdir="${configs_dir%/}/webui"
|
||
[[ -d "$wdir" ]] || return 0
|
||
local dcfg="${configs_dir%/}/general/general_docker_install"
|
||
local duser
|
||
duser=$(grep -h '^CFG_DOCKER_INSTALL_USER=' "$dcfg" 2>/dev/null | head -1 | cut -d= -f2 | awk '{print $1}')
|
||
duser="${duser:-dockerinstall}"
|
||
id "$duser" &>/dev/null || return 0
|
||
sudo chmod o+x "${configs_dir%/}" 2>/dev/null
|
||
sudo chown "$sudo_user_name:$duser" "$wdir" 2>/dev/null
|
||
sudo chmod 0751 "$wdir" 2>/dev/null
|
||
sudo find "$wdir" -maxdepth 1 -type f \
|
||
-exec chown "$sudo_user_name:$duser" {} \; -exec chmod 0640 {} \; 2>/dev/null
|
||
}
|
||
|
||
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"
|
||
# ...but configs/webui/* are BIND-MOUNTED into the WebUI container, which
|
||
# reads them through its GROUP (files are 0640, group = container owner).
|
||
# The blanket chown above resets that group to the manager, and the portal
|
||
# then dies with EACCES on /app/webui_logins the next time it restarts —
|
||
# not immediately, so the breakage surfaces long after the deploy that
|
||
# caused it. Hand group access straight back.
|
||
restoreWebuiBindAccess
|
||
|
||
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"
|
||
# The nine slots stay exactly as they were — every dispatcher reads them,
|
||
# and unset ones must keep arriving as the literal "empty". The REAL argv
|
||
# rides after them so commands that take an open-ended list of app names
|
||
# are not silently cut to four; start.sh reads it back as "${@:10}".
|
||
./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/architecture/system-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-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" "/usr/local/bin/libreportal-relocate" "root-only relocate command"
|
||
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"
|
||
printf " %-34s ${DIM}%s${NC}\n" "/etc/modules-load.d/libreportal*" "iptables module drop-in"
|
||
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 /usr/local/bin/libreportal-relocate
|
||
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
|
||
# Drop the modules-load drop-in too. The modules stay loaded until reboot,
|
||
# which is fine — we only stop *forcing* them on for a machine that no
|
||
# longer runs rootless docker.
|
||
rm -f /etc/modules-load.d/libreportal-rootless.conf
|
||
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."
|
||
|
||
# App data on an ADDITIONAL storage location is not removed, while the data
|
||
# on the primary root went with that root. Both are defensible on their own —
|
||
# deleting a user's data off a separate disk unasked is worse than leaving it
|
||
# — but doing one silently and the other silently is the one option nobody
|
||
# can predict. So say exactly what is still there.
|
||
#
|
||
# It also stops being obvious: the directories are owned by a uid that no
|
||
# longer maps to a user once the container account is gone, and each location
|
||
# keeps its .libreportal-storage marker, which a later install will recognise
|
||
# and adopt.
|
||
local _reg="/usr/local/lib/libreportal/storage.roots"
|
||
[[ -r "$_reg" ]] || _reg="${lp_lib_dir:-/usr/local/lib/libreportal}/storage.roots"
|
||
if [[ -r "$_reg" ]]; then
|
||
local _id _path _rest _left=0
|
||
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
|
||
[[ -z "$_path" || "$_id" == \#* ]] && continue
|
||
[[ -d "$_path" ]] || continue
|
||
local _n; _n=$(find "$_path" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
|
||
(( _n > 0 )) || continue
|
||
(( _left == 0 )) && { echo ""; isNotice "App data left on these storage locations — nothing here was deleted:"; }
|
||
printf ' %-40s %s app director%s\n' "$_path" "$_n" "$( (( _n == 1 )) && echo y || echo ies )"
|
||
_left=1
|
||
done < "$_reg"
|
||
if (( _left )); then
|
||
echo ""
|
||
printf ' %s\n' "Remove them by hand if you meant to, or leave them — a reinstall can adopt"
|
||
printf ' %s\n' "the location again and the apps restore back onto it."
|
||
echo ""
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# 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
|
||
# New setup or restore? Asked before anything else, because the two
|
||
# are different installs rather than a setting.
|
||
initPickMode
|
||
|
||
# Ask where things go before anything is validated or created.
|
||
# No-op when unattended, when the flags were passed, or when there
|
||
# is only one possible answer.
|
||
initPickRoots
|
||
libreportalDerivePaths
|
||
|
||
# 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
|
||
# Restore mode: the platform is up, now fill it from the backup.
|
||
initRestoreFromBackup
|
||
completeInitMessage
|
||
elif [[ "$param1" == "uninstall" ]]; then
|
||
runFullUninstall
|
||
fi
|
||
fi
|