feat(install): New setup or Restore from backup — two installer paths
The first question the installer asks is now which of the two this is, because they are different installs rather than a setting: a restore skips the setup wizard entirely, since identity, domains and app choices all come out of the backup instead of being asked for twice. Putting this in the INSTALLER rather than the WebUI wizard is what makes it tractable. The repository password is typed at a root terminal and handed to 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 — the blocker recorded in first-run-restore.md §4.1. Here that problem simply does not arise. The password is read with -s, never echoed, and written straight into the 0640 manager-owned location config rather than passed as an argument, so it does not appear in ps or any log. Order follows what the CLI already enforces: connect, discover, restore the SYSTEM CONFIG first — it carries every other location's credentials, so the one password the user remembers unlocks the rest — then the apps via restoreFirstRunBulk. Finally the setup-wizard lock is set, because the backup has already answered everything the wizard would ask. Verified under a pty: mode default, a repository path that does not exist, and an empty password are each refused with the reason. The host and app parsing was checked against the live repository on this box — one host, 13 apps extracted correctly from real restic output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d44ebf0ca7
commit
e165e28ceb
206
init.sh
206
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user