@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.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/0028_capability_bindings.sql +26 -0
- package/drizzle/0029_module_instances.sql +58 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +2 -2
- package/src/capabilities/validation.test.ts +51 -0
- package/src/capabilities/validation.ts +22 -8
- package/src/cli/commands/module-show.ts +1 -0
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/foreign-keys.test.ts +101 -0
- package/src/db/schema.ts +182 -9
- 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/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/manifest/validate-provider-views.test.ts +61 -0
- package/src/manifest/validate.ts +21 -14
- package/src/module/import.ts +19 -1
- 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 +96 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +129 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +68 -7
- 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 +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.test.ts +40 -3
- package/src/services/consumer-cleanup.ts +13 -7
- package/src/services/dns-internal-records.test.ts +74 -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/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +8 -4
- package/src/services/port-forwards.ts +0 -11
- package/src/services/trusted-sources.test.ts +3 -3
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/ingress-ip.test.ts +31 -0
- package/src/test-utils/database.ts +31 -1
- package/src/variables/context.ts +75 -10
- package/src/variables/lxc-nameserver.test.ts +144 -0
- 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);
|