@celilo/cli 1.13.0 → 2.0.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 +31 -5
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +251 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- 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 +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -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 +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -0
- package/src/services/restore-from-file.ts +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system ensure-fleet-key` — mint celilo's fleet SSH keypair if it
|
|
3
|
+
* is absent, record its public half, and print it.
|
|
4
|
+
*
|
|
5
|
+
* The celilo-side half of "minting the fleet key is not the module's job"
|
|
6
|
+
* (openspec/changes/hook-process-boundary, design D9b). celilo-mgmt's
|
|
7
|
+
* `on_install` used to generate the keypair itself, writing into celilo's
|
|
8
|
+
* data directory from inside a hook — the one directory the hook jail
|
|
9
|
+
* exists to keep out of the mount set. The hook now asks for the key
|
|
10
|
+
* through this command and receives the public half, rather than producing
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: an existing key is reused. Re-keying would strand every
|
|
14
|
+
* machine whose authorized_keys holds the old public half.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getDb } from '../../db/client';
|
|
18
|
+
import { ensureFleetKey, getFleetSshDir } from '../../services/fleet-key';
|
|
19
|
+
import { initializeSystem } from '../../services/system-init';
|
|
20
|
+
import type { CommandResult } from '../types';
|
|
21
|
+
|
|
22
|
+
export async function handleSystemEnsureFleetKey(): Promise<CommandResult> {
|
|
23
|
+
let key: ReturnType<typeof ensureFleetKey>;
|
|
24
|
+
try {
|
|
25
|
+
key = ensureFleetKey();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
success: false,
|
|
29
|
+
error: `Could not create the fleet SSH key in ${getFleetSshDir()}: ${
|
|
30
|
+
err instanceof Error ? err.message : String(err)
|
|
31
|
+
}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
initializeSystem(getDb(), { 'ssh.public_key': key.publicKey });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: `Fleet key is on disk but recording ssh.public_key failed: ${
|
|
41
|
+
err instanceof Error ? err.message : String(err)
|
|
42
|
+
}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The public key alone, and `rawOutput` to say so on the record: this
|
|
47
|
+
// command exists to be read by celilo-mgmt's on_install as much as by an
|
|
48
|
+
// operator, and the key IS the answer either way (an operator pastes it into
|
|
49
|
+
// a machine's authorized_keys). Wrapping it in prose would make the caller
|
|
50
|
+
// parse for it, which is how a hook ends up depending on a sentence.
|
|
51
|
+
return { success: true, message: key.publicKey, rawOutput: true };
|
|
52
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { getDb } from '../db/client';
|
|
|
8
8
|
import { capabilities, modules } from '../db/schema';
|
|
9
9
|
import type { ModuleManifest } from '../manifest/schema';
|
|
10
10
|
import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source';
|
|
11
|
+
import { HEALTH_COVERAGE_CHECK } from '../services/alerting/health-coverage';
|
|
12
|
+
import { HOOK_JAIL_CHECK } from '../services/alerting/hook-jail';
|
|
11
13
|
import { listPrincipals } from '../services/api-access';
|
|
12
14
|
import { listBackups } from '../services/backup-metadata';
|
|
13
15
|
import { listBackupStorages } from '../services/backup-storage';
|
|
@@ -532,7 +534,9 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
532
534
|
|
|
533
535
|
// Monitor targets - a module ID or one of celilo's own schedulable checks.
|
|
534
536
|
// Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so
|
|
535
|
-
// a new built-in check is completable the moment it is schedulable.
|
|
537
|
+
// a new built-in check is completable the moment it is schedulable. The two
|
|
538
|
+
// local-state checks (health coverage, hook jail) are deliberately not in
|
|
539
|
+
// that list — it is the audit categories — so they ride alongside by name.
|
|
536
540
|
if (
|
|
537
541
|
command === 'monitor' &&
|
|
538
542
|
(args[1] === 'add' ||
|
|
@@ -548,7 +552,10 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
548
552
|
.from(modules)
|
|
549
553
|
.all()
|
|
550
554
|
.map((m) => m.id);
|
|
551
|
-
return filterSuggestions(
|
|
555
|
+
return filterSuggestions(
|
|
556
|
+
[...SCHEDULABLE_BUILTIN_CHECKS, HEALTH_COVERAGE_CHECK, HOOK_JAIL_CHECK, ...moduleIds],
|
|
557
|
+
args[2] || '',
|
|
558
|
+
);
|
|
552
559
|
}
|
|
553
560
|
|
|
554
561
|
// Alerts subcommands
|
|
@@ -674,6 +681,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
674
681
|
'init',
|
|
675
682
|
'apply-config',
|
|
676
683
|
'discover-network',
|
|
684
|
+
'ensure-fleet-key',
|
|
677
685
|
'config',
|
|
678
686
|
'secret',
|
|
679
687
|
'vault-password',
|
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';
|
|
@@ -618,7 +619,7 @@ Subcommands:
|
|
|
618
619
|
show-config <id> Show all config including auto-derived values
|
|
619
620
|
show-zone <id> Show module's network zone and config
|
|
620
621
|
|
|
621
|
-
build <module-id> Execute build scripts (
|
|
622
|
+
build <module-id> Execute module build scripts (Ansible)
|
|
622
623
|
|
|
623
624
|
generate <id> Generate infrastructure code for module
|
|
624
625
|
Options:
|
|
@@ -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
|
/**
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
* caddy-internal" for a further round after that. Prose did not hold this.
|
|
16
16
|
*/
|
|
17
17
|
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
18
20
|
import { parse } from 'yaml';
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
import.meta.url,
|
|
23
|
-
).pathname;
|
|
22
|
+
const MODULE_DIR = new URL('../../../../modules/celilo-web-console/', import.meta.url).pathname;
|
|
23
|
+
const MANIFEST_PATH = join(MODULE_DIR, 'manifest.yml');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Capabilities that give something a way in from the data plane.
|
|
@@ -73,3 +73,81 @@ describe('celilo-web-console stays in the control plane', () => {
|
|
|
73
73
|
expect(manifest.provides?.capabilities ?? []).toEqual([]);
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The build output the manifest's `build:` step writes, which is not source.
|
|
79
|
+
*
|
|
80
|
+
* Excluded from the walk below for two reasons, and the second is the one that
|
|
81
|
+
* matters. It holds two ~100MB compiled binaries, so reading it as text is
|
|
82
|
+
* absurd. And it holds a minified React bundle, which contains almost every
|
|
83
|
+
* short string you could think to search for — so scanning it reports ingress
|
|
84
|
+
* that nobody wrote. Both are gitignored, so on a fresh checkout the directory
|
|
85
|
+
* is absent entirely and the walk would silently be checking a different set of
|
|
86
|
+
* files than it checks on a machine that has run a build.
|
|
87
|
+
*/
|
|
88
|
+
const BUILD_OUTPUT_DIR = 'files';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every source file in the module's deployable body.
|
|
92
|
+
*
|
|
93
|
+
* A walk rather than a list, because the hole this guards is a file somebody
|
|
94
|
+
* ADDS. Listing today's files would pass over exactly the new Ansible task that
|
|
95
|
+
* opens the port.
|
|
96
|
+
*/
|
|
97
|
+
function deployableBody(): { path: string; text: string }[] {
|
|
98
|
+
const found: { path: string; text: string }[] = [];
|
|
99
|
+
const walk = (dir: string) => {
|
|
100
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
if (entry.name !== BUILD_OUTPUT_DIR) walk(join(dir, entry.name));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const path = join(dir, entry.name);
|
|
106
|
+
found.push({ path, text: readFileSync(path, 'utf8') });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (const sub of ['terraform', 'ansible']) walk(join(MODULE_DIR, sub));
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How a deployable body would open a path in from the data plane.
|
|
115
|
+
*
|
|
116
|
+
* A reverse-proxy vhost, a DNAT or port-forward rule, a `natIp`, or a dedicated
|
|
117
|
+
* ingress address. None belongs on a `secure-mgmt` module, and every one is a
|
|
118
|
+
* plausible one-line addition by someone who wanted to reach the console
|
|
119
|
+
* without bringing up the VPN.
|
|
120
|
+
*/
|
|
121
|
+
const INGRESS_TOKENS = [
|
|
122
|
+
'caddy',
|
|
123
|
+
'reverse_proxy',
|
|
124
|
+
'natip',
|
|
125
|
+
'exposeservice',
|
|
126
|
+
'port_forward',
|
|
127
|
+
'portforward',
|
|
128
|
+
'dnat',
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
describe("the console's deployable body opens no path in", () => {
|
|
132
|
+
test('the walk reaches the terraform and ansible it is checking', () => {
|
|
133
|
+
// Without this the assertion below is a confident statement about an empty
|
|
134
|
+
// set as soon as either directory is renamed — and the same is true on a
|
|
135
|
+
// fresh checkout, where the excluded build output does not exist at all.
|
|
136
|
+
const paths = deployableBody().map((file) => file.path);
|
|
137
|
+
expect(paths.some((path) => path.endsWith('terraform/main.tf.tpl'))).toBe(true);
|
|
138
|
+
expect(paths.some((path) => path.includes('/ansible/roles/'))).toBe(true);
|
|
139
|
+
expect(paths.some((path) => path.endsWith('.service.j2'))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('reads no build output, so a local build cannot change the verdict', () => {
|
|
143
|
+
const paths = deployableBody().map((file) => file.path);
|
|
144
|
+
expect(paths.filter((path) => path.includes(`/${BUILD_OUTPUT_DIR}/`))).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('names no reverse proxy, port forward or ingress address', () => {
|
|
148
|
+
const offenders = deployableBody()
|
|
149
|
+
.filter((file) => INGRESS_TOKENS.some((token) => file.text.toLowerCase().includes(token)))
|
|
150
|
+
.map((file) => file.path.slice(MODULE_DIR.length));
|
|
151
|
+
expect(offenders).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -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 (
|
package/src/db/schema.ts
CHANGED
|
@@ -447,7 +447,6 @@ export const moduleBuilds = sqliteTable('module_builds', {
|
|
|
447
447
|
version: text('version').notNull(),
|
|
448
448
|
builtAt: integer('built_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
449
449
|
artifacts: text('artifacts', { mode: 'json' }).$type<string[]>().notNull(), // Array of artifact paths
|
|
450
|
-
environment: text('environment').$type<'nix' | 'system'>(),
|
|
451
450
|
status: text('status').$type<BuildStatus>().notNull(),
|
|
452
451
|
buildLog: text('build_log'), // Build output for debugging
|
|
453
452
|
});
|