diff --git a/src/backupScheduler.js b/src/backupScheduler.js index 1b0ea43..a4f00f0 100644 --- a/src/backupScheduler.js +++ b/src/backupScheduler.js @@ -174,7 +174,65 @@ function latestDump(dir = process.env.BACKUP_DIR || '/var/backups/vyndr', deps = } } +/** + * List the OFF-BOX copies actually present on the Storage Box. + * + * Exit 0 from the backup script is NOT proof the dump landed — the script + * deliberately keeps its exit code tied to on-box durability. This reads the + * remote directory back, so "the off-box copy exists" is a verified fact rather + * than an inference. Uses the SAME pinned known_hosts as the push; host-key + * checking is never disabled. + */ +function listOffbox(deps = {}) { + const spawn = deps.spawn || require('child_process').spawn; + const remote = process.env.BACKUP_REMOTE; + const port = process.env.BACKUP_SSH_PORT || '23'; + const knownHosts = process.env.BACKUP_KNOWN_HOSTS + || path.join(__dirname, '..', 'scripts', 'storagebox_known_hosts'); + return new Promise((resolve) => { + if (!remote) return resolve({ ok: false, error: 'BACKUP_REMOTE unset' }); + if (!process.env.BACKUP_SSH_KEY) return resolve({ ok: false, error: 'BACKUP_SSH_KEY unset' }); + const fs = require('fs'); + const os = require('os'); + let keyFile; + try { + // Same decode contract as backup-db.sh: base64 preferred, raw PEM fallback. + const raw = process.env.BACKUP_SSH_KEY; + let decoded; + try { + const d = Buffer.from(raw, 'base64').toString('utf8'); + decoded = d.includes('PRIVATE KEY') ? d : raw.replace(/\\n/g, '\n'); + } catch { decoded = raw.replace(/\\n/g, '\n'); } + keyFile = path.join(os.tmpdir(), `vyndr-offbox-${Date.now()}`); + fs.writeFileSync(keyFile, decoded.endsWith('\n') ? decoded : `${decoded}\n`, { mode: 0o600 }); + } catch (e) { + return resolve({ ok: false, error: `key write failed: ${e.message}` }); + } + const sshCmd = `ssh -p ${port} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${knownHosts} -o BatchMode=yes -i ${keyFile}`; + const child = spawn('rsync', ['--list-only', '-e', sshCmd, remote], { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + child.stdout.on('data', (b) => { out += b.toString(); }); + child.stderr.on('data', (b) => { err = (err + b.toString()).slice(-1500); }); + const done = (result) => { + try { require('fs').unlinkSync(keyFile); } catch { /* best effort */ } + resolve(result); + }; + child.on('error', (e) => done({ ok: false, error: e.message })); + child.on('close', (code) => { + const files = out.split('\n') + .map((l) => l.trim()) + .filter((l) => /vyndr-.*\.dump$/.test(l)) + .map((l) => { + const parts = l.split(/\s+/); + return { size: Number(parts[1].replace(/,/g, '')) || null, date: parts[2], time: parts[3], file: parts[parts.length - 1] }; + }); + done({ ok: code === 0, code, count: files.length, files, error: code === 0 ? null : (err || `exit ${code}`) }); + }); + }); +} + module.exports = { startBackupScheduler, runBackup, shouldArm, durabilityWarning, SCRIPT, - countRowsInDump, latestDump, + countRowsInDump, latestDump, listOffbox, }; diff --git a/src/routes/internal.js b/src/routes/internal.js index e5d72ae..1b5c470 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -329,6 +329,22 @@ router.get('/backup/verify', async (req, res) => { } }); +/** + * GET /api/internal/backup/offbox — list the dumps actually present ON the + * Storage Box. Exit 0 from the backup is not proof the file landed; this reads + * the remote directory back so off-box presence is verified, not inferred. + */ +router.get('/backup/offbox', async (req, res) => { + const { listOffbox } = require('../backupScheduler'); + try { + const result = await listOffbox(); + return res.json(result); + } catch (err) { + const message = err && err.message ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); + /** * POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the * persistent ledger (outcome + actual_value + CLV) across every sport.