Wizard: New install or Restore from backup

The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-29 05:04:43 +01:00
parent 5dd763713d
commit a361e38562
9 changed files with 847 additions and 20 deletions

View File

@ -237,6 +237,91 @@ router.post('/import-check', requireAuth, async (req, res) => {
} }
}); });
// Read a backup repository: connect, list what is in it, and report. Nothing
// on this machine is written — the host creates a location to read through and
// removes it again if the read fails.
//
// The password never appears here. It arrives as a secret:<ref> the browser
// already handed to /secret, and is redeemed once, host-side, at the moment of
// the write. This payload reaches a task command line and tasks are recorded
// world-readable.
router.post('/restore/read', requireAuth, async (req, res) => {
const loc = (req.body && req.body.location) || null;
if (!loc || typeof loc !== 'object') {
return res.status(400).json({ error: 'A backup location is required' });
}
const TYPES = ['local', 'sftp', 'rest', 's3', 'b2'];
if (!TYPES.includes(String(loc.type || ''))) {
return res.status(400).json({ error: 'Unsupported backup type' });
}
if (loc.type === 'local' && !String(loc.path || '').startsWith('/')) {
return res.status(400).json({ error: 'A full path to the backup folder is required' });
}
// Only a reference may travel; a raw password in this field would end up in
// the task file, which is exactly what the secret channel exists to prevent.
if (loc.password_ref && !/^secret:[0-9a-f]{32}$/.test(String(loc.password_ref))) {
return res.status(400).json({ error: 'Invalid password reference' });
}
for (const k of Object.keys(loc)) {
if (typeof loc[k] === 'string' && loc[k].length > 1024) {
return res.status(413).json({ error: `${k} is too long` });
}
}
// base64 so the payload survives the command line intact — it carries paths
// and URLs, which are user input.
const b64 = Buffer.from(JSON.stringify({
location: loc,
host: typeof req.body.host === 'string' ? req.body.host : ''
}), 'utf8').toString('base64');
// A nonce echoed back in the published document, so the browser can tell its
// own answer from one left by an earlier attempt. Without it a second read
// shows the first read's repository — wrong in a way that looks plausible.
const nonce = require('crypto').randomBytes(8).toString('hex');
try {
const id = await enqueueTask({
command: `libreportal restore connect ${b64} --publish ${nonce}`,
type: 'restore',
app: 'libreportal',
setupRole: 'config'
});
res.json({ ok: true, taskId: id, nonce });
} catch (e) {
res.status(500).json({ error: e.message || String(e) });
}
});
// Run the rebuild: adopt settings, reconcile domains, restore apps. The
// location index comes from the read that preceded this, so the repository the
// user actually looked at is the one restored from.
router.post('/restore/apply', requireAuth, async (req, res) => {
const idx = String((req.body && req.body.location_idx) || '');
if (!/^[0-9]+$/.test(idx)) {
return res.status(400).json({ error: 'A backup location is required' });
}
// Host names come from the repository, but they still reach a command line.
const host = String((req.body && req.body.host) || '');
if (host && !/^[A-Za-z0-9._-]{1,64}$/.test(host)) {
return res.status(400).json({ error: 'Invalid host name' });
}
const drop = (req.body && req.body.drop_domains) ? 'yes' : 'no';
try {
const id = await enqueueTask({
command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}`,
type: 'restore',
app: 'libreportal',
setupRole: 'config'
});
res.json({ ok: true, taskId: id });
} catch (e) {
res.status(500).json({ error: e.message || String(e) });
}
});
router.post('/save', requireAuth, async (req, res) => { router.post('/save', requireAuth, async (req, res) => {
const payload = req.body || {}; const payload = req.body || {};

View File

@ -23,8 +23,20 @@ class SetupWizard {
// Storage sits BEFORE Recommended on purpose: a location has to exist // 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 // 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. // offer the big apps a home other than the system disk.
this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics']; // 'Start' asks new-install-or-restore, and the answer selects one of two
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '📦', '🛡️', '📊']; // disjoint step sets: everything after it is gated on the mode. A restore
// must not be asked for an install name, domains or an app list — the
// backup already answers all three, and asking would invite someone to
// contradict what is about to be restored over their answer.
this.stepNames = ['Start', 'Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics',
'Source', 'Contents', 'Rebuild'];
this.stepIcons = ['\u{1F9ED}', '\u{1F331}', '\u{1FA90}', '\u{1F6F0}\u{FE0F}', '\u{1F4BE}', '\u{1F6DF}', '\u{1F4E6}', '\u{1F6E1}\u{FE0F}', '\u{1F4CA}',
'\u{1F5C4}\u{FE0F}', '\u{1F50D}', '\u{267B}\u{FE0F}'];
// 'new' | 'restore'. Chosen on Start; everything downstream reads it.
this.installMode = 'new';
// What `restore read` found: host, hosts, apps, domains, location_idx.
this.restoreInfo = null;
this.restoreDomains = [];
// Storage is skipped entirely when this box has nowhere else to put things // 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. // — one disk means one answer, and a step with nothing in it is noise.
// Set by loadStorage() once the candidate scan comes back. // Set by loadStorage() once the candidate scan comes back.
@ -68,6 +80,14 @@ class SetupWizard {
// only when a usable second filesystem was actually found. // only when a usable second filesystem was actually found.
_stepVisible(idx) { _stepVisible(idx) {
const name = this.stepNames[idx]; const name = this.stepNames[idx];
// Start is the branch point and always shows. After it the two sets are
// disjoint: a restore is not asked for an install name, domains or an app
// list, because the backup answers all three and asking would invite the
// user to contradict what is about to be written over their answer.
const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
if (name === 'Start') return true;
if (RESTORE_ONLY.includes(name)) return this.installMode === 'restore';
if (this.installMode === 'restore') return false;
if (name === 'Metrics') return this.installLevel === 'advanced'; if (name === 'Metrics') return this.installLevel === 'advanced';
// Storage always shows now. Even with one disk it answers "where does my // Storage always shows now. Even with one disk it answers "where does my
// data actually go?", which is worth a step in a self-hosting product — // data actually go?", which is worth a step in a self-hosting product —
@ -179,7 +199,36 @@ class SetupWizard {
doesn't get a wall of operator detail and an experienced doesn't get a wall of operator detail and an experienced
user sees everything by default. Either choice is reversible user sees everything by default. Either choice is reversible
from the Advanced toggle in any page that exposes it. --> from the Advanced toggle in any page that exposes it. -->
<!-- Step 1: New install, or a rebuild from a backup -->
<section class="setup-step" data-step="0"> <section class="setup-step" data-step="0">
<div class="setup-field setup-level-field">
<label>What are we doing?</label>
<p class="setup-step-note setup-level-note">
Rebuilding a machine? Point us at your backup and we will bring
it back settings, backup repositories and apps.
</p>
<div class="setup-level-cards">
<label class="setup-level-card" data-mode="new">
<input type="radio" name="sw-mode" value="new" checked>
<div class="setup-level-card-body">
<div class="setup-level-card-icon">\u{1F331}</div>
<div class="setup-level-card-title">New install</div>
<div class="setup-level-card-desc">Set this machine up from scratch.</div>
</div>
</label>
<label class="setup-level-card" data-mode="restore">
<input type="radio" name="sw-mode" value="restore">
<div class="setup-level-card-body">
<div class="setup-level-card-icon">\u{267B}\u{FE0F}</div>
<div class="setup-level-card-title">Restore from backup</div>
<div class="setup-level-card-desc">Rebuild this server from an existing backup.</div>
</div>
</label>
</div>
</div>
</section>
<section class="setup-step" data-step="1">
<div class="setup-field setup-level-field"> <div class="setup-field setup-level-field">
<label>Choose your experience</label> <label>Choose your experience</label>
<p class="setup-step-note setup-level-note"> <p class="setup-step-note setup-level-note">
@ -218,7 +267,7 @@ class SetupWizard {
</section> </section>
<!-- Step 2: Identity --> <!-- Step 2: Identity -->
<section class="setup-step" data-step="1"> <section class="setup-step" data-step="2">
<div class="setup-field"> <div class="setup-field">
<label for="sw-name"> <label for="sw-name">
Install Name Install Name
@ -262,7 +311,7 @@ class SetupWizard {
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS <!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
routing for apps via Traefik. Skipping leaves the install routing for apps via Traefik. Skipping leaves the install
local-only (apps reachable by IP and Port, no SSL). --> local-only (apps reachable by IP and Port, no SSL). -->
<section class="setup-step" data-step="2"> <section class="setup-step" data-step="3">
<div class="setup-field"> <div class="setup-field">
<label> <label>
Domains Domains
@ -279,7 +328,7 @@ class SetupWizard {
<!-- Step 4: Storage locations. Only rendered when the candidate <!-- Step 4: Storage locations. Only rendered when the candidate
scan found a filesystem LibrePortal isn't already using scan found a filesystem LibrePortal isn't already using
otherwise there is exactly one answer and the step is skipped. --> otherwise there is exactly one answer and the step is skipped. -->
<section class="setup-step" data-step="3"> <section class="setup-step" data-step="4">
<div class="setup-section"> <div class="setup-section">
<div class="setup-section-title">Storage <div class="setup-section-title">Storage
<span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span> <span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span>
@ -295,7 +344,7 @@ class SetupWizard {
<!-- Step 5: Backups. Always shown the people most likely to need a <!-- Step 5: Backups. Always shown the people most likely to need a
restore are the ones who never got round to configuring one, so restore are the ones who never got round to configuring one, so
this asks rather than waiting to be found in the Backup page. --> this asks rather than waiting to be found in the Backup page. -->
<section class="setup-step" data-step="4"> <section class="setup-step" data-step="5">
<div class="setup-section"> <div class="setup-section">
<div class="setup-section-title">Backups <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> <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>
@ -311,7 +360,7 @@ class SetupWizard {
Path-based, not upload: the file is already on the server, and a Path-based, not upload: the file is already on the server, and a
.lpapp is a plain tarball, so nothing secret crosses into the .lpapp is a plain tarball, so nothing secret crosses into the
browser (unlike a backup repository see first-run-restore.md). --> browser (unlike a backup repository see first-run-restore.md). -->
<section class="setup-step" data-step="5"> <section class="setup-step" data-step="6">
<div class="setup-section"> <div class="setup-section">
<div class="setup-section-title">Import <div class="setup-section-title">Import
<span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span> <span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span>
@ -329,7 +378,7 @@ class SetupWizard {
</section> </section>
<!-- Step 7: Recommended apps (Traefik + Fail2ban) --> <!-- Step 7: Recommended apps (Traefik + Fail2ban) -->
<section class="setup-step" data-step="6"> <section class="setup-step" data-step="7">
<div class="setup-section"> <div class="setup-section">
<div class="setup-section-title">Recommended Apps</div> <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> <p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
@ -353,7 +402,7 @@ class SetupWizard {
default they're only useful if the user wants the MONITORING default they're only useful if the user wants the MONITORING
toggle on apps to do anything. Advanced-only: this whole step toggle on apps to do anything. Advanced-only: this whole step
is skipped when the user chose Beginner on step 1. --> is skipped when the user chose Beginner on step 1. -->
<section class="setup-step" data-step="7"> <section class="setup-step" data-step="8">
<div class="setup-section"> <div class="setup-section">
<div class="setup-section-title">Metrics Apps</div> <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> <p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
@ -361,6 +410,53 @@ class SetupWizard {
</div> </div>
</section> </section>
<!-- Restore branch. Only ever shown when Start says "restore". -->
<!-- Where is your backup? -->
<section class="setup-step" data-step="9">
<div class="setup-section">
<div class="setup-section-title">Where is your backup?
<span class="setup-tooltip" tabindex="0" data-tip="A backup lives in a repository \u2014 a folder on a disk, or a remote server \u2014 not a single file. Point us at the repository itself.">?</span>
</div>
<p class="setup-section-hint">
Your backups live in a repository: a folder on a disk, or a
remote server. Not a single file.
</p>
<div id="sw-rs-fields"></div>
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">Password</span>
<input type="password" id="sw-rs-pass" class="form-control" placeholder="The repository password" autocomplete="off">
</div>
<p class="setup-section-hint">
This unlocks the repository. It is handed to the host through a
one-shot reference, so it never lands in a task or a log.
</p>
<div class="setup-storage-choice">
<span class="setup-storage-choice-label"></span>
<button type="button" class="setup-domain-add" id="sw-rs-read">Read this backup</button>
</div>
<div id="sw-rs-status"></div>
</div>
</section>
<!-- What is in there -->
<section class="setup-step" data-step="10">
<div class="setup-section">
<div class="setup-section-title">What is in this backup</div>
<div id="sw-rs-contents">
<p class="setup-section-hint">Go back a step and read the backup first.</p>
</div>
</div>
</section>
<!-- Confirm -->
<section class="setup-step" data-step="11">
<div class="setup-section">
<div class="setup-section-title">Rebuild this server</div>
<div id="sw-rs-summary"></div>
</div>
</section>
</div> </div>
</div> </div>
@ -409,6 +505,22 @@ class SetupWizard {
// Experience radio — updates installLevel and the visible-step count // Experience radio — updates installLevel and the visible-step count
// immediately so the progress bar reflects the choice (4 vs 5 steps). // immediately so the progress bar reflects the choice (4 vs 5 steps).
// Selection cards visually toggle via the wrapping <label>'s :has(). // Selection cards visually toggle via the wrapping <label>'s :has().
// New install or restore. Changing it swaps the whole step set, so the
// progress bar and nav have to be re-derived immediately rather than at
// the next navigation.
this.container.querySelectorAll('input[name="sw-mode"]').forEach((r) => {
r.addEventListener('change', () => {
if (!r.checked) return;
this.installMode = (r.value === 'restore') ? 'restore' : 'new';
if (this.installMode === 'restore') this.renderRestoreSource();
this.totalSteps = this._effectiveTotalSteps();
this.showStep(this.currentStep);
});
});
const rsRead = this.container.querySelector('#sw-rs-read');
if (rsRead) rsRead.addEventListener('click', () => this.readBackup());
this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => { this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => {
r.addEventListener('change', () => { r.addEventListener('change', () => {
if (!r.checked) return; if (!r.checked) return;
@ -883,6 +995,311 @@ class SetupWizard {
return blocked; return blocked;
} }
// --- restore branch -------------------------------------------------------
// The "where is your backup" fields. Same backend types the Backup page
// offers, rendered here rather than reusing the destination dialog: that
// dialog is for a place to WRITE backups and carries retention, scheduling
// and an enable toggle, none of which mean anything for a repository you are
// only going to read.
renderRestoreSource() {
const box = this.container.querySelector('#sw-rs-fields');
if (!box || box.dataset.rendered === '1') return;
box.dataset.rendered = '1';
const row = (label, id, ph, type) => `
<div class="setup-storage-choice" data-rs-group="${type}">
<span class="setup-storage-choice-label">${label}</span>
<input type="text" id="${id}" class="form-control" placeholder="${this.escapeHtml(ph)}" autocomplete="off">
</div>`;
box.innerHTML = `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">Kind</span>
<select id="sw-rs-type" class="form-control">
<option value="local">A folder on this machine or a plugged-in disk</option>
<option value="sftp">An SFTP server</option>
<option value="rest">A REST server</option>
<option value="s3">S3 or compatible</option>
<option value="b2">Backblaze B2</option>
</select>
</div>` +
row('Path', 'sw-rs-path', '/mnt/usb/libreportal-backups', 'local') +
row('SSH user', 'sw-rs-ssh-user', 'backups', 'sftp') +
row('SSH host', 'sw-rs-ssh-host', 'nas.example.com', 'sftp') +
row('Remote path', 'sw-rs-ssh-path', '/srv/libreportal', 'sftp') +
row('URL', 'sw-rs-uri', 'rest:https://backup.example.com/', 'rest') +
row('Bucket URI', 'sw-rs-s3uri', 's3:s3.amazonaws.com/my-bucket', 's3') +
row('Access key ID', 'sw-rs-s3id', 'AKIA…', 's3') +
row('Bucket URI', 'sw-rs-b2uri', 'b2:my-bucket', 'b2') +
row('Account ID', 'sw-rs-b2id', '', 'b2');
const sync = () => {
const type = (this.container.querySelector('#sw-rs-type') || {}).value || 'local';
box.querySelectorAll('[data-rs-group]').forEach((g) => {
g.style.display = g.dataset.rsGroup === type ? '' : 'none';
});
};
const sel = this.container.querySelector('#sw-rs-type');
if (sel) sel.addEventListener('change', sync);
sync();
}
// Build the location half of the payload from whichever fields are showing.
_restoreLocationPayload() {
const v = (id) => {
const el = this.container.querySelector('#' + id);
return el ? el.value.trim() : '';
};
const type = v('sw-rs-type') || 'local';
const loc = { name: 'restore-source', type };
if (type === 'local') loc.path = v('sw-rs-path');
if (type === 'sftp') {
loc.ssh_user = v('sw-rs-ssh-user');
loc.ssh_host = v('sw-rs-ssh-host');
loc.ssh_path = v('sw-rs-ssh-path');
}
if (type === 'rest') loc.uri = v('sw-rs-uri');
if (type === 's3') { loc.uri = v('sw-rs-s3uri'); loc.s3_key_id = v('sw-rs-s3id'); }
if (type === 'b2') { loc.uri = v('sw-rs-b2uri'); loc.b2_account_id = v('sw-rs-b2id'); }
return loc;
}
_restoreSourceProblem() {
const loc = this._restoreLocationPayload();
const pass = this.container.querySelector('#sw-rs-pass');
if (loc.type === 'local' && !(loc.path || '').startsWith('/')) {
return 'Give the full path to the backup folder.';
}
if (loc.type === 'sftp' && !(loc.ssh_user && loc.ssh_host && loc.ssh_path)) {
return 'SFTP needs a user, a host and a remote path.';
}
if (['rest', 's3', 'b2'].includes(loc.type) && !loc.uri) {
return 'Give the repository URL.';
}
if (!pass || !pass.value) {
return 'The repository password is needed to open the backup.';
}
return '';
}
// Connect and read. Nothing is written to this machine by this: the host
// creates a location to read through, and removes it again if the read
// fails.
async readBackup() {
const status = this.container.querySelector('#sw-rs-status');
const btn = this.container.querySelector('#sw-rs-read');
const problem = this._restoreSourceProblem();
if (problem) {
if (status) status.innerHTML = `<p class="setup-rs-error">${this.escapeHtml(problem)}</p>`;
return;
}
if (btn) { btn.disabled = true; btn.textContent = 'Reading…'; }
if (status) status.innerHTML = '<p class="setup-section-hint">Opening the repository…</p>';
try {
// The password goes through the one-shot secret channel, never in the
// payload: this reaches a task command line and tasks are recorded
// world-readable.
const passEl = this.container.querySelector('#sw-rs-pass');
const ref = await this.stashSecret(passEl.value);
if (!ref) throw new Error('Could not hand the password to the host');
// Cleared either way — it has been handed over, and there is no reason
// for it to sit in the DOM afterwards.
passEl.value = '';
const loc = this._restoreLocationPayload();
loc.password_ref = ref;
const res = await fetch('/api/setup/restore/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location: loc })
});
const queued = await res.json();
if (!res.ok || queued.error) throw new Error(queued.error || `HTTP ${res.status}`);
// The WebUI cannot read a task's stdout, so the host publishes the
// result and we poll for it. Matched on the nonce, not merely on the
// file existing: a stale document from an earlier attempt would
// otherwise be read as this attempt's answer.
const started = Date.now();
let data = null;
while (Date.now() - started < 60000) {
await new Promise(r => setTimeout(r, 1000));
try {
const f = await fetch('/data/system/restore_read.json', { cache: 'no-store' });
if (f.ok) {
const d = await f.json();
if (d && d.nonce === queued.nonce) { data = d; break; }
}
} catch { /* not written yet */ }
}
if (!data) throw new Error('the backup could not be read in time');
if (data.error) throw new Error(data.error);
this.restoreInfo = data;
if (status) {
status.innerHTML = `<p class="setup-rs-ok">Found backups from
<strong>${this.escapeHtml(data.host || '')}</strong> \u2014
${(data.apps || []).length} app(s). Continue to see what will happen.</p>`;
}
await this.renderRestoreContents();
} catch (e) {
this.restoreInfo = null;
if (status) {
status.innerHTML = `<p class="setup-rs-error">${this.escapeHtml(e.message || String(e))}</p>`;
}
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Read this backup'; }
}
}
// What is in there, and what it will mean on this machine. The domain checks
// are the part that has no equivalent anywhere else: a restored domain still
// points wherever DNS says, which after a rebuild is usually the old server.
async renderRestoreContents() {
const box = this.container.querySelector('#sw-rs-contents');
if (!box) return;
const d = this.restoreInfo;
if (!d) {
box.innerHTML = '<p class="setup-section-hint">Go back a step and read the backup first.</p>';
return;
}
const apps = d.apps || [];
const domains = d.domains || [];
box.innerHTML = `
<p class="setup-section-hint">
From <strong>${this.escapeHtml(d.host || '')}</strong>${
(d.hosts || []).length > 1
? ` \u2014 this repository also holds backups from ${this.escapeHtml((d.hosts || []).filter(h => h !== d.host).join(', '))}`
: ''}
</p>
<div class="setup-storage-divider"><span>Apps</span></div>
${apps.length
? apps.map(a => `
<div class="setup-app-card">
<span class="setup-app-name">${this.escapeHtml(a.name)}</span>
<span class="setup-app-desc">${this.escapeHtml(a.size || '')}</span>
</div>`).join('')
: '<p class="setup-section-hint">No app backups in this repository.</p>'}
<div class="setup-storage-divider"><span>Domains</span></div>
<div id="sw-rs-domains">${
domains.length
? '<p class="setup-section-hint">Checking where these point\u2026</p>'
: '<p class="setup-section-hint">This backup carries no domains.</p>'}</div>`;
if (!domains.length) return;
// Checked one at a time through the endpoint the Domains step already
// uses, rather than adding a second way to ask the same question.
const rows = [];
for (const domain of domains) {
let verdict = 'unknown', detail = 'could not check';
try {
const r = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, {
headers: { 'Accept': 'application/json' }
});
const j = await r.json();
if (j && j.matches) { verdict = 'ok'; detail = 'points at this server'; }
else if (j && j.domain_ip) { verdict = 'elsewhere'; detail = `points at ${j.domain_ip}, not this server`; }
else { verdict = 'unresolved'; detail = 'no DNS record found'; }
} catch { /* left as unknown — see below */ }
rows.push({ domain, verdict, detail });
}
this.restoreDomains = rows;
const strays = rows.filter(r => r.verdict === 'elsewhere' || r.verdict === 'unresolved');
const el = this.container.querySelector('#sw-rs-domains');
if (!el) return;
el.innerHTML =
rows.map(r => `
<div class="setup-app-card">
<span class="setup-app-name">${this.escapeHtml(r.domain)}</span>
<span class="setup-storage-badge ${r.verdict === 'ok' ? 'setup-storage-badge-ok' : 'setup-storage-badge-warn'}">${
r.verdict === 'ok' ? 'points here' : 'not here yet'}</span>
<span class="setup-app-desc">${this.escapeHtml(r.detail)}</span>
</div>`).join('') +
(strays.length
? `<p class="setup-section-hint">
${strays.length} of ${rows.length} do not point at this server yet. That is
normal mid-rebuild \u2014 update their DNS A records and they will work.
Until then Traefik cannot get a certificate for them.
</p>
<label class="setup-storage-choice">
<input type="checkbox" id="sw-rs-drop-domains">
<span>&nbsp;Leave those ${strays.length} out for now (you can add them back later)</span>
</label>`
: '<p class="setup-section-hint">Every domain in this backup already points at this server.</p>');
}
renderRestoreSummary() {
const box = this.container.querySelector('#sw-rs-summary');
if (!box) return;
const d = this.restoreInfo;
if (!d) {
box.innerHTML = '<p class="setup-section-hint">Read a backup first.</p>';
return;
}
const apps = d.apps || [];
box.innerHTML = `
<p class="setup-section-hint">This will, in order:</p>
<div class="setup-app-card"><span class="setup-app-name">1. Settings and credentials</span>
<span class="setup-app-desc">Including every backup repository you had, so one password brings the rest back.</span></div>
<div class="setup-app-card"><span class="setup-app-name">2. ${apps.length} app(s)</span>
<span class="setup-app-desc">${this.escapeHtml(apps.map(a => a.name).join(', '))}</span></div>
<p class="setup-section-hint">
Apps this version no longer ships, or that will not fit, are skipped
rather than restored into something that cannot start \u2014 you will see
which, as it goes.
</p>`;
}
// Hand the rebuild to the host. Everything it needs was established on the
// previous steps: which repository (by the index the read returned) and
// whether to keep the domains that do not point at this server.
async submitRestore() {
const btn = this.container.querySelector('#sw-submit');
const setLabel = (s) => {
const el = btn && btn.querySelector('.setup-launch-text');
if (el) el.textContent = s;
};
if (!this.restoreInfo || !this.restoreInfo.location_idx) {
this.showError('Read a backup first \u2014 go back to "Where is your backup?".');
return;
}
if (btn) btn.disabled = true;
setLabel('Restoring\u2026');
const dropBox = this.container.querySelector('#sw-rs-drop-domains');
try {
const res = await fetch('/api/setup/restore/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
location_idx: String(this.restoreInfo.location_idx),
host: this.restoreInfo.host || '',
drop_domains: !!(dropBox && dropBox.checked)
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
// The restore runs as a task and takes a while, so hand over to the same
// progress view a normal install uses rather than inventing a second one.
if (typeof this.onComplete === 'function') this.onComplete();
} catch (e) {
if (btn) btn.disabled = false;
setLabel('Restore');
this.showError(`Could not start the restore: ${e.message || e}`);
}
}
renderStorageSystemMsg() { renderStorageSystemMsg() {
const msg = this.container.querySelector('#sw-storage-system-msg'); const msg = this.container.querySelector('#sw-storage-system-msg');
if (!msg) return; if (!msg) return;
@ -1470,15 +1887,19 @@ class SetupWizard {
}, 350); }, 350);
} }
// Keyed on the step's NAME, not its index. The indices drifted once already
// when Storage, Backups and Import were inserted, and a validator that
// silently starts checking the wrong step is invisible until someone submits
// something invalid.
validateStep(idx) { validateStep(idx) {
const $ = (id) => this.container.querySelector(id); const $ = (id) => this.container.querySelector(id);
// Step 0 (Experience): always valid — radios are pre-checked. const name = this.stepNames[idx];
if (idx === 1) { if (name === 'Identity') {
const name = $('#sw-name').value.trim(); const name = $('#sw-name').value.trim();
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.'; if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
if (!$('#sw-timezone').value) return 'Please select a timezone.'; if (!$('#sw-timezone').value) return 'Please select a timezone.';
} }
if (idx === 2) { if (name === 'Domains') {
// Domains are optional, but any non-empty input must be valid. // Domains are optional, but any non-empty input must be valid.
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input')); const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
for (const input of inputs) { for (const input of inputs) {
@ -1488,14 +1909,13 @@ class SetupWizard {
} }
} }
} }
// 3 = Storage. Only a typed custom path can be invalid; the scanned // Only an edited exact path can be invalid; the scanned options all came
// options all came from the backend. // from the backend.
if (idx === 3) { if (name === 'Storage') {
const problem = this._syncStorageNav(); const problem = this._syncStorageNav();
if (problem) return problem; if (problem) return problem;
} }
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along). if (name === 'Recommended') {
if (idx === 6) {
const traefikBox = this.container.querySelector('input[data-app="traefik"]'); const traefikBox = this.container.querySelector('input[data-app="traefik"]');
if (traefikBox && traefikBox.checked) { if (traefikBox && traefikBox.checked) {
const tEmail = $('#sw-traefik-email').value.trim(); const tEmail = $('#sw-traefik-email').value.trim();
@ -1809,6 +2229,19 @@ class SetupWizard {
const $ = (id) => this.container.querySelector(id); const $ = (id) => this.container.querySelector(id);
this.clearError(); this.clearError();
// A restore submits something else entirely. The normal payload is built
// from steps a restore never showed — install name, domains, app list — so
// running it here would post an empty name and be rejected by the route,
// which is a confusing way to find out the branch was never wired.
if (this.installMode === 'restore') {
try {
await this.submitRestore();
} finally {
this._submitting = false;
}
return;
}
const visible = this._visibleSteps(); const visible = this._visibleSteps();
for (let pos = 0; pos < visible.length; pos++) { for (let pos = 0; pos < visible.length; pos++) {
const i = visible[pos]; const i = visible[pos];

View File

@ -465,6 +465,53 @@ is allowed to read each one. Adoption now preserves the destination's existing
ownership and mode, defaults closed only for a file that did not exist, and ownership and mode, defaults closed only for a file that did not exist, and
never re-permissions a directory it merely passes through. never re-permissions a directory it merely passes through.
## 3.12 — The WebUI branch
§2 described the wizard's first question becoming "is this a new server, or a
replacement for one?", and it now is. `Start` asks, and the answer selects one
of **two disjoint step sets**:
```
new Start -> Experience -> Identity -> Domains -> Storage
-> Backups -> Import -> Recommended -> (Metrics)
restore Start -> Source -> Contents -> Rebuild
```
Disjoint on purpose. A restore is never asked for an install name, domains or
an app list, because the backup answers all three — asking would invite someone
to type an answer that is about to be written over, which is worse than not
asking. The test asserts non-overlap in *both* directions rather than only that
the restore steps appear.
**Source** collects the repository the same way the Backup page does, minus
everything that only means something for a place you WRITE to: no retention, no
schedule, no enable toggle. The password leaves through the one-shot
`secret:<ref>` channel and is cleared from the DOM; the test asserts the value
never appears in the payload, since that payload reaches a task command line
and tasks are recorded world-readable.
**Contents** is §3's reconciliation, rendered. Apps with sizes, and the domains
with a verdict each — checked through the same `/api/setup/dns-check` the
Domains step uses rather than adding a second way to ask the question — plus
the offer to leave the strays out until DNS is repointed.
**Rebuild** hands over to `restore rebuild`, which is the installer's order
with the same reasoning: settings first (they carry every other repository's
credentials), then domains, then apps with no explicit list so `bulk` discovers
and re-preflights them itself.
### The index that moved
Inserting `Start` shifted every step index by one, and `validateStep` was a
chain of `idx === 1 … idx === 6` with a comment already explaining which
earlier insertions had moved them. It is keyed on the step NAME now.
`lp-storage-step-test` had the same pin and did not survive the change: it
called `validateStep(3)` for Storage, which had become Domains, and reported
that nothing blocked. That reads exactly like validation being broken — and if
the four assertions had happened to be less specific it would have read like
everything passing instead. Tests look their step up by name too now.
## 4. The password problem, stated plainly ## 4. The password problem, stated plainly
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means. **An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
@ -563,6 +610,7 @@ change — and the docs should say so plainly so nobody uses it as their backup.
| **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps. *The system-config half only staged until §3.7; it now adopts.* | | **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps. *The system-config half only staged until §3.7; it now adopts.* |
| **3** ✅ | Preflight reconciliation report in the installer (§3) | | **3** ✅ | Preflight reconciliation report in the installer (§3) |
| **4** ✅ | `app export` / `app import` (§7). The installer's `.lpapp` option is still open — see §9.5 | | **4** ✅ | `app export` / `app import` (§7). The installer's `.lpapp` option is still open — see §9.5 |
| **5** ✅ | The WebUI branch: New install / Restore from backup, through source → contents → rebuild (§3.12) |
## 9. Open questions ## 9. Open questions

View File

@ -41,11 +41,22 @@ cliHandleRestoreCommands()
# staging dir; never overwrites live config. Optional location idx. # staging dir; never overwrites live config. Optional location idx.
backupRestoreSystemConfig "$action" backupRestoreSystemConfig "$action"
;; ;;
rebuild)
# The WebUI's restore: adopt settings, reconcile domains, restore
# apps. restore rebuild <location-idx> <host> [drop-domains]
restoreWebuiRebuild "$action" "$name" "$extra"
;;
connect) connect)
# Connect a repository from a base64 JSON payload and report what # Connect a repository from a base64 JSON payload and report what
# it holds. The WebUI's restore branch; see restore_inspect.sh. # it holds. The WebUI's restore branch; see restore_inspect.sh.
# restore connect <base64-json> # restore connect <base64-json>
restoreConnectInspect "$action" # With --publish the result is written where the WebUI polls for
# it, tagged with the caller's nonce; otherwise it goes to stdout.
if [[ "$name" == "--publish" ]]; then
restoreConnectInspectPublish "$action" "$extra"
else
restoreConnectInspect "$action"
fi
;; ;;
inspect) inspect)
# Report what a restore from this repository would bring, without # Report what a restore from this repository would bring, without

View File

@ -0,0 +1,158 @@
#!/bin/bash
# The wizard's New install / Restore branch, driven in a real browser.
#
# scripts/dev/lp-restore-wizard-test # needs a running WebUI
#
# The branch point is the whole design: Start asks new-or-restore, and the
# answer selects one of two DISJOINT step sets. A restore must never be asked
# for an install name, domains or an app list — the backup answers all three,
# and asking invites the user to contradict what is about to be written over
# their answer. So the test asserts the sets do not overlap, in both
# directions, rather than only that the restore steps appear.
#
# It also asserts the two things that would be invisible until someone had
# already lost by them: that the repository password leaves through the
# one-shot secret channel and does not linger in the DOM, and that submit()
# routes to the restore path — the normal payload is built from steps a restore
# never showed, so submitting it posts an empty install name and is rejected by
# the route, which is a confusing way to find out the branch was never wired.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
SHOT="$REPO/scripts/dev/lp-shot"
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
lp_reachable() {
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
[[ -n "$u" ]] || return 1
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
}
read -r -d '' DRIVE <<'JS'
const out = {};
const w = window.setupWizard;
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
const $ = s => document.querySelector(s);
const visible = () => w.stepNames.filter((n, i) => w._stepVisible(i));
const newRadio = $('input[name="sw-mode"][value="new"]');
const resRadio = $('input[name="sw-mode"][value="restore"]');
if (!newRadio || !resRadio) return JSON.stringify({ error: 'Start step has no mode cards' });
out.newSteps = visible();
out.newIsDefault = w.installMode === 'new';
resRadio.checked = true; fire(resRadio, 'change');
out.restoreSteps = visible();
out.mode = w.installMode;
// Disjoint in both directions, apart from Start itself.
const NEW_ONLY = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
out.restoreLeaksNewStep = out.restoreSteps.some(s => NEW_ONLY.includes(s));
out.newLeaksRestoreStep = out.newSteps.some(s => RESTORE_ONLY.includes(s));
out.startAlwaysShown = out.newSteps[0] === 'Start' && out.restoreSteps[0] === 'Start';
// The source form offers every backend, and shows only the chosen one.
out.kinds = Array.from(document.querySelectorAll('#sw-rs-type option')).map(o => o.value);
const groupsFor = (t) => {
const sel = $('#sw-rs-type'); sel.value = t; fire(sel, 'change');
return Array.from(document.querySelectorAll('[data-rs-group]'))
.filter(g => g.style.display !== 'none')
.map(g => g.dataset.rsGroup)
.filter((v, i, a) => a.indexOf(v) === i);
};
out.localShowsOnlyLocal = JSON.stringify(groupsFor('local')) === JSON.stringify(['local']);
out.sftpShowsOnlySftp = JSON.stringify(groupsFor('sftp')) === JSON.stringify(['sftp']);
// Validation, before anything is sent.
$('#sw-rs-type').value = 'local'; fire($('#sw-rs-type'), 'change');
$('#sw-rs-path').value = ''; $('#sw-rs-pass').value = '';
out.emptyPathRefused = !!w._restoreSourceProblem();
$('#sw-rs-path').value = 'relative/path';
out.relativePathRefused = !!w._restoreSourceProblem();
$('#sw-rs-path').value = '/somewhere/backups';
out.missingPasswordRefused = !!w._restoreSourceProblem();
$('#sw-rs-pass').value = 'x';
out.completeAccepted = !w._restoreSourceProblem();
// A password must leave as a reference and not linger in the DOM. Stubbed:
// the real channel is covered by lp-secret-channel-test, and what matters
// here is that readBackup routes through it at all rather than putting the
// value in the payload.
let stashedValue = null, sentBody = null;
w.stashSecret = async (v) => { stashedValue = v; return 'secret:' + '0'.repeat(32); };
const realFetch = window.fetch;
window.fetch = async (url, opts) => {
if (String(url).includes('/api/setup/restore/read')) {
sentBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ ok: true, taskId: 't', nonce: 'n' }) };
}
return realFetch(url, opts);
};
$('#sw-rs-pass').value = 'hunter2-not-a-real-password';
const readPromise = w.readBackup();
// Do not wait out the poll: what is under test is what left the browser.
await new Promise(r => setTimeout(r, 500));
out.passwordWasStashed = stashedValue === 'hunter2-not-a-real-password';
out.passwordClearedFromDom = $('#sw-rs-pass').value === '';
out.payloadCarriesRef = !!(sentBody && sentBody.location && sentBody.location.password_ref);
out.payloadCarriesNoPassword = !!(sentBody && sentBody.location
&& !JSON.stringify(sentBody.location).includes('hunter2'));
window.fetch = realFetch;
// Deliberately NOT awaited: the stubbed response has no matching document to
// find, so readBackup polls for a full minute before giving up. What is
// under test already happened — what left the browser — and waiting for the
// timeout only makes the test take a minute longer than it needs to.
readPromise.catch(() => {});
// submit() must route to the restore path, not the install payload.
let routedTo = null;
w.submitRestore = async () => { routedTo = 'restore'; };
w._submitting = false;
await w.submit();
out.submitRoutedToRestore = routedTo === 'restore';
return JSON.stringify(out);
JS
J=$("$SHOT" --eval "/" "$DRIVE" 2>/dev/null)
if [[ -z "$J" ]] || ! jq -e . >/dev/null 2>&1 <<< "$J"; then
if lp_reachable; then
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
fi
echo " SKIP no WebUI reachable"; exit 0
fi
g(){ jq -r "$1" <<< "$J" 2>/dev/null; }
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
echo "the branch"
chk "new install is the default" "$(g .newIsDefault)" true
chk "picking restore switches mode" "$(g .mode)" restore
chk "Start shows in both" "$(g .startAlwaysShown)" true
chk "restore shows no install steps" "$(g .restoreLeaksNewStep)" false
chk "new shows no restore steps" "$(g .newLeaksRestoreStep)" false
chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Source,Contents,Rebuild"
echo "the backup source form"
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
chk "sftp shows only its own" "$(g .sftpShowsOnlySftp)" true
chk "empty path refused" "$(g .emptyPathRefused)" true
chk "relative path refused" "$(g .relativePathRefused)" true
chk "missing password refused" "$(g .missingPasswordRefused)" true
chk "a complete source accepted" "$(g .completeAccepted)" true
echo "the password"
chk "goes through the secret channel" "$(g .passwordWasStashed)" true
chk "leaves the payload as a ref" "$(g .payloadCarriesRef)" true
chk "and never as a value" "$(g .payloadCarriesNoPassword)" true
chk "and is cleared from the DOM" "$(g .passwordClearedFromDom)" true
echo "submit"
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
exit $fail

View File

@ -45,6 +45,12 @@ read -r -d '' DRIVE <<'JS'
const w = window.setupWizard; const w = window.setupWizard;
if (!w) return JSON.stringify({ error: 'wizard handle missing' }); if (!w) return JSON.stringify({ error: 'wizard handle missing' });
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true })); const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
// Looked up by name, never hardcoded. Inserting the Start step moved Storage
// from 3 to 4, and a test pinned to the old number silently began validating
// the Domains step instead — reporting that nothing blocked, which is
// indistinguishable from validation being broken.
const STORAGE = w.stepNames.indexOf('Storage');
if (STORAGE < 0) return JSON.stringify({ error: 'no Storage step' });
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
// Re-queried after every renderStorage(), which rebuilds the selects — a // Re-queried after every renderStorage(), which rebuilds the selects — a
@ -116,9 +122,9 @@ read -r -d '' DRIVE <<'JS'
// --- editing a path --- // --- editing a path ---
const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err'); const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
out.untouchedDefaultBlocks = !!w.validateStep(3); out.untouchedDefaultBlocks = !!w.validateStep(STORAGE);
const probe = (v) => { inp.value = v; fire(inp, 'input'); const probe = (v) => { inp.value = v; fire(inp, 'input');
return { blocks: !!w.validateStep(3), return { blocks: !!w.validateStep(STORAGE),
marked: inp.classList.contains('is-invalid'), marked: inp.classList.contains('is-invalid'),
said: (err && err.textContent) || '' }; }; said: (err && err.textContent) || '' }; };
out.relative = probe('mnt/nas'); out.relative = probe('mnt/nas');

View File

@ -100,3 +100,54 @@ restoreFirstRunBulk()
isSuccessful "First-run restore complete — $ok apps restored" isSuccessful "First-run restore complete — $ok apps restored"
return 0 return 0
} }
# The WebUI's rebuild: adopt the settings, reconcile the domains, restore the
# apps. Same order the installer uses and for the same reason — the system
# config carries every other backup location's credentials, so one password the
# user remembers unlocks the rest, and only then are apps worth restoring.
#
# restoreWebuiRebuild <location-idx> <host> [drop-domains]
#
# Called from a task, so its output is the progress the user watches.
restoreWebuiRebuild()
{
local idx="${1:-}" host="${2:-}" drop="${3:-no}"
if [[ -z "$idx" ]]; then
isError "restoreWebuiRebuild requires a backup location"
return 1
fi
isHeader "Rebuilding from backup"
# --- settings first ------------------------------------------------------
isNotice "Restoring settings and credentials…"
if backupRestoreSystemConfig "$idx" >/dev/null 2>&1; then
# --force: the WebUI is only reachable at all because this machine has
# a working install on it, so restoreAdoptIsFirstRun will say no. The
# user asked for this explicitly on the Rebuild step, which is the
# confirmation the guard exists to require.
if restoreSystemAdopt "" --force; then
isSuccessful "Settings and backup repositories restored"
else
isNotice "Settings were staged but could not be adopted — apps will still be restored."
fi
else
isNotice "No system config in this backup — apps will still be restored."
fi
# --- domains -------------------------------------------------------------
restoreDomainReport || true
if [[ "$drop" == "yes" || "$drop" == "true" ]]; then
restoreDomainsDropElsewhere || true
fi
# --- apps ----------------------------------------------------------------
# No app list, deliberately: bulk discovers the host's apps and re-applies
# the preflight itself. Passing a list here is what let a 13-app restore
# arrive as four and still report success.
isNotice "Restoring apps — this takes a while."
restoreFirstRunBulk "$idx" "$host"
isSuccessful "Rebuild complete"
return 0
}

View File

@ -253,3 +253,32 @@ restoreConnectInspect()
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out" jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
return 0 return 0
} }
# Same connect-and-inspect, published where the WebUI can read it.
#
# The WebUI cannot read a task's stdout, so the result is written beside the
# other generated data and polled for. A nonce echoed back from the request is
# what lets the browser tell ITS answer from a stale document left by an
# earlier attempt — without it a second read shows the first read's repository,
# which is the kind of wrong that looks entirely plausible.
restoreConnectInspectPublish()
{
local b64="${1:-}" nonce="${2:-}"
local out_dir; out_dir="$(webuiDir)/frontend/data/system"
local out_file="$out_dir/restore_read.json"
createFolders "quiet" "$sudo_user_name" "$out_dir"
local body rc
body=$(restoreConnectInspect "$b64"); rc=$?
[[ -n "$body" ]] || body='{"error":"the read produced no result"}'
local tmp; tmp=$(mktemp) || return 1
jq -c --arg nonce "$nonce" --arg at "$(date -Iseconds)" \
'. + {nonce: $nonce, checked: $at}' <<< "$body" > "$tmp" 2>/dev/null \
|| printf '{"error":"the read produced unreadable output","nonce":"%s"}\n' \
"$(_lpJsonStr "$nonce")" > "$tmp"
runFileWrite "$out_file" < "$tmp"
rm -f "$tmp"
return $rc
}

View File

@ -911,6 +911,7 @@ declare -gA LP_FN_MAP=(
[restoreAppRunHook]="restore/restore_app_hooks.sh" [restoreAppRunHook]="restore/restore_app_hooks.sh"
[restoreAppStart]="restore/restore_app_start.sh" [restoreAppStart]="restore/restore_app_start.sh"
[restoreConnectInspect]="restore/restore_inspect.sh" [restoreConnectInspect]="restore/restore_inspect.sh"
[restoreConnectInspectPublish]="restore/restore_inspect.sh"
[restoreDbRehydratePreStart]="backup/db/backup_db.sh" [restoreDbRehydratePreStart]="backup/db/backup_db.sh"
[restoreDbReplayPostStart]="backup/db/backup_db.sh" [restoreDbReplayPostStart]="backup/db/backup_db.sh"
[restoreDomainCheck]="restore/restore_domains.sh" [restoreDomainCheck]="restore/restore_domains.sh"
@ -930,6 +931,7 @@ declare -gA LP_FN_MAP=(
[restorePreflightReport]="restore/restore_preflight.sh" [restorePreflightReport]="restore/restore_preflight.sh"
[restoreServerPublicIp]="restore/restore_domains.sh" [restoreServerPublicIp]="restore/restore_domains.sh"
[restoreSystemAdopt]="restore/restore_system_adopt.sh" [restoreSystemAdopt]="restore/restore_system_adopt.sh"
[restoreWebuiRebuild]="restore/restore_first_run.sh"
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
[_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh" [_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh"
@ -2175,6 +2177,7 @@ declare -gA LP_FN_ROOT=(
[restoreAppRunHook]="scripts" [restoreAppRunHook]="scripts"
[restoreAppStart]="scripts" [restoreAppStart]="scripts"
[restoreConnectInspect]="scripts" [restoreConnectInspect]="scripts"
[restoreConnectInspectPublish]="scripts"
[restoreDbRehydratePreStart]="scripts" [restoreDbRehydratePreStart]="scripts"
[restoreDbReplayPostStart]="scripts" [restoreDbReplayPostStart]="scripts"
[restoreDomainCheck]="scripts" [restoreDomainCheck]="scripts"
@ -2194,6 +2197,7 @@ declare -gA LP_FN_ROOT=(
[restorePreflightReport]="scripts" [restorePreflightReport]="scripts"
[restoreServerPublicIp]="scripts" [restoreServerPublicIp]="scripts"
[restoreSystemAdopt]="scripts" [restoreSystemAdopt]="scripts"
[restoreWebuiRebuild]="scripts"
[_rocketchatApi]="containers" [_rocketchatApi]="containers"
[_rocketchatBaseUrl]="containers" [_rocketchatBaseUrl]="containers"
[_rocketchatError]="containers" [_rocketchatError]="containers"
@ -3477,6 +3481,7 @@ restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${inst
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; } restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; } restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; } restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; }
restoreConnectInspectPublish() { unset -f restoreConnectInspectPublish; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspectPublish "$@"; }
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; } restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; } restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; } restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
@ -3496,6 +3501,7 @@ restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; } restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; } restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; }
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; } restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
restoreWebuiRebuild() { unset -f restoreWebuiRebuild; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreWebuiRebuild "$@"; }
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; } _rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; } _rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
_rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; } _rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; }