openvibers/openvibe.host Commits
ovhost archive (roadmap WS-S task 6, hazard H14): dead and backup database files encrypted with the backup key and copied to <prefix>-archive/<host>/<stamp>/ (outside the backup runs, so retention never prunes them), each upload checked by size, an HMAC'd manifest and a local record; archive list and restore (checked against the manifest). Refuses live inventory databases, open files (fuser), symlinks, paths outside /opt /var/lib /root /srv /home. Never deletes. docs/db-inventory.md: the 58 closed database files on the host, classified with a recommended action (legacy archived first; IP-bearing and secret backups keep their dated deletions, never archived). The in-memory S3 moved to test/s3-mock.js
Changes
@@@ -106,6 +106,7 @@ ovhost snapshot <service> [--out <file>]ovhost backup <service>ovhost backup --all [--offsite] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]ovhost offsite push [--run <run>] | offsite list [<service>] | offsite check++ovhost archive push <file>... [--note <text>] | archive list | archive restore <stamp> <path> --out <dir>ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>]ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)```nnew file mode 100644@@@ -0,0 +1,76 @@++# Database files on the host (WS-S task 6, hazard H14)++++Inventory of 2026-09-26: every SQLite file under /opt, /var/lib, /root, /home and /srv that no process had open. 42 open files (the live databases) are not listed. Paths, sizes and dates only; nothing was read.++++`ovhost archive push <file>...` encrypts files with the backup key and copies them to `<BACKUP_S3_PREFIX>-archive/<host>/<stamp>/`, outside the backup runs so retention never prunes them; `ovhost archive list` and `ovhost archive restore <stamp> <path> --out <dir>` read them back. It never deletes a local file.++++| Class | File | Size | Modified | Recommended action |++|---|---|---|---|---|++| legacy | `/opt/backups/hobo.quest-2026-08-16/data/world.db` | 0.1 MB | 2026-08-16 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/legacy-quest-20260817/hobo-quest.db` | 69.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/pre-openvibe-20260817/hobo-tools-final.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/pre-openvibe-20260817/hobo-tools.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/pre-openvibe-20260817/hobostreamer-final.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/pre-openvibe-20260817/hobostreamer.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/backups/pre-openvibe-20260817/world-final.db` | 0.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobo.quest-old-20260816/data/world.db` | 0.1 MB | 2026-08-16 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobo.quest/data/world.db` | 0.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobo/hobo-quest/data/hobo-quest.db` | 69.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobo/hobo-tools/data/hobo-tools.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobo/hobo-tools/data/hobotools.db` | 0.0 MB | 2026-03-22 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobostreamer/data/hobostreamer.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |++| legacy | `/opt/hobostreamer/hobostreamer.db` | 0.0 MB | 2026-08-11 | archive (done: see below), then delete locally with the owner's OK |++| live-backup | `/opt/openvibe.live/shared/data/backups/live-20260926-072748.db` | 270.9 MB | 2026-09-26 | delete (owner OK) once its migration is a release old; archive first if wanted |++| live-backup | `/opt/openvibe.live/shared/data/backups/live-pre-643a2e6.db` | 252.5 MB | 2026-09-17 | delete (owner OK) once its migration is a release old; archive first if wanted |++| live-backup | `/opt/openvibe.live/shared/data/live-before-rs-slots-20260924T0153Z.db` | 266.2 MB | 2026-09-24 | delete (owner OK) once its migration is a release old; archive first if wanted |++| live-backup | `/opt/openvibe.live/shared/data/live.db.bak-preAIstate` | 175.9 MB | 2026-08-19 | delete (owner OK) once its migration is a release old; archive first if wanted |++| rehearsal | `/var/lib/openvibe-billing/live-snapshot-202609230121.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |++| rehearsal | `/var/lib/openvibe-chat/live-snap-a.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |++| rehearsal | `/var/lib/openvibe-chat/live-snap-b.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |++| rehearsal | `/var/lib/openvibe-chat/live-snapshot-0159.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |++| rehearsal | `/var/lib/openvibe-chat/rehearsal.db` | 0.3 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |++| migration-backup | `/var/lib/openvibe-community/community.pre-c24-20260925T185117Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |++| migration-backup | `/var/lib/openvibe-community/community.pre-c24b-20260925T185400Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |++| migration-backup | `/var/lib/openvibe-community/community.pre-c24c-20260925T185425Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |++| migration-backup | `/var/lib/openvibe-community/community.pre-live-comments-20260923T191345Z.db` | 1.9 MB | 2026-09-23 | delete (owner OK); the migrations are verified |++| ip-bearing | `/opt/hobo/hobo-audio/data/analytics.db` | 24.7 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-docs/data/analytics.db` | 1.4 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-food/data/analytics.db` | 0.5 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-img/data/analytics.db` | 3.3 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-maps/data/analytics.db` | 1.0 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-text/data/analytics.db` | 6.2 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/hobo/hobo-yt/data/analytics.db` | 0.5 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.live/shared/data/backups/analytics-pre-adr021-2026-09-23.db` | 1682.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/audio.analytics.db` | 57.5 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/docs.analytics.db` | 1.6 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/food.analytics.db` | 0.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/img.analytics.db` | 5.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/maps.analytics.db` | 0.6 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/text.analytics.db` | 9.1 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/yt.analytics.db` | 0.9 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |++| secrets | `/opt/openvibe.network/data/network-pre-refresh-hash-2026-09-24.db` | 266.2 MB | 2026-09-24 | delete after the rollback window (WS-S task 5), never archive |++| secrets | `/opt/openvibe.network/data/network-pre-secrets-2026-09-24.db` | 266.2 MB | 2026-09-24 | delete after the rollback window (WS-S task 5), never archive |++| stale | `/opt/openvibe.blog/data/blog.db` | 0.4 MB | 2026-09-24 | delete (owner OK) |++| stale | `/opt/openvibe.community/data/community.db` | 0.2 MB | 2026-09-25 | delete (owner OK) |++| stale | `/opt/openvibe.games/data/legacy-import/world-before-20260923T184930Z.db` | 0.2 MB | 2026-09-23 | delete (owner OK) |++| stale | `/opt/openvibe.live/shared/data/hobo.db` | 0.0 MB | 2026-03-13 | delete (owner OK) |++| stale | `/opt/openvibe.live/shared/data/openvibe.db` | 0.0 MB | 2026-08-18 | delete (owner OK) |++| stale | `/opt/openvibe.live/shared/data/rs-companion.db` | 8.2 MB | 2026-03-10 | delete (owner OK) |++| migration-backup | `/opt/openvibe.media/data/backups/owner-subject-20260923.media.db` | 9.1 MB | 2026-09-23 | delete (owner OK); the owner-subject migration is verified |++| migration-backup | `/opt/openvibe.media/data/reports/h15-repair-applied-20260925.media.db` | 10.8 MB | 2026-09-25 | delete (owner OK) once the H15 repair is a release old |++| stale | `/opt/openvibe.tips/data/tips.db` | 0.0 MB | 2026-09-23 | delete (owner OK) |++| in use | `/opt/openvibe.tools/apps/gateway/data/token-revocations.db` | 0.0 MB | 2026-09-25 | keep: Tools' guard opens it per request (apps/_shared/guard/revocations.js) |++| stale | `/root/blog-stale-db-before-revert-20260924T174243.db` | 0.4 MB | 2026-09-24 | delete (owner OK) |++| system | `/var/lib/PackageKit/transactions.db` | 0.0 MB | 2026-05-11 | leave |++| system | `/var/lib/command-not-found/commands.db` | 3.6 MB | 2026-07-27 | leave |++| system | `/var/lib/fwupd/pending.db` | 0.0 MB | 2026-07-27 | leave |++++Classes:++- **legacy**: the pre-OpenVibe sites' databases; the only copies, so they are archived before anything else happens.++- **live-backup**, **rehearsal**, **migration-backup**: copies made around a migration; the nightly encrypted backups cover the live data.++- **ip-bearing**, **secrets**: dated deletions in WS-S task 5 (section 10); archiving them would defeat the point.++- **stale**: files an older layout left behind (H14 names the Blog one).++- **system**: Ubuntu's own.++- **in use**: closed at the moment of the inventory but opened on demand; never archive or delete.++++Deleting anything here is the owner's call (WS-S task 6). The files a deletion may touch are exactly the rows above.nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++/**++ * ovhost archive (roadmap WS-S task 6, hazard H14): dead and backup database files on the host are++ * encrypted and copied off-host before anyone deletes them. This never deletes a local file: that++ * waits for the owner, with the archive's manifest in hand.++ *++ * ovhost archive push <file>... [--note <text>]++ * Each file must be an absolute path under ALLOWED_ROOTS, a regular file (no symlink), not++ * open by any process (fuser -s), and not a database the inventory declares. Each is++ * encrypted with the backup key (lib/backup-crypto.js, the same AES-256-GCM format as the++ * nightly off-host copies) and uploaded to <prefix>-archive/<host>/<stamp>/<name>.ovbk, where++ * <name> is the path with "/" as "__". The upload is checked by the object's size. Then a++ * manifest.json (HMAC with the backup key) lists each original path, size, mtime and SHA-256,++ * and a local record goes to <stateDir>/archives/<stamp>.json. The archive prefix is outside++ * the backup runs' prefix, so backup retention never prunes it.++ * ovhost archive list++ * The archives off-host: stamp, file count, bytes, note.++ * ovhost archive restore <stamp> <path> --out <dir>++ * One file back, decrypted into a new root-only directory and checked against the manifest.++ *++ * Credentials and the key come from the backup env file (lib/offsite.js); values are never printed.++ */++const path = require('path');++const { pipeline } = require('stream/promises');++const bcrypto = require('./backup-crypto');++const offsite = require('./offsite');++++const ALLOWED_ROOTS = ['/opt/', '/var/lib/', '/root/', '/srv/', '/home/'];++const NAME_RE = /^[A-Za-z0-9._-]+(__[A-Za-z0-9._-]+)*$/;++const STAMP_RE = /^\d{8}-\d{6}$/;++++class ArchiveError extends Error {++ constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }++}++++const archivePrefix = (cfg, host) => `${cfg.prefix}-archive/${host}/`;++const objectName = (file) => file.replace(/^\/+/, '').split('/').join('__');++function stamp(ms) { return new Date(ms).toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d+Z$/, ''); }++++/** Why `file` may not be archived, or null. */++async function refusal(exec, inv, file) {++ if (typeof file !== 'string' || !path.isAbsolute(file) || path.normalize(file) !== file) return 'not a normalised absolute path';++ if (!ALLOWED_ROOTS.some((r) => file.startsWith(r))) return `outside ${ALLOWED_ROOTS.join(', ')}`;++ if (!NAME_RE.test(objectName(file))) return 'the path has characters an object name cannot carry';++ const declared = Object.values(inv.services || {}).flatMap((s) => (s.databases || []).map((d) => d.path || d));++ if (declared.includes(file)) return 'a database the inventory declares (a live database)';++ const st = await exec.stat(file);++ if (!st) return 'no such file';++ if (st.isSymlink || !st.isFile) return 'not a regular file';++ const inUse = await exec.run('fuser', ['-s', file], { privileged: true });++ if (inUse.code === 0) return 'open by a running process';++ if (inUse.code !== 1) return `could not tell whether it is open (fuser exited ${inUse.code})`;++ return null;++}++++async function push(ctx, files, { cfg, client, note = '' } = {}) {++ const { exec, inv, log } = ctx;++ if (!files.length) throw new ArchiveError('name at least one file');++ const unique = [...new Set(files)];++ const problems = [];++ for (const f of unique) { const why = await refusal(exec, inv, f); if (why) problems.push(`${f}: ${why}`); }++ if (problems.length) throw new ArchiveError(`refusing (nothing uploaded):\n ${problems.join('\n ')}`);++ const master = await offsite.loadKey(exec, cfg);++ const host = await offsite.hostName(exec, inv);++ const st = stamp(exec.now());++ const base = `${archivePrefix(cfg, host)}${st}/`;++ const manifest = { format: 'openvibe-archive-manifest/1', encryption: 'OVBKAES1', host, stamp: st, createdAt: new Date(exec.now()).toISOString(), keyId: bcrypto.keyId(master), note: String(note || '').slice(0, 300), files: [] };++ const failures = [];++ for (const file of unique) {++ const key = `${base}${objectName(file)}.ovbk`;++ const plain = bcrypto.createDigestTap();++ const cipher = bcrypto.createDigestTap();++ try {++ const s = await exec.stat(file);++ await pipeline(exec.readStream(file), plain, bcrypto.createEncryptStream(master), cipher, async (source) => { await offsite.uploadIterable(client, cfg.bucket, key, source); });++ const p = plain.result(); const c = cipher.result();++ const listed = (await offsite.listAll(client, cfg.bucket, key)).find((o) => o.key === key);++ if (!listed || Number(listed.size) !== c.bytes) throw new Error(`the stored object is ${listed ? listed.size : 'missing'}, expected ${c.bytes} bytes`);++ manifest.files.push({ path: file, object: key, mtime: s.mtime ? new Date(s.mtime).toISOString() : null, plainBytes: p.bytes, plainSha256: p.sha256, cipherBytes: c.bytes, cipherSha256: c.sha256 });++ log(`archived ${file} → s3://${cfg.bucket}/${key} (${p.bytes} bytes, ${c.bytes} encrypted)`);++ } catch (err) {++ failures.push({ path: file, error: err.message });++ log(`✗ ${file}: ${err.message}`);++ }++ }++ manifest.failures = failures;++ const { hmac, ...rest } = manifest; void hmac;++ manifest.hmac = bcrypto.hmacManifest(master, JSON.stringify(rest));++ await offsite.uploadIterable(client, cfg.bucket, `${base}manifest.json`, [Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)], { contentType: 'application/json' });++ const record = { stamp: st, host, location: `s3://${cfg.bucket}/${base}`, note: manifest.note, files: manifest.files.map(({ path: p, plainBytes, plainSha256 }) => ({ path: p, plainBytes, plainSha256 })), failures };++ const dir = path.join(inv.stateDir, 'archives');++ await exec.mkdir(dir, { owner: 'root', mode: 0o700 });++ await exec.writeFile(path.join(dir, `${st}.json`), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });++ return { ok: failures.length === 0, ...record };++}++++async function readManifest(client, cfg, master, host, st) {++ const chunks = [];++ for await (const c of await offsite.getObjectStream(client, cfg.bucket, `${archivePrefix(cfg, host)}${st}/manifest.json`)) chunks.push(c);++ const m = JSON.parse(Buffer.concat(chunks).toString('utf8'));++ const { hmac, ...rest } = m;++ if (hmac !== bcrypto.hmacManifest(master, JSON.stringify(rest))) throw new ArchiveError(`archive ${st}: the manifest's HMAC does not match this host's backup key`, 2);++ return m;++}++++async function list(ctx, { cfg, client }) {++ const { exec, inv } = ctx;++ const master = await offsite.loadKey(exec, cfg);++ const host = await offsite.hostName(exec, inv);++ const stamps = [...new Set((await offsite.listAll(client, cfg.bucket, archivePrefix(cfg, host))).map((o) => o.key.slice(archivePrefix(cfg, host).length).split('/')[0]).filter((s) => STAMP_RE.test(s)))].sort().reverse();++ const out = [];++ for (const st of stamps) {++ try {++ const m = await readManifest(client, cfg, master, host, st);++ out.push({ stamp: st, files: m.files.length, plainBytes: m.files.reduce((a, f) => a + f.plainBytes, 0), failures: (m.failures || []).length, note: m.note || '' });++ } catch (err) { out.push({ stamp: st, error: err.message }); }++ }++ return out;++}++++async function restore(ctx, st, file, { cfg, client, out }) {++ const { exec, inv } = ctx;++ if (!STAMP_RE.test(String(st || ''))) throw new ArchiveError('<stamp> looks like 20260926-101500');++ if (!out || !path.isAbsolute(out)) throw new ArchiveError('--out must be an absolute path');++ const master = await offsite.loadKey(exec, cfg);++ const host = await offsite.hostName(exec, inv);++ const m = await readManifest(client, cfg, master, host, st);++ const f = m.files.find((x) => x.path === file);++ if (!f) throw new ArchiveError(`archive ${st} has no ${file}`);++ if (f.object !== `${archivePrefix(cfg, host)}${st}/${objectName(file)}.ovbk`) throw new ArchiveError('the manifest names an unexpected object', 2);++ if (await exec.stat(out)) throw new ArchiveError(`${out} already exists; restore never overwrites`);++ await exec.mkdir(out, { owner: 'root', mode: 0o700 });++ const dest = path.join(out, path.basename(file));++ const plain = bcrypto.createDigestTap(); const cipher = bcrypto.createDigestTap();++ await pipeline(await offsite.getObjectStream(client, cfg.bucket, f.object), cipher, bcrypto.createDecryptStream(master), plain, exec.writeStream(dest, { mode: 0o600 }));++ const p = plain.result(); const c = cipher.result();++ if (p.sha256 !== f.plainSha256 || c.sha256 !== f.cipherSha256 || p.bytes !== f.plainBytes) {++ await exec.removeFile(dest);++ throw new ArchiveError(`${file}: the download does not match the manifest`, 2);++ }++ return { file: dest, bytes: p.bytes, sha256: p.sha256 };++}++++module.exports = { push, list, restore, refusal, objectName, ArchiveError, ALLOWED_ROOTS };@@@ -58,6 +58,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]offsite list [<service>] [--from-host <name>]runs stored off-host, newest firstoffsite check config, key and bucket access; uploads nothing++ archive push <file>... [--note <text>] encrypt + upload dead or backup database files (closed, not++ declared by the inventory) to <prefix>-archive/; never deletes them++ archive list the archives off-host++ archive restore <stamp> <path> --out <dir> one archived file back, checked against the manifestrestore-download <service> <run|latest> [--out <dir>] [--from-host <name>]download, verify and decrypt one service's databases into aNEW root-only directory; never touches a live database@@@ -76,7 +80,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupportedbackup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;--const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env']);++const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);function parseArgs(argv) {const pos = [];@@@ -363,6 +367,39 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procelse out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);return o.ok ? 0 : 2;}++ case 'archive': {++ // WS-S task 6: dead and backup database files, encrypted off-host before the owner deletes them.++ const sub = pos[1];++ if (!['push', 'list', 'restore'].includes(sub)) throw new UsageError('usage: ovhost archive push <file>... [--note <text>] | archive list | archive restore <stamp> <path> --out <dir>');++ const cfg = await lib.offsite.loadConfig(exec, { file: opts['backup-env'], env });++ const client = s3(cfg);++ try {++ if (sub === 'list') {++ const rows = await lib.archive.list(ctx, { cfg, client });++ if (json) { emit(rows); return 0; }++ if (!rows.length) out('no archives off-host');++ for (const x of rows) out(x.error ? `${x.stamp} ${x.error}` : `${x.stamp} ${String(x.files).padStart(3)} file(s) ${bytes(x.plainBytes).padStart(9)}${x.failures ? ` ${x.failures} failed` : ''} ${x.note}`);++ return 0;++ }++ if (sub === 'restore') {++ if (!pos[2] || !pos[3]) throw new UsageError('archive restore needs <stamp> <path> --out <dir>');++ const r = await lib.archive.restore(ctx, pos[2], pos[3], { cfg, client, out: opts.out });++ if (json) emit(r); else out(`restored ${pos[3]} into ${r.file} (${bytes(r.bytes)}, sha256 ${r.sha256.slice(0, 16)}…, root-only)`);++ return 0;++ }++ const r = await lib.archive.push(ctx, pos.slice(2), { cfg, client, note: opts.note || '' });++ if (json) emit(r);++ else {++ for (const f of r.files) out(`archived ${f.path} ${bytes(f.plainBytes)} sha256 ${f.plainSha256.slice(0, 16)}…`);++ for (const f of r.failures) out(`✗ ${f.path}: ${f.error}`);++ out(`ARCHIVE ${r.ok ? 'ok' : 'INCOMPLETE'}: ${r.files.length} file(s) at ${r.location} (record ${inv.stateDir}/archives/${r.stamp}.json). Nothing was deleted.`);++ }++ return r.ok ? 0 : 2;++ } catch (err) {++ if (err instanceof lib.archive.ArchiveError) { (json ? emit({ ok: false, error: err.message }) : out(`ARCHIVE refused: ${err.message}`)); return err.exitCode; }++ throw err;++ }++ }case 'restore-download': {const id = needService();if (!pos[2]) throw new UsageError('restore-download needs a <run> (a run id such as 20260923-033000, or latest)');@@@ -13,7 +13,7 @@* readFile(path, { privileged }) -> string | null* writeFile(path, content, { privileged, mode }) ; appendFile(path, text) ; removeFile(path, { privileged })* symlink(target, path, { privileged }) ; readlink(path) -> string | null-- * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size, nlink } | null++ * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size, nlink, mtime } | null* readdir(path) -> [{ name, isDir }] | null* mkdir(path, { owner, mode }) ; createExclusive(path, content) -> bool ; pidAlive(pid) -> bool* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file@@@ -144,7 +144,7 @@ function createSystemExecutor({ env = process.env } = {}) {try {const l = fs.lstatSync(p);const s = l.isSymbolicLink() ? fs.statSync(p) : l;-- return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size, nlink: l.nlink };++ return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size, nlink: l.nlink, mtime: s.mtimeMs };} catch { return null; }}@@@ -33,6 +33,7 @@ module.exports = {writeMetrics,BackupError,offsite,++ archive: require('./archive'),snapshot,drill,DrillError,@@@ -408,6 +408,9 @@ async function check(ctx, { cfg, client }) {module.exports = {loadConfig,++ loadKey,++ listAll,++ getObjectStream,createClient,push,pruneRemote,nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * ovhost archive (WS-S task 6, H14): dead and backup database files are encrypted and copied off-host++ * under <prefix>-archive/, checked, listed and restored, and never deleted locally. Refused: live++ * (inventory) databases, open files, symlinks, paths outside the allowed roots, relative paths.++ * Nothing leaves the process (an in-memory S3).++ */++const assert = require('assert');++const crypto = require('crypto');++const { scenario, test, runTests, SECRET } = require('./helpers');++const { main } = require('../lib/cli');++const { mockS3 } = require('./s3-mock');++++const KEY_HEX = crypto.createHash('sha256').update('archive test key').digest('hex');++const S3_SECRET = `${SECRET}-s3`;++++function archiveHost() {++ const host = scenario();++ host.put('/etc/openvibe/backup.env', [++ 'BACKUP_S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com',++ 'BACKUP_S3_BUCKET=openvibe-backups-test',++ 'BACKUP_S3_PREFIX=openvibe-backups',++ 'BACKUP_S3_KEY_ID=004keyid0000000000000001',++ `BACKUP_S3_SECRET="${S3_SECRET}"`,++ 'BACKUP_ENCRYPTION_KEY_FILE=/etc/openvibe/backup.key',++ '',++ ].join('\n'), { mode: 0o600, owner: 'root' });++ host.put('/etc/openvibe/backup.key', `${KEY_HEX}\n`, { mode: 0o600, owner: 'root' });++ host.put('/opt/backups/old/hobostreamer.db', Buffer.from(`SQLite format 3\0 old site ${'x'.repeat(9000)}`), { owner: 'root' });++ host.put('/var/lib/openvibe-chat/live-snap-a.db', Buffer.from(`SQLite format 3\0 snapshot ${'y'.repeat(3000)}`), { owner: 'root' });++ host.put('/var/lib/openvibe-chat/in-use.db', 'SQLite format 3\0 open', { owner: 'root' });++ host.put('/etc/passwd-copy.db', 'nope', { owner: 'root' });++ host.openFiles = new Set(['/var/lib/openvibe-chat/in-use.db']);++ host.s3 = mockS3();++ host.cli = async (...argv) => {++ const lines = [];++ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, s3: () => host.s3 });++ return { code, out: lines.join('\n') };++ };++ return host;++}++++const noSecrets = (text) => { for (const s of [S3_SECRET, SECRET, KEY_HEX]) assert.ok(!String(text).includes(s), 'a secret value leaked'); };++++runTests([++ test('push encrypts, uploads under the archive prefix, checks, records, and deletes nothing', async () => {++ const host = archiveHost();++ const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.db', '/var/lib/openvibe-chat/live-snap-a.db', '--note', 'H14 first pass');++ assert.strictEqual(r.code, 0, r.out);++ noSecrets(r.out);++ assert.match(r.out, /ARCHIVE ok: 2 file\(s\) at s3:\/\/openvibe-backups-test\/openvibe-backups-archive\/.+\/\d{8}-\d{6}\/.*Nothing was deleted/);++ const keys = [...host.s3.objects.keys()];++ assert.ok(keys.every((k) => k.startsWith('openvibe-backups-archive/')), 'outside the backup runs prefix (retention never prunes it)');++ const obj = keys.find((k) => k.endsWith('opt__backups__old__hobostreamer.db.ovbk'));++ assert.ok(obj, keys.join(', '));++ assert.ok(!host.s3.objects.get(obj).includes(Buffer.from('old site')), 'the bytes are encrypted');++ assert.ok(host.read('/opt/backups/old/hobostreamer.db') !== null && host.read('/var/lib/openvibe-chat/live-snap-a.db') !== null, 'the local files stay');++ const manifestKey = keys.find((k) => k.endsWith('/manifest.json'));++ const m = JSON.parse(host.s3.objects.get(manifestKey).toString('utf8'));++ assert.deepStrictEqual(m.files.map((f) => f.path), ['/opt/backups/old/hobostreamer.db', '/var/lib/openvibe-chat/live-snap-a.db']);++ assert.strictEqual(m.note, 'H14 first pass');++ assert.ok(m.hmac && m.files.every((f) => /^[0-9a-f]{64}$/.test(f.plainSha256)));++ const rec = [...host.files.keys()].find((k) => k.startsWith('/var/lib/openvibe-host/archives/'));++ assert.ok(rec, 'a local record');++ noSecrets(host.read(rec));++++ const list = await host.cli('archive', 'list');++ assert.strictEqual(list.code, 0);++ assert.match(list.out, /\d{8}-\d{6}\s+2 file\(s\).*H14 first pass/);++++ const st = m.stamp;++ const back = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.db', '--out', '/var/lib/openvibe-restore/h14');++ assert.strictEqual(back.code, 0, back.out);++ assert.strictEqual(host.read('/var/lib/openvibe-restore/h14/hobostreamer.db'), host.read('/opt/backups/old/hobostreamer.db'), 'restored byte for byte');++ const again = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.db', '--out', '/var/lib/openvibe-restore/h14');++ assert.notStrictEqual(again.code, 0, 'restore never overwrites');++ }),++++ test('refusals: live databases, open files, symlinks, outside roots, relative paths; nothing uploaded', async () => {++ const host = archiveHost();++ const liveDb = Object.values(host.inv.services).flatMap((s) => s.databases.map((d) => d.path))[0];++ assert.ok(liveDb, 'the scenario declares a database');++ host.put(liveDb, 'SQLite format 3\0 live', { owner: 'ubuntu' });++ await host.exec.symlink('/opt/backups/old/hobostreamer.db', '/opt/backups/old/link.db');++ for (const [file, why] of [['/opt/backups/old/link.db', /not a regular file/], [liveDb, /inventory declares/], ['/var/lib/openvibe-chat/in-use.db', /open by a running process/], ['/etc/passwd-copy.db', /outside/], ['relative.db', /absolute/], ['/opt/backups/../etc/x.db', /normalised/], ['/opt/backups/none.db', /no such file/]]) {++ const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.db', file);++ assert.strictEqual(r.code, 1, `${file}: ${r.out}`);++ assert.match(r.out, why, file);++ assert.strictEqual(host.s3.objects.size, 0, `${file}: nothing is uploaded when any file is refused`);++ }++ }),++]);@@@ -14,68 +14,12 @@ const bcrypto = require('../lib/backup-crypto');const retention = require('../lib/retention');const offsite = require('../lib/offsite');const { normalise } = require('../lib/inventory');++const { mockS3 } = require('./s3-mock');const DAY = 86400000;const KEY_HEX = crypto.createHash('sha256').update('test backup key').digest('hex');const S3_SECRET = `${SECRET}-s3`;--/** An in-memory S3 that records every command it is sent. */--function mockS3({ pageSize = 1000 } = {}) {-- const objects = new Map();-- const uploads = new Map();-- const sent = [];-- let seq = 0;-- const s3 = {-- objects,-- sent,-- failPut: null, // (key) -> true to fail that PutObject/UploadPart-- async send(cmd) {-- const name = cmd.constructor.name;-- const i = cmd.input;-- sent.push({ name, input: { ...i, Body: i.Body ? `<${Buffer.byteLength(i.Body)} bytes>` : undefined } });-- switch (name) {-- case 'PutObjectCommand':-- if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');-- assert.strictEqual(i.ContentLength, i.Body.length);-- objects.set(i.Key, Buffer.from(i.Body));-- return { ETag: '"put"' };-- case 'CreateMultipartUploadCommand': { const id = `up-${++seq}`; uploads.set(id, { key: i.Key, parts: new Map() }); return { UploadId: id }; }-- case 'UploadPartCommand':-- if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');-- uploads.get(i.UploadId).parts.set(i.PartNumber, Buffer.from(i.Body));-- return { ETag: `"p${i.PartNumber}"` };-- case 'CompleteMultipartUploadCommand': {-- const u = uploads.get(i.UploadId);-- const nums = i.MultipartUpload.Parts.map((p) => p.PartNumber);-- assert.deepStrictEqual(nums, [...u.parts.keys()].sort((a, b) => a - b));-- objects.set(u.key, Buffer.concat(nums.map((n) => u.parts.get(n))));-- uploads.delete(i.UploadId);-- return {};-- }-- case 'AbortMultipartUploadCommand': uploads.delete(i.UploadId); return {};-- case 'ListObjectsV2Command': {-- const keys = [...objects.keys()].filter((k) => k.startsWith(i.Prefix || '')).sort();-- const start = i.ContinuationToken ? Number(i.ContinuationToken) : 0;-- const size = Math.min(pageSize, i.MaxKeys || 1000);-- const page = keys.slice(start, start + size);-- const more = start + size < keys.length;-- return { Contents: page.map((k) => ({ Key: k, Size: objects.get(k).length })), IsTruncated: more, NextContinuationToken: more ? String(start + size) : undefined };-- }-- case 'GetObjectCommand': {-- const o = objects.get(i.Key);-- if (!o) throw Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });-- const pieces = [];-- for (let k = 0; k < o.length; k += 1000) pieces.push(o.subarray(k, k + 1000));-- return { Body: Readable.from(pieces) };-- }-- case 'DeleteObjectCommand': objects.delete(i.Key); return {};-- default: throw new Error(`mock S3: unexpected ${name}`);-- }-- },-- };-- return s3;--}--/** The standard fake host plus AI (two databases) and OpenRe (not deployed), a backup env and key. */function backupHost({ envMode = 0o600, keyMode = 0o600, envOwner = 'root', env = null } = {}) {const host = scenario();@@@ -284,6 +284,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', streturn { code: 0, stdout: '', stderr: '' };}case 'node': return { code: 0, stdout: '', stderr: '' };++ case 'fuser': return { code: (host.openFiles || new Set()).has(path.resolve(args[args.length - 1])) ? 0 : 1, stdout: '', stderr: '' };case 'systemd-run': return systemdRunCmd(args);case 'cp': {const src = args[args.length - 2];nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++// An in-memory S3 client for tests ({ send(command) }, the @aws-sdk/client-s3 commands ovhost uses).++const assert = require('assert');++const { Readable } = require('stream');++++/** An in-memory S3 that records every command it is sent. */++function mockS3({ pageSize = 1000 } = {}) {++ const objects = new Map();++ const uploads = new Map();++ const sent = [];++ let seq = 0;++ const s3 = {++ objects,++ sent,++ failPut: null, // (key) -> true to fail that PutObject/UploadPart++ async send(cmd) {++ const name = cmd.constructor.name;++ const i = cmd.input;++ sent.push({ name, input: { ...i, Body: i.Body ? `<${Buffer.byteLength(i.Body)} bytes>` : undefined } });++ switch (name) {++ case 'PutObjectCommand':++ if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');++ assert.strictEqual(i.ContentLength, i.Body.length);++ objects.set(i.Key, Buffer.from(i.Body));++ return { ETag: '"put"' };++ case 'CreateMultipartUploadCommand': { const id = `up-${++seq}`; uploads.set(id, { key: i.Key, parts: new Map() }); return { UploadId: id }; }++ case 'UploadPartCommand':++ if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');++ uploads.get(i.UploadId).parts.set(i.PartNumber, Buffer.from(i.Body));++ return { ETag: `"p${i.PartNumber}"` };++ case 'CompleteMultipartUploadCommand': {++ const u = uploads.get(i.UploadId);++ const nums = i.MultipartUpload.Parts.map((p) => p.PartNumber);++ assert.deepStrictEqual(nums, [...u.parts.keys()].sort((a, b) => a - b));++ objects.set(u.key, Buffer.concat(nums.map((n) => u.parts.get(n))));++ uploads.delete(i.UploadId);++ return {};++ }++ case 'AbortMultipartUploadCommand': uploads.delete(i.UploadId); return {};++ case 'ListObjectsV2Command': {++ const keys = [...objects.keys()].filter((k) => k.startsWith(i.Prefix || '')).sort();++ const start = i.ContinuationToken ? Number(i.ContinuationToken) : 0;++ const size = Math.min(pageSize, i.MaxKeys || 1000);++ const page = keys.slice(start, start + size);++ const more = start + size < keys.length;++ return { Contents: page.map((k) => ({ Key: k, Size: objects.get(k).length })), IsTruncated: more, NextContinuationToken: more ? String(start + size) : undefined };++ }++ case 'GetObjectCommand': {++ const o = objects.get(i.Key);++ if (!o) throw Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });++ const pieces = [];++ for (let k = 0; k < o.length; k += 1000) pieces.push(o.subarray(k, k + 1000));++ return { Body: Readable.from(pieces) };++ }++ case 'DeleteObjectCommand': objects.delete(i.Key); return {};++ default: throw new Error(`mock S3: unexpected ${name}`);++ }++ },++ };++ return s3;++}++++module.exports = { mockS3 };
@@ -106,6 +106,7 @@ ovhost snapshot <service> [--out <file>]
@@ -106,6 +106,7 @@ ovhost snapshot <service> [--out <file>]
ovhost backup <service>
ovhost backup <service>
ovhost backup --all [--offsite] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]
ovhost backup --all [--offsite] [--no-prune] [--keep-daily <n>] [--keep-weekly <n>]
ovhost offsite push [--run <run>] | offsite list [<service>] | offsite check
ovhost offsite push [--run <run>] | offsite list [<service>] | offsite check
+ovhost archive push <file>... [--note <text>] | archive list | archive restore <stamp> <path> --out <dir>
ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>]
ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>]
ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
```
```
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+# Database files on the host (WS-S task 6, hazard H14)
+
+Inventory of 2026-09-26: every SQLite file under /opt, /var/lib, /root, /home and /srv that no process had open. 42 open files (the live databases) are not listed. Paths, sizes and dates only; nothing was read.
+
+`ovhost archive push <file>...` encrypts files with the backup key and copies them to `<BACKUP_S3_PREFIX>-archive/<host>/<stamp>/`, outside the backup runs so retention never prunes them; `ovhost archive list` and `ovhost archive restore <stamp> <path> --out <dir>` read them back. It never deletes a local file.
+
+| Class | File | Size | Modified | Recommended action |
+|---|---|---|---|---|
+| legacy | `/opt/backups/hobo.quest-2026-08-16/data/world.db` | 0.1 MB | 2026-08-16 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/legacy-quest-20260817/hobo-quest.db` | 69.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/pre-openvibe-20260817/hobo-tools-final.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/pre-openvibe-20260817/hobo-tools.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/pre-openvibe-20260817/hobostreamer-final.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/pre-openvibe-20260817/hobostreamer.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/backups/pre-openvibe-20260817/world-final.db` | 0.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobo.quest-old-20260816/data/world.db` | 0.1 MB | 2026-08-16 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobo.quest/data/world.db` | 0.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobo/hobo-quest/data/hobo-quest.db` | 69.1 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobo/hobo-tools/data/hobo-tools.db` | 731.0 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobo/hobo-tools/data/hobotools.db` | 0.0 MB | 2026-03-22 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobostreamer/data/hobostreamer.db` | 177.7 MB | 2026-08-17 | archive (done: see below), then delete locally with the owner's OK |
+| legacy | `/opt/hobostreamer/hobostreamer.db` | 0.0 MB | 2026-08-11 | archive (done: see below), then delete locally with the owner's OK |
+| live-backup | `/opt/openvibe.live/shared/data/backups/live-20260926-072748.db` | 270.9 MB | 2026-09-26 | delete (owner OK) once its migration is a release old; archive first if wanted |
+| live-backup | `/opt/openvibe.live/shared/data/backups/live-pre-643a2e6.db` | 252.5 MB | 2026-09-17 | delete (owner OK) once its migration is a release old; archive first if wanted |
+| live-backup | `/opt/openvibe.live/shared/data/live-before-rs-slots-20260924T0153Z.db` | 266.2 MB | 2026-09-24 | delete (owner OK) once its migration is a release old; archive first if wanted |
+| live-backup | `/opt/openvibe.live/shared/data/live.db.bak-preAIstate` | 175.9 MB | 2026-08-19 | delete (owner OK) once its migration is a release old; archive first if wanted |
+| rehearsal | `/var/lib/openvibe-billing/live-snapshot-202609230121.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |
+| rehearsal | `/var/lib/openvibe-chat/live-snap-a.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |
+| rehearsal | `/var/lib/openvibe-chat/live-snap-b.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |
+| rehearsal | `/var/lib/openvibe-chat/live-snapshot-0159.db` | 266.2 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |
+| rehearsal | `/var/lib/openvibe-chat/rehearsal.db` | 0.3 MB | 2026-09-23 | delete (owner OK); the nightly backups cover Live |
+| migration-backup | `/var/lib/openvibe-community/community.pre-c24-20260925T185117Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |
+| migration-backup | `/var/lib/openvibe-community/community.pre-c24b-20260925T185400Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |
+| migration-backup | `/var/lib/openvibe-community/community.pre-c24c-20260925T185425Z.db` | 2.1 MB | 2026-09-25 | delete (owner OK); the migrations are verified |
+| migration-backup | `/var/lib/openvibe-community/community.pre-live-comments-20260923T191345Z.db` | 1.9 MB | 2026-09-23 | delete (owner OK); the migrations are verified |
+| ip-bearing | `/opt/hobo/hobo-audio/data/analytics.db` | 24.7 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-docs/data/analytics.db` | 1.4 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-food/data/analytics.db` | 0.5 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-img/data/analytics.db` | 3.3 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-maps/data/analytics.db` | 1.0 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-text/data/analytics.db` | 6.2 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/hobo/hobo-yt/data/analytics.db` | 0.5 MB | 2026-08-17 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.live/shared/data/backups/analytics-pre-adr021-2026-09-23.db` | 1682.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/audio.analytics.db` | 57.5 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/docs.analytics.db` | 1.6 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/food.analytics.db` | 0.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/img.analytics.db` | 5.4 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/maps.analytics.db` | 0.6 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/text.analytics.db` | 9.1 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| ip-bearing | `/opt/openvibe.tools/backups/analytics-2026-09-23/yt.analytics.db` | 0.9 MB | 2026-09-23 | delete after 2026-10-07 (WS-S task 5), never archive |
+| secrets | `/opt/openvibe.network/data/network-pre-refresh-hash-2026-09-24.db` | 266.2 MB | 2026-09-24 | delete after the rollback window (WS-S task 5), never archive |
+| secrets | `/opt/openvibe.network/data/network-pre-secrets-2026-09-24.db` | 266.2 MB | 2026-09-24 | delete after the rollback window (WS-S task 5), never archive |
+| stale | `/opt/openvibe.blog/data/blog.db` | 0.4 MB | 2026-09-24 | delete (owner OK) |
+| stale | `/opt/openvibe.community/data/community.db` | 0.2 MB | 2026-09-25 | delete (owner OK) |
+| stale | `/opt/openvibe.games/data/legacy-import/world-before-20260923T184930Z.db` | 0.2 MB | 2026-09-23 | delete (owner OK) |
+| stale | `/opt/openvibe.live/shared/data/hobo.db` | 0.0 MB | 2026-03-13 | delete (owner OK) |
+| stale | `/opt/openvibe.live/shared/data/openvibe.db` | 0.0 MB | 2026-08-18 | delete (owner OK) |
+| stale | `/opt/openvibe.live/shared/data/rs-companion.db` | 8.2 MB | 2026-03-10 | delete (owner OK) |
+| migration-backup | `/opt/openvibe.media/data/backups/owner-subject-20260923.media.db` | 9.1 MB | 2026-09-23 | delete (owner OK); the owner-subject migration is verified |
+| migration-backup | `/opt/openvibe.media/data/reports/h15-repair-applied-20260925.media.db` | 10.8 MB | 2026-09-25 | delete (owner OK) once the H15 repair is a release old |
+| stale | `/opt/openvibe.tips/data/tips.db` | 0.0 MB | 2026-09-23 | delete (owner OK) |
+| in use | `/opt/openvibe.tools/apps/gateway/data/token-revocations.db` | 0.0 MB | 2026-09-25 | keep: Tools' guard opens it per request (apps/_shared/guard/revocations.js) |
+| stale | `/root/blog-stale-db-before-revert-20260924T174243.db` | 0.4 MB | 2026-09-24 | delete (owner OK) |
+| system | `/var/lib/PackageKit/transactions.db` | 0.0 MB | 2026-05-11 | leave |
+| system | `/var/lib/command-not-found/commands.db` | 3.6 MB | 2026-07-27 | leave |
+| system | `/var/lib/fwupd/pending.db` | 0.0 MB | 2026-07-27 | leave |
+
+Classes:
+- **legacy**: the pre-OpenVibe sites' databases; the only copies, so they are archived before anything else happens.
+- **live-backup**, **rehearsal**, **migration-backup**: copies made around a migration; the nightly encrypted backups cover the live data.
+- **ip-bearing**, **secrets**: dated deletions in WS-S task 5 (section 10); archiving them would defeat the point.
+- **stale**: files an older layout left behind (H14 names the Blog one).
+- **system**: Ubuntu's own.
+- **in use**: closed at the moment of the inventory but opened on demand; never archive or delete.
+
+Deleting anything here is the owner's call (WS-S task 6). The files a deletion may touch are exactly the rows above.
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+/**
+ * ovhost archive (roadmap WS-S task 6, hazard H14): dead and backup database files on the host are
+ * encrypted and copied off-host before anyone deletes them. This never deletes a local file: that
+ * waits for the owner, with the archive's manifest in hand.
+ *
+ * ovhost archive push <file>... [--note <text>]
+ * Each file must be an absolute path under ALLOWED_ROOTS, a regular file (no symlink), not
+ * open by any process (fuser -s), and not a database the inventory declares. Each is
+ * encrypted with the backup key (lib/backup-crypto.js, the same AES-256-GCM format as the
+ * nightly off-host copies) and uploaded to <prefix>-archive/<host>/<stamp>/<name>.ovbk, where
+ * <name> is the path with "/" as "__". The upload is checked by the object's size. Then a
+ * manifest.json (HMAC with the backup key) lists each original path, size, mtime and SHA-256,
+ * and a local record goes to <stateDir>/archives/<stamp>.json. The archive prefix is outside
+ * the backup runs' prefix, so backup retention never prunes it.
+ * ovhost archive list
+ * The archives off-host: stamp, file count, bytes, note.
+ * ovhost archive restore <stamp> <path> --out <dir>
+ * One file back, decrypted into a new root-only directory and checked against the manifest.
+ *
+ * Credentials and the key come from the backup env file (lib/offsite.js); values are never printed.
+ */
+const path = require('path');
+const { pipeline } = require('stream/promises');
+const bcrypto = require('./backup-crypto');
+const offsite = require('./offsite');
+
+const ALLOWED_ROOTS = ['/opt/', '/var/lib/', '/root/', '/srv/', '/home/'];
+const NAME_RE = /^[A-Za-z0-9._-]+(__[A-Za-z0-9._-]+)*$/;
+const STAMP_RE = /^\d{8}-\d{6}$/;
+
+class ArchiveError extends Error {
+ constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
+}
+
+const archivePrefix = (cfg, host) => `${cfg.prefix}-archive/${host}/`;
+const objectName = (file) => file.replace(/^\/+/, '').split('/').join('__');
+function stamp(ms) { return new Date(ms).toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d+Z$/, ''); }
+
+/** Why `file` may not be archived, or null. */
+async function refusal(exec, inv, file) {
+ if (typeof file !== 'string' || !path.isAbsolute(file) || path.normalize(file) !== file) return 'not a normalised absolute path';
+ if (!ALLOWED_ROOTS.some((r) => file.startsWith(r))) return `outside ${ALLOWED_ROOTS.join(', ')}`;
+ if (!NAME_RE.test(objectName(file))) return 'the path has characters an object name cannot carry';
+ const declared = Object.values(inv.services || {}).flatMap((s) => (s.databases || []).map((d) => d.path || d));
+ if (declared.includes(file)) return 'a database the inventory declares (a live database)';
+ const st = await exec.stat(file);
+ if (!st) return 'no such file';
+ if (st.isSymlink || !st.isFile) return 'not a regular file';
+ const inUse = await exec.run('fuser', ['-s', file], { privileged: true });
+ if (inUse.code === 0) return 'open by a running process';
+ if (inUse.code !== 1) return `could not tell whether it is open (fuser exited ${inUse.code})`;
+ return null;
+}
+
+async function push(ctx, files, { cfg, client, note = '' } = {}) {
+ const { exec, inv, log } = ctx;
+ if (!files.length) throw new ArchiveError('name at least one file');
+ const unique = [...new Set(files)];
+ const problems = [];
+ for (const f of unique) { const why = await refusal(exec, inv, f); if (why) problems.push(`${f}: ${why}`); }
+ if (problems.length) throw new ArchiveError(`refusing (nothing uploaded):\n ${problems.join('\n ')}`);
+ const master = await offsite.loadKey(exec, cfg);
+ const host = await offsite.hostName(exec, inv);
+ const st = stamp(exec.now());
+ const base = `${archivePrefix(cfg, host)}${st}/`;
+ const manifest = { format: 'openvibe-archive-manifest/1', encryption: 'OVBKAES1', host, stamp: st, createdAt: new Date(exec.now()).toISOString(), keyId: bcrypto.keyId(master), note: String(note || '').slice(0, 300), files: [] };
+ const failures = [];
+ for (const file of unique) {
+ const key = `${base}${objectName(file)}.ovbk`;
+ const plain = bcrypto.createDigestTap();
+ const cipher = bcrypto.createDigestTap();
+ try {
+ const s = await exec.stat(file);
+ await pipeline(exec.readStream(file), plain, bcrypto.createEncryptStream(master), cipher, async (source) => { await offsite.uploadIterable(client, cfg.bucket, key, source); });
+ const p = plain.result(); const c = cipher.result();
+ const listed = (await offsite.listAll(client, cfg.bucket, key)).find((o) => o.key === key);
+ if (!listed || Number(listed.size) !== c.bytes) throw new Error(`the stored object is ${listed ? listed.size : 'missing'}, expected ${c.bytes} bytes`);
+ manifest.files.push({ path: file, object: key, mtime: s.mtime ? new Date(s.mtime).toISOString() : null, plainBytes: p.bytes, plainSha256: p.sha256, cipherBytes: c.bytes, cipherSha256: c.sha256 });
+ log(`archived ${file} → s3://${cfg.bucket}/${key} (${p.bytes} bytes, ${c.bytes} encrypted)`);
+ } catch (err) {
+ failures.push({ path: file, error: err.message });
+ log(`✗ ${file}: ${err.message}`);
+ }
+ }
+ manifest.failures = failures;
+ const { hmac, ...rest } = manifest; void hmac;
+ manifest.hmac = bcrypto.hmacManifest(master, JSON.stringify(rest));
+ await offsite.uploadIterable(client, cfg.bucket, `${base}manifest.json`, [Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)], { contentType: 'application/json' });
+ const record = { stamp: st, host, location: `s3://${cfg.bucket}/${base}`, note: manifest.note, files: manifest.files.map(({ path: p, plainBytes, plainSha256 }) => ({ path: p, plainBytes, plainSha256 })), failures };
+ const dir = path.join(inv.stateDir, 'archives');
+ await exec.mkdir(dir, { owner: 'root', mode: 0o700 });
+ await exec.writeFile(path.join(dir, `${st}.json`), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
+ return { ok: failures.length === 0, ...record };
+}
+
+async function readManifest(client, cfg, master, host, st) {
+ const chunks = [];
+ for await (const c of await offsite.getObjectStream(client, cfg.bucket, `${archivePrefix(cfg, host)}${st}/manifest.json`)) chunks.push(c);
+ const m = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ const { hmac, ...rest } = m;
+ if (hmac !== bcrypto.hmacManifest(master, JSON.stringify(rest))) throw new ArchiveError(`archive ${st}: the manifest's HMAC does not match this host's backup key`, 2);
+ return m;
+}
+
+async function list(ctx, { cfg, client }) {
+ const { exec, inv } = ctx;
+ const master = await offsite.loadKey(exec, cfg);
+ const host = await offsite.hostName(exec, inv);
+ const stamps = [...new Set((await offsite.listAll(client, cfg.bucket, archivePrefix(cfg, host))).map((o) => o.key.slice(archivePrefix(cfg, host).length).split('/')[0]).filter((s) => STAMP_RE.test(s)))].sort().reverse();
+ const out = [];
+ for (const st of stamps) {
+ try {
+ const m = await readManifest(client, cfg, master, host, st);
+ out.push({ stamp: st, files: m.files.length, plainBytes: m.files.reduce((a, f) => a + f.plainBytes, 0), failures: (m.failures || []).length, note: m.note || '' });
+ } catch (err) { out.push({ stamp: st, error: err.message }); }
+ }
+ return out;
+}
+
+async function restore(ctx, st, file, { cfg, client, out }) {
+ const { exec, inv } = ctx;
+ if (!STAMP_RE.test(String(st || ''))) throw new ArchiveError('<stamp> looks like 20260926-101500');
+ if (!out || !path.isAbsolute(out)) throw new ArchiveError('--out must be an absolute path');
+ const master = await offsite.loadKey(exec, cfg);
+ const host = await offsite.hostName(exec, inv);
+ const m = await readManifest(client, cfg, master, host, st);
+ const f = m.files.find((x) => x.path === file);
+ if (!f) throw new ArchiveError(`archive ${st} has no ${file}`);
+ if (f.object !== `${archivePrefix(cfg, host)}${st}/${objectName(file)}.ovbk`) throw new ArchiveError('the manifest names an unexpected object', 2);
+ if (await exec.stat(out)) throw new ArchiveError(`${out} already exists; restore never overwrites`);
+ await exec.mkdir(out, { owner: 'root', mode: 0o700 });
+ const dest = path.join(out, path.basename(file));
+ const plain = bcrypto.createDigestTap(); const cipher = bcrypto.createDigestTap();
+ await pipeline(await offsite.getObjectStream(client, cfg.bucket, f.object), cipher, bcrypto.createDecryptStream(master), plain, exec.writeStream(dest, { mode: 0o600 }));
+ const p = plain.result(); const c = cipher.result();
+ if (p.sha256 !== f.plainSha256 || c.sha256 !== f.cipherSha256 || p.bytes !== f.plainBytes) {
+ await exec.removeFile(dest);
+ throw new ArchiveError(`${file}: the download does not match the manifest`, 2);
+ }
+ return { file: dest, bytes: p.bytes, sha256: p.sha256 };
+}
+
+module.exports = { push, list, restore, refusal, objectName, ArchiveError, ALLOWED_ROOTS };
@@ -58,6 +58,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -58,6 +58,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
offsite list [<service>] [--from-host <name>]
offsite list [<service>] [--from-host <name>]
runs stored off-host, newest first
runs stored off-host, newest first
offsite check config, key and bucket access; uploads nothing
offsite check config, key and bucket access; uploads nothing
+ archive push <file>... [--note <text>] encrypt + upload dead or backup database files (closed, not
+ declared by the inventory) to <prefix>-archive/; never deletes them
+ archive list the archives off-host
+ archive restore <stamp> <path> --out <dir> one archived file back, checked against the manifest
restore-download <service> <run|latest> [--out <dir>] [--from-host <name>]
restore-download <service> <run|latest> [--out <dir>] [--from-host <name>]
download, verify and decrypt one service's databases into a
download, verify and decrypt one service's databases into a
NEW root-only directory; never touches a live database
NEW root-only directory; never touches a live database
@@ -76,7 +80,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported
@@ -76,7 +80,7 @@ drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
`;
-const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env']);
+const FLAGS_WITH_VALUE = new Set(['inventory', 'to', 'limit', 'warn-days', 'variant', 'manifest', 'out', 'ready-timeout', 'wildcard-cert', 'backup', 'run', 'keep-daily', 'keep-weekly', 'from-host', 'backup-env', 'release', 'commit', 'origin', 'events-env', 'note']);
function parseArgs(argv) {
function parseArgs(argv) {
const pos = [];
const pos = [];
@@ -363,6 +367,39 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -363,6 +367,39 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
else out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);
else out(`OFFSITE ${o.ok ? 'ok' : 'FAILED'}: ${o.uploaded.length} file(s) to s3://${o.bucket}/${o.location}${o.failures.length ? `; failures: ${o.failures.map((f) => `${f.service ? `${f.service}/` : ''}${f.file}: ${f.error}`).join('; ')}` : ''}`);
return o.ok ? 0 : 2;
return o.ok ? 0 : 2;
}
}
+ case 'archive': {
+ // WS-S task 6: dead and backup database files, encrypted off-host before the owner deletes them.
+ const sub = pos[1];
+ if (!['push', 'list', 'restore'].includes(sub)) throw new UsageError('usage: ovhost archive push <file>... [--note <text>] | archive list | archive restore <stamp> <path> --out <dir>');
+ const cfg = await lib.offsite.loadConfig(exec, { file: opts['backup-env'], env });
+ const client = s3(cfg);
+ try {
+ if (sub === 'list') {
+ const rows = await lib.archive.list(ctx, { cfg, client });
+ if (json) { emit(rows); return 0; }
+ if (!rows.length) out('no archives off-host');
+ for (const x of rows) out(x.error ? `${x.stamp} ${x.error}` : `${x.stamp} ${String(x.files).padStart(3)} file(s) ${bytes(x.plainBytes).padStart(9)}${x.failures ? ` ${x.failures} failed` : ''} ${x.note}`);
+ return 0;
+ }
+ if (sub === 'restore') {
+ if (!pos[2] || !pos[3]) throw new UsageError('archive restore needs <stamp> <path> --out <dir>');
+ const r = await lib.archive.restore(ctx, pos[2], pos[3], { cfg, client, out: opts.out });
+ if (json) emit(r); else out(`restored ${pos[3]} into ${r.file} (${bytes(r.bytes)}, sha256 ${r.sha256.slice(0, 16)}…, root-only)`);
+ return 0;
+ }
+ const r = await lib.archive.push(ctx, pos.slice(2), { cfg, client, note: opts.note || '' });
+ if (json) emit(r);
+ else {
+ for (const f of r.files) out(`archived ${f.path} ${bytes(f.plainBytes)} sha256 ${f.plainSha256.slice(0, 16)}…`);
+ for (const f of r.failures) out(`✗ ${f.path}: ${f.error}`);
+ out(`ARCHIVE ${r.ok ? 'ok' : 'INCOMPLETE'}: ${r.files.length} file(s) at ${r.location} (record ${inv.stateDir}/archives/${r.stamp}.json). Nothing was deleted.`);
+ }
+ return r.ok ? 0 : 2;
+ } catch (err) {
+ if (err instanceof lib.archive.ArchiveError) { (json ? emit({ ok: false, error: err.message }) : out(`ARCHIVE refused: ${err.message}`)); return err.exitCode; }
+ throw err;
+ }
+ }
case 'restore-download': {
case 'restore-download': {
const id = needService();
const id = needService();
if (!pos[2]) throw new UsageError('restore-download needs a <run> (a run id such as 20260923-033000, or latest)');
if (!pos[2]) throw new UsageError('restore-download needs a <run> (a run id such as 20260923-033000, or latest)');
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* readFile(path, { privileged }) -> string | null
* readFile(path, { privileged }) -> string | null
* writeFile(path, content, { privileged, mode }) ; appendFile(path, text) ; removeFile(path, { privileged })
* writeFile(path, content, { privileged, mode }) ; appendFile(path, text) ; removeFile(path, { privileged })
* symlink(target, path, { privileged }) ; readlink(path) -> string | null
* symlink(target, path, { privileged }) ; readlink(path) -> string | null
- * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size, nlink } | null
+ * stat(path) -> { mode, uid, gid, owner, isFile, isDir, isSymlink, size, nlink, mtime } | null
* readdir(path) -> [{ name, isDir }] | null
* readdir(path) -> [{ name, isDir }] | null
* mkdir(path, { owner, mode }) ; createExclusive(path, content) -> bool ; pidAlive(pid) -> bool
* mkdir(path, { owner, mode }) ; createExclusive(path, content) -> bool ; pidAlive(pid) -> bool
* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file
* readStream(path) -> Readable ; writeStream(path, { mode }) -> Writable (creates the file
@@ -144,7 +144,7 @@ function createSystemExecutor({ env = process.env } = {}) {
@@ -144,7 +144,7 @@ function createSystemExecutor({ env = process.env } = {}) {
try {
try {
const l = fs.lstatSync(p);
const l = fs.lstatSync(p);
const s = l.isSymbolicLink() ? fs.statSync(p) : l;
const s = l.isSymbolicLink() ? fs.statSync(p) : l;
- return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size, nlink: l.nlink };
+ return { mode: s.mode & 0o7777, uid: s.uid, gid: s.gid, owner: userById(s.uid), isFile: s.isFile(), isDir: s.isDirectory(), isSymlink: l.isSymbolicLink(), size: s.size, nlink: l.nlink, mtime: s.mtimeMs };
} catch { return null; }
} catch { return null; }
}
}
@@ -33,6 +33,7 @@ module.exports = {
@@ -33,6 +33,7 @@ module.exports = {
writeMetrics,
writeMetrics,
BackupError,
BackupError,
offsite,
offsite,
+ archive: require('./archive'),
snapshot,
snapshot,
drill,
drill,
DrillError,
DrillError,
@@ -408,6 +408,9 @@ async function check(ctx, { cfg, client }) {
@@ -408,6 +408,9 @@ async function check(ctx, { cfg, client }) {
module.exports = {
module.exports = {
loadConfig,
loadConfig,
+ loadKey,
+ listAll,
+ getObjectStream,
createClient,
createClient,
push,
push,
pruneRemote,
pruneRemote,
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * ovhost archive (WS-S task 6, H14): dead and backup database files are encrypted and copied off-host
+ * under <prefix>-archive/, checked, listed and restored, and never deleted locally. Refused: live
+ * (inventory) databases, open files, symlinks, paths outside the allowed roots, relative paths.
+ * Nothing leaves the process (an in-memory S3).
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { scenario, test, runTests, SECRET } = require('./helpers');
+const { main } = require('../lib/cli');
+const { mockS3 } = require('./s3-mock');
+
+const KEY_HEX = crypto.createHash('sha256').update('archive test key').digest('hex');
+const S3_SECRET = `${SECRET}-s3`;
+
+function archiveHost() {
+ const host = scenario();
+ host.put('/etc/openvibe/backup.env', [
+ 'BACKUP_S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com',
+ 'BACKUP_S3_BUCKET=openvibe-backups-test',
+ 'BACKUP_S3_PREFIX=openvibe-backups',
+ 'BACKUP_S3_KEY_ID=004keyid0000000000000001',
+ `BACKUP_S3_SECRET="${S3_SECRET}"`,
+ 'BACKUP_ENCRYPTION_KEY_FILE=/etc/openvibe/backup.key',
+ '',
+ ].join('\n'), { mode: 0o600, owner: 'root' });
+ host.put('/etc/openvibe/backup.key', `${KEY_HEX}\n`, { mode: 0o600, owner: 'root' });
+ host.put('/opt/backups/old/hobostreamer.db', Buffer.from(`SQLite format 3\0 old site ${'x'.repeat(9000)}`), { owner: 'root' });
+ host.put('/var/lib/openvibe-chat/live-snap-a.db', Buffer.from(`SQLite format 3\0 snapshot ${'y'.repeat(3000)}`), { owner: 'root' });
+ host.put('/var/lib/openvibe-chat/in-use.db', 'SQLite format 3\0 open', { owner: 'root' });
+ host.put('/etc/passwd-copy.db', 'nope', { owner: 'root' });
+ host.openFiles = new Set(['/var/lib/openvibe-chat/in-use.db']);
+ host.s3 = mockS3();
+ host.cli = async (...argv) => {
+ const lines = [];
+ const code = await main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, s3: () => host.s3 });
+ return { code, out: lines.join('\n') };
+ };
+ return host;
+}
+
+const noSecrets = (text) => { for (const s of [S3_SECRET, SECRET, KEY_HEX]) assert.ok(!String(text).includes(s), 'a secret value leaked'); };
+
+runTests([
+ test('push encrypts, uploads under the archive prefix, checks, records, and deletes nothing', async () => {
+ const host = archiveHost();
+ const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.db', '/var/lib/openvibe-chat/live-snap-a.db', '--note', 'H14 first pass');
+ assert.strictEqual(r.code, 0, r.out);
+ noSecrets(r.out);
+ assert.match(r.out, /ARCHIVE ok: 2 file\(s\) at s3:\/\/openvibe-backups-test\/openvibe-backups-archive\/.+\/\d{8}-\d{6}\/.*Nothing was deleted/);
+ const keys = [...host.s3.objects.keys()];
+ assert.ok(keys.every((k) => k.startsWith('openvibe-backups-archive/')), 'outside the backup runs prefix (retention never prunes it)');
+ const obj = keys.find((k) => k.endsWith('opt__backups__old__hobostreamer.db.ovbk'));
+ assert.ok(obj, keys.join(', '));
+ assert.ok(!host.s3.objects.get(obj).includes(Buffer.from('old site')), 'the bytes are encrypted');
+ assert.ok(host.read('/opt/backups/old/hobostreamer.db') !== null && host.read('/var/lib/openvibe-chat/live-snap-a.db') !== null, 'the local files stay');
+ const manifestKey = keys.find((k) => k.endsWith('/manifest.json'));
+ const m = JSON.parse(host.s3.objects.get(manifestKey).toString('utf8'));
+ assert.deepStrictEqual(m.files.map((f) => f.path), ['/opt/backups/old/hobostreamer.db', '/var/lib/openvibe-chat/live-snap-a.db']);
+ assert.strictEqual(m.note, 'H14 first pass');
+ assert.ok(m.hmac && m.files.every((f) => /^[0-9a-f]{64}$/.test(f.plainSha256)));
+ const rec = [...host.files.keys()].find((k) => k.startsWith('/var/lib/openvibe-host/archives/'));
+ assert.ok(rec, 'a local record');
+ noSecrets(host.read(rec));
+
+ const list = await host.cli('archive', 'list');
+ assert.strictEqual(list.code, 0);
+ assert.match(list.out, /\d{8}-\d{6}\s+2 file\(s\).*H14 first pass/);
+
+ const st = m.stamp;
+ const back = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.db', '--out', '/var/lib/openvibe-restore/h14');
+ assert.strictEqual(back.code, 0, back.out);
+ assert.strictEqual(host.read('/var/lib/openvibe-restore/h14/hobostreamer.db'), host.read('/opt/backups/old/hobostreamer.db'), 'restored byte for byte');
+ const again = await host.cli('archive', 'restore', st, '/opt/backups/old/hobostreamer.db', '--out', '/var/lib/openvibe-restore/h14');
+ assert.notStrictEqual(again.code, 0, 'restore never overwrites');
+ }),
+
+ test('refusals: live databases, open files, symlinks, outside roots, relative paths; nothing uploaded', async () => {
+ const host = archiveHost();
+ const liveDb = Object.values(host.inv.services).flatMap((s) => s.databases.map((d) => d.path))[0];
+ assert.ok(liveDb, 'the scenario declares a database');
+ host.put(liveDb, 'SQLite format 3\0 live', { owner: 'ubuntu' });
+ await host.exec.symlink('/opt/backups/old/hobostreamer.db', '/opt/backups/old/link.db');
+ for (const [file, why] of [['/opt/backups/old/link.db', /not a regular file/], [liveDb, /inventory declares/], ['/var/lib/openvibe-chat/in-use.db', /open by a running process/], ['/etc/passwd-copy.db', /outside/], ['relative.db', /absolute/], ['/opt/backups/../etc/x.db', /normalised/], ['/opt/backups/none.db', /no such file/]]) {
+ const r = await host.cli('archive', 'push', '/opt/backups/old/hobostreamer.db', file);
+ assert.strictEqual(r.code, 1, `${file}: ${r.out}`);
+ assert.match(r.out, why, file);
+ assert.strictEqual(host.s3.objects.size, 0, `${file}: nothing is uploaded when any file is refused`);
+ }
+ }),
+]);
@@ -14,68 +14,12 @@ const bcrypto = require('../lib/backup-crypto');
@@ -14,68 +14,12 @@ const bcrypto = require('../lib/backup-crypto');
const retention = require('../lib/retention');
const retention = require('../lib/retention');
const offsite = require('../lib/offsite');
const offsite = require('../lib/offsite');
const { normalise } = require('../lib/inventory');
const { normalise } = require('../lib/inventory');
+const { mockS3 } = require('./s3-mock');
const DAY = 86400000;
const DAY = 86400000;
const KEY_HEX = crypto.createHash('sha256').update('test backup key').digest('hex');
const KEY_HEX = crypto.createHash('sha256').update('test backup key').digest('hex');
const S3_SECRET = `${SECRET}-s3`;
const S3_SECRET = `${SECRET}-s3`;
-/** An in-memory S3 that records every command it is sent. */
-function mockS3({ pageSize = 1000 } = {}) {
- const objects = new Map();
- const uploads = new Map();
- const sent = [];
- let seq = 0;
- const s3 = {
- objects,
- sent,
- failPut: null, // (key) -> true to fail that PutObject/UploadPart
- async send(cmd) {
- const name = cmd.constructor.name;
- const i = cmd.input;
- sent.push({ name, input: { ...i, Body: i.Body ? `<${Buffer.byteLength(i.Body)} bytes>` : undefined } });
- switch (name) {
- case 'PutObjectCommand':
- if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');
- assert.strictEqual(i.ContentLength, i.Body.length);
- objects.set(i.Key, Buffer.from(i.Body));
- return { ETag: '"put"' };
- case 'CreateMultipartUploadCommand': { const id = `up-${++seq}`; uploads.set(id, { key: i.Key, parts: new Map() }); return { UploadId: id }; }
- case 'UploadPartCommand':
- if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');
- uploads.get(i.UploadId).parts.set(i.PartNumber, Buffer.from(i.Body));
- return { ETag: `"p${i.PartNumber}"` };
- case 'CompleteMultipartUploadCommand': {
- const u = uploads.get(i.UploadId);
- const nums = i.MultipartUpload.Parts.map((p) => p.PartNumber);
- assert.deepStrictEqual(nums, [...u.parts.keys()].sort((a, b) => a - b));
- objects.set(u.key, Buffer.concat(nums.map((n) => u.parts.get(n))));
- uploads.delete(i.UploadId);
- return {};
- }
- case 'AbortMultipartUploadCommand': uploads.delete(i.UploadId); return {};
- case 'ListObjectsV2Command': {
- const keys = [...objects.keys()].filter((k) => k.startsWith(i.Prefix || '')).sort();
- const start = i.ContinuationToken ? Number(i.ContinuationToken) : 0;
- const size = Math.min(pageSize, i.MaxKeys || 1000);
- const page = keys.slice(start, start + size);
- const more = start + size < keys.length;
- return { Contents: page.map((k) => ({ Key: k, Size: objects.get(k).length })), IsTruncated: more, NextContinuationToken: more ? String(start + size) : undefined };
- }
- case 'GetObjectCommand': {
- const o = objects.get(i.Key);
- if (!o) throw Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });
- const pieces = [];
- for (let k = 0; k < o.length; k += 1000) pieces.push(o.subarray(k, k + 1000));
- return { Body: Readable.from(pieces) };
- }
- case 'DeleteObjectCommand': objects.delete(i.Key); return {};
- default: throw new Error(`mock S3: unexpected ${name}`);
- }
- },
- };
- return s3;
-}
-
/** The standard fake host plus AI (two databases) and OpenRe (not deployed), a backup env and key. */
/** The standard fake host plus AI (two databases) and OpenRe (not deployed), a backup env and key. */
function backupHost({ envMode = 0o600, keyMode = 0o600, envOwner = 'root', env = null } = {}) {
function backupHost({ envMode = 0o600, keyMode = 0o600, envOwner = 'root', env = null } = {}) {
const host = scenario();
const host = scenario();
@@ -284,6 +284,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -284,6 +284,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return { code: 0, stdout: '', stderr: '' };
return { code: 0, stdout: '', stderr: '' };
}
}
case 'node': return { code: 0, stdout: '', stderr: '' };
case 'node': return { code: 0, stdout: '', stderr: '' };
+ case 'fuser': return { code: (host.openFiles || new Set()).has(path.resolve(args[args.length - 1])) ? 0 : 1, stdout: '', stderr: '' };
case 'systemd-run': return systemdRunCmd(args);
case 'systemd-run': return systemdRunCmd(args);
case 'cp': {
case 'cp': {
const src = args[args.length - 2];
const src = args[args.length - 2];
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+// An in-memory S3 client for tests ({ send(command) }, the @aws-sdk/client-s3 commands ovhost uses).
+const assert = require('assert');
+const { Readable } = require('stream');
+
+/** An in-memory S3 that records every command it is sent. */
+function mockS3({ pageSize = 1000 } = {}) {
+ const objects = new Map();
+ const uploads = new Map();
+ const sent = [];
+ let seq = 0;
+ const s3 = {
+ objects,
+ sent,
+ failPut: null, // (key) -> true to fail that PutObject/UploadPart
+ async send(cmd) {
+ const name = cmd.constructor.name;
+ const i = cmd.input;
+ sent.push({ name, input: { ...i, Body: i.Body ? `<${Buffer.byteLength(i.Body)} bytes>` : undefined } });
+ switch (name) {
+ case 'PutObjectCommand':
+ if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');
+ assert.strictEqual(i.ContentLength, i.Body.length);
+ objects.set(i.Key, Buffer.from(i.Body));
+ return { ETag: '"put"' };
+ case 'CreateMultipartUploadCommand': { const id = `up-${++seq}`; uploads.set(id, { key: i.Key, parts: new Map() }); return { UploadId: id }; }
+ case 'UploadPartCommand':
+ if (s3.failPut && s3.failPut(i.Key)) throw new Error('503 SlowDown');
+ uploads.get(i.UploadId).parts.set(i.PartNumber, Buffer.from(i.Body));
+ return { ETag: `"p${i.PartNumber}"` };
+ case 'CompleteMultipartUploadCommand': {
+ const u = uploads.get(i.UploadId);
+ const nums = i.MultipartUpload.Parts.map((p) => p.PartNumber);
+ assert.deepStrictEqual(nums, [...u.parts.keys()].sort((a, b) => a - b));
+ objects.set(u.key, Buffer.concat(nums.map((n) => u.parts.get(n))));
+ uploads.delete(i.UploadId);
+ return {};
+ }
+ case 'AbortMultipartUploadCommand': uploads.delete(i.UploadId); return {};
+ case 'ListObjectsV2Command': {
+ const keys = [...objects.keys()].filter((k) => k.startsWith(i.Prefix || '')).sort();
+ const start = i.ContinuationToken ? Number(i.ContinuationToken) : 0;
+ const size = Math.min(pageSize, i.MaxKeys || 1000);
+ const page = keys.slice(start, start + size);
+ const more = start + size < keys.length;
+ return { Contents: page.map((k) => ({ Key: k, Size: objects.get(k).length })), IsTruncated: more, NextContinuationToken: more ? String(start + size) : undefined };
+ }
+ case 'GetObjectCommand': {
+ const o = objects.get(i.Key);
+ if (!o) throw Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });
+ const pieces = [];
+ for (let k = 0; k < o.length; k += 1000) pieces.push(o.subarray(k, k + 1000));
+ return { Body: Readable.from(pieces) };
+ }
+ case 'DeleteObjectCommand': objects.delete(i.Key); return {};
+ default: throw new Error(`mock S3: unexpected ${name}`);
+ }
+ },
+ };
+ return s3;
+}
+
+module.exports = { mockS3 };