@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +27 -7
- package/package.json +6 -5
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- package/src/cli/commands/module-status.ts +124 -0
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +35 -9
- package/src/cli/index.ts +59 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/sweep-runner.test.ts +51 -3
- package/src/services/alerting/sweep-runner.ts +30 -7
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/zone-detector.test.ts +34 -3
- 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 {
|
|
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?:
|
|
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 {
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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 -
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: ${
|
|
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 =
|
|
113
|
-
if (threshold === null) continue;
|
|
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: ${
|
|
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
|
+
});
|