Off-box backup: pin the host key, guarantee the remote dir, page on failure
PHASE 1 — HOST KEY STATICALLY PINNED. ssh-keyscan -p 23 returned an ED25519 key whose fingerprint EQUALS the out-of-band value SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM, so it is safe to pin. scripts/storagebox_known_hosts now carries that verified line and ships to the container (Dockerfile already COPYs scripts/). backup-db.sh uses StrictHostKeyChecking=yes + UserKnownHostsFile=<pin> instead of accept-new, which was trust-on-first-use and would have accepted an impostor on the very first run. A missing pin file REFUSES the push rather than silently falling back. Never weakened to accept-new/=no//dev/null — a test asserts that on executable lines. PHASE 1b — REMOTE DIR GUARANTEED. The box has only .ssh/, and rsyncing a file into a missing parent either fails or silently writes the dump AS the directory name — one file, overwritten nightly, reading as "backups exist" while retaining exactly one. Uses rsync --mkpath when available, else an explicit remote mkdir -p ahead of the push. PHASE 2b — FAILED OFF-BOX PUSH IS NOW LOUD. Off-box is required, so the failed-push path pages at "urgent" (was "low"/deferred) and the script emits a machine-readable OFFBOX_OK=1/0/deferred that POST /api/internal/backup/run surfaces as a distinct offbox_ok field. Exit code deliberately still reflects ON-BOX durability — a good on-box dump must not raise a false total-failure alarm. Surfacing the truth, not manufacturing a failure. No key material is echoed anywhere; only the PUBLIC host key is committed. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Session 64 — off-box backup hardening.
|
||||
*
|
||||
* Locks three properties that are easy to silently undo later:
|
||||
* 1. the Storage Box host key is STATICALLY PINNED (no trust-on-first-use)
|
||||
* 2. the remote directory is guaranteed before a push
|
||||
* 3. a failed REQUIRED off-box push pages and reports offbox_ok:false
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const SCRIPT = fs.readFileSync(path.join(ROOT, 'scripts', 'backup-db.sh'), 'utf8');
|
||||
const KNOWN_HOSTS_PATH = path.join(ROOT, 'scripts', 'storagebox_known_hosts');
|
||||
|
||||
const EXPECTED_FP = 'SHA256:XqONwb1S0zuj5A1CDxpOSuD2hnAArV1A3wKY7Z3sdgM';
|
||||
|
||||
describe('host key is statically pinned', () => {
|
||||
test('the pinned known_hosts file ships in the repo', () => {
|
||||
expect(fs.existsSync(KNOWN_HOSTS_PATH)).toBe(true);
|
||||
});
|
||||
|
||||
test('it contains the VERIFIED Storage Box ED25519 fingerprint', () => {
|
||||
// ssh-keygen is the authority — parse the file the way ssh will.
|
||||
const out = execFileSync('ssh-keygen', ['-lf', KNOWN_HOSTS_PATH], { encoding: 'utf8' });
|
||||
expect(out).toContain(EXPECTED_FP);
|
||||
expect(out).toContain('[u635423.your-storagebox.de]:23');
|
||||
});
|
||||
|
||||
test('the script uses StrictHostKeyChecking=yes with the pinned file', () => {
|
||||
expect(SCRIPT).toMatch(/StrictHostKeyChecking=yes/);
|
||||
expect(SCRIPT).toMatch(/UserKnownHostsFile=\$\{KNOWN_HOSTS\}/);
|
||||
});
|
||||
|
||||
test('trust-on-first-use and disabled checking are GONE and stay gone', () => {
|
||||
// Assert on EXECUTABLE lines only — the comments deliberately name
|
||||
// accept-new to explain what was removed and why it must not come back.
|
||||
const code = SCRIPT.split('\n')
|
||||
.filter((l) => !l.trim().startsWith('#'))
|
||||
.join('\n');
|
||||
expect(code).not.toMatch(/StrictHostKeyChecking=accept-new/);
|
||||
expect(code).not.toMatch(/StrictHostKeyChecking=no\b/);
|
||||
expect(code).not.toMatch(/UserKnownHostsFile=\/dev\/null/);
|
||||
});
|
||||
|
||||
test('a missing pin file refuses the push rather than falling back', () => {
|
||||
expect(SCRIPT).toMatch(/refusing to push without host-key verification/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote directory is guaranteed', () => {
|
||||
test('uses --mkpath when available, else an explicit remote mkdir -p', () => {
|
||||
expect(SCRIPT).toMatch(/--mkpath/);
|
||||
expect(SCRIPT).toMatch(/mkdir -p/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('off-box failure is loud (Phase 2b)', () => {
|
||||
test('a failed REQUIRED push pages at urgent, not low', () => {
|
||||
const failBlock = SCRIPT.slice(SCRIPT.indexOf('off-box push FAILED'));
|
||||
expect(failBlock).toMatch(/notify "VYNDR OFF-BOX PUSH FAILED" "urgent"/);
|
||||
expect(SCRIPT).not.toMatch(/notify "VYNDR off-box push deferred" "low"/);
|
||||
});
|
||||
|
||||
test('the script emits a machine-readable off-box result', () => {
|
||||
expect(SCRIPT).toMatch(/OFFBOX_OK=1/);
|
||||
expect(SCRIPT).toMatch(/OFFBOX_OK=0/);
|
||||
});
|
||||
|
||||
test('exit code still reflects ON-BOX durability (no false total-failure)', () => {
|
||||
// The push lives in an if/else; neither branch exits non-zero.
|
||||
const pushSection = SCRIPT.slice(SCRIPT.indexOf('OFF-BOX COPY'));
|
||||
expect(pushSection).not.toMatch(/exit 1/);
|
||||
});
|
||||
|
||||
test('the API surfaces offbox_ok distinctly from ok', () => {
|
||||
const route = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'internal.js'), 'utf8');
|
||||
expect(route).toMatch(/offbox_ok/);
|
||||
expect(route).toMatch(/OFFBOX_OK=1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offbox_ok parsing', () => {
|
||||
// Mirrors the route's parse so the three-state logic is locked.
|
||||
const parse = (tail) => (/OFFBOX_OK=1/.test(tail) ? true : (/OFFBOX_OK=0/.test(tail) ? false : null));
|
||||
|
||||
test('success → true', () => expect(parse('...\nOFFBOX_OK=1\n')).toBe(true));
|
||||
test('failure → false (never null, never true)', () => expect(parse('...\nOFFBOX_OK=0\n')).toBe(false));
|
||||
test('deferred/absent → null, distinct from false', () => {
|
||||
expect(parse('OFFBOX_OK=deferred')).toBeNull();
|
||||
expect(parse('')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user