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

OP OpenVibers committed 26 Sept 2026 · 6e5e4999cd · 11 files, +422 −60


Changes

ddiff --git a/README.md b/README.md
iindex 3ae3178..3a1652a 100644
---- a/README.md
++++ b/README.md
@@@ -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)
```
ddiff --git a/docs/db-inventory.md b/docs/db-inventory.md
nnew file mode 100644
iindex 0000000..ce87986
---- /dev/null
++++ b/docs/db-inventory.md
@@@ -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.
ddiff --git a/lib/archive.js b/lib/archive.js
nnew file mode 100644
iindex 0000000..cd94413
---- /dev/null
++++ b/lib/archive.js
@@@ -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 };
ddiff --git a/lib/cli.js b/lib/cli.js
iindex fed662a..10c33ee 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -58,6 +58,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
offsite list [<service>] [--from-host <name>]
runs stored off-host, newest first
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>]
download, verify and decrypt one service's databases into a
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
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) {
const pos = [];
@@@ -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('; ')}` : ''}`);
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)');
ddiff --git a/lib/executor.js b/lib/executor.js
iindex bc1dcb9..09d1a1b 100644
---- a/lib/executor.js
++++ b/lib/executor.js
@@@ -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; }
}
ddiff --git a/lib/index.js b/lib/index.js
iindex 35b498e..f016d0f 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -33,6 +33,7 @@ module.exports = {
writeMetrics,
BackupError,
offsite,
++ archive: require('./archive'),
snapshot,
drill,
DrillError,
ddiff --git a/lib/offsite.js b/lib/offsite.js
iindex 190206e..2c4910a 100644
---- a/lib/offsite.js
++++ b/lib/offsite.js
@@@ -408,6 +408,9 @@ async function check(ctx, { cfg, client }) {
module.exports = {
loadConfig,
++ loadKey,
++ listAll,
++ getObjectStream,
createClient,
push,
pruneRemote,
ddiff --git a/test/archive.test.js b/test/archive.test.js
nnew file mode 100644
iindex 0000000..0a7c241
---- /dev/null
++++ b/test/archive.test.js
@@@ -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`);
++ }
++ }),
++]);
ddiff --git a/test/backup-offsite.test.js b/test/backup-offsite.test.js
iindex 6ac1154..a9e89a3 100644
---- a/test/backup-offsite.test.js
++++ b/test/backup-offsite.test.js
@@@ -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();
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 65bae8e..775fbde 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -284,6 +284,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
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 'cp': {
const src = args[args.length - 2];
ddiff --git a/test/s3-mock.js b/test/s3-mock.js
nnew file mode 100644
iindex 0000000..ee4f47a
---- /dev/null
++++ b/test/s3-mock.js
@@@ -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 };