@celilo/cli 0.23.0 → 0.24.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.
Files changed (61) 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 +64 -2
  14. package/src/cli/commands/module-config.ts +159 -8
  15. package/src/cli/commands/module-status.ts +124 -0
  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/types.ts +2 -1
  24. package/src/manifest/contracts/v1.ts +16 -0
  25. package/src/manifest/schema.ts +40 -65
  26. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  27. package/src/services/alerting/cadence-migration.test.ts +155 -0
  28. package/src/services/alerting/cadence-migration.ts +90 -0
  29. package/src/services/alerting/coverage-source.ts +8 -11
  30. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  31. package/src/services/alerting/deploy-hooks.ts +11 -5
  32. package/src/services/alerting/health-cadence.test.ts +58 -0
  33. package/src/services/alerting/health-cadence.ts +128 -0
  34. package/src/services/alerting/health-coverage.ts +18 -8
  35. package/src/services/alerting/monitors.ts +50 -15
  36. package/src/services/alerting/sweep-runner.test.ts +51 -3
  37. package/src/services/alerting/sweep-runner.ts +30 -7
  38. package/src/services/audit/backup-source.ts +24 -1
  39. package/src/services/audit/backups.test.ts +95 -10
  40. package/src/services/audit/backups.ts +40 -37
  41. package/src/services/audit/interface-classification.test.ts +220 -0
  42. package/src/services/audit/interface-classification.ts +167 -0
  43. package/src/services/audit/types.ts +2 -1
  44. package/src/services/backup-age-agreement.test.ts +118 -0
  45. package/src/services/backup-create.ts +36 -30
  46. package/src/services/backup-metadata.ts +52 -1
  47. package/src/services/backup-retention.test.ts +123 -0
  48. package/src/services/backup-retention.ts +66 -5
  49. package/src/services/backup-schedule.test.ts +166 -0
  50. package/src/services/backup-schedule.ts +105 -15
  51. package/src/services/backup-staging.ts +14 -1
  52. package/src/services/backup-sweep.test.ts +22 -3
  53. package/src/services/backup-sweep.ts +15 -5
  54. package/src/services/cadence.test.ts +97 -0
  55. package/src/services/cadence.ts +165 -0
  56. package/src/services/machine-detector.ts +23 -1
  57. package/src/services/module-config.ts +33 -0
  58. package/src/services/storage-providers/s3.test.ts +96 -13
  59. package/src/services/storage-providers/s3.ts +48 -15
  60. package/src/services/zone-detector.test.ts +34 -3
  61. package/src/services/zone-detector.ts +33 -13
@@ -1,19 +1,19 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import type { ModuleManifest } from '../../manifest/schema';
3
- import { type InstalledModuleBackupInfo, auditBackups } from './backups';
3
+ import { parseCadence } from '../cadence';
4
+ import { type InstalledModuleBackupInfo, auditBackups, backupStaleThresholdMs } from './backups';
4
5
 
5
6
  const NOW = new Date('2026-04-25T00:00:00Z').getTime();
6
7
  const HOUR = 60 * 60 * 1000;
7
8
  const ONE_DAY = 24 * HOUR;
8
9
 
