diff --git a/configs/backup/backup_general b/configs/backup/backup_general index b6debb6..ba519a3 100755 --- a/configs/backup/backup_general +++ b/configs/backup/backup_general @@ -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 diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 4ab2002..2ca011c 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -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 :, +// 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 + // : — 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('/')) { diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 4fd92c0..c17667b 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -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 { ?
+
Destinations
+

@@ -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 = `
- Back up to - + ${modes.map(m => ``).join('')}
`; - 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 + ? 'default' + : 'new'; + const warn = sameDisk + ? 'same disk as your apps' + : ''; + const where = l.type === 'local' + ? (l.path || 'default location') + : `${this.escapeHtml(l.type.toUpperCase())} \u00b7 ${this.escapeHtml(l.ssh_host || l.host || '')}`; + return ` +
+ + + ${this.escapeHtml(l.name)} ${badge} ${warn} + ${this.escapeHtml(where)} + + +
`; + }).join(''); + + box.innerHTML = rows + ` + `; + + 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 = ` +
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + 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 || [] }; diff --git a/scripts/crontab/app/crontab_backup_scheduler.sh b/scripts/crontab/app/crontab_backup_scheduler.sh index fcbaefd..42c2b20 100644 --- a/scripts/crontab/app/crontab_backup_scheduler.sh +++ b/scripts/crontab/app/crontab_backup_scheduler.sh @@ -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" diff --git a/scripts/dev/lp-backup-setup-test b/scripts/dev/lp-backup-setup-test new file mode 100755 index 0000000..0ae4781 --- /dev/null +++ b/scripts/dev/lp-backup-setup-test @@ -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 + local payload="$1" + cat > "$BASE/run.sh" < "\$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" < "$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." diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index b0bbe37..16da386 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -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