setup: rebuild the wizard's Backups step around the locations that exist

The step asked one question — pick a destination, or "not now" — while the
system underneath already had a full location model: eight backend types, per
location engine, path mode, credentials and retention, and a generated
locations.json carrying all of it. None of that was reachable during setup, so a
second destination, or even seeing where the first one points, meant finding the
Backup page afterwards.

Now it mirrors the Storage step — the choice above, the list below:

  Backups        Automatic — daily, on a schedule  |  Manual
  Destinations   Local disk [default] /libreportal-backups/1   [Edit]
                 + Add destination

Automatic/Manual needed a setting, because there was no off switch:
crontabSetupBackupScheduler installed the entry unconditionally. CFG_BACKUP_MODE
is explicit rather than overloading "empty schedule", so it reads properly in
the config editor too, and Manual REMOVES an entry that is already installed
rather than merely declining to add one — otherwise answering Manual changes
nothing. The schedule itself is left alone, so switching back restores the time
the user picked.

Destinations are seeded from locations.json, so the default one is shown and
editable instead of being discovered later, and only entries the user actually
added or changed are submitted. A destination on the same disk as the app data
says so on the card rather than in a paragraph under the step.

Remote destinations are what the secret channel was for. The wizard payload is
base64'd into a task's command string and tasks are recorded world-readable, so
a password is POSTed to /api/setup/secret, which writes it where only the
manager can read it and returns an opaque reference; the reference travels in
the payload and setup_apply redeems it once, at the write. A reference that
cannot be redeemed leaves the password alone and says so, rather than blanking
it.

Verified in the browser on a clean install: the step renders both modes, lists
the existing destination at its resolved path, and the add dialog swaps between
local and remote fields. scripts/dev/lp-backup-setup-test covers the apply side,
including that what reaches the config is the secret and never the reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-28 10:20:23 +01:00
parent 0aa92e128e
commit 34512f7b28
6 changed files with 494 additions and 20 deletions

View File

@ -2,5 +2,6 @@
# Backup General - Scheduling
# @icon 💾
# ================================================================================
CFG_BACKUP_MODE=automatic # Backups - Automatic runs them on the schedule below; Manual means you start them yourself from the Backup page [automatic:Automatic|manual:Manual]
CFG_BACKUP_CRONTAB_APP="0 5 * * *" # App Backup Schedule - Crontab schedule for application backups
CFG_BACKUP_DASHBOARD_REFRESH_INTERVAL=30 # Dashboard Refresh Interval - Minutes between routine restic pulls that refresh the Backups dashboard

View File

