@celilo/cli 0.23.0 → 0.24.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.
Files changed (68) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +70 -3
  14. package/src/cli/commands/module-config.ts +262 -28
  15. package/src/cli/commands/module-status.ts +155 -12
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/load-hook-config.test.ts +169 -1
  24. package/src/hooks/load-hook-config.ts +118 -20
  25. package/src/hooks/types.ts +2 -1
  26. package/src/manifest/contracts/v1.ts +16 -0
  27. package/src/manifest/schema.ts +40 -65
  28. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  29. package/src/services/alerting/cadence-migration.test.ts +155 -0
  30. package/src/services/alerting/cadence-migration.ts +90 -0
  31. package/src/services/alerting/coverage-source.ts +8 -11
  32. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  33. package/src/services/alerting/deploy-hooks.ts +11 -5
  34. package/src/services/alerting/health-cadence.test.ts +58 -0
  35. package/src/services/alerting/health-cadence.ts +128 -0
  36. package/src/services/alerting/health-coverage.ts +18 -8
  37. package/src/services/alerting/monitors.ts +50 -15
  38. package/src/services/alerting/sweep-runner.test.ts +51 -3
  39. package/src/services/alerting/sweep-runner.ts +30 -7
  40. package/src/services/audit/backup-source.ts +24 -1
  41. package/src/services/audit/backups.test.ts +95 -10
  42. package/src/services/audit/backups.ts +40 -37
  43. package/src/services/audit/interface-classification.test.ts +220 -0
  44. package/src/services/audit/interface-classification.ts +167 -0
  45. package/src/services/audit/types.ts +2 -1
  46. package/src/services/backup-age-agreement.test.ts +118 -0
  47. package/src/services/backup-create.ts +36 -30
  48. package/src/services/backup-metadata.ts +52 -1
  49. package/src/services/backup-retention.test.ts +123 -0
  50. package/src/services/backup-retention.ts +66 -5
  51. package/src/services/backup-schedule.test.ts +166 -0
  52. package/src/services/backup-schedule.ts +105 -15
  53. package/src/services/backup-staging.ts +14 -1
  54. package/src/services/backup-sweep.test.ts +22 -3
  55. package/src/services/backup-sweep.ts +15 -5
  56. package/src/services/cadence.test.ts +97 -0
  57. package/src/services/cadence.ts +165 -0
  58. package/src/services/config-provenance.test.ts +155 -0
  59. package/src/services/config-provenance.ts +104 -0
  60. package/src/services/machine-detector.ts +23 -1
  61. package/src/services/module-config.ts +33 -0
  62. package/src/services/storage-providers/s3.test.ts +96 -13
  63. package/src/services/storage-providers/s3.ts +48 -15
  64. package/src/services/zone-detector.test.ts +34 -3
  65. package/src/services/zone-detector.ts +33 -13
  66. package/src/variables/context.ts +69 -15
  67. package/src/variables/declarative-derivation.test.ts +53 -0
  68. package/src/variables/declarative-derivation.ts +13 -2
@@ -1,24 +1,25 @@
1
1
  /**
2
2
  * Backup freshness drift check.
3
3
  *
4
- * For each installed module that declares an `on_backup` hook,
5
- * decides whether the most recent successful backup is too old based
6
- * on the module's declared `manifest.backup.schedule`. Each schedule
7
- * tier has a threshold with a small grace window so a slightly-late
8
- * scheduled run doesn't flag drift on every audit.
4
+ * For each installed module that declares an `on_backup` hook, decides whether
5
+ * the most recent successful backup is too old, against the module's EFFECTIVE
6
+ * cadence the operator's override if they set one, else the manifest's
7
+ * suggestion. The threshold is that cadence plus a grace allowance, so a
8
+ * slightly-late scheduled run doesn't flag drift on every audit.
9
9
  *
10
- * Modules without an `on_backup` hook are skipped — there's nothing
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.
10
+ * Modules without an `on_backup` hook are skipped — there's nothing to back up.
11
+ * A module whose effective cadence is `manual` is skipped entirely: it has
12
+ * opted out, and a finding asking it to back up is one no operator action can
13
+ * clear, because the action it asks for is the one they declined. An unset
14
+ * cadence is `daily`, not `manual` — see [[services/backup-schedule.ts]] for
15
+ * why that default matters.
16
16
  *
17
17
  * Time is injected so tests can pin "now" deterministically.
18
18
  */
