@celilo/cli 0.14.4 → 0.16.1
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 +19 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- 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-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -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 +3 -30
- package/src/cli/completion.ts +20 -1
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +14 -0
- package/src/db/schema.ts +5 -3
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +17 -2
- 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 +235 -8
- package/src/services/alerting/inbound-poller.ts +95 -34
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- 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/alerting/tokens.ts +39 -1
- 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/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -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
|
@@ -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
|
+
}
|
|
@@ -3,15 +3,7 @@
|
|
|
3
3
|
* Orchestrates the backup workflow: create temp files, encrypt, upload to storage.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
copyFileSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
rmSync,
|
|
12
|
-
statSync,
|
|
13
|
-
writeFileSync,
|
|
14
|
-
} from 'node:fs';
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
7
|
import { tmpdir } from 'node:os';
|
|
16
8
|
import { join } from 'node:path';
|
|
17
9
|
import { eq } from 'drizzle-orm';
|
|
@@ -21,11 +13,13 @@ import { moduleConfigs, modules, secrets as secretsTable } from '../db/schema';
|
|
|
21
13
|
import { invokeHook } from '../hooks/executor';
|
|
22
14
|
import { createConsoleLogger } from '../hooks/logger';
|
|
23
15
|
import type { ModuleManifest } from '../manifest/schema';
|
|
24
|
-
import { decryptSecret
|
|
16
|
+
import { decryptSecret } from '../secrets/encryption';
|
|
25
17
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
26
18
|
import { shellEscape } from '../utils/shell';
|
|
19
|
+
import { encryptFileToFile } from './backup-cipher';
|
|
27
20
|
import { buildManifest } from './backup-manifest';
|
|
28
21
|
import { completeBackup, createBackupRecord, failBackup, listBackups } from './backup-metadata';
|
|
22
|
+
import type { BackupSchedule } from './backup-schedule';
|
|
29
23
|
import {
|
|
30
24
|
createStorageProvider,
|
|
31
25
|
getBackupStorage,
|
|
@@ -55,7 +49,7 @@ export interface BackupCreateResult {
|
|
|
55
49
|
error?: string;
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
export type BackupSchedule
|
|
52
|
+
export type { BackupSchedule } from './backup-schedule';
|
|
59
53
|
|
|
60
54
|
/**
|
|
61
55
|
* Resolve the target storage destination
|
|
@@ -155,15 +149,13 @@ export async function createSystemStateBackup(
|
|
|
155
149
|
const { execSync } = await import('node:child_process');
|
|
156
150
|
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
|
|
157
151
|
|
|
158
|
-
// Encrypt the tar.
|
|
159
|
-
|
|
152
|
+
// Encrypt the tar, streamed — see backup-cipher.ts. The plaintext is
|
|
153
|
+
// never held in memory, so a large fleet DB can't OOM the snapshot.
|
|
160
154
|
const masterKey = await getOrCreateMasterKey();
|
|
161
|
-
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
162
|
-
|
|
163
|
-
// Write encrypted payload to temp file
|
|
164
155
|
const encryptedPath = join(tempDir, 'system.enc');
|
|
165
|
-
|
|
156
|
+
await encryptFileToFile(tarPath, encryptedPath, masterKey);
|
|
166
157
|
|
|
158
|
+
const tarSize = statSync(tarPath).size;
|
|
167
159
|
const encryptedSize = statSync(encryptedPath).size;
|
|
168
160
|
|
|
169
161
|
// Upload to storage
|
|
@@ -173,7 +165,7 @@ export async function createSystemStateBackup(
|
|
|
173
165
|
completeBackup(record.id, {
|
|
174
166
|
sizeBytes: encryptedSize,
|
|
175
167
|
metadata: {
|
|
176
|
-
originalSizeBytes:
|
|
168
|
+
originalSizeBytes: tarSize,
|
|
177
169
|
dbPath,
|
|
178
170
|
envelopeSchemaVersion: manifest.schemaVersion,
|
|
179
171
|
},
|
|
@@ -411,14 +403,14 @@ export async function createModuleBackup(
|
|
|
411
403
|
const { execSync } = await import('node:child_process');
|
|
412
404
|
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(envelopeDir)} .`);
|
|
413
405
|
|
|
414
|
-
// Encrypt the tar
|
|
415
|
-
|
|
406
|
+
// Encrypt the tar, streamed — see backup-cipher.ts. Module artifacts run
|
|
407
|
+
// to hundreds of MB (forgejo's are ~774 MB); holding one in memory is
|
|
408
|
+
// what OOM-killed that backup.
|
|
416
409
|
const masterKey = await getOrCreateMasterKey();
|
|
417
|
-
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
418
|
-
|
|
419
410
|
const encryptedPath = join(tempDir, 'backup.tar.enc');
|
|
420
|
-
|
|
411
|
+
await encryptFileToFile(tarPath, encryptedPath, masterKey);
|
|
421
412
|
|
|
413
|
+
const tarSize = statSync(tarPath).size;
|
|
422
414
|
const encryptedSize = statSync(encryptedPath).size;
|
|
423
415
|
|
|
424
416
|
// Upload to storage
|
|
@@ -429,7 +421,7 @@ export async function createModuleBackup(
|
|
|
429
421
|
sizeBytes: encryptedSize,
|
|
430
422
|
metadata: {
|
|
431
423
|
artifactCount: hookResult.outputs.artifact_count,
|
|
432
|
-
originalSizeBytes:
|
|
424
|
+
originalSizeBytes: tarSize,
|
|
433
425
|
envelopeSchemaVersion: backupManifest.schemaVersion,
|
|
434
426
|
},
|
|
435
427
|
schemaVersion: dataSchemaVersion,
|
|
@@ -566,14 +558,12 @@ export async function importModuleBackup(
|
|
|
566
558
|
const { execSync } = await import('node:child_process');
|
|
567
559
|
execSync(`tar -cf ${shellEscape(tarPath)} -C ${shellEscape(artifactDir)} .`);
|
|
568
560
|
|
|
569
|
-
// Encrypt the tar
|
|
570
|
-
const tarData = readFileSync(tarPath);
|
|
561
|
+
// Encrypt the tar, streamed — see backup-cipher.ts.
|
|
571
562
|
const masterKey = await getOrCreateMasterKey();
|
|
572
|
-
const encrypted = encryptSecret(tarData.toString('base64'), masterKey);
|
|
573
|
-
|
|
574
563
|
const encryptedPath = join(tempDir, 'backup.tar.enc');
|
|
575
|
-
|
|
564
|
+
await encryptFileToFile(tarPath, encryptedPath, masterKey);
|
|
576
565
|
|
|
566
|
+
const tarSize = statSync(tarPath).size;
|
|
577
567
|
const encryptedSize = statSync(encryptedPath).size;
|
|
578
568
|
|
|
579
569
|
// Upload to storage
|
|
@@ -584,7 +574,7 @@ export async function importModuleBackup(
|
|
|
584
574
|
sizeBytes: encryptedSize,
|
|
585
575
|
metadata: {
|
|
586
576
|
...analyzedMetadata,
|
|
587
|
-
originalSizeBytes:
|
|
577
|
+
originalSizeBytes: tarSize,
|
|
588
578
|
},
|
|
589
579
|
schemaVersion: analyzedSchemaVersion,
|
|
590
580
|
});
|
|
@@ -22,8 +22,8 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { closeDb, getDb } from '../db/client';
|
|
23
23
|
import { runMigrations } from '../db/migrate';
|
|
24
24
|
import { backups, systemConfig } from '../db/schema';
|
|
25
|
-
import { decryptSecret } from '../secrets/encryption';
|
|
26
25
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
26
|
+
import { decryptFileToFile, encryptFileToFile } from './backup-cipher';
|
|
27
27
|
import { createSystemStateBackup } from './backup-create';
|
|
28
28
|
import { MANIFEST_SCHEMA_VERSION, parseManifest } from './backup-manifest';
|
|
29
29
|
import { restoreSystemStateBackup } from './backup-restore';
|
|
@@ -77,15 +77,11 @@ describe('backup envelope round-trip', () => {
|
|
|
77
77
|
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
78
78
|
expect(existsSync(artifactPath)).toBe(true);
|
|
79
79
|
|
|
80
|
-
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
81
80
|
const masterKey = await getOrCreateMasterKey();
|
|
82
|
-
const base64 = decryptSecret(encrypted, masterKey);
|
|
83
|
-
const tarBytes = Buffer.from(base64, 'base64');
|
|
84
|
-
|
|
85
81
|
const extractDir = join(dir, 'extract');
|
|
86
82
|
mkdirSync(extractDir, { recursive: true });
|
|
87
83
|
const tarPath = join(dir, 'envelope.tar');
|
|
88
|
-
|
|
84
|
+
await decryptFileToFile(artifactPath, tarPath, masterKey);
|
|
89
85
|
execSync(`tar -xf '${tarPath}' -C '${extractDir}'`);
|
|
90
86
|
|
|
91
87
|
// manifest.json present at root, valid, matches expectations.
|
|
@@ -129,14 +125,11 @@ describe('backup envelope round-trip', () => {
|
|
|
129
125
|
// Create a valid backup, then poison the manifest by re-packing.
|
|
130
126
|
const result = await createSystemStateBackup();
|
|
131
127
|
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
132
|
-
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
133
128
|
const masterKey = await getOrCreateMasterKey();
|
|
134
|
-
const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
|
|
135
|
-
|
|
136
129
|
const repackDir = join(dir, 'repack');
|
|
137
130
|
mkdirSync(repackDir, { recursive: true });
|
|
138
131
|
const tarPath = join(dir, 'orig.tar');
|
|
139
|
-
|
|
132
|
+
await decryptFileToFile(artifactPath, tarPath, masterKey);
|
|
140
133
|
execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
|
|
141
134
|
|
|
142
135
|
// Rewrite manifest to claim kind='module'.
|
|
@@ -148,12 +141,7 @@ describe('backup envelope round-trip', () => {
|
|
|
148
141
|
// Re-tar + re-encrypt + overwrite the storage entry.
|
|
149
142
|
const repackedTarPath = join(dir, 'repacked.tar');
|
|
150
143
|
execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
|
|
151
|
-
|
|
152
|
-
const repackedEncrypted = encryptSecret(
|
|
153
|
-
readFileSync(repackedTarPath).toString('base64'),
|
|
154
|
-
masterKey,
|
|
155
|
-
);
|
|
156
|
-
writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
|
|
144
|
+
await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
|
|
157
145
|
|
|
158
146
|
const db = getDb();
|
|
159
147
|
const backupRow = db.select().from(backups).all()[0];
|
|
@@ -165,14 +153,11 @@ describe('backup envelope round-trip', () => {
|
|
|
165
153
|
it('system restore refuses an incompatible schemaVersion', async () => {
|
|
166
154
|
const result = await createSystemStateBackup();
|
|
167
155
|
const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string);
|
|
168
|
-
const encrypted = JSON.parse(readFileSync(artifactPath, 'utf-8'));
|
|
169
156
|
const masterKey = await getOrCreateMasterKey();
|
|
170
|
-
const tarBytes = Buffer.from(decryptSecret(encrypted, masterKey), 'base64');
|
|
171
|
-
|
|
172
157
|
const repackDir = join(dir, 'repack-schema');
|
|
173
158
|
mkdirSync(repackDir, { recursive: true });
|
|
174
159
|
const tarPath = join(dir, 'orig-schema.tar');
|
|
175
|
-
|
|
160
|
+
await decryptFileToFile(artifactPath, tarPath, masterKey);
|
|
176
161
|
execSync(`tar -xf '${tarPath}' -C '${repackDir}'`);
|
|
177
162
|
|
|
178
163
|
// Bump schemaVersion to a different MAJOR version.
|
|
@@ -183,12 +168,7 @@ describe('backup envelope round-trip', () => {
|
|
|
183
168
|
|
|
184
169
|
const repackedTarPath = join(dir, 'repacked-schema.tar');
|
|
185
170
|
execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`);
|
|
186
|
-
|
|
187
|
-
const repackedEncrypted = encryptSecret(
|
|
188
|
-
readFileSync(repackedTarPath).toString('base64'),
|
|
189
|
-
masterKey,
|
|
190
|
-
);
|
|
191
|
-
writeFileSync(artifactPath, JSON.stringify(repackedEncrypted));
|
|
171
|
+
await encryptFileToFile(repackedTarPath, artifactPath, masterKey);
|
|
192
172
|
|
|
193
173
|
const db = getDb();
|
|
194
174
|
const backupRow = db.select().from(backups).all()[0];
|