@celilo/cli 0.13.3 → 0.14.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 +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/capabilities/well-known.ts +11 -1
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/module-show.ts +11 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +371 -2
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +77 -4
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/machine-pool.ts +2 -1
- package/src/services/module-deploy.ts +17 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
import { eq } from 'drizzle-orm';
|
|
9
9
|
import { FuelGauge } from '../cli/fuel-gauge';
|
|
10
10
|
import type { DbClient } from '../db/client';
|
|
11
|
-
import { modules } from '../db/schema';
|
|
11
|
+
import { type ModuleState, modules } from '../db/schema';
|
|
12
12
|
import { loadCapabilityFunctions } from '../hooks/capability-loader';
|
|
13
13
|
import { invokeHook } from '../hooks/executor';
|
|
14
14
|
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
15
15
|
import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
|
|
16
|
-
import type { HookResult } from '../hooks/types';
|
|
16
|
+
import type { HookLogger, HookResult } from '../hooks/types';
|
|
17
17
|
import type { ModuleManifest } from '../manifest/schema';
|
|
18
18
|
import { decryptSecret } from '../secrets/encryption';
|
|
19
19
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
@@ -33,21 +33,108 @@ export interface HealthCheckResult {
|
|
|
33
33
|
error?: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface HealthCheckOptions {
|
|
37
|
+
debug?: boolean;
|
|
38
|
+
noInteractive?: boolean;
|
|
39
|
+
onProgress?: (msg: string) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
|
|
42
|
+
* that matters beyond cosmetics — the module's lifecycle state is NOT
|
|
43
|
+
* touched. See `createHealthCheckPresentation` and the state-transition
|
|
44
|
+
* guard below.
|
|
45
|
+
*/
|
|
46
|
+
unattended?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How a health-check run reports itself while it runs.
|
|
51
|
+
*
|
|
52
|
+
* The four modes differ ONLY in which logger they build and whether a
|
|
53
|
+
* gauge needs stopping afterwards, so they are resolved here rather than
|
|
54
|
+
* branching around four copies of the `invokeHook` call (Rule 10.1 —
|
|
55
|
+
* presentation is not execution).
|
|
56
|
+
*/
|
|
57
|
+
interface HealthCheckPresentation {
|
|
58
|
+
logger: HookLogger;
|
|
59
|
+
/** Called once the hook returns; stops the gauge when there is one. */
|
|
60
|
+
finish(success: boolean): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const NO_OP_FINISH = (): void => {};
|
|
64
|
+
|
|
65
|
+
function createHealthCheckPresentation(
|
|
66
|
+
moduleId: string,
|
|
67
|
+
options: HealthCheckOptions,
|
|
68
|
+
): HealthCheckPresentation {
|
|
69
|
+
if (options.debug) {
|
|
70
|
+
return { logger: createConsoleLogger(moduleId, 'health_check'), finish: NO_OP_FINISH };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Scheduled run: capture hook output rather than letting it reach a
|
|
74
|
+
// terminal nobody is watching.
|
|
75
|
+
if (options.unattended) {
|
|
76
|
+
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// TUI mode: emit a progress message instead of drawing FuelGauge
|
|
80
|
+
// (which writes to stdout and would corrupt the alt-screen render).
|
|
81
|
+
// Use a capturing logger to absorb hook-level log lines that would
|
|
82
|
+
// otherwise leak to stdout via createConsoleLogger.
|
|
83
|
+
if (options.onProgress) {
|
|
84
|
+
options.onProgress(`Checking ${moduleId}`);
|
|
85
|
+
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
|
|
89
|
+
gauge.start();
|
|
90
|
+
return {
|
|
91
|
+
logger: createGaugeLogger(gauge, moduleId, 'health_check'),
|
|
92
|
+
finish: (success) => gauge.stop(success),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decide whether a health-check result should move a module's lifecycle
|
|
98
|
+
* state, returning the new state or `null` to leave it alone.
|
|
99
|
+
*
|
|
100
|
+
* A SCHEDULED run observes only. `VERIFIED` asserts that a check was
|
|
101
|
+
* deliberately run and passed; letting a recurring unattended check drive it
|
|
102
|
+
* would silently change what the state means — from "someone verified this"
|
|
103
|
+
* to "it was up N minutes ago" — and would churn it on every transient
|
|
104
|
+
* failure, damping that the alert lifecycle already provides and module state
|
|
105
|
+
* does not. Continuously observed health is reported through alerts and the
|
|
106
|
+
* `celilo module list` health column instead.
|
|
107
|
+
*
|
|
108
|
+
* Pure on purpose: this rule is the one thing in the scheduling work that
|
|
109
|
+
* regresses invisibly (a wrong boolean here just makes state flicker), so it
|
|
110
|
+
* is testable without a database or a hook subprocess.
|
|
111
|
+
*
|
|
112
|
+
* See openspec/changes/add-alerting/design.md D15.
|
|
113
|
+
*/
|
|
114
|
+
export function nextModuleState(
|
|
115
|
+
current: ModuleState,
|
|
116
|
+
status: 'healthy' | 'degraded' | 'unhealthy',
|
|
117
|
+
unattended: boolean,
|
|
118
|
+
): ModuleState | null {
|
|
119
|
+
if (unattended) return null;
|
|
120
|
+
if (status === 'healthy' || status === 'degraded') return 'VERIFIED';
|
|
121
|
+
return current === 'VERIFIED' ? 'INSTALLED' : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
36
124
|
/**
|
|
37
125
|
* Run health checks for a single module
|
|
38
126
|
*
|
|
39
127
|
* `onProgress` (when supplied) replaces the FuelGauge animation. Used
|
|
40
128
|
* by the audit TUI to stream progress messages into its own UI rather
|
|
41
129
|
* than letting them leak to stdout before the alt-screen takes over.
|
|
130
|
+
*
|
|
131
|
+
* `unattended` marks a monitor-driven run: silent, and non-mutating with
|
|
132
|
+
* respect to module state.
|
|
42
133
|
*/
|
|
43
134
|
export async function runModuleHealthCheck(
|
|
44
135
|
moduleId: string,
|
|
45
136
|
db: DbClient,
|
|
46
|
-
options: {
|
|
47
|
-
debug?: boolean;
|
|
48
|
-
noInteractive?: boolean;
|
|
49
|
-
onProgress?: (msg: string) => void;
|
|
50
|
-
} = {},
|
|
137
|
+
options: HealthCheckOptions = {},
|
|
51
138
|
): Promise<HealthCheckResult> {
|
|
52
139
|
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
53
140
|
if (!module) {
|
|
@@ -83,75 +170,25 @@ export async function runModuleHealthCheck(
|
|
|
83
170
|
// Run the hook. Logger is constructed BEFORE loadCapabilityFunctions
|
|
84
171
|
// so the auto-logging wrapper (HOOK_API_V2 D6) can capture it for
|
|
85
172
|
// every capability call.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
);
|
|
106
|
-
} else if (options.onProgress) {
|
|
107
|
-
// TUI mode: emit a progress message instead of drawing FuelGauge
|
|
108
|
-
// (which writes to stdout and would corrupt the alt-screen render).
|
|
109
|
-
// Use a capturing logger to absorb hook-level log lines that would
|
|
110
|
-
// otherwise leak to stdout via createConsoleLogger.
|
|
111
|
-
options.onProgress(`Checking ${moduleId}`);
|
|
112
|
-
const { logger } = createCapturingLogger();
|
|
113
|
-
const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
|
|
114
|
-
hookResult = await invokeHook(
|
|
115
|
-
module.sourcePath,
|
|
116
|
-
'health_check',
|
|
117
|
-
manifest.celilo_contract,
|
|
118
|
-
hookDef,
|
|
119
|
-
{},
|
|
120
|
-
configMap,
|
|
121
|
-
secretMap,
|
|
122
|
-
logger,
|
|
123
|
-
{
|
|
124
|
-
debug: false,
|
|
125
|
-
capabilities: capabilityFunctions,
|
|
126
|
-
requiredCapabilities,
|
|
127
|
-
systems: getModuleSystems(moduleId, db),
|
|
128
|
-
},
|
|
129
|
-
);
|
|
130
|
-
} else {
|
|
131
|
-
const gauge = new FuelGauge('Testing app', {
|
|
132
|
-
skipAnimation: options.noInteractive,
|
|
133
|
-
});
|
|
134
|
-
gauge.start();
|
|
135
|
-
const logger = createGaugeLogger(gauge, moduleId, 'health_check');
|
|
136
|
-
const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
|
|
137
|
-
hookResult = await invokeHook(
|
|
138
|
-
module.sourcePath,
|
|
139
|
-
'health_check',
|
|
140
|
-
manifest.celilo_contract,
|
|
141
|
-
hookDef,
|
|
142
|
-
{},
|
|
143
|
-
configMap,
|
|
144
|
-
secretMap,
|
|
145
|
-
logger,
|
|
146
|
-
{
|
|
147
|
-
debug: false,
|
|
148
|
-
capabilities: capabilityFunctions,
|
|
149
|
-
requiredCapabilities,
|
|
150
|
-
systems: getModuleSystems(moduleId, db),
|
|
151
|
-
},
|
|
152
|
-
);
|
|
153
|
-
gauge.stop(hookResult.success);
|
|
154
|
-
}
|
|
173
|
+
const presentation = createHealthCheckPresentation(moduleId, options);
|
|
174
|
+
const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, presentation.logger);
|
|
175
|
+
const hookResult: HookResult = await invokeHook(
|
|
176
|
+
module.sourcePath,
|
|
177
|
+
'health_check',
|
|
178
|
+
manifest.celilo_contract,
|
|
179
|
+
hookDef,
|
|
180
|
+
{},
|
|
181
|
+
configMap,
|
|
182
|
+
secretMap,
|
|
183
|
+
presentation.logger,
|
|
184
|
+
{
|
|
185
|
+
debug: options.debug ?? false,
|
|
186
|
+
capabilities: capabilityFunctions,
|
|
187
|
+
requiredCapabilities,
|
|
188
|
+
systems: getModuleSystems(moduleId, db),
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
presentation.finish(hookResult.success);
|
|
155
192
|
|
|
156
193
|
if (!hookResult.success) {
|
|
157
194
|
return {
|
|
@@ -178,12 +215,9 @@ export async function runModuleHealthCheck(
|
|
|
178
215
|
? 'degraded'
|
|
179
216
|
: 'healthy';
|
|
180
217
|
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
db.update(modules).set({ state:
|
|
184
|
-
} else if (module.state === 'VERIFIED') {
|
|
185
|
-
// Revert to INSTALLED if previously verified but now unhealthy
|
|
186
|
-
db.update(modules).set({ state: 'INSTALLED' }).where(eq(modules.id, moduleId)).run();
|
|
218
|
+
const nextState = nextModuleState(module.state, status, options.unattended ?? false);
|
|
219
|
+
if (nextState) {
|
|
220
|
+
db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
|
|
187
221
|
}
|
|
188
222
|
|
|
189
223
|
return { moduleId, status, checks };
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, eq, inArray } from 'drizzle-orm';
|
|
3
3
|
import { getDb } from '../db/client';
|
|
4
4
|
import {
|
|
5
|
+
type AllocatableZone,
|
|
5
6
|
type NetworkZone,
|
|
6
7
|
containerServices,
|
|
7
8
|
ipAllocations,
|
|
@@ -355,7 +356,7 @@ export async function getContainerSystemsByZone(
|
|
|
355
356
|
eq(moduleInfrastructure.infrastructureType, 'container_service'),
|
|
356
357
|
// ip_allocations.zone is narrower than NetworkZone (no 'external');
|
|
357
358
|
// safe to cast — any 'external' input would just match zero rows.
|
|
358
|
-
inArray(ipAllocations.zone, zones as
|
|
359
|
+
inArray(ipAllocations.zone, zones as AllocatableZone[]),
|
|
359
360
|
),
|
|
360
361
|
);
|
|
361
362
|
|
|
@@ -268,14 +268,25 @@ export async function deployModule(
|
|
|
268
268
|
await import('./celilo-events');
|
|
269
269
|
const { startOperation, completeOperation, failOperation } = await import('./module-operations');
|
|
270
270
|
|
|
271
|
+
const { closeDeployWindows, ensureMonitorOnDeploy, openDeployWindow } = await import(
|
|
272
|
+
'./alerting/deploy-hooks'
|
|
273
|
+
);
|
|
274
|
+
|
|
271
275
|
const opId = startOperation(moduleId, 'deploy');
|
|
272
276
|
emitDeployStarted({ module: moduleId, startedAt });
|
|
273
277
|
|
|
278
|
+
// A deploy restarts services, which fails their own health checks. Without
|
|
279
|
+
// this the operator is paged about the deploy they are personally running.
|
|
280
|
+
openDeployWindow(db, moduleId, new Date());
|
|
281
|
+
|
|
274
282
|
let result: DeployResult;
|
|
275
283
|
try {
|
|
276
284
|
result = await deployModuleImpl(moduleId, db, options);
|
|
277
285
|
} catch (err) {
|
|
278
286
|
const error = err instanceof Error ? err.message : String(err);
|
|
287
|
+
// Close on the failure path too: a window left open would silence this
|
|
288
|
+
// module forever, which is far worse than a few noisy alerts.
|
|
289
|
+
closeDeployWindows(db, moduleId, new Date());
|
|
279
290
|
failOperation(opId, err);
|
|
280
291
|
emitDeployFailed({
|
|
281
292
|
module: moduleId,
|
|
@@ -287,7 +298,13 @@ export async function deployModule(
|
|
|
287
298
|
}
|
|
288
299
|
|
|
289
300
|
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
301
|
+
closeDeployWindows(db, moduleId, new Date());
|
|
302
|
+
|
|
290
303
|
if (result.success) {
|
|
304
|
+
// A module that declares a suggested interval starts being watched on its
|
|
305
|
+
// first successful deploy — watching is the default, not a thing to
|
|
306
|
+
// remember.
|
|
307
|
+
ensureMonitorOnDeploy(db, moduleId);
|
|
291
308
|
completeOperation(opId);
|
|
292
309
|
emitDeployCompleted({ module: moduleId, startedAt, durationMs });
|
|
293
310
|
} else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import type { SystemConfigProperty, SystemConfigSchema } from './system-config-schema-types';
|
|
5
5
|
import { validateKey, validateValue } from './system-config-validator';
|
|
@@ -182,4 +182,34 @@ describe('shipped system_config.json covers every placement zone', () => {
|
|
|
182
182
|
expect(validateKey(`network.${zone}.gateway`, shipped).valid).toBe(true);
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
// The same defect one step further out: a module can declare
|
|
187
|
+
// `derive_from: "$system:<key>"` for a key the shipped schema never accepts.
|
|
188
|
+
// technitium's `vpn_subnet` derived from `$system:network.vpn.subnet` for
|
|
189
|
+
// months while `system config set network.vpn.subnet` rejected the key — the
|
|
190
|
+
// derive had no source and DNS silently lost its VPN split-horizon view.
|
|
191
|
+
// Enumerating the manifests keeps this from needing a hand-maintained list.
|
|
192
|
+
const MODULES_DIR = join(import.meta.dir, '../../../../modules');
|
|
193
|
+
const derivedSystemKeys = new Set<string>();
|
|
194
|
+
for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) {
|
|
195
|
+
if (!entry.isDirectory()) continue;
|
|
196
|
+
const manifest = join(MODULES_DIR, entry.name, 'manifest.yml');
|
|
197
|
+
if (!existsSync(manifest)) continue;
|
|
198
|
+
// `derive_from:` only — a bare `$system:` also appears in prose descriptions.
|
|
199
|
+
for (const m of readFileSync(manifest, 'utf-8').matchAll(
|
|
200
|
+
/derive_from:\s*["']?\$system:([\w.-]+)/g,
|
|
201
|
+
)) {
|
|
202
|
+
derivedSystemKeys.add(m[1]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
it('found system-derived keys to check (the scan itself must not silently pass)', () => {
|
|
207
|
+
expect(derivedSystemKeys.size).toBeGreaterThan(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
for (const key of [...derivedSystemKeys].sort()) {
|
|
211
|
+
it(`accepts ${key}, which a module derives from`, () => {
|
|
212
|
+
expect(validateKey(key, shipped).valid).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
185
215
|
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { DbClient } from '../db/client';
|
|
6
|
+
import { systemConfig } from '../db/schema';
|
|
7
|
+
import { loadTrustedSubnets } from '../hooks/capability-loader';
|
|
8
|
+
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
9
|
+
import {
|
|
10
|
+
buildTrustedSourceStore,
|
|
11
|
+
composeTrustedSubnets,
|
|
12
|
+
listAllTrustedSources,
|
|
13
|
+
listTrustedSourcesFor,
|
|
14
|
+
parseOperatorTrustedSubnets,
|
|
15
|
+
} from './trusted-sources';
|
|
16
|
+
|
|
17
|
+
const FW = '192.168.0.254';
|
|
18
|
+
const VPN = '10.255.255.0/24';
|
|
19
|
+
const CONTROL_PLANE = '192.168.0.0/24';
|
|
20
|
+
|
|
21
|
+
describe('trusted-source store', () => {
|
|
22
|
+
let dir: string;
|
|
23
|
+
let db: DbClient;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), 'ts-'));
|
|
27
|
+
const dbPath = join(dir, 'celilo.db');
|
|
28
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
29
|
+
db = await setupTestDatabase(dbPath);
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
db.$client.close();
|
|
33
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
34
|
+
try {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('stamps registeredBy from the binding, not the caller', () => {
|
|
42
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
43
|
+
store.add(FW, { subnet: VPN, description: 'admin VPN clients' });
|
|
44
|
+
|
|
45
|
+
expect(store.list(FW)).toEqual([
|
|
46
|
+
{ subnet: VPN, description: 'admin VPN clients', registeredBy: 'wireguard' },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('upserts by subnet — registering twice yields one row', () => {
|
|
51
|
+
const store = buildTrustedSourceStore(db, 'wireguard');
|
|
52
|
+
store.add(FW, { subnet: VPN, description: 'first' });
|
|
53
|
+
store.add(FW, { subnet: VPN, description: 'second' });
|
|
54
|
+
|
|
55
|
+
expect(store.list(FW)).toHaveLength(1);
|
|
56
|
+
expect(store.list(FW)[0].description).toBe('second');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('removes by subnet and leaves other firewalls alone', () => {
|
|
60
|
+
buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
|
|
61
|
+
buildTrustedSourceStore(db, 'other').add('10.0.0.1', { subnet: VPN, description: 'elsewhere' });
|
|
62
|
+
|
|
63
|
+
buildTrustedSourceStore(db, 'wireguard').remove(FW, VPN);
|
|
64
|
+
|
|
65
|
+
expect(buildTrustedSourceStore(db, 'wireguard').list(FW)).toEqual([]);
|
|
66
|
+
expect(buildTrustedSourceStore(db, 'other').list('10.0.0.1')).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reads one firewall’s registrations without a module binding', () => {
|
|
70
|
+
// Trust registered against one firewall is not trust granted by another —
|
|
71
|
+
// the read is scoped, and the reader needs no identity to look.
|
|
72
|
+
buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
|
|
73
|
+
buildTrustedSourceStore(db, 'other').add('10.0.0.1', {
|
|
74
|
+
subnet: '172.16.9.0/24',
|
|
75
|
+
description: 'elsewhere',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(listTrustedSourcesFor(db, FW).map((s) => s.subnet)).toEqual([VPN]);
|
|
79
|
+
expect(listTrustedSourcesFor(db, '10.0.0.1').map((s) => s.subnet)).toEqual(['172.16.9.0/24']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports every registration across firewalls, with who registered it', () => {
|
|
83
|
+
buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'vpn' });
|
|
84
|
+
|
|
85
|
+
expect(listAllTrustedSources(db)).toEqual([
|
|
86
|
+
{ firewallIp: FW, subnet: VPN, description: 'vpn', registeredBy: 'wireguard' },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Caught by e2e, and it is the whole reason the render input and the reporting
|
|
92
|
+
// view are different calls. `on_uninstall` withdrew the VPN's trusted source,
|
|
93
|
+
// logged success — and the reach rules were re-rendered anyway, because the
|
|
94
|
+
// snapshot the loader had injected into the provider's config still listed the
|
|
95
|
+
// subnet, and the provider unions that snapshot with the live registry. The set
|
|
96
|
+
// could grow but never shrink. One value, two sources of truth.
|
|
97
|
+
describe('the render input excludes registrations; the reporting view includes them', () => {
|
|
98
|
+
let dir: string;
|
|
99
|
+
let db: DbClient;
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
dir = mkdtempSync(join(tmpdir(), 'ts-render-'));
|
|
103
|
+
const dbPath = join(dir, 'celilo.db');
|
|
104
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
105
|
+
db = await setupTestDatabase(dbPath);
|
|
106
|
+
db.insert(systemConfig).values({ key: 'network.internal.subnet', value: CONTROL_PLANE }).run();
|
|
107
|
+
buildTrustedSourceStore(db, 'wireguard').add(FW, { subnet: VPN, description: 'admin VPN' });
|
|
108
|
+
});
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
db.$client.close();
|
|
111
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
112
|
+
try {
|
|
113
|
+
rmSync(dir, { recursive: true, force: true });
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('omits registrations when no firewall is named (the render input)', () => {
|
|
120
|
+
// The provider unions the LIVE registry itself; folding a snapshot in here
|
|
121
|
+
// too is what stopped a withdrawal from ever taking effect.
|
|
122
|
+
expect(loadTrustedSubnets(db).map((e) => e.subnet)).toEqual([CONTROL_PLANE]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('includes them when a firewall IS named (the reporting view)', () => {
|
|
126
|
+
const composed = loadTrustedSubnets(db, FW);
|
|
127
|
+
expect(composed.map((e) => e.subnet)).toEqual([CONTROL_PLANE, VPN]);
|
|
128
|
+
expect(composed[1].registeredBy).toBe('wireguard');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('composing the trusted-subnet set', () => {
|
|
133
|
+
// The property that makes composition safe to ship AHEAD of any module that
|
|
134
|
+
// registers: a fleet with no contributors renders exactly what it rendered
|
|
135
|
+
// when the set was derived from the control plane alone.
|
|
136
|
+
it('with no registrations and no override, yields the derived subnet alone', () => {
|
|
137
|
+
expect(
|
|
138
|
+
composeTrustedSubnets({
|
|
139
|
+
controlPlaneSubnet: CONTROL_PLANE,
|
|
140
|
+
registered: [],
|
|
141
|
+
operatorOverride: [],
|
|
142
|
+
}),
|
|
143
|
+
).toEqual([{ subnet: CONTROL_PLANE, origin: 'derived-control-plane' }]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('with no control plane and no contributors, yields nothing', () => {
|
|
147
|
+
expect(
|
|
148
|
+
composeTrustedSubnets({
|
|
149
|
+
controlPlaneSubnet: undefined,
|
|
150
|
+
registered: [],
|
|
151
|
+
operatorOverride: [],
|
|
152
|
+
}),
|
|
153
|
+
).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('merges all three origins', () => {
|
|
157
|
+
const composed = composeTrustedSubnets({
|
|
158
|
+
controlPlaneSubnet: CONTROL_PLANE,
|
|
159
|
+
registered: [{ subnet: VPN, description: 'admin VPN', registeredBy: 'wireguard' }],
|
|
160
|
+
operatorOverride: ['172.16.5.0/24'],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(composed.map((e) => e.subnet)).toEqual([CONTROL_PLANE, VPN, '172.16.5.0/24']);
|
|
164
|
+
expect(composed.map((e) => e.origin)).toEqual([
|
|
165
|
+
'derived-control-plane',
|
|
166
|
+
'registered',
|
|
167
|
+
'operator-override',
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('names WHO holds zone-wide reach, so it is never anonymous', () => {
|
|
172
|
+
const composed = composeTrustedSubnets({
|
|
173
|
+
controlPlaneSubnet: CONTROL_PLANE,
|
|
174
|
+
registered: [{ subnet: VPN, description: 'admin VPN', registeredBy: 'wireguard' }],
|
|
175
|
+
operatorOverride: [],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(composed[1]).toEqual({
|
|
179
|
+
subnet: VPN,
|
|
180
|
+
origin: 'registered',
|
|
181
|
+
registeredBy: 'wireguard',
|
|
182
|
+
description: 'admin VPN',
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('deduplicates an overlapping subnet, keeping the strongest claim', () => {
|
|
187
|
+
const composed = composeTrustedSubnets({
|
|
188
|
+
controlPlaneSubnet: CONTROL_PLANE,
|
|
189
|
+
registered: [{ subnet: CONTROL_PLANE, description: 'dup', registeredBy: 'wireguard' }],
|
|
190
|
+
operatorOverride: [CONTROL_PLANE],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(composed).toEqual([{ subnet: CONTROL_PLANE, origin: 'derived-control-plane' }]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('an operator override adds to the derived set rather than replacing it', () => {
|
|
197
|
+
// Replacing would let an operator strand celilo's own control plane, which
|
|
198
|
+
// blocks the SSH every hook and converge runs over.
|
|
199
|
+
const composed = composeTrustedSubnets({
|
|
200
|
+
controlPlaneSubnet: CONTROL_PLANE,
|
|
201
|
+
registered: [],
|
|
202
|
+
operatorOverride: ['172.16.5.0/24'],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(composed.map((e) => e.subnet)).toContain(CONTROL_PLANE);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('parsing the operator override', () => {
|
|
210
|
+
it('splits, trims, and drops empties', () => {
|
|
211
|
+
expect(parseOperatorTrustedSubnets(' 10.1.0.0/24 , 10.2.0.0/24 ,, ')).toEqual([
|
|
212
|
+
'10.1.0.0/24',
|
|
213
|
+
'10.2.0.0/24',
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('an unset key contributes nothing', () => {
|
|
218
|
+
expect(parseOperatorTrustedSubnets(undefined)).toEqual([]);
|
|
219
|
+
expect(parseOperatorTrustedSubnets('')).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
});
|