19
19
 
20
20
  import type { ModuleManifest } from '../../manifest/schema';
21
21
  import { effectiveBackupSchedule } from '../backup-schedule';
22
+ import { type Cadence, cadenceMs, formatCadence } from '../cadence';
22
23
  import type { DriftFinding } from './types';
23
24
 
24
25
  export interface InstalledModuleBackupInfo {
@@ -26,6 +27,8 @@ export interface InstalledModuleBackupInfo {
26
27
  /** Lifecycle state — non-deployed modules have nothing to back up. */
27
28
  state: string;
28
29
  manifest: ModuleManifest;
30
+ /** The operator's `backup_schedule` override, or undefined when unset. */
31
+ scheduleOverride: string | undefined;
29
32
  /** Most recent successful backup timestamp (ms since epoch), or null. */
30
33
  lastSuccessfulBackupAt: number | null;
31
34
  }
@@ -33,9 +36,9 @@ export interface InstalledModuleBackupInfo {
33
36
  export interface BackupsAuditDeps {
34
37
  modules: InstalledModuleBackupInfo[];
35
38
  /**
36
- * Test-only override: forces this threshold for every module,
37
- * ignoring `manifest.backup.schedule`. Production code never sets
38
- * this — schedule-based thresholds are the right behavior.
39
+ * Test-only override: forces this threshold for every module whose
40
+ * effective cadence is not `manual`. Production code never sets this —
41
+ * cadence-derived thresholds are the right behavior.
39
42
  */
40
43
  staleAfterMs?: number;
41
44
  /** Defaults to `Date.now()`. */
@@ -46,32 +49,26 @@ const HOUR = 60 * 60 * 1000;
46
49
  const DAY = 24 * HOUR;
47
50
 
48
51
  /**
49
- * Schedule stale threshold. Each tier gets a grace window so a
50
- * legitimately-just-late run doesn't trip the audit:
52
+ * How old a backup may get before it is drift: the cadence itself plus a grace
53
+ * allowance, so a legitimately-just-late run doesn't trip the audit.
51
54
  *
52
- * - hourly → 2h (1h cadence + 1h grace)
53
- * - daily → 25h (24h + 1h grace)
54
- * - weekly → 8d (7d + 1d grace)
55
- * - monthly 32d (~30d + 2d grace)
56
- * - manual null (no staleness check; operator-driven cadence)
55
+ * A formula rather than a table because a table cannot answer for `6h`, and an
56
+ * operator who sets a custom cadence would get either no staleness reporting or
57
+ * an arbitrary threshold. The four previous values were not a formula in
58
+ * disguise — hourly had 100% grace, daily 4%, weekly 14%, monthly 7% so this
59
+ * moves them: daily 25h 26.4h, weekly 8d → 7.7d (the only one that alerts
60
+ * EARLIER), monthly 32d → 33d, hourly unchanged at 2h. See design.md D7.
57
61
  */
58
- const SCHEDULE_THRESHOLDS = {
59
- hourly: 2 * HOUR,
60
- daily: 25 * HOUR,
61
- weekly: 8 * DAY,
62
- monthly: 32 * DAY,
63
- manual: null,
64
- } as const;
62
+ export function backupStaleThresholdMs(cadence: Cadence): number | null {
63
+ if (cadence === 'manual') return null;
64
+ const interval = cadenceMs(cadence);
65
+ return interval + Math.max(HOUR, interval * 0.1);
66
+ }
65
67
 
66
68
  function moduleHasBackupHook(manifest: ModuleManifest): boolean {
67
69
  return Boolean(manifest.hooks?.on_backup);
68
70
  }
69
71
 
70
- function thresholdFor(manifest: ModuleManifest, override: number | undefined): number | null {
71
- if (override !== undefined) return override;
72
- return SCHEDULE_THRESHOLDS[effectiveBackupSchedule(manifest)];
73
- }
74
-
75
72
  function formatAge(ms: number): string {
76
73
  const days = Math.floor(ms / DAY);
77
74
  if (days >= 1) return `${days}d`;
@@ -96,12 +93,18 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
96
93
  // audit.
97
94
  if (!DEPLOYED_STATES.has(m.state)) continue;
98
95
 
96
+ const cadence = effectiveBackupSchedule(m.manifest, m.scheduleOverride);
97
+ // BEFORE the never-backed-up check, not after. A module opted out of
98
+ // scheduled backups was reported as missing one forever, and the only
99
+ // remediation offered was the very thing the operator declined.
100
+ if (cadence === 'manual') continue;
101
+
99
102
  if (m.lastSuccessfulBackupAt === null) {
100
103
  findings.push({
101
104
  category: 'backups',
102
105
  severity: 'drift',
103
106
  code: 'backup_missing',
104
- message: `${m.id}: no successful backup recorded (schedule: ${effectiveBackupSchedule(m.manifest)})`,
107
+ message: `${m.id}: no successful backup recorded (schedule: ${formatCadence(cadence)})`,
105
108
  remediation: `celilo backup create ${m.id} --force`,
106
109
  actionable: true,
107
110
  subject: m.id,
@@ -109,8 +112,8 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
109
112
  continue;
110
113
  }
111
114
 
112
- const threshold = thresholdFor(m.manifest, deps.staleAfterMs);
113
- if (threshold === null) continue; // manual cadence — staleness is user-defined
115
+ const threshold = deps.staleAfterMs ?? backupStaleThresholdMs(cadence);
116
+ if (threshold === null) continue;
114
117
 
115
118
  const age = now - m.lastSuccessfulBackupAt;
116
119
  if (age > threshold) {
@@ -118,7 +121,7 @@ export async function auditBackups(deps: BackupsAuditDeps): Promise<DriftFinding
118
121
  category: 'backups',
119
122
  severity: 'drift',
120
123
  code: 'backup_stale',
121
- message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${effectiveBackupSchedule(m.manifest)}, threshold: ${formatAge(threshold)})`,
124
+ message: `${m.id}: last successful backup is ${formatAge(age)} old (schedule: ${formatCadence(cadence)}, threshold: ${formatAge(threshold)})`,
122
125
  remediation: `celilo backup create ${m.id} --force`,
123
126
  actionable: true,
124
127
  subject: m.id,
@@ -0,0 +1,220 @@
1
+ /**
2
+ * §10 — what the audit says about a firewall's interfaces.
3
+ *
4
+ * The condition that produced `fw-keeper.sh` was not that celilo lacked
5
+ * information; it was that celilo said nothing. So the load-bearing assertions
6
+ * here are about what gets NAMED — an alien interface by name AND address, a
7
+ * carrier leg, an ambiguous edge — and about §10.2's converse: a fully declared
8
+ * firewall must produce no findings at all, or the report trains the operator to
9
+ * ignore it.
10
+ */
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import {
14
+ type FirewallInterfaceView,
15
+ auditFirewallInterfaces,
16
+ auditInterfaceClassification,
17
+ describeClassification,
18
+ } from './interface-classification';
19
+
20
+ const DECLARED = [
21
+ { zone: 'internal', subnet: '192.168.0.0/24' },
22
+ { zone: 'dmz', subnet: '10.0.10.0/24' },
23
+ { zone: 'app', subnet: '10.0.20.0/24' },
24
+ { zone: 'secure', subnet: '10.0.30.0/24' },
25
+ ];
26
+
27
+ /** celilo's own firewall: five RFC1918 legs, four declared, wg0 not. */
28
+ function liveFleetShape(overrides: Partial<FirewallInterfaceView> = {}): FirewallInterfaceView {
29
+ return {
30
+ hostname: 'fw-main',
31
+ interfaces: [
32
+ { name: 'eth0', ip: '192.168.0.254' },
33
+ { name: 'eth1', ip: '10.0.10.1' },
34
+ { name: 'eth2', ip: '10.0.20.1' },
35
+ { name: 'eth3', ip: '10.0.30.1' },
36
+ { name: 'wg0', ip: '10.255.255.1' },
37
+ ],
38
+ zones: DECLARED,
39
+ defaultRouteInterface: 'eth0',
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function codes(findings: { code: string }[]): string[] {
45
+ return findings.map((f) => f.code);
46
+ }
47
+
48
+ describe('§10.2 — a fully declared firewall produces NO interface findings', () => {
49
+ test('every leg declared, default route on internal, no findings', () => {
50
+ // The positive control, and not a formality: a check that cries wolf on a
51
+ // healthy fleet is worse than no check, because it teaches an operator to
52
+ // ignore the one signal that matters.
53
+ const view = liveFleetShape({
54
+ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }],
55
+ });
56
+ expect(auditFirewallInterfaces(view)).toEqual([]);
57
+ });
58
+
59
+ test('a firewall that owns its WAN, fully declared, produces no findings', () => {
60
+ const view: FirewallInterfaceView = {
61
+ hostname: 'vps-fw',
62
+ interfaces: [
63
+ { name: 'eth0', ip: '10.0.10.1' },
64
+ { name: 'eth1', ip: '203.0.113.100' },
65
+ ],
66
+ zones: [{ zone: 'dmz', subnet: '10.0.10.0/24' }],
67
+ defaultRouteInterface: 'eth1',
68
+ };
69
+ expect(auditFirewallInterfaces(view)).toEqual([]);
70
+ });
71
+
72
+ test('loopback does not produce a finding', () => {
73
+ const view = liveFleetShape({
74
+ interfaces: [{ name: 'lo', ip: '127.0.0.1' }, ...liveFleetShape().interfaces.slice(0, 4)],
75
+ defaultRouteInterface: 'eth0',
76
+ });
77
+ expect(auditFirewallInterfaces(view)).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe('§10.1 — alien interfaces are named, by name AND address', () => {
82
+ const findings = auditFirewallInterfaces(liveFleetShape());
83
+
84
+ test('the undeclared VPN leg is reported', () => {
85
+ expect(codes(findings)).toContain('alien_interfaces');
86
+ });
87
+
88
+ test('the message carries both the interface name and its address', () => {
89
+ // "one alien interface" is useless at 3am. The operator needs to know
90
+ // which, and where.
91
+ const alien = findings.find((f) => f.code === 'alien_interfaces');
92
+ expect(alien?.message).toContain('wg0');
93
+ expect(alien?.message).toContain('10.255.255.1');
94
+ });
95
+
96
+ test('it is drift, not blocked — the converge decides, the audit reports', () => {
97
+ expect(findings.find((f) => f.code === 'alien_interfaces')?.severity).toBe('drift');
98
+ });
99
+
100
+ test('it is subjected on the hostname, never a UUID', () => {
101
+ // Suppression resolves a machine's ancestor key from its hostname; a
102
+ // finding subjected on a UUID produces an alert key nothing can match
103
+ // (#596). Users never see UUIDs either.
104
+ expect(findings.find((f) => f.code === 'alien_interfaces')?.subject).toBe('fw-main');
105
+ });
106
+
107
+ test('several alien legs are all named, not just the first', () => {
108
+ const view = liveFleetShape({ zones: [{ zone: 'internal', subnet: '192.168.0.0/24' }] });
109
+ const alien = auditFirewallInterfaces(view).find((f) => f.code === 'alien_interfaces');
110
+ for (const ip of ['10.0.10.1', '10.0.20.1', '10.0.30.1', '10.255.255.1']) {
111
+ expect(alien?.message).toContain(ip);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe('§10.1 — the blocking findings match what the converge does', () => {
117
+ test('a carrier-grade NAT leg blocks, naming the interface and address', () => {
118
+ const view = liveFleetShape({
119
+ interfaces: [...liveFleetShape().interfaces, { name: 'eth9', ip: '100.83.4.17' }],
120
+ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }],
121
+ });
122
+ const finding = auditFirewallInterfaces(view).find(
123
+ (f) => f.code === 'carrier_grade_nat_interface',
124
+ );
125
+ expect(finding?.severity).toBe('blocked');
126
+ expect(finding?.message).toContain('eth9');
127
+ expect(finding?.message).toContain('100.83.4.17');
128
+ });
129
+
130
+ test('two public legs with no designation block, naming both candidates', () => {
131
+ const view: FirewallInterfaceView = {
132
+ hostname: 'fw-two-wans',
133
+ interfaces: [
134
+ { name: 'eth0', ip: '203.0.113.100' },
135
+ { name: 'eth1', ip: '198.51.100.7' },
136
+ ],
137
+ zones: [],
138
+ defaultRouteInterface: 'eth0',
139
+ };
140
+ const finding = auditFirewallInterfaces(view).find((f) => f.code === 'ambiguous_external_edge');
141
+ expect(finding?.severity).toBe('blocked');
142
+ expect(finding?.message).toContain('203.0.113.100');
143
+ expect(finding?.message).toContain('198.51.100.7');
144
+ expect(finding?.remediation).toContain('zone.external.ip');
145
+ });
146
+
147
+ test('a designated edge resolves the ambiguity, producing no finding', () => {
148
+ const view: FirewallInterfaceView = {
149
+ hostname: 'fw-two-wans',
150
+ interfaces: [
151
+ { name: 'eth0', ip: '203.0.113.100' },
152
+ { name: 'eth1', ip: '198.51.100.7' },
153
+ ],
154
+ zones: [],
155
+ defaultRouteInterface: 'eth0',
156
+ designatedExternalIp: '198.51.100.7',
157
+ };
158
+ expect(codes(auditFirewallInterfaces(view))).not.toContain('ambiguous_external_edge');
159
+ });
160
+
161
+ test('a default route on a segmented zone blocks', () => {
162
+ const view = liveFleetShape({
163
+ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }],
164
+ defaultRouteInterface: 'eth1', // the dmz leg
165
+ });
166
+ const finding = auditFirewallInterfaces(view).find((f) => f.code === 'default_route_off_edge');
167
+ expect(finding?.severity).toBe('blocked');
168
+ expect(finding?.message).toContain('eth1');
169
+ });
170
+
171
+ test('an unknown default route is not checked rather than guessed', () => {
172
+ // `undefined` means celilo did not read the routing table. Reporting a
173
+ // finding from an absent measurement would be inventing evidence.
174
+ const view = liveFleetShape({
175
+ zones: [...DECLARED, { zone: 'control-plane-vpn', subnet: '10.255.255.0/24' }],
176
+ defaultRouteInterface: undefined,
177
+ });
178
+ expect(codes(auditFirewallInterfaces(view))).not.toContain('default_route_off_edge');
179
+ });
180
+
181
+ test('blocking findings come before drift, so the report reads top-down', () => {
182
+ const view = liveFleetShape({
183
+ interfaces: [...liveFleetShape().interfaces, { name: 'eth9', ip: '100.83.4.17' }],
184
+ });
185
+ const severities = auditFirewallInterfaces(view).map((f) => f.severity);
186
+ expect(severities[0]).toBe('blocked');
187
+ expect(severities.at(-1)).toBe('drift');
188
+ });
189
+ });
190
+
191
+ describe('describeClassification — the picture, not a finding', () => {
192
+ test('names the role of every interface, and why an alien one is alien', () => {
193
+ const lines = describeClassification(liveFleetShape());
194
+ expect(lines).toContain('eth0: 192.168.0.254 → zone:internal');
195
+ expect(lines).toContain('wg0: 10.255.255.1 → alien (no declared subnet contains it)');
196
+ });
197
+
198
+ test('a publicly routable but unclaimed leg is described as external', () => {
199
+ const lines = describeClassification({
200
+ hostname: 'vps-fw',
201
+ interfaces: [{ name: 'eth1', ip: '203.0.113.100' }],
202
+ zones: [],
203
+ });
204
+ expect(lines[0]).toBe('eth1: 203.0.113.100 → external (the WAN edge)');
205
+ });
206
+ });
207
+
208
+ describe('auditInterfaceClassification — every firewall, flattened', () => {
209
+ test('reports across several firewalls, each subjected on its own hostname', () => {
210
+ const findings = auditInterfaceClassification([
211
+ liveFleetShape(),
212
+ liveFleetShape({ hostname: 'fw-branch' }),
213
+ ]);
214
+ expect(findings.map((f) => f.subject).sort()).toEqual(['fw-branch', 'fw-main']);
215
+ });
216
+
217
+ test('no firewalls means no findings', () => {
218
+ expect(auditInterfaceClassification([])).toEqual([]);
219
+ });
220
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Firewall interface classification, reported where an operator already looks.
3
+ *
4
+ * The condition that produced `fw-keeper.sh` was not that celilo lacked
5
+ * information — it was that celilo said nothing. An interface it cannot
6
+ * attribute is an unowned network segment on a firewall, and one that is never
7
+ * surfaced is discovered as an outage rather than as a finding.
8
+ *
9
+ * What this reports, per firewall:
10
+ *
11
+ * - every interface celilo cannot attribute, by name AND address;
12
+ * - a carrier-grade NAT interface, which celilo refuses to converge on;
13
+ * - an ambiguous external edge — more than one public leg, none designated;
14
+ * - a default route leaving through neither `internal` nor `external`.
15
+ *
16
+ * The last three are BLOCKING, matching what the converge does with them. An
17
+ * audit that called them drift while the converge refused would be telling the
18
+ * operator something different from what the system does.
19
+ *
20
+ * Pure, like every other checker here: it consumes a pre-computed view so it is
21
+ * unit-testable without SSH or a live firewall.
22
+ *
23
+ * openspec: firewall-interface-classification §10 (design D1, D2, D11)
24
+ */
25
+
26
+ import {
27
+ type InterfaceAddress,
28
+ type ZoneDeclaration,
29
+ classifyInterfaces,
30
+ defaultRouteFinding,
31
+ externalEdge,
32
+ isPubliclyRoutable,
33
+ subnetContains,
34
+ } from '@celilo/capabilities';
35
+ import type { DriftFinding } from './types';
36
+
37
+ /** RFC 6598. celilo refuses to converge on one — see design D2 (amended). */
38
+ const CARRIER_GRADE_NAT = '100.64.0.0/10';
39
+
40
+ export interface FirewallInterfaceView {
41
+ /**
42
+ * User-facing hostname, and the key the finding is subjected on.
43
+ *
44
+ * NOT the machine UUID: suppression resolves a machine's ancestor key from
45
+ * its hostname, so a finding subjected on a UUID produces an alert key
46
+ * suppression can never match (#596). Users never see UUIDs either.
47
+ */
48
+ hostname: string;
49
+ /** Every address the firewall holds, as read from the box. */
50
+ interfaces: InterfaceAddress[];
51
+ /** The zones this firewall declares, with the subnets that define them. */
52
+ zones: ZoneDeclaration[];
53
+ /** The interface carrying the default route, when celilo knows it. */
54
+ defaultRouteInterface?: string | null;
55
+ /** The operator's designated edge, from the firewall's `zone.external.ip`. */
56
+ designatedExternalIp?: string | null;
57
+ }
58
+
59
+ /**
60
+ * Findings for one firewall's interfaces.
61
+ *
62
+ * Ordered most-severe first so the report reads top-down: the reasons a
63
+ * converge would refuse, then the interfaces that are merely unaccounted for.
64
+ */
65
+ export function auditFirewallInterfaces(view: FirewallInterfaceView): DriftFinding[] {
66
+ const findings: DriftFinding[] = [];
67
+ const classifications = classifyInterfaces(view.interfaces, view.zones);
68
+
69
+ // ── Blocking: a carrier address celilo cannot reason about ────────────────
70
+ const carrier = view.interfaces.filter((i) => subnetContains(CARRIER_GRADE_NAT, i.ip));
71
+ for (const iface of carrier) {
72
+ findings.push({
73
+ category: 'interface_classification',
74
+ severity: 'blocked',
75
+ code: 'carrier_grade_nat_interface',
76
+ subject: view.hostname,
77
+ message: `${view.hostname}: ${iface.name} holds a carrier-grade NAT address (${iface.ip})`,
78
+ details:
79
+ "RFC 6598 space belongs to the carrier and is shared with other subscribers, so it cannot tell celilo the fleet's real external address. celilo does not know what such an interface is for, whether to NAT on it, or what reach it should have, so it refuses to converge rather than guessing. Only ISP equipment should hold one.",
80
+ remediation:
81
+ 'Remove the carrier-grade NAT interface from this firewall, or place the ISP boundary on separate equipment (celilo drives an upstream router through the firewall capability).',
82
+ actionable: false,
83
+ });
84
+ }
85
+
86
+ // ── Blocking: two public legs and no designation ──────────────────────────
87
+ const edge = externalEdge(classifications, view.designatedExternalIp);
88
+ if (edge.kind === 'ambiguous') {
89
+ const named = edge.candidates.map((c) => `${c.name} (${c.ip})`).join(', ');
90
+ findings.push({
91
+ category: 'interface_classification',
92
+ severity: 'blocked',
93
+ code: 'ambiguous_external_edge',
94
+ subject: view.hostname,
95
+ message: `${view.hostname}: ${edge.candidates.length} publicly routable interfaces and none designated — ${named}`,
96
+ details:
97
+ 'celilo will not pick an external edge by interface order. Before this check the first non-private address won silently, so a box with two public addresses had its edge chosen by enumeration with no warning and no way to override.',
98
+ remediation: `celilo module config set <firewall-module> zone.external.ip <one of: ${edge.candidates
99
+ .map((c) => c.ip)
100
+ .join(', ')}>`,
101
+ actionable: false,
102
+ });
103
+ }
104
+
105
+ // ── Blocking: egress leaving through a segment celilo cannot describe ─────
106
+ if (view.defaultRouteInterface !== undefined) {
107
+ const routeFinding = defaultRouteFinding(classifications, view.defaultRouteInterface);
108
+ if (routeFinding) {
109
+ findings.push({
110
+ category: 'interface_classification',
111
+ severity: 'blocked',
112
+ code: 'default_route_off_edge',
113
+ subject: view.hostname,
114
+ message: `${view.hostname}: ${routeFinding.message}`,
115
+ details:
116
+ 'celilo supports two shapes: a firewall that owns its WAN egresses through its external leg, and a downstream firewall egresses via an upstream router on the internal segment. Egress anywhere else leaves through a segment celilo cannot describe.',
117
+ remediation:
118
+ 'Move the default route onto the external or internal leg, or declare a zone for the interface that carries it.',
119
+ actionable: false,
120
+ });
121
+ }
122
+ }
123
+
124
+ // ── Drift: interfaces nobody accounted for ────────────────────────────────
125
+ const alien = classifications.filter((c) => c.role === 'alien');
126
+ if (alien.length > 0) {
127
+ const named = alien.map((c) => `${c.name} (${c.ip})`).join(', ');
128
+ findings.push({
129
+ category: 'interface_classification',
130
+ severity: 'drift',
131
+ code: 'alien_interfaces',
132
+ subject: view.hostname,
133
+ message: `${view.hostname}: ${alien.length} interface(s) celilo cannot attribute — ${named}`,
134
+ details:
135
+ 'An interface in no declared zone is an unowned network segment on a firewall. celilo refuses to converge a firewall it is onboarding until every interface is accounted for; on one it has already brought to a known-good state, a NEWLY appeared interface like this is disabled.',
136
+ remediation: `celilo system config set network.<zone>.subnet <cidr> # then add the zone to the firewall's declared zones`,
137
+ actionable: false,
138
+ });
139
+ }
140
+
141
+ return findings;
142
+ }
143
+
144
+ /** Every firewall's findings, flattened. */
145
+ export function auditInterfaceClassification(views: FirewallInterfaceView[]): DriftFinding[] {
146
+ return views.flatMap(auditFirewallInterfaces);
147
+ }
148
+
149
+ /**
150
+ * The classification itself, for the report body rather than as a finding.
151
+ *
152
+ * §10.2 requires a fully declared firewall to produce NO findings, so the
153
+ * per-interface roles cannot be findings — they are what an operator reads to
154
+ * confirm the picture is what they intended.
155
+ */
156
+ export function describeClassification(view: FirewallInterfaceView): string[] {
157
+ return classifyInterfaces(view.interfaces, view.zones).map((c) => {
158
+ if (c.role === 'zone') return `${c.name}: ${c.ip} → zone:${c.zone}`;
159
+ if (c.role === 'external') {
160
+ return `${c.name}: ${c.ip} → external (the WAN edge)`;
161
+ }
162
+ const why = isPubliclyRoutable(c.ip)
163
+ ? 'publicly routable but unclaimed'
164
+ : 'no declared subnet contains it';
165
+ return `${c.name}: ${c.ip} → alien (${why})`;
166
+ });
167
+ }
@@ -40,7 +40,8 @@ export type DriftCategory =
40
40
  | 'public_dns'
41
41
  | 'disk_space'
42
42
  | 'transport_reads'
43
- | 'trusted_sources';
43
+ | 'trusted_sources'
44
+ | 'interface_classification';
44
45
 
45
46
  export type DriftSeverity = 'todo' | 'drift' | 'blocked';
46
47
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The run path and the alert path must agree about how old a backup is.
3
+ *
4
+ * They did not. `isBackupDue` measured from `backups.startedAt` while the
5
+ * freshness audit measured from `completedAt`, so the two disagreed by however
6
+ * long the backup itself took — 5.5 minutes for forgejo, and unbounded for
7
+ * anything larger. The shared accessor prevented drift where it was looking and
8
+ * the drift moved into the columns underneath it (design.md D6).
9
+ *
10
+ * This is the gate for that: one completed backup whose start and finish are
11
+ * far apart, read by both paths, which must return the same instant.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { closeDb, getDb } from '../db/client';
19
+ import { runMigrations } from '../db/migrate';
20
+ import { backupStorages, backups, modules } from '../db/schema';
21
+ import { loadBackupAuditInfo } from './audit/backup-source';
22
+ import { findBackupEligibleModules } from './backup-create';
23
+ import { loadBackupHistory } from './backup-metadata';
24
+ import { BACKUP_SCHEDULE_CONFIG_KEY, effectiveBackupSchedule } from './backup-schedule';
25
+ import { parseCadence } from './cadence';
26
+ import { configOverride, upsertModuleConfig } from './module-config';
27
+
28
+ const STARTED = new Date('2026-04-25T00:00:00Z');
29
+ // Six hours later — an extreme but not absurd gap for a multi-gigabyte module.
30
+ const COMPLETED = new Date('2026-04-25T06:00:00Z');
31
+
32
+ describe('backup age: the run path and the audit path read the same column', () => {
33
+ let dir: string;
34
+
35
+ beforeEach(async () => {
36
+ dir = mkdtempSync(join(tmpdir(), 'celilo-backup-age-'));
37
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
38
+ await runMigrations(process.env.CELILO_DB_PATH);
39
+
40
+ const db = getDb();
41
+ db.insert(modules)
42
+ .values({
43
+ id: 'forgejo',
44
+ name: 'Forgejo',
45
+ sourcePath: dir,
46
+ version: '1.0.0',
47
+ state: 'INSTALLED',
48
+ manifestData: {
49
+ id: 'forgejo',
50
+ hooks: { on_backup: { script: './backup.ts' } },
51
+ backup: { schedule: 'daily' },
52
+ },
53
+ })
54
+ .run();
55
+ db.insert(backupStorages)
56
+ .values({
57
+ id: 'storage-1',
58
+ storageId: 'local',
59
+ name: 'Local',
60
+ providerName: 'local',
61
+ credentialsEncrypted: '',
62
+ providerConfig: {},
63
+ verified: true,
64
+ })
65
+ .run();
66
+ db.insert(backups)
67
+ .values({
68
+ id: 'backup-1',
69
+ moduleId: 'forgejo',
70
+ storageId: 'storage-1',
71
+ storagePath: '2026-04-25/forgejo.backup',
72
+ backupType: 'module_data',
73
+ status: 'completed',
74
+ startedAt: STARTED,
75
+ completedAt: COMPLETED,
76
+ })
77
+ .run();
78
+ });
79
+
80
+ afterEach(() => {
81
+ closeDb();
82
+ process.env.CELILO_DB_PATH = undefined;
83
+ rmSync(dir, { recursive: true, force: true });
84
+ });
85
+
86
+ test('both measure from when the backup COMPLETED, not when it started', () => {
87
+ const history = loadBackupHistory('forgejo');
88
+ const [audited] = loadBackupAuditInfo(getDb());
89
+
90
+ expect(history.lastSuccessAt?.getTime()).toBe(COMPLETED.getTime());
91
+ expect(audited.lastSuccessfulBackupAt).toBe(COMPLETED.getTime());
92
+ expect(history.lastSuccessAt?.getTime()).toBe(audited.lastSuccessfulBackupAt ?? 0);
93
+ });
94
+
95
+ test('both resolve the same cadence for the same module', () => {
96
+ const [audited] = loadBackupAuditInfo(getDb());
97
+ const fromAudit = effectiveBackupSchedule(audited.manifest, audited.scheduleOverride);
98
+
99
+ expect(fromAudit).toEqual({ minutes: 24 * 60 });
100
+ expect(parseCadence('daily')).toEqual(fromAudit);
101
+ });
102
+
103
+ test("an operator's override reaches the sweep and the audit alike", () => {
104
+ upsertModuleConfig(getDb(), 'forgejo', BACKUP_SCHEDULE_CONFIG_KEY, '6h');
105
+
106
+ const [audited] = loadBackupAuditInfo(getDb());
107
+ const [eligible] = findBackupEligibleModules();
108
+
109
+ const fromAudit = effectiveBackupSchedule(audited.manifest, audited.scheduleOverride);
110
+ const fromSweep = effectiveBackupSchedule(
111
+ eligible.manifest,
112
+ configOverride(eligible.configs, BACKUP_SCHEDULE_CONFIG_KEY),
113
+ );
114
+
115
+ expect(fromAudit).toEqual({ minutes: 360 });
116
+ expect(fromSweep).toEqual(fromAudit);
117
+ });
118
+ });