9
- type Schedule = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'manual';
10
-
11
10
  function makeModule(
12
11
  id: string,
13
12
  opts: {
14
13
  hasBackupHook: boolean;
15
14
  lastSuccessfulBackupAt: number | null;
16
- schedule?: Schedule;
15
+ schedule?: string;
16
+ scheduleOverride?: string;
17
17
  },
18
18
  ): InstalledModuleBackupInfo {
19
19
  const manifest = {
@@ -27,9 +27,42 @@ function makeModule(
27
27
  // Default to INSTALLED — existing tests assert backup findings
28
28
  // fire, which is the deployed-module behavior. Tests for the
29
29
  // non-deployed-skip behavior override this explicitly.
30
- return { id, state: 'INSTALLED', manifest, lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt };
30
+ return {
31
+ id,
32
+ state: 'INSTALLED',
33
+ manifest,
34
+ scheduleOverride: opts.scheduleOverride,
35
+ lastSuccessfulBackupAt: opts.lastSuccessfulBackupAt,
36
+ };
31
37
  }
32
38
 
39
+ describe('backupStaleThresholdMs', () => {
40
+ function cadence(value: string) {
41
+ const parsed = parseCadence(value);
42
+ if (parsed === null) throw new Error(`test fixture is not a cadence: ${value}`);
43
+ return parsed;
44
+ }
45
+
46
+ // The four values the change's release note promises. A table could not
47
+ // answer for `6h`, so these come out of `cadence + max(1h, cadence × 0.1)`;
48
+ // `weekly` is the only one that now alerts EARLIER than it used to (8d).
49
+ test('the four documented thresholds', () => {
50
+ expect(backupStaleThresholdMs(cadence('hourly'))).toBe(2 * HOUR);
51
+ expect(backupStaleThresholdMs(cadence('daily'))).toBe(26.4 * HOUR);
52
+ expect(backupStaleThresholdMs(cadence('weekly'))).toBe(7.7 * ONE_DAY);
53
+ expect(backupStaleThresholdMs(cadence('monthly'))).toBe(33 * ONE_DAY);
54
+ });
55
+
56
+ test('a custom duration has a defined threshold', () => {
57
+ expect(backupStaleThresholdMs(cadence('6h'))).toBe(7 * HOUR);
58
+ expect(backupStaleThresholdMs(cadence('90m'))).toBe(90 * 60_000 + HOUR);
59
+ });
60
+
61
+ test('manual has none — an opted-out module is never stale', () => {
62
+ expect(backupStaleThresholdMs('manual')).toBeNull();
63
+ });
64
+ });
65
+
33
66
  describe('auditBackups', () => {
34
67
  test('skips modules without an on_backup hook', async () => {
35
68
  const result = await auditBackups({
@@ -39,7 +72,10 @@ describe('auditBackups', () => {
39
72
  expect(result).toEqual([]);
40
73
  });
41
74
 
42
- test('reports missing backup regardless of schedule (still want at least one)', async () => {
75
+ // A module opted out of scheduled backups used to be reported as missing one
76
+ // forever: the never-backed-up check fired BEFORE the manual check, and the
77
+ // only remediation offered was the very thing the operator declined.
78
+ test('a module opted out is not reported as missing a backup', async () => {
43
79
  const result = await auditBackups({
44
80
  modules: [
45
81
  makeModule('lunacycle', {
@@ -51,14 +87,63 @@ describe('auditBackups', () => {
51
87
  now: () => NOW,
52
88
  });
53
89
 
90
+ expect(result).toEqual([]);
91
+ });
92
+
93
+ test('a module that has NOT opted out is still reported as missing a backup', async () => {
94
+ const result = await auditBackups({
95
+ modules: [
96
+ makeModule('authentik', {
97
+ hasBackupHook: true,
98
+ lastSuccessfulBackupAt: null,
99
+ schedule: 'daily',
100
+ }),
101
+ ],
102
+ now: () => NOW,
103
+ });
104
+
54
105
  expect(result).toHaveLength(1);
55
106
  expect(result[0]).toMatchObject({
56
107
  category: 'backups',
57
108
  severity: 'drift',
58
109
  code: 'backup_missing',
59
- subject: 'lunacycle',
110
+ subject: 'authentik',
111
+ });
112
+ });
113
+
114
+ test("an operator's manual override silences a module the manifest wanted backed up", async () => {
115
+ const result = await auditBackups({
116
+ modules: [
117
+ makeModule('authentik', {
118
+ hasBackupHook: true,
119
+ lastSuccessfulBackupAt: null,
120
+ schedule: 'daily',
121
+ scheduleOverride: 'manual',
122
+ }),
123
+ ],
124
+ now: () => NOW,
125
+ });
126
+
127
+ expect(result).toEqual([]);
128
+ });
129
+
130
+ test('staleness is judged against the override, not the suggestion', async () => {
131
+ // Manifest says weekly (fresh at 2 days); the operator asked for hourly.
132
+ const result = await auditBackups({
133
+ modules: [
134
+ makeModule('caddy', {
135
+ hasBackupHook: true,
136
+ lastSuccessfulBackupAt: NOW - 2 * ONE_DAY,
137
+ schedule: 'weekly',
138
+ scheduleOverride: 'hourly',
139
+ }),
140
+ ],
141
+ now: () => NOW,
60
142
  });
61
- expect(result[0].message).toContain('manual');
143
+
144
+ expect(result).toHaveLength(1);
145
+ expect(result[0].code).toBe('backup_stale');
146
+ expect(result[0].message).toContain('hourly');
62
147
  });
63
148
 
64
149
  test('manual schedule: never flags stale (user-driven cadence)', async () => {
@@ -93,12 +178,12 @@ describe('auditBackups', () => {
93
178
  expect(result[0].message).toContain('daily');
94
179
  });
95
180
 
96
- test('daily schedule: 26h-old is stale', async () => {
181
+ test('daily schedule: 27h-old is stale', async () => {
97
182
  const result = await auditBackups({
98
183
  modules: [
99
184
  makeModule('authentik', {
100
185
  hasBackupHook: true,
101
- lastSuccessfulBackupAt: NOW - 26 * HOUR,
186
+ lastSuccessfulBackupAt: NOW - 27 * HOUR,
102
187
  schedule: 'daily',
103
188
  }),
104
189
  ],
@@ -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
+ });