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) => {
const payload = req.body || {};

View File

@ -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>&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() {
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];

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

View File

@ -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>
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)
# 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;
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');

View File

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

View File

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

View File

@ -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 "$@"; }