@celilo/cli 1.8.0 → 1.9.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 -0
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +161 -5
- package/src/hooks/broker.test.ts +152 -0
- package/src/hooks/broker.ts +307 -0
- package/src/hooks/capability-loader-bindings.test.ts +163 -0
- package/src/hooks/capability-loader-firewall.test.ts +108 -0
- package/src/hooks/capability-loader.test.ts +10 -2
- package/src/hooks/capability-loader.ts +59 -2
- package/src/hooks/executor.ts +234 -111
- package/src/hooks/hook-protocol.test.ts +192 -0
- package/src/hooks/hook-protocol.ts +275 -0
- package/src/hooks/hook-runner.ts +231 -0
- package/src/hooks/hook-timeout.test.ts +103 -0
- package/src/hooks/hook-trespass.test.ts +201 -0
- package/src/hooks/injected-capabilities.test.ts +75 -0
- package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
- package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/packaging/module-state-directory.test.ts +99 -0
- package/src/module/packaging/package-rules.ts +10 -2
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/capability-shape.ts +13 -1
- package/src/policy/module-business-baseline.ts +36 -0
- package/src/services/alerting/ack.test.ts +2 -2
- package/src/services/alerting/deferral.test.ts +2 -2
- package/src/services/alerting/delivery-loop.test.ts +2 -2
- package/src/services/alerting/deploy-hooks.test.ts +2 -2
- package/src/services/alerting/inbound-poller.test.ts +2 -2
- package/src/services/alerting/inbound.test.ts +2 -2
- package/src/services/alerting/notification-responder.test.ts +2 -2
- package/src/services/alerting/run-monitor.test.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -2
- package/src/services/alerting/sweep-runner.test.ts +2 -2
- package/src/services/alerting/tokens.test.ts +2 -2
- package/src/services/capability-bindings.test.ts +104 -0
- package/src/services/capability-bindings.ts +107 -0
- package/src/services/capability-table-rows.test.ts +2 -2
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/dns-internal-records.test.ts +3 -3
- package/src/services/fleet-checks.test.ts +4 -4
- package/src/services/module-instances.test.ts +198 -0
- package/src/services/module-instances.ts +96 -0
- package/src/services/module-journal.test.ts +2 -2
- package/src/services/module-subscriptions.test.ts +1 -1
- package/src/services/port-forwards.test.ts +2 -2
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/test-utils/setup-test-db.ts +0 -80
|
@@ -78,6 +78,28 @@ export function createFirewall(config, store, upstreamFirewall, logger) {
|
|
|
78
78
|
}
|
|
79
79
|
`;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Reports back the FirewallConfig it was handed, so a test can assert on what
|
|
83
|
+
* the loader actually forwarded rather than on a downstream effect.
|
|
84
|
+
*
|
|
85
|
+
* The real iptables module reads `defaultRouteZone` and `isolateTransitNetwork`
|
|
86
|
+
* off this object and nothing else in celilo constructs one. So if the loader
|
|
87
|
+
* drops a field, the setting is simply dead: the operator sets it, the config
|
|
88
|
+
* row exists, `module config get` shows it, and no rule changes. That is what
|
|
89
|
+
* happened to both of these, and it is invisible from every surface except the
|
|
90
|
+
* rendered ruleset.
|
|
91
|
+
*/
|
|
92
|
+
const MOCK_CONFIG_REPORTER = `
|
|
93
|
+
export function createFirewall(config, store, upstreamFirewall, logger) {
|
|
94
|
+
return {
|
|
95
|
+
receivedConfig: () => config,
|
|
96
|
+
exposeService: async (opts) => ({ externalIp: '203.0.113.10', natIp: config.natIp }),
|
|
97
|
+
unexposeService: async () => {},
|
|
98
|
+
listExposedServices: async () => [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
81
103
|
describe('Firewall Chain Building', () => {
|
|
82
104
|
let db: DbClient;
|
|
83
105
|
let tempDir: string;
|
|
@@ -235,4 +257,90 @@ describe('Firewall Chain Building', () => {
|
|
|
235
257
|
// But buildFirewallChain is only called with >1 providers
|
|
236
258
|
// Single provider loads normally via the standard path
|
|
237
259
|
});
|
|
260
|
+
|
|
261
|
+
describe('operator settings reach the module', () => {
|
|
262
|
+
/**
|
|
263
|
+
* Both settings are opt-in and both default to a no-op, which is exactly
|
|
264
|
+
* why losing them is silent. A dropped `isolateTransitNetwork` renders the
|
|
265
|
+
* ruleset celilo has always rendered, and a dropped `defaultRouteZone`
|
|
266
|
+
* falls back to `internal`, which is the common case. Nothing errors,
|
|
267
|
+
* nothing warns, and the only observable difference is a DROP that is
|
|
268
|
+
* absent from a chain nobody reads.
|
|
269
|
+
*/
|
|
270
|
+
function installReporter(moduleId: string, capabilityData: string, zones: string) {
|
|
271
|
+
const path = join(tempDir, moduleId);
|
|
272
|
+
const scripts = join(path, 'scripts');
|
|
273
|
+
mkdirSync(scripts, { recursive: true });
|
|
274
|
+
writeFileSync(join(scripts, 'firewall-functions.ts'), MOCK_CONFIG_REPORTER);
|
|
275
|
+
db.$client.run(
|
|
276
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${path}', '{}')`,
|
|
277
|
+
);
|
|
278
|
+
db.$client.run(
|
|
279
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('${moduleId}', 'firewall', '1.0.0', '${capabilityData}', '${zones}')`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function loadedConfig(): Promise<Record<string, unknown>> {
|
|
284
|
+
const result = await loadCapabilityFunctions('test-consumer', db, noopLogger);
|
|
285
|
+
const fw = result.firewall as { receivedConfig: () => Record<string, unknown> };
|
|
286
|
+
expect(fw, 'no firewall capability loaded').toBeTruthy();
|
|
287
|
+
return fw.receivedConfig();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
test('single provider: default_route_zone and isolate_transit_network are forwarded', async () => {
|
|
291
|
+
installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]');
|
|
292
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
293
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
294
|
+
upsertModuleConfig(db, 'iptables', 'default_route_zone', 'isp-transit');
|
|
295
|
+
upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
|
|
296
|
+
|
|
297
|
+
const config = await loadedConfig();
|
|
298
|
+
expect(config.defaultRouteZone).toBe('isp-transit');
|
|
299
|
+
// A REAL boolean, not the string 'true'. `parseStoredConfigValue`
|
|
300
|
+
// preserves the manifest-declared type, and the module tests
|
|
301
|
+
// `state.isolateTransitNetwork && ...` — where 'false' would be truthy.
|
|
302
|
+
expect(config.isolateTransitNetwork).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('chained provider: the downstream layer gets them too', async () => {
|
|
306
|
+
// greenwave owns the WAN, so iptables is built through buildFirewallChain
|
|
307
|
+
// rather than the single-provider path. That is a SECOND construction
|
|
308
|
+
// site with its own field list, and it is the one a real downstream
|
|
309
|
+
// firewall goes through.
|
|
310
|
+
const gwPath = join(tempDir, 'greenwave');
|
|
311
|
+
const gwScripts = join(gwPath, 'scripts');
|
|
312
|
+
mkdirSync(gwScripts, { recursive: true });
|
|
313
|
+
writeFileSync(join(gwScripts, 'firewall-functions.ts'), MOCK_GREENWAVE_MODULE);
|
|
314
|
+
db.$client.run(
|
|
315
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('greenwave', 'GreenWave', '1.0.0', '${gwPath}', '{}')`,
|
|
316
|
+
);
|
|
317
|
+
db.$client.run(
|
|
318
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, zones) VALUES ('greenwave', 'firewall', '1.0.0', '{"has_external":true}', '["internal"]')`,
|
|
319
|
+
);
|
|
320
|
+
upsertModuleConfig(db, 'greenwave', 'router_ip', '192.168.0.1');
|
|
321
|
+
|
|
322
|
+
installReporter('iptables', '{}', '["dmz","app","secure"]');
|
|
323
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
324
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
325
|
+
upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
|
|
326
|
+
upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
|
|
327
|
+
|
|
328
|
+
const config = await loadedConfig();
|
|
329
|
+
expect(config.defaultRouteZone).toBe('internal');
|
|
330
|
+
expect(config.isolateTransitNetwork).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('unset settings arrive undefined, not as a wrong default', async () => {
|
|
334
|
+
installReporter('iptables', '{"has_external":true}', '["dmz"]');
|
|
335
|
+
upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
|
|
336
|
+
upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
|
|
337
|
+
|
|
338
|
+
const config = await loadedConfig();
|
|
339
|
+
// The module owns both defaults (`?? 'internal'` and `?? false`). The
|
|
340
|
+
// loader must not invent one, or a future change to the module's default
|
|
341
|
+
// would be silently overridden by a stale copy here.
|
|
342
|
+
expect(config.defaultRouteZone).toBeUndefined();
|
|
343
|
+
expect(config.isolateTransitNetwork).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
238
346
|
});
|
|
@@ -161,6 +161,14 @@ describe('Capability Loader', () => {
|
|
|
161
161
|
db.$client.run(
|
|
162
162
|
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
|
|
163
163
|
);
|
|
164
|
+
// The two route CONSUMERS, as real rows. `web_routes.module_id` is a foreign
|
|
165
|
+
// key onto `modules`, and seeding a route without its module was accepted
|
|
166
|
+
// only because the test helper ran with foreign keys off (celilo#1074).
|
|
167
|
+
for (const consumer of ['apt-repo', 'authentik']) {
|
|
168
|
+
db.$client.run(
|
|
169
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${consumer}', '${consumer}', '1.0.0', '${tempDir}/${consumer}', '{}')`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
164
172
|
db.$client.run(
|
|
165
173
|
`INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('apt--root', 'apt-repo', 'reverse_proxy', '/', 'apt.example.com', '10.0.20.50', 8080, 0)`,
|
|
166
174
|
);
|
|
@@ -172,10 +180,10 @@ describe('Capability Loader', () => {
|
|
|
172
180
|
const provider = await loadCapabilityFunctions('caddy', db, noopLogger);
|
|
173
181
|
expect(provider).toHaveProperty('web_routes');
|
|
174
182
|
const view = provider.web_routes as RouteReadView;
|
|
175
|
-
const all = view.getAllRoutes();
|
|
183
|
+
const all = await view.getAllRoutes();
|
|
176
184
|
expect(all).toHaveLength(2);
|
|
177
185
|
expect(all.map((r) => r.hostname).sort()).toEqual(['apt.example.com', 'auth.example.com']);
|
|
178
|
-
expect(view.getRoutes('apt-repo').map((r) => r.hostname)).toEqual(['apt.example.com']);
|
|
186
|
+
expect((await view.getRoutes('apt-repo')).map((r) => r.hostname)).toEqual(['apt.example.com']);
|
|
179
187
|
|
|
180
188
|
// A consumer (not the provider) never sees the route table.
|
|
181
189
|
const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from '../db/schema';
|
|
41
41
|
import { decryptSecret } from '../secrets/encryption';
|
|
42
42
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
43
|
+
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
43
44
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
44
45
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
45
46
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
@@ -224,6 +225,11 @@ export async function loadCapabilityFunctions(
|
|
|
224
225
|
logger: HookLogger,
|
|
225
226
|
): Promise<Record<string, unknown>> {
|
|
226
227
|
const result: Record<string, unknown> = {};
|
|
228
|
+
// Which provider each injected capability came from, so the binding recorded
|
|
229
|
+
// at the return names the provider the consumer actually reached. Provider
|
|
230
|
+
// self-views (`firewall_registry`, `web_routes`) are deliberately absent —
|
|
231
|
+
// a module reading its own registry has not bound to anything.
|
|
232
|
+
const providerByCapability = new Map<string, string>();
|
|
227
233
|
|
|
228
234
|
const masterKey = await getOrCreateMasterKey();
|
|
229
235
|
|
|
@@ -270,6 +276,11 @@ export async function loadCapabilityFunctions(
|
|
|
270
276
|
);
|
|
271
277
|
if (chain) {
|
|
272
278
|
result[capName] = chain;
|
|
279
|
+
// The consumer talks to the OUTERMOST layer, which is not necessarily
|
|
280
|
+
// the edge provider. Every layer is `stampProvider`'d on the way out,
|
|
281
|
+
// so the one it was handed names itself.
|
|
282
|
+
const chainProvider = (chain as { providerModuleId?: string }).providerModuleId;
|
|
283
|
+
if (chainProvider) providerByCapability.set(capName, chainProvider);
|
|
273
284
|
}
|
|
274
285
|
// The chain hands a CONSUMER the innermost layer, which is not this
|
|
275
286
|
// provider's own layer when it sits further out. `on_consumer_removed`
|
|
@@ -377,6 +388,7 @@ export async function loadCapabilityFunctions(
|
|
|
377
388
|
// greenwave — true, useless, and a violation of the contract's
|
|
378
389
|
// requirement to name the provider.
|
|
379
390
|
result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
|
|
391
|
+
providerByCapability.set(capName, capability.moduleId);
|
|
380
392
|
debugLog(`${capName}: loaded via defineCapabilityFunction`);
|
|
381
393
|
continue;
|
|
382
394
|
}
|
|
@@ -403,6 +415,7 @@ export async function loadCapabilityFunctions(
|
|
|
403
415
|
result[capName] = withLedger(
|
|
404
416
|
wrapWithLogging(capabilityInterface as object, logger, capName),
|
|
405
417
|
);
|
|
418
|
+
providerByCapability.set(capName, capability.moduleId);
|
|
406
419
|
debugLog(`${capName}: loaded via legacy factory`);
|
|
407
420
|
// Sole firewall provider running its OWN hook: the interface just built
|
|
408
421
|
// IS its layer, so hand it back under the provider-view name too.
|
|
@@ -544,6 +557,10 @@ export async function loadCapabilityFunctions(
|
|
|
544
557
|
}
|
|
545
558
|
|
|
546
559
|
try {
|
|
560
|
+
// The name comes off the row rather than being restated here: core
|
|
561
|
+
// naming a capability is what `no-module-business-in-core` Scan B counts,
|
|
562
|
+
// and this is bookkeeping, not a branch.
|
|
563
|
+
providerByCapability.set(provider.capabilityName, provider.moduleId);
|
|
547
564
|
result.public_web = createPublicWeb({
|
|
548
565
|
moduleId: consumingModuleId,
|
|
549
566
|
logger,
|
|
@@ -610,9 +627,12 @@ export async function loadCapabilityFunctions(
|
|
|
610
627
|
// — symmetric with how consumers get the public_web capability. Consumers
|
|
611
628
|
// never see this; only the provider does.
|
|
612
629
|
if (consumingModuleId === provider.moduleId) {
|
|
630
|
+
// Async because the consumer is a hook, which now runs in its own
|
|
631
|
+
// process. `routeOps` itself stays synchronous: it is bun:sqlite and it
|
|
632
|
+
// is called in-process by `createPublicWeb`.
|
|
613
633
|
const routeView: RouteReadView = {
|
|
614
|
-
getAllRoutes: () => routeOps.getAllRoutes(),
|
|
615
|
-
getRoutes: (m: string) => routeOps.getRoutes(m),
|
|
634
|
+
getAllRoutes: async () => routeOps.getAllRoutes(),
|
|
635
|
+
getRoutes: async (m: string) => routeOps.getRoutes(m),
|
|
616
636
|
};
|
|
617
637
|
result.web_routes = routeView;
|
|
618
638
|
debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`);
|
|
@@ -621,6 +641,32 @@ export async function loadCapabilityFunctions(
|
|
|
621
641
|
debugLog('public_web: not registered in DB, skipping');
|
|
622
642
|
}
|
|
623
643
|
|
|
644
|
+
// celilo#1072: the CALL is the binding, not the resolution. Everything above
|
|
645
|
+
// is injected whether or not the consumer declared it — the loop's own
|
|
646
|
+
// comment says "not just required ones" — so recording what was resolved
|
|
647
|
+
// would name every provider on the fleet. A method invocation is the only
|
|
648
|
+
// event that separates the optional capability a module uses from the ones it
|
|
649
|
+
// merely declares.
|
|
650
|
+
for (const [capName, iface] of Object.entries(result)) {
|
|
651
|
+
const providerModuleId = providerByCapability.get(capName);
|
|
652
|
+
// A module that provides and consumes the same capability is not bound to
|
|
653
|
+
// itself, and a provider reading its own registry is not a consumer.
|
|
654
|
+
if (!providerModuleId || providerModuleId === consumingModuleId) continue;
|
|
655
|
+
if (!iface || typeof iface !== 'object') continue;
|
|
656
|
+
result[capName] = withBindingRecord(iface as object, () => {
|
|
657
|
+
try {
|
|
658
|
+
recordCapabilityBinding(db, consumingModuleId, capName, providerModuleId);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
// Bookkeeping must never fail the call it is observing. Loud, not silent.
|
|
661
|
+
logger.warn(
|
|
662
|
+
`Could not record the ${capName} binding ${consumingModuleId} → ${providerModuleId}: ${
|
|
663
|
+
error instanceof Error ? error.message : String(error)
|
|
664
|
+
}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
624
670
|
return result;
|
|
625
671
|
}
|
|
626
672
|
|
|
@@ -674,6 +720,11 @@ function buildCapabilityInterface(
|
|
|
674
720
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
675
721
|
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
676
722
|
frontedSubnets: zones?.frontedSubnets ?? [],
|
|
723
|
+
// Operator settings, forwarded verbatim. `parseStoredConfigValue`
|
|
724
|
+
// preserves each manifest-declared type, so the boolean arrives as a
|
|
725
|
+
// boolean and needs no coercion here.
|
|
726
|
+
defaultRouteZone: config.default_route_zone as string | undefined,
|
|
727
|
+
isolateTransitNetwork: config.isolate_transit_network as boolean | undefined,
|
|
677
728
|
},
|
|
678
729
|
store,
|
|
679
730
|
undefined, // no upstream — the chain path handles that
|
|
@@ -1191,6 +1242,12 @@ async function buildFirewallChain(
|
|
|
1191
1242
|
// Absent means the box has never converged cleanly, so an interface
|
|
1192
1243
|
// celilo cannot attribute refuses rather than being disabled.
|
|
1193
1244
|
interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline),
|
|
1245
|
+
// Which declared zone carries this firewall's default route, and
|
|
1246
|
+
// whether fronted zones may initiate into it. Both are operator
|
|
1247
|
+
// settings and both are read HERE or nowhere: the module declares them
|
|
1248
|
+
// on `FirewallConfig`, and nothing else in celilo constructs one.
|
|
1249
|
+
defaultRouteZone: provConfig.default_route_zone as string | undefined,
|
|
1250
|
+
isolateTransitNetwork: provConfig.isolate_transit_network as boolean | undefined,
|
|
1194
1251
|
},
|
|
1195
1252
|
store,
|
|
1196
1253
|
currentUpstream,
|