From 34512f7b2825d2b905e5f4aaa16c186b26fd7b38 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 28 Aug 2026 10:20:23 +0100 Subject: [PATCH] setup: rebuild the wizard's Backups step around the locations that exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- configs/backup/backup_general | 1 + .../backend/routes/setup-routes.js | 52 ++++ .../frontend/core/setup/js/setup-wizard.js | 268 ++++++++++++++++-- .../crontab/app/crontab_backup_scheduler.sh | 15 +- scripts/dev/lp-backup-setup-test | 102 +++++++ scripts/setup/setup_apply.sh | 76 +++++ 6 files changed, 494 insertions(+), 20 deletions(-) create mode 100755 scripts/dev/lp-backup-setup-test 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