LibrePortal/scripts/setup/setup_apply.sh
librelad 7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00

249 lines
10 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 // []')
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
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"
}