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:
parent
5dd763713d
commit
a361e38562
@ -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) => {
|
||||
const payload = req.body || {};
|
||||
|
||||
|
||||
@ -23,8 +23,20 @@ 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', 'Backups', 'Import', 'Recommended', 'Metrics'];
|
||||
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '📦', '🛡️', '📊'];
|
||||
// 'Start' asks new-install-or-restore, and the answer selects one of two
|
||||
// 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
|
||||
// — one disk means one answer, and a step with nothing in it is noise.
|
||||
// Set by loadStorage() once the candidate scan comes back.
|
||||
@ -68,6 +80,14 @@ class SetupWizard {
|
||||
// only when a usable second filesystem was actually found.
|
||||
_stepVisible(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';
|
||||
// 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 —
|
||||
@ -179,7 +199,36 @@ class SetupWizard {
|
||||
doesn't get a wall of operator detail and an experienced
|
||||
user sees everything by default. Either choice is reversible
|
||||
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">
|
||||
<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">
|
||||
<label>Choose your experience</label>
|
||||
<p class="setup-step-note setup-level-note">
|
||||
@ -218,7 +267,7 @@ class SetupWizard {
|
||||
</section>
|
||||
|
||||
<!-- Step 2: Identity -->
|
||||
<section class="setup-step" data-step="1">
|
||||
<section class="setup-step" data-step="2">
|
||||
<div class="setup-field">
|
||||
<label for="sw-name">
|
||||
Install Name
|
||||
@ -262,7 +311,7 @@ class SetupWizard {
|
||||
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
|
||||
routing for apps via Traefik. Skipping leaves the install
|
||||
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">
|
||||
<label>
|
||||
Domains
|
||||
@ -279,7 +328,7 @@ class SetupWizard {
|
||||
<!-- Step 4: Storage locations. Only rendered when the candidate
|
||||
scan found a filesystem LibrePortal isn't already using —
|
||||
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-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>
|
||||
@ -295,7 +344,7 @@ class SetupWizard {
|
||||
<!-- 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">
|
||||
<section class="setup-step" data-step="5">
|
||||
<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>
|
||||
@ -311,7 +360,7 @@ class SetupWizard {
|
||||
Path-based, not upload: the file is already on the server, and a
|
||||
.lpapp is a plain tarball, so nothing secret crosses into the
|
||||
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-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>
|
||||
@ -329,7 +378,7 @@ class SetupWizard {
|
||||
</section>
|
||||
|
||||
<!-- 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-title">Recommended Apps</div>
|
||||
<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
|
||||
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="7">
|
||||
<section class="setup-step" data-step="8">
|
||||
<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>
|
||||
@ -361,6 +410,53 @@ class SetupWizard {
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@ -409,6 +505,22 @@ class SetupWizard {
|
||||
// Experience radio — updates installLevel and the visible-step count
|
||||
// immediately so the progress bar reflects the choice (4 vs 5 steps).
|
||||
// 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) => {
|
||||
r.addEventListener('change', () => {
|
||||
if (!r.checked) return;
|
||||
@ -883,6 +995,311 @@ class SetupWizard {
|
||||
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> 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() {
|
||||
const msg = this.container.querySelector('#sw-storage-system-msg');
|
||||
if (!msg) return;
|
||||
@ -1470,15 +1887,19 @@ class SetupWizard {
|
||||
}, 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) {
|
||||
const $ = (id) => this.container.querySelector(id);
|
||||
// Step 0 (Experience): always valid — radios are pre-checked.
|
||||
if (idx === 1) {
|
||||
const name = this.stepNames[idx];
|
||||
if (name === 'Identity') {
|
||||
const name = $('#sw-name').value.trim();
|
||||
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 (idx === 2) {
|
||||
if (name === 'Domains') {
|
||||
// Domains are optional, but any non-empty input must be valid.
|
||||
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
|
||||
for (const input of inputs) {
|
||||
@ -1488,14 +1909,13 @@ class SetupWizard {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3 = Storage. Only a typed custom path can be invalid; the scanned
|
||||
// options all came from the backend.
|
||||
if (idx === 3) {
|
||||
// Only an edited exact path can be invalid; the scanned options all came
|
||||
// from the backend.
|
||||
if (name === 'Storage') {
|
||||
const problem = this._syncStorageNav();
|
||||
if (problem) return problem;
|
||||
}
|
||||
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
|
||||
if (idx === 6) {
|
||||
if (name === 'Recommended') {
|
||||
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||
if (traefikBox && traefikBox.checked) {
|
||||
const tEmail = $('#sw-traefik-email').value.trim();
|
||||
@ -1809,6 +2229,19 @@ class SetupWizard {
|
||||
const $ = (id) => this.container.querySelector(id);
|
||||
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();
|
||||
for (let pos = 0; pos < visible.length; pos++) {
|
||||
const i = visible[pos];
|
||||
|
||||
@ -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
|
||||
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
|
||||
|
||||
**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.* |
|
||||
| **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 |
|
||||
| **5** ✅ | The WebUI branch: New install / Restore from backup, through source → contents → rebuild (§3.12) |
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
|
||||
@ -41,11 +41,22 @@ cliHandleRestoreCommands()
|
||||
# staging dir; never overwrites live config. Optional location idx.
|
||||
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 a repository from a base64 JSON payload and report what
|
||||
# it holds. The WebUI's restore branch; see restore_inspect.sh.
|
||||
# restore connect <base64-json>
|
||||
# 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)
|
||||
# Report what a restore from this repository would bring, without
|
||||
|
||||
158
scripts/dev/lp-restore-wizard-test
Executable file
158
scripts/dev/lp-restore-wizard-test
Executable 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
|
||||
@ -45,6 +45,12 @@ read -r -d '' DRIVE <<'JS'
|
||||
const w = window.setupWizard;
|
||||
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
|
||||
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);
|
||||
|
||||
// Re-queried after every renderStorage(), which rebuilds the selects — a
|
||||
@ -116,9 +122,9 @@ read -r -d '' DRIVE <<'JS'
|
||||
|
||||
// --- editing a path ---
|
||||
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');
|
||||
return { blocks: !!w.validateStep(3),
|
||||
return { blocks: !!w.validateStep(STORAGE),
|
||||
marked: inp.classList.contains('is-invalid'),
|
||||
said: (err && err.textContent) || '' }; };
|
||||
out.relative = probe('mnt/nas');
|
||||
|
||||
@ -100,3 +100,54 @@ restoreFirstRunBulk()
|
||||
isSuccessful "First-run restore complete — $ok apps restored"
|
||||
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
|
||||
}
|
||||
|
||||
@ -253,3 +253,32 @@ restoreConnectInspect()
|
||||
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
|
||||
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
|
||||
}
|
||||
|
||||
@ -911,6 +911,7 @@ declare -gA LP_FN_MAP=(
|
||||
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
||||
[restoreAppStart]="restore/restore_app_start.sh"
|
||||
[restoreConnectInspect]="restore/restore_inspect.sh"
|
||||
[restoreConnectInspectPublish]="restore/restore_inspect.sh"
|
||||
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
||||
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
||||
[restoreDomainCheck]="restore/restore_domains.sh"
|
||||
@ -930,6 +931,7 @@ declare -gA LP_FN_MAP=(
|
||||
[restorePreflightReport]="restore/restore_preflight.sh"
|
||||
[restoreServerPublicIp]="restore/restore_domains.sh"
|
||||
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
||||
[restoreWebuiRebuild]="restore/restore_first_run.sh"
|
||||
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
[_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh"
|
||||
@ -2175,6 +2177,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[restoreAppRunHook]="scripts"
|
||||
[restoreAppStart]="scripts"
|
||||
[restoreConnectInspect]="scripts"
|
||||
[restoreConnectInspectPublish]="scripts"
|
||||
[restoreDbRehydratePreStart]="scripts"
|
||||
[restoreDbReplayPostStart]="scripts"
|
||||
[restoreDomainCheck]="scripts"
|
||||
@ -2194,6 +2197,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[restorePreflightReport]="scripts"
|
||||
[restoreServerPublicIp]="scripts"
|
||||
[restoreSystemAdopt]="scripts"
|
||||
[restoreWebuiRebuild]="scripts"
|
||||
[_rocketchatApi]="containers"
|
||||
[_rocketchatBaseUrl]="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 "$@"; }
|
||||
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 "$@"; }
|
||||
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 "$@"; }
|
||||
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 "$@"; }
|
||||
@ -3496,6 +3501,7 @@ restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${
|
||||
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 "$@"; }
|
||||
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 "$@"; }
|
||||
_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 "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user