@celilo/cli 1.7.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.
Files changed (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -0,0 +1,307 @@
1
+ /**
2
+ * The hook broker.
3
+ *
4
+ * celilo keeps the database, the master key and the live capability objects.
5
+ * The hook runs in its own process and reaches them only by asking. This is
6
+ * the answering half; `hook-runner.ts` is the asking half.
7
+ *
8
+ * **It does not know what a capability is** (design D2). At handshake it sends
9
+ * a shape descriptor built by walking the object `loadCapabilityFunctions`
10
+ * already returns, and thereafter it dispatches `call` frames against that
11
+ * same object by name. That works because every hook-facing capability method
12
+ * is already `(request: JSON) => Promise<JSON>` — measured: twelve
13
+ * capabilities, thirty-seven methods, no callbacks, no streams, no handles —
14
+ * and because `wrapWithLogging` already treats a capability as an opaque table
15
+ * of async methods keyed by name. This is that walk with a process in the
16
+ * middle.
17
+ *
18
+ * Execution function (Rule 10.1) — owns the socket and performs the calls.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync } from 'node:fs';
22
+ import { type Server, type Socket, createServer } from 'node:net';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import {
26
+ type CapabilityShape,
27
+ type HookError,
28
+ createLineReader,
29
+ encodeFrame,
30
+ parseChildFrame,
31
+ serializeError,
32
+ versionMismatch,
33
+ } from './hook-protocol';
34
+ import type { HookLogger } from './types';
35
+
36
+ /**
37
+ * How long to wait after the child exits for its last frame to arrive. Short,
38
+ * because by this point the writer is already gone and the bytes are either in
39
+ * the buffer or they are never coming.
40
+ */
41
+ const DRAIN_GRACE_MS = 1_000;
42
+
43
+ /** What the hook returned, or what it threw. */
44
+ export type HookOutcome =
45
+ | { ok: true; outputs: Record<string, unknown> }
46
+ | { ok: false; error: HookError };
47
+
48
+ export interface BrokerOptions {
49
+ /** The live capability map, exactly as `loadCapabilityFunctions` built it. */
50
+ capabilities: Record<string, unknown>;
51
+ /** The serialisable half of `HookContext` — no `logger`, no `capabilities`. */
52
+ context: Record<string, unknown>;
53
+ /** Absolute path of the hook script, sent to the child rather than argv'd. */
54
+ scriptPath: string;
55
+ /** Where the hook's own log lines go. */
56
+ logger: HookLogger;
57
+ /** Called on every frame, so the idle timer measures the whole channel. */
58
+ onActivity: () => void;
59
+ }
60
+
61
+ export interface Broker {
62
+ /** Unix socket path, handed to the child in its environment. */
63
+ socketPath: string;
64
+ /**
65
+ * Resolves once the child's connection has closed, so its last frame has
66
+ * certainly been delivered.
67
+ *
68
+ * The child writes its terminal frame and then exits, and process exit is
69
+ * what the executor waits on — but the two are different channels. The exit
70
+ * can be observed while the frame is still sitting in the kernel buffer,
71
+ * unread, which would look exactly like a hook that exited without
72
+ * returning a result. Bounded, so a socket that never closes cannot hang
73
+ * celilo waiting for a process that has already gone.
74
+ */
75
+ drained(): Promise<void>;
76
+ /**
77
+ * The hook's terminal frame, or `undefined` if it never sent one — which is
78
+ * what a crash, a `process.exit` or a kill looks like from here. Read after
79
+ * the child has exited, so there is no race to lose.
80
+ */
81
+ outcome(): HookOutcome | undefined;
82
+ /** Every protocol-level complaint, for the error message when one matters. */
83
+ faults(): string[];
84
+ /** Refuse further calls. Idempotent. Called on kill and again on cleanup. */
85
+ stop(): void;
86
+ /** Close the socket and remove its directory. */
87
+ close(): void;
88
+ }
89
+
90
+ /**
91
+ * Walk a capability map into the descriptor the child rebuilds proxies from.
92
+ *
93
+ * Function-valued keys become `methods`; everything else is copied into `data`,
94
+ * which is where `stampProvider` puts `providerModuleId` — a hook reads it to
95
+ * name the provider that could not supply what it asked for.
96
+ *
97
+ * Own string keys only, the way `wrapWithLogging` walks. Every capability the
98
+ * loader builds is a plain object (a spread, an `Object.assign`, or
99
+ * `Object.create` plus own properties), so a prototype walk would find nothing
100
+ * a hook can call today — and if that ever stopped being true, auto-logging
101
+ * would break in the same breath, which is a louder signal than this would be.
102
+ * Symbol keys cannot cross JSON and are dropped.
103
+ *
104
+ * Planning function (Rule 10.4) — pure, so the descriptor is testable without
105
+ * a socket.
106
+ */
107
+ export function capabilityShape(
108
+ capabilities: Record<string, unknown>,
109
+ ): Record<string, CapabilityShape> {
110
+ const shape: Record<string, CapabilityShape> = {};
111
+
112
+ for (const [name, capability] of Object.entries(capabilities)) {
113
+ if (!capability || typeof capability !== 'object') continue;
114
+
115
+ const methods: string[] = [];
116
+ const data: Record<string, unknown> = {};
117
+
118
+ for (const key of Reflect.ownKeys(capability)) {
119
+ if (typeof key !== 'string') continue;
120
+ const value = (capability as Record<string, unknown>)[key];
121
+ if (typeof value === 'function') {
122
+ methods.push(key);
123
+ } else if (isJsonSafe(value)) {
124
+ data[key] = value;
125
+ }
126
+ }
127
+
128
+ shape[name] = { methods, data };
129
+ }
130
+
131
+ return shape;
132
+ }
133
+
134
+ /** Everything `JSON.stringify` round-trips without inventing or losing a value. */
135
+ function isJsonSafe(value: unknown): boolean {
136
+ if (value === null) return true;
137
+ const type = typeof value;
138
+ if (type === 'string' || type === 'boolean') return true;
139
+ if (type === 'number') return Number.isFinite(value as number);
140
+ if (type !== 'object') return false;
141
+ try {
142
+ return JSON.parse(JSON.stringify(value)) !== undefined;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Bind a Unix socket in a fresh per-run directory and answer one hook.
150
+ *
151
+ * The socket, not stdout: seventeen module script files spawn subprocesses,
152
+ * and a grandchild writing to fd 1 would corrupt the frame stream (design D3).
153
+ * The directory is short-named because `sun_path` is 104 bytes on macOS and
154
+ * the platform temp directory already spends half of it.
155
+ */
156
+ export async function startBroker(options: BrokerOptions): Promise<Broker> {
157
+ const directory = mkdtempSync(join(tmpdir(), 'celilo-hook-'));
158
+ const socketPath = join(directory, 's');
159
+
160
+ let outcome: HookOutcome | undefined;
161
+ let stopped = false;
162
+ const faults: string[] = [];
163
+ let connection: Socket | undefined;
164
+ // Already resolved: no connection means nothing left to deliver.
165
+ let connectionClosed: Promise<void> = Promise.resolve();
166
+
167
+ const server: Server = createServer((socket) => {
168
+ if (connection) {
169
+ // One hook, one connection. A second is either a bug or a hook trying
170
+ // to hold the channel open past its own run.
171
+ faults.push('a second connection to the hook socket was refused');
172
+ socket.destroy();
173
+ return;
174
+ }
175
+ connection = socket;
176
+ connectionClosed = new Promise<void>((resolve) => socket.once('close', () => resolve()));
177
+ socket.setEncoding('utf-8');
178
+
179
+ const send = (frame: Parameters<typeof encodeFrame>[0]) => {
180
+ if (!socket.destroyed) socket.write(encodeFrame(frame));
181
+ };
182
+
183
+ const feed = createLineReader((line) => {
184
+ options.onActivity();
185
+
186
+ const parsed = parseChildFrame(line);
187
+ if (!parsed.ok) {
188
+ faults.push(`malformed frame from the hook: ${parsed.error}`);
189
+ return;
190
+ }
191
+
192
+ const frame = parsed.frame;
193
+ switch (frame.type) {
194
+ case 'ready': {
195
+ const mismatch = versionMismatch(frame.protocolVersion, 'the hook runner');
196
+ if (mismatch) {
197
+ faults.push(mismatch);
198
+ socket.destroy();
199
+ return;
200
+ }
201
+ send({
202
+ type: 'context',
203
+ protocolVersion: 1,
204
+ scriptPath: options.scriptPath,
205
+ context: options.context,
206
+ });
207
+ send({ type: 'capabilities', shape: capabilityShape(options.capabilities) });
208
+ return;
209
+ }
210
+
211
+ case 'log':
212
+ options.logger[frame.level](frame.message);
213
+ return;
214
+
215
+ case 'call':
216
+ void dispatch(frame.id, frame.capability, frame.method, frame.args, send);
217
+ return;
218
+
219
+ case 'result':
220
+ outcome = { ok: true, outputs: frame.outputs };
221
+ return;
222
+
223
+ case 'throw':
224
+ outcome = { ok: false, error: frame.error };
225
+ return;
226
+ }
227
+ });
228
+
229
+ socket.on('data', feed);
230
+ socket.on('error', (error) => faults.push(`hook socket error: ${error.message}`));
231
+ });
232
+
233
+ async function dispatch(
234
+ id: string,
235
+ capabilityName: string,
236
+ method: string,
237
+ args: unknown[],
238
+ send: (frame: Parameters<typeof encodeFrame>[0]) => void,
239
+ ): Promise<void> {
240
+ if (stopped) {
241
+ // The run is over — killed at its timeout, most likely. Refusing here is
242
+ // what makes the kill real: the whole failure celilo#1003 describes is a
243
+ // hook still registering DNS records after celilo moved on.
244
+ send({
245
+ type: 'throw',
246
+ id,
247
+ error: {
248
+ name: 'Error',
249
+ message: `Hook run has ended; refusing ${capabilityName}.${method}.`,
250
+ },
251
+ });
252
+ return;
253
+ }
254
+
255
+ const capability = options.capabilities[capabilityName];
256
+ const fn =
257
+ capability && typeof capability === 'object'
258
+ ? (capability as Record<string, unknown>)[method]
259
+ : undefined;
260
+
261
+ if (typeof fn !== 'function') {
262
+ send({
263
+ type: 'throw',
264
+ id,
265
+ error: {
266
+ name: 'TypeError',
267
+ message: `Capability '${capabilityName}' has no method '${method}'.`,
268
+ },
269
+ });
270
+ return;
271
+ }
272
+
273
+ try {
274
+ const value = await (fn as (...a: unknown[]) => unknown).apply(capability, args);
275
+ options.onActivity();
276
+ send({ type: 'return', id, value: value === undefined ? null : value });
277
+ } catch (error) {
278
+ options.onActivity();
279
+ send({ type: 'throw', id, error: serializeError(error) });
280
+ }
281
+ }
282
+
283
+ await new Promise<void>((resolve, reject) => {
284
+ server.once('error', reject);
285
+ server.listen(socketPath, resolve);
286
+ });
287
+
288
+ return {
289
+ socketPath,
290
+ drained: () =>
291
+ Promise.race([
292
+ connectionClosed,
293
+ new Promise<void>((resolve) => setTimeout(resolve, DRAIN_GRACE_MS).unref()),
294
+ ]),
295
+ outcome: () => outcome,
296
+ faults: () => [...faults],
297
+ stop: () => {
298
+ stopped = true;
299
+ },
300
+ close: () => {
301
+ stopped = true;
302
+ connection?.destroy();
303
+ server.close();
304
+ rmSync(directory, { recursive: true, force: true });
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The recurrence gate for celilo#1072.
3
+ *
4
+ * celilo could name every provider a module MIGHT be bound to and none of the
5
+ * ones it IS. The live case: `tango-nexus` declares four optional capabilities
6
+ * (external_web, public_web, source_forge, registry_publish), is deployed
7
+ * against one off-fleet host, and nothing recorded which of the four was real.
8
+ *
9
+ * This models that shape with two capabilities the loader injects and one the
10
+ * consumer calls. The gate is the NEGATIVE half: the capability that was
11
+ * injected and never used must report zero bindings. Anything that records at
12
+ * resolution time — which is where the obvious fix goes — passes the positive
13
+ * assertion and fails this one.
14
+ *
15
+ * Two different flavours of over-recording are caught here, and the second is
16
+ * easy to miss when reading this file as being about one thing. The first is a
17
+ * consumer credited with a capability it never called. The second is the
18
+ * self-binding case: `dhcp-provider` running its OWN hook is also handed
19
+ * `notification` from `notify-provider`, because the loader hands over
20
+ * everything registered. Resolution-time recording books that as a binding
21
+ * `dhcp-provider` never made, so the "not bound to itself" test fails for a
22
+ * reason that has nothing to do with self-binding.
23
+ */
24
+
25
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
26
+ import { mkdirSync, writeFileSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import type { HookLogger } from '@celilo/capabilities';
30
+ import type { DbClient } from '../db/client';
31
+ import { listCapabilityBindings } from '../services/capability-bindings';
32
+ import { upsertModuleConfig } from '../services/module-config';
33
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
34
+ import { loadCapabilityFunctions } from './capability-loader';
35
+
36
+ const noopLogger: HookLogger = {
37
+ info() {},
38
+ warn() {},
39
+ error() {},
40
+ success() {},
41
+ };
42
+
43
+ const DHCP_MODULE = `
44
+ export default function createDhcpServer(context) {
45
+ return {
46
+ async setDnsServers() {},
47
+ async getDnsServers() { return [context.config.marker]; },
48
+ async setDomainName() {},
49
+ async getDomainName() { return context.config.marker; },
50
+ };
51
+ }
52
+ `;
53
+
54
+ const NOTIFICATION_MODULE = `
55
+ export default function createNotification() {
56
+ return {
57
+ async send() { return { delivered: true }; },
58
+ };
59
+ }
60
+ `;
61
+
62
+ function installProvider(
63
+ db: DbClient,
64
+ tempDir: string,
65
+ moduleId: string,
66
+ script: string,
67
+ source: string,
68
+ capabilityName: string,
69
+ ): void {
70
+ const modulePath = join(tempDir, moduleId);
71
+ const scriptsDir = join(modulePath, 'scripts');
72
+ mkdirSync(scriptsDir, { recursive: true });
73
+ writeFileSync(join(scriptsDir, script), source);
74
+ db.$client.run(
75
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`,
76
+ );
77
+ upsertModuleConfig(db, moduleId, 'marker', moduleId);
78
+ db.$client.run(
79
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('${moduleId}', '${capabilityName}', '1.0.0', '{}', NULL, unixepoch())`,
80
+ );
81
+ }
82
+
83
+ describe('capability bindings recorded from the loader', () => {
84
+ let db: DbClient;
85
+ let tempDir: string;
86
+
87
+ beforeEach(async () => {
88
+ db = await setupTestDatabase();
89
+ // `setupTestDatabase` does not run `PRAGMA foreign_keys = ON` and
90
+ // `db/client.ts:70` does, so every cascade in the schema is enforced in
91
+ // production and off in the suite (celilo#1074). Enabled here so the
92
+ // cascade assertion below measures the real behaviour. Delete this line
93
+ // when #1074 lands.
94
+ db.$client.run('PRAGMA foreign_keys = ON');
95
+ tempDir = join(tmpdir(), `celilo-binding-${Date.now()}-${Math.random().toString(36).slice(2)}`);
96
+ mkdirSync(tempDir, { recursive: true });
97
+
98
+ installProvider(
99
+ db,
100
+ tempDir,
101
+ 'dhcp-provider',
102
+ 'dhcp-server-functions.ts',
103
+ DHCP_MODULE,
104
+ 'dhcp_server',
105
+ );
106
+ installProvider(
107
+ db,
108
+ tempDir,
109
+ 'notify-provider',
110
+ 'notification.ts',
111
+ NOTIFICATION_MODULE,
112
+ 'notification',
113
+ );
114
+ db.$client.run(
115
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('consumer', 'consumer', '1.0.0', '${tempDir}', '{}')`,
116
+ );
117
+ });
118
+
119
+ afterEach(async () => {
120
+ await cleanupTestDatabase(db);
121
+ });
122
+
123
+ test('an injected capability the consumer never calls reports zero bindings', async () => {
124
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
125
+
126
+ // Both are injected — the loader hands over every registered capability
127
+ // regardless of what the consumer declared.
128
+ expect(capabilities.dhcp_server).toBeTruthy();
129
+ expect(capabilities.notification).toBeTruthy();
130
+
131
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
132
+
133
+ const bindings = listCapabilityBindings(db, 'consumer');
134
+ expect(bindings.map((b) => b.capabilityName)).toEqual(['dhcp_server']);
135
+ expect(bindings[0].providerModuleId).toBe('dhcp-provider');
136
+ });
137
+
138
+ test('a redeploy re-asserts the binding rather than duplicating it', async () => {
139
+ for (let i = 0; i < 3; i++) {
140
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
141
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
142
+ }
143
+
144
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
145
+ });
146
+
147
+ test('the binding dies with the consumer', async () => {
148
+ const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger);
149
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
150
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1);
151
+
152
+ db.$client.run(`DELETE FROM modules WHERE id = 'consumer'`);
153
+
154
+ expect(listCapabilityBindings(db, 'consumer')).toHaveLength(0);
155
+ });
156
+
157
+ test('a provider consuming its own capability is not bound to itself', async () => {
158
+ const capabilities = await loadCapabilityFunctions('dhcp-provider', db, noopLogger);
159
+ await (capabilities.dhcp_server as { getDomainName(): Promise<string> }).getDomainName();
160
+
161
+ expect(listCapabilityBindings(db, 'dhcp-provider')).toHaveLength(0);
162
+ });
163
+ });
@@ -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);