@celilo/cli 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +4 -4
- package/src/capabilities/public-web-publish.test.ts +15 -15
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +9 -68
- package/src/cli/completion.ts +18 -1
- package/src/cli/index.ts +11 -0
- package/src/db/schema.ts +5 -3
- package/src/hooks/capability-loader.ts +0 -12
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +18 -51
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +44 -8
- package/src/services/alerting/inbound-poller.ts +65 -28
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/machine-probe.test.ts +50 -0
- package/src/services/machine-probe.ts +73 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- package/src/templates/generator.ts +119 -16
|
@@ -63,13 +63,16 @@ export interface SweepReport {
|
|
|
63
63
|
failed: number;
|
|
64
64
|
/**
|
|
65
65
|
* Live alerts nobody is configured to be told about — no escalation policy on
|
|
66
|
-
* the monitor, so `notifyDepsFor` returns null.
|
|
66
|
+
* the monitor, so `notifyDepsFor` returns null. Each carries the monitor that
|
|
67
|
+
* owns it, which is the thing an operator has to assign a policy TO.
|
|
67
68
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* Identified rather than merely counted: `1 no-policy` told an operator that
|
|
70
|
+
* something was unreachable but not WHICH thing, so the remedy the sweep
|
|
71
|
+
* printed alongside it could not be aimed at anything. Assigning the policy to
|
|
72
|
+
* all eighteen monitors then changed nothing observable (#481). A bare count
|
|
73
|
+
* is only half a step better than the silence it replaced.
|
|
71
74
|
*/
|
|
72
|
-
noPolicy:
|
|
75
|
+
noPolicy: { alertKey: string; monitor: string }[];
|
|
73
76
|
/**
|
|
74
77
|
* Deliveries escalation declined, keyed by its reason (`within_grace`,
|
|
75
78
|
* `no_eligible_route`, …).
|
|
@@ -113,7 +116,7 @@ export async function runSweep(
|
|
|
113
116
|
deferred: 0,
|
|
114
117
|
deferredDelivered: 0,
|
|
115
118
|
failed: 0,
|
|
116
|
-
noPolicy:
|
|
119
|
+
noPolicy: [],
|
|
117
120
|
skipped: {},
|
|
118
121
|
failures: [],
|
|
119
122
|
};
|
|
@@ -206,10 +209,14 @@ export async function runSweep(
|
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
// 5. Notify. Re-read: the steps above changed state under us.
|
|
212
|
+
const monitorTargets = new Map(monitors.map((m) => [m.id, m.target]));
|
|
209
213
|
for (const alert of loadAllLiveAlerts(db)) {
|
|
210
214
|
const notifyDeps = deps.notifyDepsFor(alert);
|
|
211
215
|
if (!notifyDeps) {
|
|
212
|
-
report.noPolicy
|
|
216
|
+
report.noPolicy.push({
|
|
217
|
+
alertKey: alert.key,
|
|
218
|
+
monitor: monitorTargets.get(alert.monitorId) ?? '(unknown monitor)',
|
|
219
|
+
});
|
|
213
220
|
continue;
|
|
214
221
|
}
|
|
215
222
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the roster the backup-freshness check runs against.
|
|
3
|
+
*
|
|
4
|
+
* Split out from the check itself so the decision — is this backup too
|
|
5
|
+
* old — stays pure, while the queries that feed it live here (Rule 2.3).
|
|
6
|
+
* Shared by `celilo system audit` and by the scheduled `backups` monitor,
|
|
7
|
+
* so both judge the same fleet from the same data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { eq } from 'drizzle-orm';
|
|
11
|
+
import type { DbClient } from '../../db/client';
|
|
12
|
+
import { backups, modules } from '../../db/schema';
|
|
13
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
14
|
+
import type { InstalledModuleBackupInfo } from './backups';
|
|
15
|
+
|
|
16
|
+
const DEPLOYED_STATES = ['INSTALLED', 'VERIFIED'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Most recent COMPLETED backup per module, in epoch ms.
|
|
20
|
+
*
|
|
21
|
+
* The `backups` table is added via inline ALTER statements in
|
|
22
|
+
* db/client.ts for upgraded databases, so a freshly-initialized DB built
|
|
23
|
+
* from the drizzle journal alone may not have it yet. Absence means "no
|
|
24
|
+
* backups recorded", never a crashed audit.
|
|
25
|
+
*/
|
|
26
|
+
function latestSuccessfulBackupByModule(db: DbClient): Map<string, number> {
|
|
27
|
+
const latest = new Map<string, number>();
|
|
28
|
+
try {
|
|
29
|
+
for (const backup of db.select().from(backups).where(eq(backups.status, 'completed')).all()) {
|
|
30
|
+
if (!backup.moduleId || !backup.completedAt) continue;
|
|
31
|
+
const at = backup.completedAt.getTime();
|
|
32
|
+
const previous = latest.get(backup.moduleId);
|
|
33
|
+
if (previous === undefined || at > previous) latest.set(backup.moduleId, at);
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Table missing — leave the map empty.
|
|
37
|
+
}
|
|
38
|
+
return latest;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadBackupAuditInfo(db: DbClient): InstalledModuleBackupInfo[] {
|
|
42
|
+
const latest = latestSuccessfulBackupByModule(db);
|
|
43
|
+
return db
|
|
44
|
+
.select()
|
|
45
|
+
.from(modules)
|
|
46
|
+
.all()
|
|
47
|
+
.filter((module) => DEPLOYED_STATES.includes(module.state))
|
|
48
|
+
.map((module) => ({
|
|
49
|
+
id: module.id,
|
|
50
|
+
state: module.state,
|
|
51
|
+
manifest: module.manifestData as ModuleManifest,
|
|
52
|
+
lastSuccessfulBackupAt: latest.get(module.id) ?? null,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
@@ -75,7 +75,10 @@ describe('auditBackups', () => {
|
|
|
75
75
|
expect(result).toEqual([]);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// Silence-by-default is the bug: an undeclared cadence is how celilo-mgmt
|
|
79
|
+
// went 55 days without a backup and nobody was told. Opting out takes an
|
|
80
|
+
// explicit `manual` — see services/backup-schedule.ts.
|
|
81
|
+
test('no schedule declared → daily, so a year-old backup is stale', async () => {
|
|
79
82
|
const result = await auditBackups({
|
|
80
83
|
modules: [
|
|
81
84
|
makeModule('lunacycle', {
|
|
@@ -85,7 +88,9 @@ describe('auditBackups', () => {
|
|
|
85
88
|
],
|
|
86
89
|
now: () => NOW,
|
|
87
90
|
});
|
|
88
|
-
expect(result).
|
|
91
|
+
expect(result).toHaveLength(1);
|
|
92
|
+
expect(result[0]).toMatchObject({ code: 'backup_stale', subject: 'lunacycle' });
|
|
93
|
+
expect(result[0].message).toContain('daily');
|
|
89
94
|
});
|
|
90
95
|
|
|
91
96
|
test('daily schedule: 26h-old is stale', async () => {
|
|
@@ -8,14 +8,17 @@
|
|
|
8
8
|
* scheduled run doesn't flag drift on every audit.
|
|
9
9
|
*
|
|
10
10
|
* Modules without an `on_backup` hook are skipped — there's nothing
|
|
11
|
-
* to back up. Modules whose schedule is `manual`
|
|
12
|
-
* staleness check (the
|
|
13
|
-
* `backup_missing` finding if no backup has ever been recorded.
|
|
11
|
+
* to back up. Modules whose schedule is explicitly `manual` skip the
|
|
12
|
+
* staleness check (the operator decides cadence) but still get a
|
|
13
|
+
* `backup_missing` finding if no backup has ever been recorded. An
|
|
14
|
+
* unset schedule is `daily`, not `manual` — see
|
|
15
|
+
* [[services/backup-schedule.ts]] for why that default matters.
|
|
14
16
|
*
|
|
15
17
|
* Time is injected so tests can pin "now" deterministically.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
21
|
+
import { effectiveBackupSchedule } from '../backup-schedule';
|
|
19
22
|
import type { DriftFinding } from './types';
|
|
20
23
|
|
|
21
24
|
export interface InstalledModuleBackupInfo {
|
|
@@ -50,10 +53,7 @@ const DAY = 24 * HOUR;
|
|
|
50
53
|
* - daily → 25h (24h + 1h grace)
|
|
51
54
|
* - weekly → 8d (7d + 1d grace)
|
|
52
55
|
* - monthly → 32d (~30d + 2d grace)
|
|
53
|
-
* - manual → null (no staleness check;
|
|
54
|
-
*
|
|
55
|
-
* `undefined` (no `backup:` block in manifest) is treated as
|
|
56
|
-
* `manual` — author opted out of declaring a cadence.
|
|
56
|
+
* - manual → null (no staleness check; operator-driven cadence)
|
|
57
57
|
*/
|
|
58
58
|
const SCHEDULE_THRESHOLDS = {
|
|
59
59
|
hourly: 2 * HOUR,
|
|
@@ -63,21 +63,13 @@ const SCHEDULE_THRESHOLDS = {
|
|
|
63
63
|
manual: null,
|
|
64
64
|
} as const;
|
|
65
65
|
|
|
66
|
-
type ScheduleKey = keyof typeof SCHEDULE_THRESHOLDS;
|
|
67
|
-
|
|
68
66
|
function moduleHasBackupHook(manifest: ModuleManifest): boolean {
|
|
69
67
|
return Boolean(manifest.hooks?.on_backup);
|
|
70
68
|
}
|
|
71
69
|
|
|
72
|
-
function scheduleFor(manifest: ModuleManifest): ScheduleKey {
|
|
73
|
-
const s = manifest.backup?.schedule;
|
|
74
|
-
if (s === 'hourly' || s === 'daily' || s === 'weekly' || s === 'monthly') return s;
|
|
75
|
-
return 'manual';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
70
|
function thresholdFor(manifest: ModuleManifest, override: number | undefined): number | null {
|
|
79
71
|
if (override !== undefined) return override;
|
|
80
|
-
return SCHEDULE_THRESHOLDS[
|
|
72
|
+
return SCHEDULE_THRESHOLDS[effectiveBackupSchedule(manifest)];
|
|
81
73
|
}
|
|
82
74
|
|
|
83
75
|
function formatAge(ms: number): string {
|
|
@@ -109,7 +101,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
|
|
|
109
101
|
category: 'backups',
|
|
110
102
|
severity: 'drift',
|
|
111
103
|
code: 'backup_missing',
|
|
112
|
-
message: `${m.id}: no successful backup recorded (schedule: ${
|
|
104
|
+
message: `${m.id}: no successful backup recorded (schedule: ${effectiveBackupSchedule(m.manifest)})`,
|
|
113
105
|
remediation: `celilo backup create ${m.id} --force`,
|
|
114
106
|
actionable: true,
|
|
115
107
|
subject: m.id,
|
|
@@ -126,7 +118,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
|
|
|
126
118
|
category: 'backups',
|
|
127
119
|
severity: 'drift',
|
|
128
120
|
code: 'backup_stale',
|
|
129
|
-
message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${
|
|
121
|
+
message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${effectiveBackupSchedule(m.manifest)}, threshold: ${formatAge(threshold)})`,
|
|
130
122
|
remediation: `celilo backup create ${m.id} --force`,
|
|
131
123
|
actionable: true,
|
|
132
124
|
subject: m.id,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup artifact encryption.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing test here is `writes a streamed artifact, not a JSON
|
|
5
|
+
* envelope`. Everything else could pass while someone quietly reintroduces
|
|
6
|
+
* the whole-file-in-memory path — the format assertion is what goes red if
|
|
7
|
+
* they do, without needing an 800 MB fixture to prove it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { encryptSecret } from '../secrets/encryption';
|
|
16
|
+
import {
|
|
17
|
+
ARTIFACT_FORMAT_VERSION,
|
|
18
|
+
ARTIFACT_MAGIC,
|
|
19
|
+
decryptFileToFile,
|
|
20
|
+
encryptFileToFile,
|
|
21
|
+
isStreamedArtifact,
|
|
22
|
+
} from './backup-cipher';
|
|
23
|
+
|
|
24
|
+
const MASTER_KEY = Buffer.alloc(32, 7);
|
|
25
|
+
const OTHER_KEY = Buffer.alloc(32, 9);
|
|
26
|
+
|
|
27
|
+
let dir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cipher-test-'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function paths(name: string) {
|
|
38
|
+
return {
|
|
39
|
+
plain: join(dir, `${name}.tar`),
|
|
40
|
+
enc: join(dir, `${name}.enc`),
|
|
41
|
+
out: join(dir, `${name}.out`),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An artifact in the pre-streaming format: JSON envelope of base64-of-hex. */
|
|
46
|
+
function writeLegacyArtifact(path: string, payload: Buffer): void {
|
|
47
|
+
writeFileSync(path, JSON.stringify(encryptSecret(payload.toString('base64'), MASTER_KEY)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('backup-cipher', () => {
|
|
51
|
+
test('round-trips a payload byte-for-byte', async () => {
|
|
52
|
+
const p = paths('roundtrip');
|
|
53
|
+
const payload = randomBytes(3 * 1024 * 1024);
|
|
54
|
+
writeFileSync(p.plain, payload);
|
|
55
|
+
|
|
56
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
57
|
+
await decryptFileToFile(p.enc, p.out, MASTER_KEY);
|
|
58
|
+
|
|
59
|
+
expect(readFileSync(p.out).equals(payload)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('writes a streamed artifact, not a JSON envelope', async () => {
|
|
63
|
+
const p = paths('format');
|
|
64
|
+
writeFileSync(p.plain, randomBytes(4096));
|
|
65
|
+
|
|
66
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
67
|
+
|
|
68
|
+
const written = readFileSync(p.enc);
|
|
69
|
+
expect(written.subarray(0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC)).toBe(true);
|
|
70
|
+
expect(written[ARTIFACT_MAGIC.length]).toBe(ARTIFACT_FORMAT_VERSION);
|
|
71
|
+
expect(isStreamedArtifact(p.enc)).toBe(true);
|
|
72
|
+
|
|
73
|
+
// The old path produced base64-of-hex wrapped in JSON, which is 2.67x
|
|
74
|
+
// the input and starts with '{'. Both are what made it OOM.
|
|
75
|
+
expect(written[0]).not.toBe('{'.charCodeAt(0));
|
|
76
|
+
expect(written.length).toBeLessThan(4096 * 2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('artifact is barely larger than its plaintext', async () => {
|
|
80
|
+
const p = paths('overhead');
|
|
81
|
+
const size = 1024 * 1024;
|
|
82
|
+
writeFileSync(p.plain, randomBytes(size));
|
|
83
|
+
|
|
84
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
85
|
+
|
|
86
|
+
// header (25) + tag (16). AES-GCM is a stream cipher — no block padding.
|
|
87
|
+
expect(statSync(p.enc).size).toBe(size + 41);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('still reads artifacts in the legacy JSON format', async () => {
|
|
91
|
+
const p = paths('legacy');
|
|
92
|
+
const payload = randomBytes(64 * 1024);
|
|
93
|
+
writeLegacyArtifact(p.enc, payload);
|
|
94
|
+
|
|
95
|
+
expect(isStreamedArtifact(p.enc)).toBe(false);
|
|
96
|
+
await decryptFileToFile(p.enc, p.out, MASTER_KEY);
|
|
97
|
+
|
|
98
|
+
expect(readFileSync(p.out).equals(payload)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('rejects a wrong master key rather than emitting garbage', async () => {
|
|
102
|
+
const p = paths('wrongkey');
|
|
103
|
+
writeFileSync(p.plain, randomBytes(8192));
|
|
104
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
105
|
+
|
|
106
|
+
expect(decryptFileToFile(p.enc, p.out, OTHER_KEY)).rejects.toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('rejects tampered ciphertext', async () => {
|
|
110
|
+
const p = paths('tamper');
|
|
111
|
+
writeFileSync(p.plain, randomBytes(8192));
|
|
112
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
113
|
+
|
|
114
|
+
const bytes = readFileSync(p.enc);
|
|
115
|
+
bytes[100] ^= 0xff;
|
|
116
|
+
writeFileSync(p.enc, bytes);
|
|
117
|
+
|
|
118
|
+
expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('rejects a tampered auth tag', async () => {
|
|
122
|
+
const p = paths('tamper-tag');
|
|
123
|
+
writeFileSync(p.plain, randomBytes(8192));
|
|
124
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
125
|
+
|
|
126
|
+
const bytes = readFileSync(p.enc);
|
|
127
|
+
bytes[bytes.length - 1] ^= 0xff;
|
|
128
|
+
writeFileSync(p.enc, bytes);
|
|
129
|
+
|
|
130
|
+
expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('reports a truncated artifact clearly', async () => {
|
|
134
|
+
const p = paths('truncated');
|
|
135
|
+
writeFileSync(p.enc, Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION])]));
|
|
136
|
+
|
|
137
|
+
expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/truncated/i);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('refuses an artifact written by a newer celilo', async () => {
|
|
141
|
+
const p = paths('future');
|
|
142
|
+
writeFileSync(p.plain, randomBytes(1024));
|
|
143
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
144
|
+
|
|
145
|
+
const bytes = readFileSync(p.enc);
|
|
146
|
+
bytes[ARTIFACT_MAGIC.length] = ARTIFACT_FORMAT_VERSION + 1;
|
|
147
|
+
writeFileSync(p.enc, bytes);
|
|
148
|
+
|
|
149
|
+
expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/Upgrade celilo/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('handles an empty plaintext', async () => {
|
|
153
|
+
const p = paths('empty');
|
|
154
|
+
writeFileSync(p.plain, Buffer.alloc(0));
|
|
155
|
+
|
|
156
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
157
|
+
// Header + tag only — nothing to stream back, and a real tar is never
|
|
158
|
+
// empty, so this is reported rather than silently producing 0 bytes.
|
|
159
|
+
expect(decryptFileToFile(p.enc, p.out, MASTER_KEY)).rejects.toThrow(/no data/i);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The ceiling the old format could not clear: hex-of-base64 is 2.67 chars
|
|
164
|
+
* per input byte against a ~2^31 max string length, so anything over
|
|
165
|
+
* ~805 MB was unrepresentable regardless of available RAM.
|
|
166
|
+
*
|
|
167
|
+
* Opt-in — it writes ~900 MB to disk and takes tens of seconds, which does
|
|
168
|
+
* not belong in every CI run. Run deliberately after touching this file:
|
|
169
|
+
* CELILO_TEST_LARGE_BACKUP=1 bun test src/services/backup-cipher.test.ts
|
|
170
|
+
*/
|
|
171
|
+
test.skipIf(!process.env.CELILO_TEST_LARGE_BACKUP)(
|
|
172
|
+
'round-trips an artifact past the old format ceiling',
|
|
173
|
+
async () => {
|
|
174
|
+
const p = paths('huge');
|
|
175
|
+
const chunk = randomBytes(1024 * 1024);
|
|
176
|
+
const file = Bun.file(p.plain).writer();
|
|
177
|
+
for (let i = 0; i < 900; i++) file.write(chunk);
|
|
178
|
+
await file.end();
|
|
179
|
+
|
|
180
|
+
await encryptFileToFile(p.plain, p.enc, MASTER_KEY);
|
|
181
|
+
await decryptFileToFile(p.enc, p.out, MASTER_KEY);
|
|
182
|
+
|
|
183
|
+
expect(existsSync(p.out)).toBe(true);
|
|
184
|
+
expect(statSync(p.out).size).toBe(statSync(p.plain).size);
|
|
185
|
+
},
|
|
186
|
+
300_000,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption for backup artifacts — file in, file out, streamed.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT `encryptSecret`/`decryptSecret`. Those are string-in,
|
|
5
|
+
* string-out and correct for what they were built for: short values headed
|
|
6
|
+
* for a DB column. Backup artifacts are the opposite shape, and running them
|
|
7
|
+
* through a string API cost three full in-memory copies with an expansion
|
|
8
|
+
* factor at each step:
|
|
9
|
+
*
|
|
10
|
+
* read the tar 774 MB Buffer
|
|
11
|
+
* .toString('base64') 1032 MB string
|
|
12
|
+
* encrypt to hex 2064 MB string (hex is 2 chars per byte)
|
|
13
|
+
* JSON.stringify 2064 MB string
|
|
14
|
+
*
|
|
15
|
+
* — about 6.9 GB live for one 774 MB module, which is what OOM-killed the
|
|
16
|
+
* forgejo backup. Worse, it had a ceiling no amount of RAM could raise:
|
|
17
|
+
* hex-of-base64 is 2.67 chars per input byte against a ~2^31 max string
|
|
18
|
+
* length, so the old path simply could not represent a tar over ~805 MB.
|
|
19
|
+
*
|
|
20
|
+
* Here the plaintext never exists in memory at all. `createCipheriv` is a
|
|
21
|
+
* Transform, so file → cipher → file runs in constant memory regardless of
|
|
22
|
+
* artifact size, and both intermediate encodings disappear (the ciphertext
|
|
23
|
+
* is written as raw bytes, so an artifact is now *smaller* than its tar
|
|
24
|
+
* rather than 2.67x larger).
|
|
25
|
+
*
|
|
26
|
+
* On-disk format, all binary:
|
|
27
|
+
*
|
|
28
|
+
* magic 8 bytes "CELILOBK"
|
|
29
|
+
* version 1 byte currently 1
|
|
30
|
+
* iv 16 bytes
|
|
31
|
+
* ciphertext ... streamed
|
|
32
|
+
* auth tag 16 bytes trailer — GCM only produces it after final()
|
|
33
|
+
*
|
|
34
|
+
* The tag has to be a trailer because it does not exist until the last byte
|
|
35
|
+
* has been encrypted, and seeking back to patch a header would mean the
|
|
36
|
+
* writer could no longer be a plain stream. Reading it costs one 16-byte
|
|
37
|
+
* positional read before the stream starts.
|
|
38
|
+
*
|
|
39
|
+
* `decryptFileToFile` also reads the previous format (a JSON envelope of
|
|
40
|
+
* base64-of-hex). The magic bytes are the discriminator: the old writer
|
|
41
|
+
* always emitted JSON, so a file starting with `{` is legacy. The envelope's
|
|
42
|
+
* own schemaVersion cannot serve — it lives *inside* the encrypted tar and
|
|
43
|
+
* is unreadable until after decryption.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
47
|
+
import {
|
|
48
|
+
appendFileSync,
|
|
49
|
+
closeSync,
|
|
50
|
+
createReadStream,
|
|
51
|
+
createWriteStream,
|
|
52
|
+
openSync,
|
|
53
|
+
readFileSync,
|
|
54
|
+
readSync,
|
|
55
|
+
statSync,
|
|
56
|
+
writeFileSync,
|
|
57
|
+
} from 'node:fs';
|
|
58
|
+
import { pipeline } from 'node:stream/promises';
|
|
59
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
60
|
+
import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
61
|
+
|
|
62
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
63
|
+
|
|
64
|
+
/** Identifies a streamed artifact. Legacy artifacts begin with `{`. */
|
|
65
|
+
export const ARTIFACT_MAGIC = Buffer.from('CELILOBK', 'ascii');
|
|
66
|
+
|
|
67
|
+
/** Bumped only for an incompatible layout change; readers reject unknown values. */
|
|
68
|
+
export const ARTIFACT_FORMAT_VERSION = 1;
|
|
69
|
+
|
|
70
|
+
const IV_LENGTH = 16;
|
|
71
|
+
const AUTH_TAG_LENGTH = 16;
|
|
72
|
+
const HEADER_LENGTH = ARTIFACT_MAGIC.length + 1 + IV_LENGTH;
|
|
73
|
+
|
|
74
|
+
/** Read `length` bytes at `offset` without opening a stream. */
|
|
75
|
+
function readBytesAt(path: string, offset: number, length: number): Buffer {
|
|
76
|
+
const buffer = Buffer.alloc(length);
|
|
77
|
+
const fd = openSync(path, 'r');
|
|
78
|
+
try {
|
|
79
|
+
readSync(fd, buffer, 0, length, offset);
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
return buffer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether this artifact uses the streamed format rather than the JSON envelope. */
|
|
87
|
+
export function isStreamedArtifact(path: string): boolean {
|
|
88
|
+
if (statSync(path).size < ARTIFACT_MAGIC.length) return false;
|
|
89
|
+
return readBytesAt(path, 0, ARTIFACT_MAGIC.length).equals(ARTIFACT_MAGIC);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Encrypt `srcPath` to `destPath` in constant memory.
|
|
94
|
+
*/
|
|
95
|
+
export async function encryptFileToFile(
|
|
96
|
+
srcPath: string,
|
|
97
|
+
destPath: string,
|
|
98
|
+
masterKey: Buffer,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const iv = randomBytes(IV_LENGTH);
|
|
101
|
+
const cipher = createCipheriv(ALGORITHM, masterKey, iv);
|
|
102
|
+
|
|
103
|
+
const out = createWriteStream(destPath);
|
|
104
|
+
out.write(Buffer.concat([ARTIFACT_MAGIC, Buffer.from([ARTIFACT_FORMAT_VERSION]), iv]));
|
|
105
|
+
await pipeline(createReadStream(srcPath), cipher, out);
|
|
106
|
+
|
|
107
|
+
// Available only once the stream has run final(), i.e. after the pipeline
|
|
108
|
+
// resolves. Appending 16 bytes is O(1) and keeps the writer a plain stream.
|
|
109
|
+
appendFileSync(destPath, cipher.getAuthTag());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decrypt `srcPath` to `destPath`. Handles both the streamed format and the
|
|
114
|
+
* legacy JSON envelope.
|
|
115
|
+
*
|
|
116
|
+
* Throws on a truncated artifact, an unknown format version, or a failed
|
|
117
|
+
* authentication tag (wrong master key, or tampered/corrupted ciphertext).
|
|
118
|
+
*/
|
|
119
|
+
export async function decryptFileToFile(
|
|
120
|
+
srcPath: string,
|
|
121
|
+
destPath: string,
|
|
122
|
+
masterKey: Buffer,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
if (!isStreamedArtifact(srcPath)) {
|
|
125
|
+
decryptLegacyArtifact(srcPath, destPath, masterKey);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const size = statSync(srcPath).size;
|
|
130
|
+
const overhead = HEADER_LENGTH + AUTH_TAG_LENGTH;
|
|
131
|
+
if (size < overhead) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Backup artifact is truncated: ${size} bytes, but the format needs at least ${overhead}.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (size === overhead) {
|
|
137
|
+
throw new Error('Backup artifact contains no data (header and auth tag only).');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const header = readBytesAt(srcPath, 0, HEADER_LENGTH);
|
|
141
|
+
const version = header[ARTIFACT_MAGIC.length];
|
|
142
|
+
if (version !== ARTIFACT_FORMAT_VERSION) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Backup artifact uses format version ${version}, but this celilo understands ${ARTIFACT_FORMAT_VERSION}. Upgrade celilo to restore it.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const decipher = createDecipheriv(
|
|
149
|
+
ALGORITHM,
|
|
150
|
+
masterKey,
|
|
151
|
+
header.subarray(ARTIFACT_MAGIC.length + 1),
|
|
152
|
+
);
|
|
153
|
+
decipher.setAuthTag(readBytesAt(srcPath, size - AUTH_TAG_LENGTH, AUTH_TAG_LENGTH));
|
|
154
|
+
|
|
155
|
+
// `end` is inclusive, so the last ciphertext byte is the one before the tag.
|
|
156
|
+
await pipeline(
|
|
157
|
+
createReadStream(srcPath, { start: HEADER_LENGTH, end: size - AUTH_TAG_LENGTH - 1 }),
|
|
158
|
+
decipher,
|
|
159
|
+
createWriteStream(destPath),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read an artifact written before the streamed format.
|
|
165
|
+
*
|
|
166
|
+
* Reads the whole thing into memory, which is fine precisely because these
|
|
167
|
+
* are the artifacts the old writer produced: it could not emit one much over
|
|
168
|
+
* ~805 MB without dying, so the bound this function relies on is the same bug
|
|
169
|
+
* that motivated the new format. New artifacts never take this path.
|
|
170
|
+
*/
|
|
171
|
+
function decryptLegacyArtifact(srcPath: string, destPath: string, masterKey: Buffer): void {
|
|
172
|
+
const envelope = parseJsonWithValidation(
|
|
173
|
+
readFileSync(srcPath, 'utf-8'),
|
|
174
|
+
EncryptionEnvelopeSchema,
|
|
175
|
+
'backup artifact envelope',
|
|
176
|
+
);
|
|
177
|
+
writeFileSync(destPath, Buffer.from(decryptSecret(envelope, masterKey), 'base64'));
|
|
178
|
+
}
|