The step asked one question — pick a destination, or "not now" — while the
system underneath already had a full location model: eight backend types, per
location engine, path mode, credentials and retention, and a generated
locations.json carrying all of it. None of that was reachable during setup, so a
second destination, or even seeing where the first one points, meant finding the
Backup page afterwards.
Now it mirrors the Storage step — the choice above, the list below:
Backups Automatic — daily, on a schedule | Manual
Destinations Local disk [default] /libreportal-backups/1 [Edit]
+ Add destination
Automatic/Manual needed a setting, because there was no off switch:
crontabSetupBackupScheduler installed the entry unconditionally. CFG_BACKUP_MODE
is explicit rather than overloading "empty schedule", so it reads properly in
the config editor too, and Manual REMOVES an entry that is already installed
rather than merely declining to add one — otherwise answering Manual changes
nothing. The schedule itself is left alone, so switching back restores the time
the user picked.
Destinations are seeded from locations.json, so the default one is shown and
editable instead of being discovered later, and only entries the user actually
added or changed are submitted. A destination on the same disk as the app data
says so on the card rather than in a paragraph under the step.
Remote destinations are what the secret channel was for. The wizard payload is
base64'd into a task's command string and tasks are recorded world-readable, so
a password is POSTed to /api/setup/secret, which writes it where only the
manager can read it and returns an opaque reference; the reference travels in
the payload and setup_apply redeems it once, at the write. A reference that
cannot be redeemed leaves the password alone and says so, rather than blanking
it.
Verified in the browser on a clean install: the step renders both modes, lists
the existing destination at its resolved path, and the add dialog swaps between
local and remote fields. scripts/dev/lp-backup-setup-test covers the apply side,
including that what reaches the config is the secret and never the reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
18 KiB
Bash
406 lines
18 KiB
Bash
#!/bin/bash
|
|
|
|
setupApplyConfig()
|
|
{
|
|
local payload_b64="$1"
|
|
|
|
if [[ -z "$payload_b64" ]]; then
|
|
isError "setupApplyConfig: no payload provided"
|
|
return 1
|
|
fi
|
|
|
|
local payload
|
|
payload=$(echo "$payload_b64" | base64 -d 2>/dev/null)
|
|
if [[ -z "$payload" ]]; then
|
|
isError "setupApplyConfig: failed to decode payload"
|
|
return 1
|
|
fi
|
|
|
|
isHeader "Applying Setup Wizard Configuration"
|
|
|
|
local install_name=$(echo "$payload" | jq -r '.install_name // empty')
|
|
local timezone=$(echo "$payload" | jq -r '.timezone // empty')
|
|
local install_level=$(echo "$payload" | jq -r '.install_level // empty')
|
|
local dev_mode=$(echo "$payload" | jq -r '.dev_mode // empty')
|
|
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
|
|
local domains_json=$(echo "$payload" | jq -c '.domains // []')
|
|
local storage_json=$(echo "$payload" | jq -c '.storage // []')
|
|
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
|
|
local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
|
|
local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""')
|
|
local backup_mode=$(echo "$payload" | jq -r '.backup_mode // ""')
|
|
local import_files_json=$(echo "$payload" | jq -c '.import_files // []')
|
|
|
|
if [[ -n "$install_name" ]]; then
|
|
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
|
|
isSuccessful "Install name set to '$install_name'"
|
|
fi
|
|
|
|
if [[ -n "$timezone" ]]; then
|
|
updateConfigOption "CFG_TIMEZONE" "$timezone"
|
|
isSuccessful "Timezone set to '$timezone'"
|
|
fi
|
|
|
|
# Experience level — seeds the WebUI's Advanced UI mode on first paint
|
|
# so a Beginner gets a stripped-down view and an Advanced user sees
|
|
# everything by default. The WebUI also exposes a per-browser toggle
|
|
# that overrides this; we just provide the install-time default.
|
|
if [[ "$install_level" == "beginner" || "$install_level" == "advanced" ]]; then
|
|
updateConfigOption "CFG_INSTALL_LEVEL" "$install_level"
|
|
isSuccessful "Experience level set to '$install_level'"
|
|
fi
|
|
|
|
# Developer mode — opt-in via the wizard's Advanced-card easter egg (10
|
|
# taps). Unlocks the **DEV**-marked CFG_* fields across the WebUI.
|
|
if [[ "$dev_mode" == "true" ]]; then
|
|
updateConfigOption "CFG_DEV_MODE" "true"
|
|
isSuccessful "Developer mode enabled"
|
|
fi
|
|
|
|
# Storage locations ticked on the wizard's Storage step. This is a REQUEST,
|
|
# not an instruction: storageAdd re-runs the fitness checks and the root
|
|
# helper re-runs admission, so a path that should not be accepted is not,
|
|
# no matter what arrived in the payload.
|
|
local storage_count=$(echo "$storage_json" | jq -r 'length')
|
|
if [[ "$storage_count" -gt 0 ]]; then
|
|
local s i=0
|
|
while [[ $i -lt $storage_count ]]; do
|
|
s=$(echo "$storage_json" | jq -r ".[$i]")
|
|
if [[ -n "$s" && "$s" != "null" ]]; then
|
|
# Name it after the mount point's basename — short, recognisable,
|
|
# and the user can rename it later from the location config.
|
|
local sname="${s##*/}"
|
|
[[ -z "$sname" ]] && sname="disk$((i+1))"
|
|
if storageAdd "$s" "$sname" >/dev/null; then
|
|
isSuccessful "Storage location '$sname' registered at $s"
|
|
# Only when explicitly asked on the Storage step. The helper
|
|
# validates independently and writes with nofail, so a
|
|
# missing drive can never block boot.
|
|
if echo "$storage_fstab_json" | jq -e --arg p "$s" 'index($p) != null' >/dev/null 2>&1; then
|
|
local fline
|
|
if fline=$(runStorage fstab-add "$s" 2>&1); then
|
|
isSuccessful "Added to /etc/fstab so it mounts at boot: $fline"
|
|
else
|
|
isNotice "Could not update /etc/fstab for $s: $fline"
|
|
fi
|
|
fi
|
|
else
|
|
isNotice "Could not register '$s' as a storage location — continuing without it."
|
|
fi
|
|
fi
|
|
i=$((i+1))
|
|
done
|
|
fi
|
|
|
|
# Where newly installed apps keep their data. Stored as a location NAME, not
|
|
# a path: app configs ship CFG_<APP>_STORAGE=default meaning "follow this",
|
|
# so one choice at setup places every app installed afterwards without
|
|
# touching 37 configs. Resolved AFTER registration, since the name only
|
|
# exists once the location has been accepted.
|
|
if [[ -n "$storage_default" && "$storage_default" != "primary" && "$storage_default" != "null" ]]; then
|
|
local default_name=""
|
|
if declare -f storageLocationName >/dev/null 2>&1; then
|
|
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
|
|
fi
|
|
if [[ -n "$default_name" && "$default_name" != "default" ]]; then
|
|
updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name"
|
|
isSuccessful "New apps will store their data on '$default_name'"
|
|
else
|
|
isNotice "Could not resolve '$storage_default' to a storage location — new apps will use the system disk."
|
|
fi
|
|
fi
|
|
|
|
# Backup destination chosen on the Backups step. Configured here rather than
|
|
# left for the user to find later: the people most likely to need a restore
|
|
# are the ones who never got round to setting one up.
|
|
#
|
|
# Created disabled by locationAdd, so enable it and initialise the repo —
|
|
# an un-initialised location is a destination that silently backs up
|
|
# nothing.
|
|
# Automatic or Manual. The schedule itself is left alone either way, so
|
|
# switching back to Automatic restores the time the user picked rather than
|
|
# a default; crontabSetupBackupScheduler is what acts on this, and it
|
|
# removes an already-installed entry when the answer is Manual.
|
|
if [[ "$backup_mode" == "automatic" || "$backup_mode" == "manual" ]]; then
|
|
updateConfigOption "CFG_BACKUP_MODE" "$backup_mode" \
|
|
"${configs_dir%/}/backup/backup_general" >/dev/null 2>&1
|
|
isSuccessful "Backups set to ${backup_mode}."
|
|
declare -f crontabSetupBackupScheduler >/dev/null 2>&1 && crontabSetupBackupScheduler
|
|
fi
|
|
|
|
# Destinations. An entry with an idx edits the location that already exists
|
|
# (a fresh install ships one); anything else is created.
|
|
#
|
|
# A password arrives as a REFERENCE, never a value: this whole payload was
|
|
# base64'd into the task's command string, and tasks are recorded in a
|
|
# world-readable file. webuiSecretResolve redeems it here, once, at the
|
|
# moment of the write. See scripts/webui/webui_secret.sh.
|
|
local _nloc; _nloc=$(echo "$payload" | jq -r '(.backup_locations // []) | length')
|
|
if [[ "$_nloc" =~ ^[0-9]+$ ]] && (( _nloc > 0 )); then
|
|
local _i
|
|
for (( _i=0; _i<_nloc; _i++ )); do
|
|
local _loc; _loc=$(echo "$payload" | jq -c ".backup_locations[$_i]")
|
|
local _idx _name _type _path _pwref
|
|
_idx=$(jq -r '.idx // ""' <<< "$_loc")
|
|
_name=$(jq -r '.name // "backup"' <<< "$_loc")
|
|
_type=$(jq -r '.type // "local"' <<< "$_loc")
|
|
_path=$(jq -r '.path // ""' <<< "$_loc")
|
|
_pwref=$(jq -r '.password_ref // ""' <<< "$_loc")
|
|
|
|
if [[ ! "$_idx" =~ ^[0-9]+$ ]]; then
|
|
_idx=$(locationAdd "$_name" "$_type" 2>/dev/null | tail -1)
|
|
if [[ ! "$_idx" =~ ^[0-9]+$ ]]; then
|
|
isNotice "Could not create the backup destination '$_name' — add it from the Backup page."
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
local _cfg; _cfg=$(backupLocationConfig "$_idx")
|
|
[[ -f "$_cfg" ]] || { isNotice "Backup destination $_idx has no config — skipping."; continue; }
|
|
|
|
if [[ -n "$_path" ]]; then
|
|
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PATH_MODE" "custom" "$_cfg" >/dev/null
|
|
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PATH" "$_path" "$_cfg" >/dev/null
|
|
fi
|
|
if [[ "$_type" == "sftp" ]]; then
|
|
local _k _v
|
|
for _k in ssh_user ssh_host ssh_port ssh_path; do
|
|
_v=$(jq -r --arg k "$_k" '.[$k] // ""' <<< "$_loc")
|
|
[[ -n "$_v" ]] && updateConfigOption "CFG_BACKUP_LOC_${_idx}_${_k^^}" "$_v" "$_cfg" >/dev/null
|
|
done
|
|
fi
|
|
if [[ -n "$_pwref" ]]; then
|
|
local _pw
|
|
if _pw=$(webuiSecretResolve "$_pwref" 2>/dev/null) && [[ -n "$_pw" ]]; then
|
|
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PASSWORD" "$_pw" "$_cfg" >/dev/null
|
|
_pw=""
|
|
else
|
|
isNotice "Could not read the password for '$_name' — set it on the Backup page."
|
|
fi
|
|
fi
|
|
updateConfigOption "CFG_BACKUP_LOC_${_idx}_ENABLED" "true" "$_cfg" >/dev/null
|
|
source "$_cfg" 2>/dev/null
|
|
|
|
if engineInitLocation "$_idx" >/dev/null 2>&1; then
|
|
isSuccessful "Backup destination '$_name' ready."
|
|
else
|
|
isNotice "Backup destination '$_name' was created but could not be initialised — finish it on the Backup page."
|
|
fi
|
|
done
|
|
# Handled here, so the single-destination path below stays for older
|
|
# payloads only.
|
|
backup_dest=""
|
|
fi
|
|
|
|
if [[ -n "$backup_dest" && "$backup_dest" != "null" ]]; then
|
|
local bpath bname
|
|
if [[ "$backup_dest" == "primary" ]]; then
|
|
bpath="${backup_dir%/}"
|
|
bname="local"
|
|
else
|
|
bpath="${backup_dest%/}/libreportal-backups"
|
|
bname="${backup_dest##*/}"
|
|
fi
|
|
|
|
local bidx
|
|
if bidx=$(locationAdd "$bname" local 2>/dev/null | tail -1) && [[ "$bidx" =~ ^[0-9]+$ ]]; then
|
|
local bcfg; bcfg=$(backupLocationConfig "$bidx")
|
|
if [[ "$backup_dest" != "primary" ]]; then
|
|
updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH_MODE" "custom" "$bcfg" >/dev/null
|
|
updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH" "$bpath" "$bcfg" >/dev/null
|
|
fi
|
|
updateConfigOption "CFG_BACKUP_LOC_${bidx}_ENABLED" "true" "$bcfg" >/dev/null
|
|
source "$bcfg" 2>/dev/null
|
|
|
|
if engineInitLocation "$bidx" >/dev/null 2>&1; then
|
|
isSuccessful "Backups will go to $bpath"
|
|
# Deliberately NOT echoed here: the repository password is a
|
|
# secret and task output is logged. It is shown on the Backup
|
|
# page, which is what the wizard told the user.
|
|
isNotice "The repository password is on the Backup page — keep a copy somewhere other than this machine."
|
|
else
|
|
isNotice "Backup location '$bname' was created but could not be initialised — open the Backup page to finish it."
|
|
fi
|
|
else
|
|
isNotice "Could not create a backup location at $bpath — set one up from the Backup page."
|
|
fi
|
|
fi
|
|
|
|
# Apps the user accepted on the Import step. appImport re-runs its own
|
|
# checks rather than trusting the payload — the machine may have changed
|
|
# between the check and here, and the payload arrives from a browser.
|
|
local import_count=$(echo "$import_files_json" | jq -r 'length')
|
|
if [[ "$import_count" -gt 0 ]]; then
|
|
local f i=0
|
|
while [[ $i -lt $import_count ]]; do
|
|
f=$(echo "$import_files_json" | jq -r ".[$i]")
|
|
if [[ -n "$f" && "$f" != "null" && -f "$f" ]]; then
|
|
if appImport "$f" >/dev/null 2>&1; then
|
|
isSuccessful "Imported $(basename "$f")"
|
|
else
|
|
isNotice "Could not import $(basename "$f") — run 'libreportal app import-check' on it to see why."
|
|
fi
|
|
fi
|
|
i=$((i+1))
|
|
done
|
|
fi
|
|
|
|
local domains_count=$(echo "$domains_json" | jq -r 'length')
|
|
if [[ "$domains_count" -gt 0 ]]; then
|
|
local i=0
|
|
while [[ $i -lt $domains_count && $i -lt 9 ]]; do
|
|
local d=$(echo "$domains_json" | jq -r ".[$i]")
|
|
updateConfigOption "CFG_DOMAIN_$((i+1))" "$d"
|
|
isSuccessful "Domain $((i+1)) set to '$d'"
|
|
((i++))
|
|
done
|
|
fi
|
|
|
|
if [[ -n "$traefik_email" && "$traefik_email" != "null" ]]; then
|
|
# CFG_TRAEFIK_EMAIL lives in containers/traefik/traefik.config, not in
|
|
# the system $configs_dir, so findConfigFileForOption can't auto-locate
|
|
# it. Point updateConfigOption at the source file directly. Traefik
|
|
# may not be installed yet at this point — config gets copied from
|
|
# install_containers_dir into containers_dir during the app-install
|
|
# task, so we always update the source.
|
|
local traefik_config_file="$install_containers_dir/traefik/traefik.config"
|
|
if [[ -f "$traefik_config_file" ]]; then
|
|
updateConfigOption "CFG_TRAEFIK_EMAIL" "$traefik_email" "$traefik_config_file"
|
|
isSuccessful "Traefik LetsEncrypt email set to '$traefik_email'"
|
|
else
|
|
isNotice "Traefik source config not found at $traefik_config_file; skipping email write."
|
|
fi
|
|
fi
|
|
|
|
# App sub-options are no longer handled here. The setup-routes backend
|
|
# folds payload.appOptions into each install command's config_variables
|
|
# arg (CFG_REQUIREMENT_<APP>_<OPT>=<bool>) and dockerInstallApp writes
|
|
# them into the template config before install<App> runs.
|
|
|
|
sourceScanFiles "libreportal_configs"
|
|
isSuccessful "Configuration written. Selected apps will install next."
|
|
}
|
|
|
|
setupApplyFinalize()
|
|
{
|
|
# setup_group is passed by the WebUI finalize task so we can read back the
|
|
# sibling app-install tasks and tell "install ready" apart from "install
|
|
# ran but an app failed". Empty when finalize is invoked standalone.
|
|
local setup_group="$1"
|
|
|
|
isNotice "Initializing backup engine..."
|
|
if declare -f installResticHost >/dev/null 2>&1; then
|
|
installResticHost
|
|
else
|
|
isNotice "installResticHost not loaded; backup repos will init on first backup."
|
|
fi
|
|
|
|
isNotice "Refreshing WebUI data snapshots so the config page reflects wizard changes..."
|
|
if declare -f webuiLibrePortalUpdate >/dev/null 2>&1; then
|
|
webuiLibrePortalUpdate
|
|
else
|
|
isNotice "webuiLibrePortalUpdate not loaded; skipping refresh."
|
|
fi
|
|
|
|
if declare -f webuiGenerateBackupLocations >/dev/null 2>&1; then
|
|
webuiGenerateBackupLocations
|
|
webuiGenerateBackupDashboard
|
|
webuiGenerateBackupSnapshots all
|
|
webuiGenerateBackupAppStatus
|
|
fi
|
|
|
|
setupWizardMarkComplete
|
|
|
|
# Roll up the per-app install results for this setup group. Each ticked app
|
|
# ran as its own `app install` task; the host daemon writes their terminal
|
|
# status back into the task JSON before it ever reaches this finalize task
|
|
# (FIFO, one at a time), so by now every sibling is settled. We only LOG the
|
|
# verdict here — finalize itself still succeeds (it did finalize) and the
|
|
# failed app's own task row is already red; the WebUI watcher is what gates
|
|
# the "your install is ready" hand-off on this same group-level result.
|
|
if [[ -n "$setup_group" ]]; then
|
|
local tasks_dir="$(webuiDir)/frontend/data/tasks"
|
|
local total=0 failed=0 failed_names="" f
|
|
if [[ -d "$tasks_dir" ]]; then
|
|
for f in "$tasks_dir"/task_*.json; do
|
|
[[ -f "$f" ]] || continue
|
|
local grp role
|
|
grp=$(jq -r '.setupGroup // empty' "$f" 2>/dev/null)
|
|
role=$(jq -r '.setupRole // empty' "$f" 2>/dev/null)
|
|
[[ "$grp" == "$setup_group" && "$role" == "app" ]] || continue
|
|
total=$((total + 1))
|
|
local st ec app
|
|
st=$(jq -r '.status // empty' "$f" 2>/dev/null)
|
|
ec=$(jq -r '.exit_code // .exitCode // empty' "$f" 2>/dev/null)
|
|
app=$(jq -r '.app // "app"' "$f" 2>/dev/null)
|
|
if [[ "$st" == "failed" || "$st" == "cancelled" \
|
|
|| ( -n "$ec" && "$ec" != "0" && "$ec" != "null" ) ]]; then
|
|
failed=$((failed + 1))
|
|
failed_names+="${failed_names:+, }$app"
|
|
fi
|
|
done
|
|
fi
|
|
if [[ "$total" -eq 0 ]]; then
|
|
isNotice "No apps were selected — add apps any time from the App Center."
|
|
elif [[ "$failed" -eq 0 ]]; then
|
|
isSuccessful "All $total selected app(s) installed successfully."
|
|
else
|
|
isError "$failed of $total app(s) failed to install: ${failed_names}. Setup is marked complete — retry the failed app(s) from the App Center."
|
|
fi
|
|
fi
|
|
|
|
isSuccessful "Setup Wizard complete — your install is configured and ready."
|
|
}
|
|
|
|
setupApply()
|
|
{
|
|
setupApplyConfig "$1" || return 1
|
|
|
|
local payload=$(echo "$1" | base64 -d 2>/dev/null)
|
|
local apps_json=$(echo "$payload" | jq -c '.apps // []')
|
|
local apps_count=$(echo "$apps_json" | jq -r 'length')
|
|
|
|
if [[ "$apps_count" -gt 0 ]]; then
|
|
isHeader "Installing Selected Apps"
|
|
local i=0
|
|
while [[ $i -lt $apps_count ]]; do
|
|
local app_name=$(echo "$apps_json" | jq -r ".[$i]")
|
|
isNotice "[$((i+1))/$apps_count] Installing $app_name..."
|
|
dockerInstallApp "$app_name"
|
|
((i++))
|
|
done
|
|
fi
|
|
|
|
setupApplyFinalize
|
|
}
|
|
|
|
setupGenerateName()
|
|
{
|
|
if declare -f generateInstallName >/dev/null 2>&1; then
|
|
generateInstallName
|
|
else
|
|
echo "QuantumOtter"
|
|
fi
|
|
}
|
|
|
|
setupCheckDomainPointsHere()
|
|
{
|
|
local domain="$1"
|
|
if [[ -z "$domain" ]]; then
|
|
echo '{"matches":false,"error":"no domain"}'
|
|
return 1
|
|
fi
|
|
|
|
local server_ip
|
|
server_ip=$(dig +short +time=3 +tries=1 myip.opendns.com @resolver1.opendns.com 2>/dev/null | head -1)
|
|
[[ -z "$server_ip" ]] && server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
|
|
local domain_ip
|
|
domain_ip=$(dig +short +time=3 +tries=1 "$domain" A 2>/dev/null | head -1)
|
|
|
|
local matches="false"
|
|
[[ -n "$server_ip" && "$server_ip" == "$domain_ip" ]] && matches="true"
|
|
|
|
printf '{"matches":%s,"server_ip":"%s","domain_ip":"%s"}\n' "$matches" "$server_ip" "$domain_ip"
|
|
}
|