From 15ca10530d039165f69a7f92f27a1a1e3574dc2e Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 27 Aug 2026 08:11:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(setup):=20Backups=20step=20=E2=80=94=20pha?= =?UTF-8?q?se=201=20of=20first-run=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing prompted anyone to configure backups, so the people most likely to need a restore were the least likely to have one. The wizard now asks, once, with the drives it already scanned as the options. Three messages, because the honest answer differs by choice: declined nothing is protected until you set it up same drive still covers deletion, a bad update and ransomware — not this disk failing, since the data and its only copy go together another drive the repository is encrypted; write the password down somewhere other than this machine That last one matters more than it reads. An encrypted repository cannot be opened with anything stored inside itself, and the location password lives in the system config, which is inside the backup. On a rebuilt machine the user must supply it by hand — so the wizard says so up front rather than letting them discover it during a restore. The password is deliberately NOT echoed by setupApplyConfig: task output is logged, and a secret in a log is a secret you have to treat as leaked. It is shown on the Backup page, which is what the wizard tells the user. locationAdd creates a location disabled, so the applier enables it and runs engineInitLocation — an un-initialised destination silently backs up nothing, which is the worst possible way to have "configured backups". Co-Authored-By: Claude Opus 5 --- .../frontend/core/setup/js/setup-wizard.js | 98 +++++++++++++++++-- scripts/setup/setup_apply.sh | 42 ++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 4c72abc..e1e093d 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -21,8 +21,8 @@ class SetupWizard { // Storage sits BEFORE Recommended on purpose: a location has to exist // before an app can be placed on it, and the Recommended step can then // offer the big apps a home other than the system disk. - this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Recommended', 'Metrics']; - this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛡️', '📊']; + this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Recommended', 'Metrics']; + this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '🛡️', '📊']; // Storage is skipped entirely when this box has nowhere else to put things // — one disk means one answer, and a step with nothing in it is noise. // Set by loadStorage() once the candidate scan comes back. @@ -36,6 +36,8 @@ class SetupWizard { this.storageDefault = 'primary'; // Where LibrePortal's own tree should live (root-only to change post-install). this.storageSystemChoice = 'primary'; + // Backup destination: '' = none, 'primary' = system disk, else a drive path. + this.backupDest = ''; this.installLevel = 'beginner'; this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows @@ -247,8 +249,21 @@ class SetupWizard { - +
+
+
Backups + ? +
+
+

+
+
+ + +
Recommended Apps

Pre-selected to give you a working install out of the box.

@@ -272,7 +287,7 @@ class SetupWizard { default — they're only useful if the user wants the MONITORING toggle on apps to do anything. Advanced-only: this whole step is skipped when the user chose Beginner on step 1. --> -
+
Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

@@ -543,6 +558,7 @@ class SetupWizard { }); this.renderStorageChoices(); + this.renderBackupChoices(); if (note) { note.innerHTML = this.storageCandidates.length @@ -564,6 +580,8 @@ class SetupWizard { this.storageDefault = 'primary'; // Where LibrePortal's own tree should live (root-only to change post-install). this.storageSystemChoice = 'primary'; + // Backup destination: '' = none, 'primary' = system disk, else a drive path. + this.backupDest = ''; this.storageSystemChoice = 'primary'; return; } @@ -617,6 +635,70 @@ class SetupWizard { sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system`; } + // Backups: one question, asked at setup rather than left to be discovered. + // + // Offered destinations are the drives we already scanned, minus the one + // holding app data where we can tell — a backup on the same disk as the data + // survives a bad update or a deletion, but not the disk dying, and that is + // the case people assume they are covered for. + renderBackupChoices() { + const box = this.container.querySelector('#sw-backup-choices'); + const note = this.container.querySelector('#sw-backup-note'); + if (!box) return; + + const appTarget = this.storageDefault && this.storageDefault !== 'primary' + ? this.storageDefault : (this.storageSystem ? this.storageSystem.mount : '/'); + + const opts = [{ value: '', label: 'Not now — set this up later' }]; + this.storageCandidates + .filter(c => c.verdict !== 'refuse') + .forEach(c => opts.push({ value: c.path, label: c.path, shared: c.path === appTarget })); + opts.push({ value: 'primary', label: 'System disk', shared: this.storageDefault === 'primary' }); + + box.innerHTML = ` +
+ Back up to + +
+ `; + + box.querySelector('#sw-backup-dest').addEventListener('change', (e) => { + this.backupDest = e.target.value; + this.renderBackupMsg(opts); + }); + this.renderBackupMsg(opts); + + if (note) { + note.innerHTML = this.storageCandidates.length + ? '' + : 'Only one drive found. A backup here still protects against deletions and bad updates \u2014 but not against this disk failing. Add another destination later from the Backup page.'; + } + } + + renderBackupMsg(opts) { + const msg = this.container.querySelector('#sw-backup-msg'); + if (!msg) return; + const chosen = opts.find(o => o.value === this.backupDest); + + if (!this.backupDest) { + msg.style.display = ''; + msg.innerHTML = 'No backups will be taken. You can set this up any time from the Backup page \u2014 but nothing is protected until you do.'; + return; + } + if (chosen && chosen.shared) { + msg.style.display = ''; + msg.innerHTML = 'This is the same drive your app data is on. That still covers accidental deletion, ' + + 'a bad update and ransomware \u2014 but not this disk failing, since the data and its only copy would go together.'; + return; + } + msg.style.display = ''; + msg.innerHTML = 'The repository is encrypted and its password is generated during install. ' + + 'Write it down somewhere other than this machine \u2014 without it a backup cannot be opened. ' + + 'It is shown on the Backup page once setup finishes.'; + } + // Details modal — the technical spec, every check with its full explanation, // and (when the drive isn't in fstab) the offer to make it permanent. // @@ -779,8 +861,8 @@ class SetupWizard { } } } - // 4 = Recommended (Storage was inserted at 3, shifting this along). - if (idx === 4) { + // 5 = Recommended (Storage at 3 and Backups at 4 shifted this along). + if (idx === 5) { const traefikBox = this.container.querySelector('input[data-app="traefik"]'); if (traefikBox && traefikBox.checked) { const tEmail = $('#sw-traefik-email').value.trim(); @@ -1139,7 +1221,9 @@ class SetupWizard { storage_default: (this.storageDefault && this.storageDefault !== 'primary') ? this.storageDefault : 'primary', // Recorded so the finished-setup screen can repeat the relocate command. // Nothing acts on it: moving LibrePortal's own tree needs real root. - storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary' + storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary', + // '' = don't configure backups; 'primary' = system disk; else a drive path. + backup_dest: this.backupDest || '' }; // Apply the experience choice to the WebUI immediately so the next diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index d3f6c55..7b27cb8 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -27,6 +27,7 @@ setupApplyConfig() 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 // ""') if [[ -n "$install_name" ]]; then updateConfigOption "CFG_INSTALL_NAME" "$install_name" @@ -107,6 +108,47 @@ setupApplyConfig() 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. + 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 + local domains_count=$(echo "$domains_json" | jq -r 'length') if [[ "$domains_count" -gt 0 ]]; then local i=0