diff --git a/init.sh b/init.sh index 19671d4..bb4f3a9 100755 --- a/init.sh +++ b/init.sh @@ -305,6 +305,206 @@ initPickRoots() 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 + + echo "" + echo " Apps in this backup:" + local a + while IFS= read -r a; do printf ' %s\n' "$a"; done <<< "$apps" + echo "" + isQuestion "Restore all of them? [Y/n]:" + local yn; read -r yn; echo "" + case "$yn" in + [nN]*) isNotice "Skipped. Restore them any time from the Backup page."; return 0 ;; + esac + + local -a app_list=() + while IFS= read -r a; do [[ -n "$a" ]] && app_list+=("$a"); done <<< "$apps" + isNotice "Restoring ${#app_list[@]} apps — this takes a while." + "${as_manager[@]}" libreportal restore first-run bulk "$idx" "$source_host" "${app_list[@]}" + + # 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). @@ -2038,6 +2238,10 @@ if [[ $EUID -ne 0 ]]; then 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. @@ -2077,6 +2281,8 @@ else initLibrePortalCommand initUpdateConfigs initContainerLayer + # Restore mode: the platform is up, now fill it from the backup. + initRestoreFromBackup completeInitMessage elif [[ "$param1" == "uninstall" ]]; then runFullUninstall