@celilo/cli 1.13.0 → 1.14.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +6 -2
- package/package.json +2 -2
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +6 -0
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +14 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +33 -7
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
package/src/cli/index.ts
CHANGED
|
@@ -124,6 +124,7 @@ import { handleSystemAudit } from './commands/system-audit';
|
|
|
124
124
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
125
125
|
import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
|
|
126
126
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
127
|
+
import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
|
|
127
128
|
import { handleSystemInit } from './commands/system-init';
|
|
128
129
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
129
130
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
@@ -1122,6 +1123,7 @@ Subcommands:
|
|
|
1122
1123
|
to define, not a module's.
|
|
1123
1124
|
|
|
1124
1125
|
discover-network Record the network this box sits on, from its own routing table (idempotent)
|
|
1126
|
+
ensure-fleet-key Mint celilo's fleet SSH key if absent, record ssh.public_key, print the public half
|
|
1125
1127
|
|
|
1126
1128
|
config set <key> <value> Set system-wide configuration value
|
|
1127
1129
|
config get [key] Get system configuration value(s)
|
|
@@ -2221,6 +2223,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2221
2223
|
return handleSystemDiscoverNetwork();
|
|
2222
2224
|
}
|
|
2223
2225
|
|
|
2226
|
+
if (parsed.subcommand === 'ensure-fleet-key') {
|
|
2227
|
+
return handleSystemEnsureFleetKey();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2224
2230
|
if (parsed.subcommand === 'config') {
|
|
2225
2231
|
const configSubcommand = parsed.args[0];
|
|
2226
2232
|
if (!configSubcommand) {
|
|
@@ -232,6 +232,82 @@ describe('the walk follows real bindings, not declarations', () => {
|
|
|
232
232
|
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
test('a delegated provider is reached, though no manifest names it', () => {
|
|
236
|
+
// The case the whole chain exists for. `app` is BOUND to iptables, so the
|
|
237
|
+
// manifest walk stops there and the ISP router the packet actually leaves
|
|
238
|
+
// through never appears — even though iptables cannot reach the internet
|
|
239
|
+
// without it. celilo wires that edge at hook time; nothing declares it.
|
|
240
|
+
const multi: ProviderRow[] = [
|
|
241
|
+
{ moduleId: 'iptables', capabilityName: 'firewall' },
|
|
242
|
+
{ moduleId: 'axon', capabilityName: 'firewall' },
|
|
243
|
+
];
|
|
244
|
+
const result = computeClosure({
|
|
245
|
+
rootModuleId: 'app',
|
|
246
|
+
manifests: new Map([
|
|
247
|
+
['app', manifest({ requires: ['firewall'] })],
|
|
248
|
+
['iptables', manifest({})],
|
|
249
|
+
['axon', manifest({})],
|
|
250
|
+
]),
|
|
251
|
+
providerRows: multi,
|
|
252
|
+
providerStates: deployed('iptables', 'axon'),
|
|
253
|
+
bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
|
|
254
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'axon']);
|
|
258
|
+
// Hop 2, because it is reached THROUGH iptables. Not a second hop-1
|
|
259
|
+
// dependency of the selection, which is what a fan would say.
|
|
260
|
+
expect(result.nodes.find((n) => n.moduleId === 'axon')?.hop).toBe(2);
|
|
261
|
+
// A packet has no alternative route to the internet.
|
|
262
|
+
expect(result.nodes.find((n) => n.moduleId === 'axon')?.optional).toBe(false);
|
|
263
|
+
expect(result.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a chain the walk never touched is not reported', () => {
|
|
267
|
+
// The chain is a fleet fact and the closure is about one module. A selection
|
|
268
|
+
// that consumes no firewall stands on none of it.
|
|
269
|
+
const result = computeClosure({
|
|
270
|
+
rootModuleId: 'site',
|
|
271
|
+
manifests,
|
|
272
|
+
providerRows: rows,
|
|
273
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
274
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.chains).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the depth bound cuts a delegated provider like any other', () => {
|
|
281
|
+
// Three layers, bounded at two, so the LAST link is the one the bound cuts.
|
|
282
|
+
// The delegation edges are walked inside the same loop as the declared ones
|
|
283
|
+
// and get the same bound; a rule applied after the walk would smuggle the
|
|
284
|
+
// whole chain into a two-hop answer.
|
|
285
|
+
//
|
|
286
|
+
// A two-layer chain would not test this. `iptables` is the only declared
|
|
287
|
+
// dependency, so the walk exhausts at hop 2 whether or not the delegation
|
|
288
|
+
// is followed, and the assertion would hold for the wrong reason.
|
|
289
|
+
const result = computeClosure({
|
|
290
|
+
rootModuleId: 'app',
|
|
291
|
+
manifests: new Map([
|
|
292
|
+
['app', manifest({ requires: ['firewall'] })],
|
|
293
|
+
['iptables', manifest({})],
|
|
294
|
+
['mid', manifest({})],
|
|
295
|
+
['axon', manifest({})],
|
|
296
|
+
]),
|
|
297
|
+
providerRows: [{ moduleId: 'iptables', capabilityName: 'firewall' }],
|
|
298
|
+
providerStates: deployed('iptables', 'mid', 'axon'),
|
|
299
|
+
depth: 2,
|
|
300
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'mid', 'axon'] }],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'mid']);
|
|
304
|
+
expect(result.truncated).toBe(true);
|
|
305
|
+
// Still reported WHOLE, including the member past the bound. Half a path is
|
|
306
|
+
// not a path, and the operator asked to see less, not to be told the packet
|
|
307
|
+
// stops at `mid`.
|
|
308
|
+
expect(result.chains[0]?.moduleIds).toEqual(['iptables', 'mid', 'axon']);
|
|
309
|
+
});
|
|
310
|
+
|
|
235
311
|
test('a module with no bindings at all depends on nothing', () => {
|
|
236
312
|
const result = computeClosure({
|
|
237
313
|
rootModuleId: 'site',
|
package/src/console/closure.ts
CHANGED
|
@@ -57,6 +57,32 @@ export interface ClosureRequest {
|
|
|
57
57
|
* without a database, not so the distinction is optional.
|
|
58
58
|
*/
|
|
59
59
|
bindings?: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
|
60
|
+
/**
|
|
61
|
+
* Ordered provider chains, most downstream first.
|
|
62
|
+
*
|
|
63
|
+
* These are edges NO MANIFEST DECLARES. celilo wires the `firewall`
|
|
64
|
+
* providers into a chain at hook time from `has_external`, so `iptables`
|
|
65
|
+
* genuinely stands on `axon` while neither manifest says a word about the
|
|
66
|
+
* other. A walk over manifests alone stops at `iptables`, and the box the
|
|
67
|
+
* packet actually leaves through never appears in the answer.
|
|
68
|
+
*
|
|
69
|
+
* Supplied by the caller rather than derived here so this stays pure. The CLI
|
|
70
|
+
* reads them with `loadFirewallChain`.
|
|
71
|
+
*/
|
|
72
|
+
chains?: readonly DelegationChain[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One capability's providers in delegation order.
|
|
77
|
+
*
|
|
78
|
+
* Ordered because for `firewall` they are not alternatives: the order is the
|
|
79
|
+
* packet's path. Same shape as the console protocol's `DelegationChain`, so the
|
|
80
|
+
* server hands it on rather than translating it.
|
|
81
|
+
*/
|
|
82
|
+
export interface DelegationChain {
|
|
83
|
+
capability: string;
|
|
84
|
+
/** Module ids, most downstream first. */
|
|
85
|
+
moduleIds: string[];
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
export interface ClosureNode {
|
|
@@ -78,6 +104,15 @@ export interface ClosureNode {
|
|
|
78
104
|
|
|
79
105
|
export interface ClosureResult {
|
|
80
106
|
nodes: ClosureNode[];
|
|
107
|
+
/**
|
|
108
|
+
* The delegation chains this closure touched, whole.
|
|
109
|
+
*
|
|
110
|
+
* A chain is emitted when the walk reached ANY of its members, and it carries
|
|
111
|
+
* every member rather than only the reached ones, because half a path is not
|
|
112
|
+
* a path. Empty means the closure touched no chain — which, now that
|
|
113
|
+
* something computes them, is an answer rather than a gap.
|
|
114
|
+
*/
|
|
115
|
+
chains: DelegationChain[];
|
|
81
116
|
/** Hops actually walked. Echoes the request so the console can say what it bounded. */
|
|
82
117
|
depth: number;
|
|
83
118
|
/**
|
|
@@ -107,10 +142,35 @@ function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
|
|
|
107
142
|
);
|
|
108
143
|
}
|
|
109
144
|
|
|
145
|
+
/**
|
|
146
|
+
* A chain's consecutive pairs, as edges out of each member.
|
|
147
|
+
*
|
|
148
|
+
* `[iptables, axon]` becomes one edge `iptables -> axon`. The last member has
|
|
149
|
+
* no edge out of it: it is the internet-facing end and delegates to nothing.
|
|
150
|
+
*/
|
|
151
|
+
function delegationEdgesByModule(
|
|
152
|
+
chains: readonly DelegationChain[],
|
|
153
|
+
): Map<string, { to: string; capability: string }[]> {
|
|
154
|
+
const byModule = new Map<string, { to: string; capability: string }[]>();
|
|
155
|
+
for (const chain of chains) {
|
|
156
|
+
for (let i = 0; i + 1 < chain.moduleIds.length; i += 1) {
|
|
157
|
+
const from = chain.moduleIds[i];
|
|
158
|
+
const to = chain.moduleIds[i + 1];
|
|
159
|
+
if (from === undefined || to === undefined) continue;
|
|
160
|
+
const out = byModule.get(from) ?? [];
|
|
161
|
+
out.push({ to, capability: chain.capability });
|
|
162
|
+
byModule.set(from, out);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return byModule;
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
export function computeClosure(request: ClosureRequest): ClosureResult {
|
|
111
169
|
const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
|
|
112
170
|
const providerRows = [...request.providerRows];
|
|
113
171
|
const providerStates = [...request.providerStates];
|
|
172
|
+
const chains = request.chains ?? [];
|
|
173
|
+
const delegationsOut = delegationEdgesByModule(chains);
|
|
114
174
|
|
|
115
175
|
const edges: Edge[] = [];
|
|
116
176
|
// The root counts as visited from the start, so a cycle back to it terminates
|
|
@@ -165,10 +225,36 @@ export function computeClosure(request: ClosureRequest): ClosureResult {
|
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
}
|
|
228
|
+
|
|
229
|
+
// The edges the manifests do not carry. Followed here, inside the same loop,
|
|
230
|
+
// so a delegated provider gets its hop, its cycle check and its depth bound
|
|
231
|
+
// from the one walk rather than from a second rule applied afterwards.
|
|
232
|
+
//
|
|
233
|
+
// Never optional: a packet has no alternative route to the internet, so a
|
|
234
|
+
// delegation is the strongest kind of dependency there is.
|
|
235
|
+
for (const moduleId of frontier) {
|
|
236
|
+
for (const edge of delegationsOut.get(moduleId) ?? []) {
|
|
237
|
+
if (edge.to === request.rootModuleId) continue;
|
|
238
|
+
edges.push({ to: edge.to, hop, capability: edge.capability, optional: false });
|
|
239
|
+
if (!visited.has(edge.to)) {
|
|
240
|
+
visited.add(edge.to);
|
|
241
|
+
next.push(edge.to);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
168
246
|
frontier = next;
|
|
169
247
|
}
|
|
170
248
|
|
|
171
|
-
|
|
249
|
+
const nodes = collectNodes(edges);
|
|
250
|
+
const reached = new Set([request.rootModuleId, ...nodes.map((node) => node.moduleId)]);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
nodes,
|
|
254
|
+
chains: chains.filter((chain) => chain.moduleIds.some((id) => reached.has(id))),
|
|
255
|
+
depth,
|
|
256
|
+
truncated,
|
|
257
|
+
};
|
|
172
258
|
}
|
|
173
259
|
|
|
174
260
|
/**
|
|
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
8
8
|
import type { DbClient } from '../db/client';
|
|
9
9
|
import { NETWORK_ZONES } from '../db/schema';
|
|
10
10
|
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
11
|
-
import { consoleStatus } from './projection';
|
|
11
|
+
import { consoleStatus, loadClosureInputs } from './projection';
|
|
12
12
|
|
|
13
13
|
/** A manifest big enough to notice if it ever leaked into the payload. */
|
|
14
14
|
const FAT_MANIFEST = JSON.stringify({
|
|
@@ -229,3 +229,65 @@ describe('consoleStatus derived facts', () => {
|
|
|
229
229
|
expect(moduleOf('forgejo').backupStale).toBe(true);
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The reach probe for the chain (CLAUDE.md, "a check that cannot reach the
|
|
235
|
+
* thing it is checking").
|
|
236
|
+
*
|
|
237
|
+
* `orderFirewallChain` is unit-tested against hand-built rows. That says
|
|
238
|
+
* nothing about whether the closure's loader queries the right table with the
|
|
239
|
+
* right filter, and a wrong query here returns a well-formed empty list that
|
|
240
|
+
* reads exactly like a fleet with one firewall. So this drives the REAL loader
|
|
241
|
+
* against real `capabilities` rows.
|
|
242
|
+
*/
|
|
243
|
+
describe('loadClosureInputs, and the chain it carries', () => {
|
|
244
|
+
let db: DbClient;
|
|
245
|
+
|
|
246
|
+
beforeEach(async () => {
|
|
247
|
+
db = await setupTestDatabase();
|
|
248
|
+
for (const id of ['iptables', 'axon', 'caddy']) {
|
|
249
|
+
db.$client.run(
|
|
250
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data, state)
|
|
251
|
+
VALUES ('${id}', '${id}', '1.0.0', '/path', '{}', 'VERIFIED')`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
afterEach(async () => {
|
|
257
|
+
await cleanupTestDatabase(db);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function firewallProvider(moduleId: string, data: string) {
|
|
261
|
+
db.$client.run(
|
|
262
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
263
|
+
VALUES ('${moduleId}', 'firewall', '1.0.0', '${data}')`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
test('reads the live registration and orders it, iptables first', () => {
|
|
268
|
+
firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
|
|
269
|
+
firewallProvider('axon', '{"has_external":true,"router_ip":"192.168.0.1"}');
|
|
270
|
+
|
|
271
|
+
expect(loadClosureInputs(db).chains).toEqual([
|
|
272
|
+
{ capability: 'firewall', moduleIds: ['iptables', 'axon'] },
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a non-firewall capability is not swept into the chain', () => {
|
|
277
|
+
// `axon` provides dhcp_server too. A loader that read every row would put a
|
|
278
|
+
// module in the packet's path on the strength of an unrelated capability.
|
|
279
|
+
firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
|
|
280
|
+
firewallProvider('axon', '{"has_external":true}');
|
|
281
|
+
db.$client.run(
|
|
282
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
283
|
+
VALUES ('caddy', 'public_web', '1.0.0', '{}')`,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(loadClosureInputs(db).chains[0]?.moduleIds).toEqual(['iptables', 'axon']);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('a fleet with one firewall has no chain', () => {
|
|
290
|
+
firewallProvider('axon', '{"has_external":true}');
|
|
291
|
+
expect(loadClosureInputs(db).chains).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -14,16 +14,25 @@
|
|
|
14
14
|
* succeed quietly.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { FIREWALL_CAPABILITY_NAME, orderFirewallChain } from '@celilo/capabilities';
|
|
17
18
|
import { desc, eq } from 'drizzle-orm';
|
|
18
19
|
import { z } from 'zod';
|
|
19
20
|
import type { DbClient } from '../db/client';
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
NETWORK_ZONES,
|
|
23
|
+
backups,
|
|
24
|
+
capabilities,
|
|
25
|
+
moduleSystems,
|
|
26
|
+
modules,
|
|
27
|
+
monitors,
|
|
28
|
+
} from '../db/schema';
|
|
21
29
|
import { loadObservedHealthDetail } from '../services/alerting/observed-health';
|
|
22
30
|
import { loadBackupAuditInfo } from '../services/audit/backup-source';
|
|
23
31
|
import { backupStaleThresholdMs, moduleHasBackupHook } from '../services/audit/backups';
|
|
24
32
|
import { effectiveBackupSchedule } from '../services/backup-schedule';
|
|
25
33
|
import { listCapabilityBindings } from '../services/capability-bindings';
|
|
26
34
|
import type { ConsumedCapabilities } from '../services/consumer-cleanup';
|
|
35
|
+
import type { DelegationChain } from './closure';
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* Just the capability lists, validated at the boundary.
|
|
@@ -302,10 +311,18 @@ export function loadBindings(db: DbClient, moduleId: string): Map<string, string
|
|
|
302
311
|
);
|
|
303
312
|
}
|
|
304
313
|
|
|
305
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Everything the closure walk needs, read in ONE place.
|
|
316
|
+
*
|
|
317
|
+
* The chains are in here rather than beside it deliberately. A separate loader
|
|
318
|
+
* a caller has to remember is a caller that forgets, and the answer when it
|
|
319
|
+
* forgets is an empty chain list that reads exactly like a fleet with no
|
|
320
|
+
* delegation. There is one call site, and it cannot get a partial set.
|
|
321
|
+
*/
|
|
306
322
|
export function loadClosureInputs(db: DbClient): {
|
|
307
323
|
manifests: Map<string, ConsumedCapabilities>;
|
|
308
324
|
providerStates: { moduleId: string; state: string }[];
|
|
325
|
+
chains: DelegationChain[];
|
|
309
326
|
} {
|
|
310
327
|
return {
|
|
311
328
|
manifests: new Map(
|
|
@@ -316,9 +333,29 @@ export function loadClosureInputs(db: DbClient): {
|
|
|
316
333
|
.map((row) => [row.id, CONSUMED_CAPABILITIES.parse(row.manifestData)] as const),
|
|
317
334
|
),
|
|
318
335
|
providerStates: db.select({ moduleId: modules.id, state: modules.state }).from(modules).all(),
|
|
336
|
+
chains: loadFirewallChain(db),
|
|
319
337
|
};
|
|
320
338
|
}
|
|
321
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The `firewall` providers in delegation order, as at most one chain.
|
|
342
|
+
*
|
|
343
|
+
* At most one because `firewall` is the only capability celilo chains, and
|
|
344
|
+
* `orderFirewallChain` — the SAME function the hook loader wires the live
|
|
345
|
+
* providers with — is what decides the order. A fleet with one firewall, or
|
|
346
|
+
* with none that reaches the internet, yields no chain at all.
|
|
347
|
+
*/
|
|
348
|
+
export function loadFirewallChain(db: DbClient): DelegationChain[] {
|
|
349
|
+
const providers = db
|
|
350
|
+
.select({ moduleId: capabilities.moduleId, data: capabilities.data })
|
|
351
|
+
.from(capabilities)
|
|
352
|
+
.where(eq(capabilities.capabilityName, FIREWALL_CAPABILITY_NAME))
|
|
353
|
+
.all();
|
|
354
|
+
|
|
355
|
+
const ordered = orderFirewallChain(providers).map((provider) => provider.moduleId);
|
|
356
|
+
return ordered.length === 0 ? [] : [{ capability: FIREWALL_CAPABILITY_NAME, moduleIds: ordered }];
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
/** A module's row, or undefined. Used to reject a closure request for a module that is gone. */
|
|
323
360
|
export function moduleExists(db: DbClient, moduleId: string): boolean {
|
|
324
361
|
return (
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `control_plane_api` reaches a consuming module, and only a consuming module.
|
|
3
|
+
*
|
|
4
|
+
* The rest of `loadCapabilityFunctions` hands over every capability it can
|
|
5
|
+
* build, declared or not — its own comment says "not just required ones", and
|
|
6
|
+
* `capability-loader-bindings.test.ts` asserts that shape deliberately. That is
|
|
7
|
+
* fine for a capability whose worst outcome is an unused OIDC client.
|
|
8
|
+
*
|
|
9
|
+
* This one mints an SSH principal into celilo's own control plane, so it is
|
|
10
|
+
* gated on the `requires` line instead. These pin both halves, because the
|
|
11
|
+
* failure that matters is silent in both directions: a console that declared it
|
|
12
|
+
* and got nothing fails at install with "no provider is loaded" for a capability
|
|
13
|
+
* no module ever provides, and a module that declared nothing and got it anyway
|
|
14
|
+
* holds control-plane read that no manifest review would have shown.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import type { HookLogger } from '@celilo/capabilities';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
|
|
21
|
+
import { loadCapabilityFunctions } from './capability-loader';
|
|
22
|
+
|
|
23
|
+
const noopLogger: HookLogger = {
|
|
24
|
+
info() {},
|
|
25
|
+
warn() {},
|
|
26
|
+
error() {},
|
|
27
|
+
success() {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function installModule(db: DbClient, moduleId: string, manifest: Record<string, unknown>): void {
|
|
31
|
+
db.$client.run(
|
|
32
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, '1.0.0', '/tmp/${moduleId}', ?)`,
|
|
33
|
+
[moduleId, moduleId, JSON.stringify(manifest)],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DECLARES = {
|
|
38
|
+
requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('control_plane_api injection', () => {
|
|
42
|
+
let db: DbClient;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
db = await setupTestDatabase();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await cleanupTestDatabase(db);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a module that requires it gets it, with no provider module deployed', async () => {
|
|
53
|
+
// The point of the whole decision: nothing provides this capability, and it
|
|
54
|
+
// is still there. A test that first deployed a provider would be measuring
|
|
55
|
+
// the ordinary path.
|
|
56
|
+
installModule(db, 'celilo-web-console', DECLARES);
|
|
57
|
+
|
|
58
|
+
const capabilities = await loadCapabilityFunctions('celilo-web-console', db, noopLogger);
|
|
59
|
+
|
|
60
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
61
|
+
const api = capabilities.control_plane_api as Record<string, unknown>;
|
|
62
|
+
expect(typeof api.enrol_principal).toBe('function');
|
|
63
|
+
expect(typeof api.revoke_principal).toBe('function');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a module that declares it as optional gets it too', async () => {
|
|
67
|
+
// `optional` is a declaration a reviewer reads on the same line of the same
|
|
68
|
+
// file. Treating it as no declaration would fail the module at runtime for a
|
|
69
|
+
// capability its manifest names.
|
|
70
|
+
installModule(db, 'someday-console', {
|
|
71
|
+
optional: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const capabilities = await loadCapabilityFunctions('someday-console', db, noopLogger);
|
|
75
|
+
|
|
76
|
+
expect(capabilities.control_plane_api).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a module that declares nothing does NOT get it', async () => {
|
|
80
|
+
installModule(db, 'hello-foo', { requires: { capabilities: [] } });
|
|
81
|
+
|
|
82
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
83
|
+
|
|
84
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a module that requires something ELSE does NOT get it', async () => {
|
|
88
|
+
// The near miss: a manifest with a populated `requires` block is the shape a
|
|
89
|
+
// too-loose check ("does this module require anything?") would wave through.
|
|
90
|
+
installModule(db, 'hello-bar', {
|
|
91
|
+
requires: { capabilities: [{ name: 'idp', version: '1.0.0' }] },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const capabilities = await loadCapabilityFunctions('hello-bar', db, noopLogger);
|
|
95
|
+
|
|
96
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a module celilo has no record of does NOT get it', async () => {
|
|
100
|
+
// A hook can run for a module id with no row (a removal path, a typo). The
|
|
101
|
+
// manifest lookup returns nothing, and "no manifest" must read as "declared
|
|
102
|
+
// nothing" rather than throwing or defaulting open.
|
|
103
|
+
const capabilities = await loadCapabilityFunctions('never-imported', db, noopLogger);
|
|
104
|
+
|
|
105
|
+
expect(capabilities.control_plane_api).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the injected object is scoped to the module it was built for', async () => {
|
|
109
|
+
// Scoping is what stops a module rotating a neighbour's key. It is asserted
|
|
110
|
+
// through the loader rather than only on `buildControlPlaneApi`, because the
|
|
111
|
+
// loader is where the wrong argument (a provider id, a hook name) would be
|
|
112
|
+
// passed.
|
|
113
|
+
installModule(db, 'hello-foo', DECLARES);
|
|
114
|
+
|
|
115
|
+
const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
|
|
116
|
+
const api = capabilities.control_plane_api as {
|
|
117
|
+
revoke_principal(request: { name: string }): Promise<unknown>;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
|
|
121
|
+
/only revoke its own principal/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
import {
|
|
16
16
|
createPublicWeb,
|
|
17
17
|
isCompiledCapabilityFactory,
|
|
18
|
+
orderFirewallChain,
|
|
18
19
|
wrapWithLogging,
|
|
19
20
|
} from '@celilo/capabilities';
|
|
20
21
|
import type {
|
|
@@ -38,8 +39,10 @@ import {
|
|
|
38
39
|
systemConfig,
|
|
39
40
|
webRoutes,
|
|
40
41
|
} from '../db/schema';
|
|
42
|
+
import { resolveModuleWebRoot } from '../module/web-root';
|
|
41
43
|
import { decryptSecret } from '../secrets/encryption';
|
|
42
44
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
45
|
+
import { buildControlPlaneApi } from '../services/api-principal-enrolment';
|
|
43
46
|
import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
|
|
44
47
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
45
48
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
@@ -219,6 +222,26 @@ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefin
|
|
|
219
222
|
return undefined;
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Does this module's manifest declare `capabilityName`, under `requires` or
|
|
227
|
+
* `optional`?
|
|
228
|
+
*
|
|
229
|
+
* Reads the manifest stored at import rather than the one on disk, so the
|
|
230
|
+
* answer is the one the operator's `module import` actually validated.
|
|
231
|
+
*/
|
|
232
|
+
function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean {
|
|
233
|
+
const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
234
|
+
const manifest = row?.manifestData;
|
|
235
|
+
if (!manifest) return false;
|
|
236
|
+
|
|
237
|
+
type Declarations = { capabilities?: { name?: string }[] } | undefined;
|
|
238
|
+
const declared = [
|
|
239
|
+
...((manifest.requires as Declarations)?.capabilities ?? []),
|
|
240
|
+
...((manifest.optional as Declarations)?.capabilities ?? []),
|
|
241
|
+
];
|
|
242
|
+
return declared.some((cap) => cap?.name === capabilityName);
|
|
243
|
+
}
|
|
244
|
+
|
|
222
245
|
export async function loadCapabilityFunctions(
|
|
223
246
|
consumingModuleId: string,
|
|
224
247
|
db: DbClient,
|
|
@@ -380,6 +403,11 @@ export async function loadCapabilityFunctions(
|
|
|
380
403
|
// which made a module-provided capability structurally unable to
|
|
381
404
|
// offer `unregisterRoutes()`-style methods.
|
|
382
405
|
consumerModuleId: consumingModuleId,
|
|
406
|
+
// WHERE THE CALLER'S BYTES ARE, for the same reason and by the same
|
|
407
|
+
// seam. `private_web` and `external_web` publish static sites too, and
|
|
408
|
+
// without this they could not find the caller's web root now that
|
|
409
|
+
// `sourceDir` has left the request (D10 amendment).
|
|
410
|
+
consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
383
411
|
});
|
|
384
412
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
385
413
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -563,6 +591,9 @@ export async function loadCapabilityFunctions(
|
|
|
563
591
|
providerByCapability.set(provider.capabilityName, provider.moduleId);
|
|
564
592
|
result.public_web = createPublicWeb({
|
|
565
593
|
moduleId: consumingModuleId,
|
|
594
|
+
// Resolved here, not in the capability: on a converge there is no
|
|
595
|
+
// consumer process to ask (D10 amendment).
|
|
596
|
+
webRoot: resolveModuleWebRoot(consumingModuleId, db),
|
|
566
597
|
logger,
|
|
567
598
|
config: providerConfig,
|
|
568
599
|
secrets: providerSecrets,
|
|
@@ -641,6 +672,30 @@ export async function loadCapabilityFunctions(
|
|
|
641
672
|
debugLog('public_web: not registered in DB, skipping');
|
|
642
673
|
}
|
|
643
674
|
|
|
675
|
+
// Framework-granted, so unlike everything above there is no provider row to
|
|
676
|
+
// look up and no script to import — celilo IS the management server whose
|
|
677
|
+
// principals these are (web-ui-console D7b).
|
|
678
|
+
//
|
|
679
|
+
// Gated on the DECLARATION, which the rest of this function deliberately is
|
|
680
|
+
// not: the loop injects every capability it can build "not just required
|
|
681
|
+
// ones", so a module that never asked still gets `idp` and `firewall`. That is
|
|
682
|
+
// fine for a capability whose worst outcome is an unused OIDC client. This one
|
|
683
|
+
// mints an SSH principal into celilo's own control plane, so the `requires`
|
|
684
|
+
// line is the authorization and a module that did not write one does not get
|
|
685
|
+
// the object at all.
|
|
686
|
+
if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
|
|
687
|
+
// Wrapped like every other capability, so an enrolment that fails mid-deploy
|
|
688
|
+
// leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
|
|
689
|
+
// only a thrown error further up. The `defineCapabilityFunction` path wraps
|
|
690
|
+
// itself; a framework-built table has to be wrapped here.
|
|
691
|
+
result.control_plane_api = wrapWithLogging(
|
|
692
|
+
buildControlPlaneApi(consumingModuleId),
|
|
693
|
+
logger,
|
|
694
|
+
'control_plane_api',
|
|
695
|
+
);
|
|
696
|
+
debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
644
699
|
// celilo#1072: the CALL is the binding, not the resolution. Everything above
|
|
645
700
|
// is injected whether or not the consumer declared it — the loop's own
|
|
646
701
|
// comment says "not just required ones" — so recording what was resolved
|
|
@@ -1104,20 +1159,19 @@ async function buildFirewallChain(
|
|
|
1104
1159
|
// attributable. Only the layers that render their own ruleset receive it.
|
|
1105
1160
|
const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
|
|
1106
1161
|
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
unknown
|
|
1112
|
-
>;
|
|
1113
|
-
return data.has_external === true;
|
|
1114
|
-
});
|
|
1162
|
+
// The delegation order, most downstream first. Shared with the console's
|
|
1163
|
+
// closure so the wiring and the picture cannot disagree about which firewall
|
|
1164
|
+
// stands on which — see `orderFirewallChain`.
|
|
1165
|
+
const chainOrder = orderFirewallChain(allProviders);
|
|
1115
1166
|
|
|
1116
|
-
if (
|
|
1167
|
+
if (chainOrder.length === 0) {
|
|
1117
1168
|
debugLog('firewall chain: no provider with external interface found');
|
|
1118
1169
|
return { chain: null, self: null };
|
|
1119
1170
|
}
|
|
1120
1171
|
|
|
1172
|
+
// The leaf is the upstream end: the provider with the direct internet leg.
|
|
1173
|
+
const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
|
|
1174
|
+
|
|
1121
1175
|
// Build the leaf (external) firewall first
|
|
1122
1176
|
const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
|
|
1123
1177
|
if (!leafModule) return { chain: null, self: null };
|
|
@@ -1173,7 +1227,10 @@ async function buildFirewallChain(
|
|
|
1173
1227
|
// Build downstream providers, wiring each to the upstream. iptables
|
|
1174
1228
|
// remains a legacy factory because its second arg (upstreamFirewall)
|
|
1175
1229
|
// doesn't fit defineCapabilityFunction's single-context shape.
|
|
1176
|
-
|
|
1230
|
+
// Back into wiring order: each layer is built taking the previous one as its
|
|
1231
|
+
// upstream, so the build runs from the leaf outwards while the chain reads
|
|
1232
|
+
// from the consumer inwards.
|
|
1233
|
+
const downstream = chainOrder.slice(0, -1).reverse();
|
|
1177
1234
|
|
|
1178
1235
|
// Each layer as its OWN provider sees it, so `on_consumer_removed` converges
|
|
1179
1236
|
// the firewall that declares the hook rather than whichever layer a consumer
|