@ -150,6 +150,58 @@ async function enqueueTask(spec) {
// frontend/data/system/import_check.json, which the wizard polls. Read-only,
// and a .lpapp is not encrypted, so no secret crosses this boundary — unlike a
// backup repository, which is why that one stays in the terminal installer.
// Hand a secret to the host without it ever reaching a command line.
//
// Everything else the wizard submits travels as part of a task's command
// string, which is recorded in frontend/data/tasks/*.json — 0644, inside a
// world-readable directory — and is visible in `ps` while the task runs. That
// is acceptable for a hostname; it is not for a backup repository password,
// which decrypts every backup the user has.
//
// So the value is written into a drop directory that root prepared for exactly
// this (see `libreportal-ownership secret-dir`): owned <container>:<manager>,
// mode 2730, so the setgid bit gives this file the manager's group and nobody
// else can read or even list it. The caller gets back an opaque reference and
// puts THAT in the task; the manager redeems it once, at the moment of the
// write, and unlinks it.
//
// The value is never logged, never echoed back, and never written anywhere
// else.
const SECRET_DIR = '/app/frontend/data/.secrets';
router.post('/secret', requireAuth, async (req, res) => {
const value = (req.body && typeof req.body.value === 'string') ? req.body.value : null;
if (value === null || value === '') {
return res.status(400).json({ error: 'A value is required' });
}
// Not a size limit for its own sake: this directory is readable by the
// manager, so it should never become somewhere to park arbitrary data.
if (Buffer.byteLength(value, 'utf8') > 4096) {
return res.status(413).json({ error: 'Value too large' });
}
try {
// Absent means the host has not run `libreportal-ownership secret-dir`.
// Creating it here would get the ownership wrong — only root can set
// <container>:<manager> — and a directory this container owned outright
// would not be readable by the manager, so fail loudly instead.
if (!fs.existsSync(SECRET_DIR)) {
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
}
const id = require('crypto').randomBytes(16).toString('hex');
const file = path.join(SECRET_DIR, id);
// 0640 explicitly rather than relying on the process umask: owner writes,
// the manager's group reads, nobody else.
await fsp.writeFile(file, value, { mode: 0o640, flag: 'wx' });
res.json({ ok: true, ref: `secret:${id}` });
} catch (e) {
// Deliberately not echoing the exception: it can contain the path, and on
// some failures the value.
res.status(500).json({ error: 'Could not store the value' });
}
});
router.post('/import-check', requireAuth, async (req, res) => {
const p = String((req.body && req.body.path) || '').trim();
if (!p || !p.startsWith('/')) {

View File

@ -38,6 +38,14 @@ class SetupWizard {
this.storageSystemChoice = 'primary';
// Backup destination: '' = none, 'primary' = system disk, else a drive path.
this.backupDest = '';
// Automatic runs backups on the schedule; Manual means the user starts
// them. Automatic by default — the people most likely to need a restore are
// the ones who never got round to setting one up.
this.backupMode = 'automatic';
// Destinations as the wizard will submit them. Seeded from the locations
// the install already has, so the default one is shown and editable rather
// than being a thing you discover later on the Backup page.
this.backupLocations = [];
// .lpapp exports found at the path the user gave, and which to import.
this.importResults = [];
this.importSelected = [];
@ -81,6 +89,9 @@ class SetupWizard {
// Async: the wizard is usable immediately and the Storage step appears if
// and when the scan says there is something to choose.
this.loadStorage();
// Same shape: the step is usable immediately and fills in when the
// install's existing destinations come back.
this.loadBackupLocations();
this.showStep(0);
}
@ -261,6 +272,8 @@ class SetupWizard {
<span class="setup-tooltip" tabindex="0" data-tip="Snapshots of your apps and settings, taken on a schedule. Encrypted, so keep the password somewhere other than this machine — without it a backup cannot be opened, not even by us.">?</span>
</div>
<div id="sw-backup-choices"></div>
<div class="setup-storage-divider">Destinations</div>
<div id="sw-backup-dests"></div>
<p class="setup-section-hint" id="sw-backup-note" style="margin-top:10px;"></p>
</div>
</section>
@ -672,39 +685,244 @@ class SetupWizard {
// holding app data where we can tell — a backup on the same disk as the data
// survives a bad update or a deletion, but not the disk dying, and that is
// the case people assume they are covered for.
// Load the destinations this install already has, so the default one is
// shown and editable here rather than being discovered later on the Backup
// page. Failure is not fatal: the step still offers to add one.
async loadBackupLocations() {
try {
const r = await fetch('/data/backup/generated/locations.json', { cache: 'no-store' });
const d = await r.json();
const list = Array.isArray(d) ? d : (d.locations || []);
this.backupLocations = list.map(l => ({
idx: l.idx,
name: l.name || `Location ${l.idx}`,
type: l.type || 'local',
// path is empty when the location uses Automatic path mode; uri is the
// resolved location either way, and a real folder is what someone
// deciding where their backups go needs to see.
path: l.path || l.uri || '',
existing: true,
enabled: l.enabled !== false
}));
} catch (e) {
this.backupLocations = [];
}
this.renderBackupDests();
}
// Automatic vs Manual, above the list — the same shape as the Storage step's
// choices-above-drives-below.
renderBackupChoices() {
const box = this.container.querySelector('#sw-backup-choices');
const note = this.container.querySelector('#sw-backup-note');
if (!box) return;
const appTarget = this.storageDefault && this.storageDefault !== 'primary'
? this.storageDefault : (this.storageSystem ? this.storageSystem.mount : '/');
const opts = [{ value: '', label: 'Not now — set this up later' }];
this.storageCandidates
.filter(c => c.verdict !== 'refuse')
.forEach(c => opts.push({ value: c.path, label: c.path, shared: c.path === appTarget }));
opts.push({ value: 'primary', label: this._primaryLabel(), shared: this.storageDefault === 'primary' });
const modes = [
{ value: 'automatic', label: 'Automatic — daily, on a schedule' },
{ value: 'manual', label: 'Manual — I\u2019ll start them myself' }
];
box.innerHTML = `
<div class="setup-storage-choice">
<span class="setup-storage-choice-label">Back up to</span>
<select id="sw-backup-dest" class="form-control">
${opts.map(o => `<option value="${this.escapeHtml(o.value)}"${o.value === this.backupDest ? ' selected' : ''}>${this.escapeHtml(o.label)}</option>`).join('')}
<span class="setup-storage-choice-label">Backups
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml('Automatic runs them for you overnight. Manual means nothing is backed up until you press the button on the Backup page.')}">?</span>
</span>
<select id="sw-backup-mode" class="form-control">
${modes.map(m => `<option value="${m.value}"${m.value === this.backupMode ? ' selected' : ''}>${this.escapeHtml(m.label)}</option>`).join('')}
</select>
</div>
<div class="setup-storage-choice-msg" id="sw-backup-msg" style="display:none;"></div>`;
box.querySelector('#sw-backup-dest').addEventListener('change', (e) => {
this.backupDest = e.target.value;
this.renderBackupMsg(opts);
box.querySelector('#sw-backup-mode').addEventListener('change', (e) => {
this.backupMode = e.target.value;
this.renderBackupModeMsg();
});
this.renderBackupMsg(opts);
this.renderBackupModeMsg();
}
renderBackupModeMsg() {
const msg = this.container.querySelector('#sw-backup-msg');
if (!msg) return;
if (this.backupMode === 'manual') {
msg.style.display = '';
msg.className = 'setup-storage-choice-msg setup-storage-choice-warn';
msg.textContent = 'Nothing will be backed up until you run it yourself from the Backup page.';
} else {
msg.style.display = 'none';
msg.textContent = '';
}
}
// One row per destination, plus the add button. Mirrors the drive list on the
// Storage step so the two read the same way.
renderBackupDests() {
const box = this.container.querySelector('#sw-backup-dests');
const note = this.container.querySelector('#sw-backup-note');
if (!box) return;
// Where app data lives, so we can say when a destination is the same disk.
const appTarget = this.storageDefault && this.storageDefault !== 'primary'
? this.storageDefault
: (this.storageSystem ? this.storageSystem.mount : '/');
const rows = this.backupLocations.map((l, i) => {
const sameDisk = l.type === 'local' && l.path && appTarget !== '/' && l.path.startsWith(appTarget);
const badge = l.existing
? '<span class="setup-storage-badge setup-storage-badge-ok">default</span>'
: '<span class="setup-storage-badge setup-storage-badge-ok">new</span>';
const warn = sameDisk
? '<span class="setup-storage-badge setup-storage-badge-warn">same disk as your apps</span>'
: '';
const where = l.type === 'local'
? (l.path || 'default location')
: `${this.escapeHtml(l.type.toUpperCase())} \u00b7 ${this.escapeHtml(l.ssh_host || l.host || '')}`;
return `
<div class="setup-app setup-storage-card setup-storage-locked">
<span class="setup-storage-spacer" aria-hidden="true"></span>
<span class="setup-app-body">
<span class="setup-app-name">${this.escapeHtml(l.name)} ${badge} ${warn}</span>
<span class="setup-app-desc">${this.escapeHtml(where)}</span>
</span>
<button type="button" class="setup-storage-details" data-backup-edit="${i}">Edit</button>
</div>`;
}).join('');
box.innerHTML = rows + `
<button type="button" class="setup-add-domain" id="sw-backup-add">+ Add destination</button>`;
box.querySelectorAll('[data-backup-edit]').forEach(b => {
b.addEventListener('click', () => this.showBackupDestModal(Number(b.dataset.backupEdit)));
});
const add = box.querySelector('#sw-backup-add');
if (add) add.addEventListener('click', () => this.showBackupDestModal(-1));
if (note) {
note.innerHTML = this.storageCandidates.length
const offsite = this.backupLocations.some(l => l.type !== 'local');
note.innerHTML = offsite
? ''
: 'Only one drive found. A backup here still protects against deletions and bad updates \u2014 but not against this disk failing. Add another destination later from the Backup page.';
: 'Everything here is on this machine. That protects against deletions and bad updates \u2014 but not against losing the machine. Add an SFTP or S3 destination for that.';
}
}
// Add or edit a destination. Local wants a path; everything else wants
// credentials, and those never travel in the wizard payload — the payload is
// base64'd into a task command string, and tasks are recorded in a
// world-readable file. The password is POSTed to /api/setup/secret, which
// writes it where only the manager can read it and hands back an opaque
// reference; the reference is what goes in the payload.
showBackupDestModal(index) {
if (typeof window.openEoModal !== 'function') return;
const adding = index < 0;
const loc = adding
? { name: '', type: 'local', path: '' }
: Object.assign({}, this.backupLocations[index]);
const types = [
['local', 'This machine or a plugged-in disk'],
['sftp', 'SFTP server'],
['s3', 'S3'],
['b2', 'Backblaze B2']
];
const body = `
<div class="setup-field">
<label class="setup-label">Name</label>
<input type="text" id="bk-name" class="form-control" value="${this.escapeHtml(loc.name || '')}" placeholder="Offsite">
</div>
<div class="setup-field">
<label class="setup-label">Where</label>
<select id="bk-type" class="form-control">
${types.map(([v, l]) => `<option value="${v}"${v === loc.type ? ' selected' : ''}>${this.escapeHtml(l)}</option>`).join('')}
</select>
</div>
<div id="bk-local">
<div class="setup-field">
<label class="setup-label">Folder</label>
<input type="text" id="bk-path" class="form-control" value="${this.escapeHtml(loc.path || '')}" placeholder="/mnt/usb/libreportal-backups">
</div>
</div>
<div id="bk-remote" style="display:none;">
<div class="setup-field"><label class="setup-label">Host</label>
<input type="text" id="bk-host" class="form-control" value="${this.escapeHtml(loc.ssh_host || '')}"></div>
<div class="setup-field"><label class="setup-label">User</label>
<input type="text" id="bk-user" class="form-control" value="${this.escapeHtml(loc.ssh_user || '')}"></div>
<div class="setup-field"><label class="setup-label">Path on that host</label>
<input type="text" id="bk-rpath" class="form-control" value="${this.escapeHtml(loc.ssh_path || '')}"></div>
<div class="setup-field"><label class="setup-label">Password</label>
<input type="password" id="bk-pass" class="form-control" autocomplete="new-password">
<span class="setup-section-hint">Sent straight to this machine and stored where only LibrePortal can read it it is never part of the task log.</span></div>
</div>`;
const m = window.openEoModal({
id: 'lp-backup-dest',
size: 'md',
title: adding ? 'Add a backup destination' : `Edit ${loc.name || 'destination'}`,
body,
actions: [
{ label: 'Cancel', variant: 'secondary' },
{ label: adding ? 'Add' : 'Save', variant: 'primary', keep: true, onClick: async () => {
const root = document;
const type = root.querySelector('#bk-type').value;
const name = (root.querySelector('#bk-name').value || '').trim() || (type === 'local' ? 'Local disk' : type);
const next = Object.assign({}, loc, { name, type });
if (type === 'local') {
next.path = (root.querySelector('#bk-path').value || '').trim();
} else {
next.ssh_host = (root.querySelector('#bk-host').value || '').trim();
next.ssh_user = (root.querySelector('#bk-user').value || '').trim();
next.ssh_path = (root.querySelector('#bk-rpath').value || '').trim();
const pw = root.querySelector('#bk-pass').value || '';
if (pw) {
const ref = await this.stashSecret(pw);
if (!ref) return; // stashSecret already reported why
next.password_ref = ref;
}
}
if (adding) {
this.backupLocations.push(next);
} else {
// Mark it so the payload carries it: an existing destination is
// only submitted when the user actually changed something.
next.dirty = true;
this.backupLocations[index] = next;
}
this.renderBackupDests();
if (m && typeof m.close === 'function') m.close();
} }
]
});
// Local and remote want different fields; swap them as the type changes.
const sync = () => {
const type = document.querySelector('#bk-type').value;
document.querySelector('#bk-local').style.display = type === 'local' ? '' : 'none';
document.querySelector('#bk-remote').style.display = type === 'local' ? 'none' : '';
};
const sel = document.querySelector('#bk-type');
if (sel) { sel.addEventListener('change', sync); sync(); }
}
// Hand a secret to the host and get back a reference to put in the payload.
// Returns null and says why on failure, so a destination is never saved with
// the caller believing a password went with it.
async stashSecret(value) {
try {
const r = await fetch('/api/setup/secret', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
});
const d = await r.json();
if (!r.ok || !d.ref) throw new Error(d.error || 'could not store the password');
return d.ref;
} catch (e) {
const sys = (typeof window.ensureNotificationSystem === 'function')
? window.ensureNotificationSystem() : window.notificationSystem;
if (sys && typeof sys.show === 'function') {
sys.show(`Could not save that password: ${e.message}`, 'error');
}
return null;
}
}
@ -1343,6 +1561,20 @@ class SetupWizard {
storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary',
// '' = don't configure backups; 'primary' = system disk; else a drive path.
backup_dest: this.backupDest || '',
backup_mode: this.backupMode || 'automatic',
// Only what the user actually changed or added. An untouched default
// destination is already configured on the host, so re-sending it would
// be a no-op write for no reason.
backup_locations: (this.backupLocations || []).filter(l => !l.existing || l.dirty).map(l => ({
idx: l.existing ? l.idx : undefined,
name: l.name,
type: l.type,
path: l.path || '',
ssh_host: l.ssh_host || '',
ssh_user: l.ssh_user || '',
ssh_path: l.ssh_path || '',
password_ref: l.password_ref || ''
})),
// Absolute paths to .lpapp files the user accepted after the check.
import_files: this.importSelected || []
};

View File

@ -20,9 +20,20 @@ crontabSetupBackupScheduler()
local marker="# CRONTAB BACKUP SCHEDULER"
local scheduler_entry="$CFG_BACKUP_CRONTAB_APP libreportal backup scheduled $marker"
# Drop any previous scheduler entry, then re-add the current one so a
# changed schedule (CFG_BACKUP_CRONTAB_APP) always takes effect.
# Drop any previous scheduler entry first. This happens whichever mode we
# are in, and it is what makes Manual actually take effect: switching to
# Manual has to REMOVE an entry that is already installed, not merely
# decline to add one.
local result; result=$(runAsManager crontab -l 2>/dev/null | grep -v "$marker" | runAsManager crontab -)
# Manual: the user starts backups themselves from the Backup page. The
# schedule below is left in the config untouched, so switching back to
# Automatic restores the time they had chosen rather than a default.
if [[ "${CFG_BACKUP_MODE:-automatic}" == "manual" ]]; then
isNotice "Backups are set to Manual — no schedule installed. Start them from the Backup page."
return 0
fi
local result; result=$( (runAsManager crontab -l 2>/dev/null; echo "$scheduler_entry") | runAsManager crontab - )
checkSuccess "Installing the daily backup scheduler entry"

102
scripts/dev/lp-backup-setup-test Executable file
View File

@ -0,0 +1,102 @@
#!/bin/bash
# The wizard's Backups step, on the apply side.
#
# scripts/dev/lp-backup-setup-test
#
# Two things the step submits, and the reason each matters:
#
# backup_mode Automatic or Manual. Manual has to REMOVE a schedule
# that is already installed, not merely decline to add
# one, or answering "Manual" changes nothing.
# backup_locations Destinations. A password arrives as a REFERENCE, never a
# value: the whole wizard payload is base64'd into a task's
# command string, and tasks are recorded in
# frontend/data/tasks/*.json — 0644, in a world-readable
# directory. A backup repository password sent that way is
# readable by any local account.
#
# So the case that matters most is the last one: what reaches the config file is
# the secret, and what was in the payload never was.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-bksetup-XXXXXX")"
trap 'rm -rf "$BASE"' EXIT
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
# Extract the block under test from setup_apply.sh so edits there are what break
# this, rather than a copy drifting out of date.
# From the mode comment to the line that hands off to the legacy single-
# destination path, which is where the new block ends.
BLOCK=$(awk '/^ # Automatic or Manual\./{f=1} f{print} /^ backup_dest=""$/{exit}' \
"$REPO/scripts/setup/setup_apply.sh")
BLOCK="$BLOCK
fi"
[[ -n "$BLOCK" ]] || { echo " FAIL could not extract the backup block from setup_apply.sh"; exit 1; }
run() { # run <payload-json>
local payload="$1"
cat > "$BASE/run.sh" <<EOS
payload='$payload'
configs_dir="$BASE/configs/"
mkdir -p "\$configs_dir/backup"
: > "\$configs_dir/backup/backup_general"
isSuccessful(){ echo "OK: \$*"; }; isNotice(){ echo "NOTE: \$*"; }; isError(){ echo "ERR: \$*"; }
crontabSetupBackupScheduler(){ echo "SCHED_CALLED"; }
locationAdd(){ echo "LOCADD:\$1:\$2" >> "$BASE/calls"; echo 7; }
# The real one returns an existing file; the block skips a destination whose
# config is missing, which is correct behaviour and not what is under test here.
backupLocationConfig(){ local f="$BASE/loc.\$1.config"; : > "\$f"; echo "\$f"; }
engineInitLocation(){ echo "INIT:\$1" >> "$BASE/calls"; return 0; }
webuiSecretResolve(){ echo "RESOLVE:\$1" >> "$BASE/calls"; printf 'the-actual-password'; }
updateConfigOption(){ echo "SET:\$1=\$2" >> "$BASE/calls"; }
backup_dest=""
# Parsed earlier in setup_apply, outside the extracted region.
backup_mode=\$(echo "\$payload" | jq -r '.backup_mode // ""')
$(printf '%s' "$BLOCK")
EOS
: > "$BASE/calls"
bash "$BASE/run.sh" 2>&1
}
echo "--- Manual is applied and the scheduler is re-run ---"
out=$(run '{"backup_mode":"manual"}')
chk "mode written" "$(grep -c 'SET:CFG_BACKUP_MODE=manual' "$BASE/calls")" "1"
chk "scheduler re-run" "$(grep -c 'SCHED_CALLED' <<< "$out")" "1"
echo "--- a new remote destination ---"
out=$(run '{"backup_mode":"automatic","backup_locations":[{"name":"Offsite","type":"sftp","ssh_host":"h.example.org","ssh_user":"lp","ssh_path":"/srv/lp","password_ref":"secret:deadbeefcafe"}]}')
chk "location created" "$(grep -c 'LOCADD:Offsite:sftp' "$BASE/calls")" "1"
chk "host set" "$(grep -c 'SET:CFG_BACKUP_LOC_7_SSH_HOST=h.example.org' "$BASE/calls")" "1"
chk "user set" "$(grep -c 'SET:CFG_BACKUP_LOC_7_SSH_USER=lp' "$BASE/calls")" "1"
chk "enabled" "$(grep -c 'SET:CFG_BACKUP_LOC_7_ENABLED=true' "$BASE/calls")" "1"
chk "repo initialised" "$(grep -c 'INIT:7' "$BASE/calls")" "1"
echo "--- the password: a reference in, the secret out ---"
chk "reference redeemed" "$(grep -c 'RESOLVE:secret:deadbeefcafe' "$BASE/calls")" "1"
chk "secret written" "$(grep -c 'SET:CFG_BACKUP_LOC_7_PASSWORD=the-actual-password' "$BASE/calls")" "1"
chk "reference never written as the value" \
"$(grep -c 'SET:CFG_BACKUP_LOC_7_PASSWORD=secret:' "$BASE/calls")" "0"
echo "--- editing the destination that already exists ---"
out=$(run '{"backup_locations":[{"idx":1,"name":"Local disk","type":"local","path":"/mnt/usb/lp"}]}')
chk "no new location" "$(grep -c 'LOCADD' "$BASE/calls")" "0"
chk "path set on 1" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PATH=/mnt/usb/lp' "$BASE/calls")" "1"
chk "switched to custom" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PATH_MODE=custom' "$BASE/calls")" "1"
echo "--- an unusable reference must not blank the password ---"
out=$(run '{"backup_locations":[{"idx":1,"type":"local","password_ref":"secret:gone"}]}' )
# webuiSecretResolve is stubbed to succeed, so re-stub it as failing for this case
cat > "$BASE/run2.sh" <<EOS
$(sed 's|^webuiSecretResolve().*|webuiSecretResolve(){ return 1; }|' "$BASE/run.sh")
EOS
: > "$BASE/calls"
out=$(bash "$BASE/run2.sh" 2>&1)
chk "password untouched" "$(grep -c 'SET:CFG_BACKUP_LOC_1_PASSWORD' "$BASE/calls")" "0"
chk "and it says so" "$(grep -c 'Could not read the password' <<< "$out")" "1"
echo ""
if (( fail )); then echo "FAILED"; exit 1; fi
echo "All backup-setup checks passed."

View File

@ -28,6 +28,7 @@ setupApplyConfig()
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""')
local backup_mode=$(echo "$payload" | jq -r '.backup_mode // ""')
local import_files_json=$(echo "$payload" | jq -c '.import_files // []')
if [[ -n "$install_name" ]]; then
@ -116,6 +117,81 @@ setupApplyConfig()
# Created disabled by locationAdd, so enable it and initialise the repo —
# an un-initialised location is a destination that silently backs up
# nothing.
# Automatic or Manual. The schedule itself is left alone either way, so
# switching back to Automatic restores the time the user picked rather than
# a default; crontabSetupBackupScheduler is what acts on this, and it
# removes an already-installed entry when the answer is Manual.
if [[ "$backup_mode" == "automatic" || "$backup_mode" == "manual" ]]; then
updateConfigOption "CFG_BACKUP_MODE" "$backup_mode" \
"${configs_dir%/}/backup/backup_general" >/dev/null 2>&1
isSuccessful "Backups set to ${backup_mode}."
declare -f crontabSetupBackupScheduler >/dev/null 2>&1 && crontabSetupBackupScheduler
fi
# Destinations. An entry with an idx edits the location that already exists
# (a fresh install ships one); anything else is created.
#
# A password arrives as a REFERENCE, never a value: this whole payload was
# base64'd into the task's command string, and tasks are recorded in a
# world-readable file. webuiSecretResolve redeems it here, once, at the
# moment of the write. See scripts/webui/webui_secret.sh.
local _nloc; _nloc=$(echo "$payload" | jq -r '(.backup_locations // []) | length')
if [[ "$_nloc" =~ ^[0-9]+$ ]] && (( _nloc > 0 )); then
local _i
for (( _i=0; _i<_nloc; _i++ )); do
local _loc; _loc=$(echo "$payload" | jq -c ".backup_locations[$_i]")
local _idx _name _type _path _pwref
_idx=$(jq -r '.idx // ""' <<< "$_loc")
_name=$(jq -r '.name // "backup"' <<< "$_loc")
_type=$(jq -r '.type // "local"' <<< "$_loc")
_path=$(jq -r '.path // ""' <<< "$_loc")
_pwref=$(jq -r '.password_ref // ""' <<< "$_loc")
if [[ ! "$_idx" =~ ^[0-9]+$ ]]; then
_idx=$(locationAdd "$_name" "$_type" 2>/dev/null | tail -1)
if [[ ! "$_idx" =~ ^[0-9]+$ ]]; then
isNotice "Could not create the backup destination '$_name' — add it from the Backup page."
continue
fi
fi
local _cfg; _cfg=$(backupLocationConfig "$_idx")
[[ -f "$_cfg" ]] || { isNotice "Backup destination $_idx has no config — skipping."; continue; }
if [[ -n "$_path" ]]; then
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PATH_MODE" "custom" "$_cfg" >/dev/null
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PATH" "$_path" "$_cfg" >/dev/null
fi
if [[ "$_type" == "sftp" ]]; then
local _k _v
for _k in ssh_user ssh_host ssh_port ssh_path; do
_v=$(jq -r --arg k "$_k" '.[$k] // ""' <<< "$_loc")
[[ -n "$_v" ]] && updateConfigOption "CFG_BACKUP_LOC_${_idx}_${_k^^}" "$_v" "$_cfg" >/dev/null
done
fi
if [[ -n "$_pwref" ]]; then
local _pw
if _pw=$(webuiSecretResolve "$_pwref" 2>/dev/null) && [[ -n "$_pw" ]]; then
updateConfigOption "CFG_BACKUP_LOC_${_idx}_PASSWORD" "$_pw" "$_cfg" >/dev/null
_pw=""
else
isNotice "Could not read the password for '$_name' — set it on the Backup page."
fi
fi
updateConfigOption "CFG_BACKUP_LOC_${_idx}_ENABLED" "true" "$_cfg" >/dev/null
source "$_cfg" 2>/dev/null
if engineInitLocation "$_idx" >/dev/null 2>&1; then
isSuccessful "Backup destination '$_name' ready."
else
isNotice "Backup destination '$_name' was created but could not be initialised — finish it on the Backup page."
fi
done
# Handled here, so the single-destination path below stays for older
# payloads only.
backup_dest=""
fi
if [[ -n "$backup_dest" && "$backup_dest" != "null" ]]; then
local bpath bname
if [[ "$backup_dest" == "primary" ]]; then