LibrePortal/scripts/setup/setup_apply.sh
librelad fd0a0fd08c Storage step: say "not connected", and allow custom paths
A registered drive that is unplugged rendered through the same path as any
other candidate — a "needs care" badge, "free of" with no numbers on either
side, an empty meter. To a first-time installer that reads as two broken disks
the scan turned up, with nothing tying the card back to a drive they registered
and later unplugged. Say "not connected", name the path, and draw no meter: a
meter with nothing in it is a claim about free space nobody measured. The same
locations are withheld from the dropdowns, since the wizard cannot stat a
directory on a drive that is absent.

Both dropdowns now end in "Custom path…", for a NAS mount or an LVM volume the
disk heuristics never rank as a candidate. Validation goes through
validateStep(3) rather than a disabled button: the apply side already refuses a
relative or system path, but its refusal is to fall back to the system disk,
and that is indistinguishable from having chosen the system disk on purpose.

A typed path is not a registered location, so setup_apply registers it via
storageAdd — which is what keeps the empty-directory admission rule and the
fitness checks in play — named after its basename, so it reads as "nas" rather
than "location-3" in the placement menus.

libreportal-storage: accept the name the listing prints. remove matched id and
path only, so `remove location-3` failed against a row displayed as
location-3. Root-owned helper changed, so footprint_version 10 -> 11.

Expose window.setupWizard: the instance was local to a promise in the
orchestrator and unreachable from the console or a test.

lp-storage-custom-test drives the step in a browser. Two holes it found in the
tests themselves, both the shape it exists to catch — a check whose failure
mode is to not run:

  - It counted the cards that say "not connected" and asserted over those.
    Turn the feature off and the count is zero, every() over an empty list is
    true, and the block passed having checked nothing. The expectation now
    comes from the feed.

  - Both browser tests exited 0 whenever the page returned nothing. Under sudo,
    where chromium will not start, they reported PASS having asserted nothing.
    They now probe with `lp-shot --url` and curl: if the WebUI answers HTTP the
    browser is the only thing that can have broken, and that is a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:09:25 +01:00

429 lines
20 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
# A path typed into the wizard's "Custom path" box is not a registered
# location yet, so the lookup above finds nothing. Register it — through
# storageAdd, because that is what enforces the empty-directory
# admission rule and the fitness checks; writing the path straight into
# the config would skip both and is how an app ends up on a directory
# root never agreed to own.
if [[ -z "$default_name" || "$default_name" == "default" ]] \
&& [[ "$storage_default" == /* ]] \
&& declare -f storageAdd >/dev/null 2>&1; then
# Named after the directory, so it reads as "nas" in the app
# placement menus rather than "location-3".
local custom_name
custom_name=$(basename -- "$storage_default" | tr -c 'a-zA-Z0-9-' '-' | sed 's/^-*//; s/-*$//')
[[ -z "$custom_name" ]] && custom_name="custom"
if storageAdd "$storage_default" "$custom_name" >/dev/null 2>&1; then
storageCacheReset 2>/dev/null || true
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
[[ -n "$default_name" ]] && isSuccessful "Registered '$storage_default' as a storage location."
else
isNotice "Could not use '$storage_default' — it must be an empty directory outside the system paths."
fi
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"
}