feat(setup): Backups step — phase 1 of first-run restore

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 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-27 08:11:36 +01:00
parent 69b62fda06
commit 15ca10530d
2 changed files with 133 additions and 7 deletions

View File

@ -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 {
</div>
</section>
<!-- Step 5: Recommended apps (Traefik + Fail2ban) -->
<!-- Step 5: Backups. Always shown the people most likely to need a
restore are the ones who never got round to configuring one, so
this asks rather than waiting to be found in the Backup page. -->
<section class="setup-step" data-step="4">
<div class="setup-section">
<div class="setup-section-title">Backups
<span class="setup-tooltip" tabindex="0" data-tip="Snapshots of your apps and settings, taken on a schedule. Encrypted, so keep the password somewhere other than this machine — without it a backup cannot be opened, not even by us.">?</span>
</div>
<div id="sw-backup-choices"></div>
<p class="setup-section-hint" id="sw-backup-note" style="margin-top:10px;"></p>
</div>
</section>
<!-- Step 6: Recommended apps (Traefik + Fail2ban) -->
<section class="setup-step" data-step="5">
<div class="setup-section">
<div class="setup-section-title">Recommended Apps</div>
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
@ -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. -->
<section class="setup-step" data-step="5">
<section class="setup-step" data-step="6">
<div class="setup-section">
<div class="setup-section-title">Metrics Apps</div>
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
@ -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 {
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemChoice)}/libreportal-system</code>`;
}
// 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 = `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">Back up to</span>
<select id="sw-backup-dest" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.backupDest ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
</select>
</div>
<div class="setup-storage-choice-msg" id="sw-backup-msg" style="display:none;"></div>`;
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. '
+ '<strong>Write it down somewhere other than this machine</strong> \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

View File

@ -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