@celilo/cli 1.12.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- 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/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- 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 +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- 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
|
@@ -102,3 +102,39 @@ describe('classifyModulePath: composes with includeNodeModulesPath', () => {
|
|
|
102
102
|
expect(classifyModulePath('node_modules/@celilo/cli/index.js')).toBe('unknown');
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('a submodule tree obeys the same rules as a module tree', () => {
|
|
107
|
+
// openspec/changes/submodules D1: a submodule declares everything an ordinary
|
|
108
|
+
// module declares, so it must be CLASSIFIED like one too. Every rule keys on
|
|
109
|
+
// segments[0], so without the recursion a nested tree gets different answers
|
|
110
|
+
// than the same file one level up.
|
|
111
|
+
test('authored source under a submodule is package', () => {
|
|
112
|
+
expect(classifyModulePath('submodules/runner/manifest.yml')).toBe('package');
|
|
113
|
+
expect(classifyModulePath('submodules/runner/ansible/site.yml')).toBe('package');
|
|
114
|
+
expect(classifyModulePath('submodules/runner/scripts/hook.ts')).toBe('package');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a submodule's e2e tree is excluded, exactly as a module's is", () => {
|
|
118
|
+
expect(classifyModulePath('e2e/thing.ts')).toBe('unknown');
|
|
119
|
+
expect(classifyModulePath('submodules/runner/e2e/thing.ts')).toBe('unknown');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a submodule's derived paths are derived", () => {
|
|
123
|
+
expect(classifyModulePath('submodules/runner/generated/terraform/x.tfstate')).toBe('derived');
|
|
124
|
+
expect(classifyModulePath('submodules/runner/celilo/types.d.ts')).toBe('derived');
|
|
125
|
+
expect(classifyModulePath('submodules/runner/cookies.json')).toBe('derived');
|
|
126
|
+
expect(classifyModulePath('submodules/runner/screenshots/a.png')).toBe('derived');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('source-tree noise under a submodule is still a finding', () => {
|
|
130
|
+
expect(classifyModulePath('submodules/runner/.DS_Store')).toBe('unknown');
|
|
131
|
+
expect(classifyModulePath('submodules/runner/tsconfig.json')).toBe('unknown');
|
|
132
|
+
expect(classifyModulePath('submodules/runner/a.test.ts')).toBe('unknown');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The directories themselves are shipped bytes and belong in the baseline.
|
|
136
|
+
test('the submodules directory itself is package', () => {
|
|
137
|
+
expect(classifyModulePath('submodules')).toBe('package');
|
|
138
|
+
expect(classifyModulePath('submodules/runner')).toBe('package');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -69,10 +69,28 @@ export type ModulePathClass = 'package' | 'derived' | 'unknown';
|
|
|
69
69
|
* composes this function with `includeNodeModulesPath` rather than restating
|
|
70
70
|
* either rule.
|
|
71
71
|
*/
|
|
72
|
+
export const SUBMODULES_DIR = 'submodules';
|
|
73
|
+
|
|
72
74
|
export function classifyModulePath(relPath: string): ModulePathClass {
|
|
73
75
|
const segments = relPath.split('/');
|
|
74
76
|
const name = segments[segments.length - 1] ?? '';
|
|
75
77
|
|
|
78
|
+
// A submodule's tree obeys exactly the same rules as a module's tree
|
|
79
|
+
// (openspec/changes/submodules D1: a submodule declares everything an
|
|
80
|
+
// ordinary module declares). Every rule below keys on `segments[0]`, so
|
|
81
|
+
// without this a nested tree silently gets different answers: a submodule's
|
|
82
|
+
// own `e2e/` would be checksummed where a module's is excluded, because
|
|
83
|
+
// `segments[0]` reads `submodules` rather than `e2e`.
|
|
84
|
+
//
|
|
85
|
+
// Recursing on the remainder means one set of rules rather than two that
|
|
86
|
+
// drift, and every rule applies at both levels: a submodule's `e2e/` is
|
|
87
|
+
// excluded, its `celilo/types.d.ts` and `cookies.json` are derived, its
|
|
88
|
+
// `.DS_Store` is a finding. `submodules/` and `submodules/<name>` themselves
|
|
89
|
+
// stay `package`, which is right: the directories ARE shipped bytes.
|
|
90
|
+
if (segments[0] === SUBMODULES_DIR && segments.length > 2) {
|
|
91
|
+
return classifyModulePath(segments.slice(2).join('/'));
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
// The module's own e2e/ tree is tests plus their deps, including a
|
|
77
95
|
// node_modules of its own. Excluded whole, before anything below.
|
|
78
96
|
if (segments[0] === 'e2e') return 'unknown';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a module's built web site lives on this host.
|
|
3
|
+
*
|
|
4
|
+
* ONE application of the `site/dist` convention. It is deliberately not applied
|
|
5
|
+
* inside the providers: `public_web` is framework-provided, but `private_web`
|
|
6
|
+
* and `external_web` are implemented by modules (`caddy-internal`,
|
|
7
|
+
* `generic-cpanel-hosting-provider`), and three copies of one convention is how
|
|
8
|
+
* the copies drift.
|
|
9
|
+
*
|
|
10
|
+
* Core is also the only party that CAN resolve it in the case that matters.
|
|
11
|
+
* When the `public_web` provider converges a rebuilt host, no consumer is
|
|
12
|
+
* running to be asked where its bytes are (design D10). So the path has to come
|
|
13
|
+
* from the module id, and `modules.source_path` is what core already holds.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { MODULE_WEB_ROOT } from '@celilo/capabilities';
|
|
18
|
+
import { eq } from 'drizzle-orm';
|
|
19
|
+
import type { DbClient } from '../db/client';
|
|
20
|
+
import { modules } from '../db/schema';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path to `<module root>/site/dist`, or undefined when no such module
|
|
24
|
+
* is installed.
|
|
25
|
+
*
|
|
26
|
+
* Does NOT check that the directory exists. Resolving a path and deciding
|
|
27
|
+
* whether it is usable are different jobs, and the second one belongs where the
|
|
28
|
+
* error can name the caller (Rule 10.1). `createPublicWeb`'s `requireWebRoot`
|
|
29
|
+
* does the existence check and says what to ship.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveModuleWebRoot(moduleId: string, db: DbClient): string | undefined {
|
|
32
|
+
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
33
|
+
if (!module) return undefined;
|
|
34
|
+
return join(module.sourcePath, MODULE_WEB_ROOT);
|
|
35
|
+
}
|
|
@@ -77,6 +77,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
77
77
|
version: '1.0.0',
|
|
78
78
|
hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
|
|
79
79
|
},
|
|
80
|
+
control_plane_api: {
|
|
81
|
+
version: '1.0.0',
|
|
82
|
+
hash: '2c5d41d37a9a828a8b9a6dc633a2d48f24670d0831d879ec18426f0f9f8e5be5',
|
|
83
|
+
},
|
|
80
84
|
private_web: {
|
|
81
85
|
version: '1.0.0',
|
|
82
86
|
hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
|
|
@@ -85,6 +89,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
|
|
|
85
89
|
version: '1.0.0',
|
|
86
90
|
hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
|
|
87
91
|
},
|
|
92
|
+
celilo_module_deploy_worker: {
|
|
93
|
+
version: '1.0.0',
|
|
94
|
+
hash: '0b0e1f26aab0ebcf5d28283b9c5ba711f6d182f8f7ad474379c4ab02c354a2b8',
|
|
95
|
+
},
|
|
88
96
|
web_routes: {
|
|
89
97
|
version: '1.0.0',
|
|
90
98
|
hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
|
|
@@ -111,6 +111,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
111
111
|
count: 1,
|
|
112
112
|
why: 'PERMANENT — the public_web implementation naming its own table; the capability package doing its job',
|
|
113
113
|
},
|
|
114
|
+
{
|
|
115
|
+
file: 'packages/capabilities/src/capability-contract.ts',
|
|
116
|
+
capability: 'control_plane_api',
|
|
117
|
+
count: 1,
|
|
118
|
+
why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
|
|
119
|
+
},
|
|
114
120
|
{
|
|
115
121
|
file: 'packages/capabilities/src/capability-contract.ts',
|
|
116
122
|
capability: 'web_routes',
|
|
@@ -249,6 +255,18 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
249
255
|
count: 1,
|
|
250
256
|
why: "PERMANENT — validating a manifest's declared capability names against the registry is core's job",
|
|
251
257
|
},
|
|
258
|
+
{
|
|
259
|
+
file: 'apps/celilo/src/manifest/validate.ts',
|
|
260
|
+
capability: 'control_plane_api',
|
|
261
|
+
count: 1,
|
|
262
|
+
why: 'PERMANENT — framework-granted, so celilo satisfies it and no module provides it; the resolver has to be told that by name (web-ui-console D7b)',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
file: 'apps/celilo/src/hooks/capability-loader.ts',
|
|
266
|
+
capability: 'control_plane_api',
|
|
267
|
+
count: 4,
|
|
268
|
+
why: "PERMANENT — framework-granted like web_routes above; core builds the method table because enrolment writes celilo's own api_principals row, which no module script can reach (web-ui-console D7b)",
|
|
269
|
+
},
|
|
252
270
|
{
|
|
253
271
|
file: 'apps/celilo/src/services/alerting/inbound-poller.ts',
|
|
254
272
|
capability: 'notification',
|
|
@@ -327,6 +345,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
327
345
|
count: 1,
|
|
328
346
|
why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
|
|
329
347
|
},
|
|
348
|
+
{
|
|
349
|
+
file: 'packages/capabilities/src/capability-contract.ts',
|
|
350
|
+
capability: 'celilo_module_deploy_worker',
|
|
351
|
+
count: 1,
|
|
352
|
+
why: 'PERMANENT — X10, a contract declaration with no implementation; this is the package doing its job',
|
|
353
|
+
},
|
|
330
354
|
{
|
|
331
355
|
file: 'packages/capabilities/src/capability-contract.ts',
|
|
332
356
|
capability: 'control_plane_vpn',
|
|
@@ -408,8 +432,8 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
|
|
|
408
432
|
{
|
|
409
433
|
file: 'packages/capabilities/src/firewall.ts',
|
|
410
434
|
capability: 'firewall',
|
|
411
|
-
count:
|
|
412
|
-
why: 'PERMANENT — X10, the firewall capability contract',
|
|
435
|
+
count: 3,
|
|
436
|
+
why: 'PERMANENT — X10, the firewall capability contract. The third is FIREWALL_CAPABILITY_NAME, exported so core queries the provider registry without spelling the literal; it moved a name OUT of apps/celilo/src/console/projection.ts rather than adding one',
|
|
413
437
|
},
|
|
414
438
|
{
|
|
415
439
|
file: 'packages/capabilities/src/public-web.ts',
|
|
@@ -38,6 +38,28 @@ describe('module script scan — SSH rules', () => {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
describe('module script scan — namespace tools (hygiene, not a boundary)', () => {
|
|
42
|
+
const NS = 'namespace escape (bwrap/unshare/nsenter)';
|
|
43
|
+
|
|
44
|
+
// Each token asserted separately and by name. An alternation that silently
|
|
45
|
+
// loses one leg still passes a test that only ever exercises the first.
|
|
46
|
+
it.each([
|
|
47
|
+
['bwrap', 'run(`bwrap --dev-bind / / ${cmd}`);'],
|
|
48
|
+
['unshare', "run('unshare --user --map-root-user id');"],
|
|
49
|
+
['nsenter', "run('nsenter -t 1 -m -- ls /');"],
|
|
50
|
+
['CLONE_NEWUSER', 'const flags = CLONE_NEWUSER | CLONE_NEWNS;'],
|
|
51
|
+
])('catches %s', (_token, src) => {
|
|
52
|
+
expect(rules(src)).toContain(NS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The word boundaries are the whole of this rule's false-positive defence, so
|
|
56
|
+
// the negative case has to be a word that CONTAINS a token. `buildSharedConfig`
|
|
57
|
+
// would pass whether or not the `\b`s are there, which makes it no test at all.
|
|
58
|
+
it('does not fire on an identifier that merely contains a token', () => {
|
|
59
|
+
expect(rules('const unshared = pending.filter((p) => !p.shared);')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
41
63
|
describe('module script scan — raw-exec escape hatch', () => {
|
|
42
64
|
it('flags a runAppCommand call with no justification', () => {
|
|
43
65
|
expect(rules('const r = runAppCommand(system, "rm -f /tmp/x", run);')).toContain(
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* **modules never hand-build SSH, and the one sanctioned raw-exec path is
|
|
4
4
|
* always justified in writing.**
|
|
5
5
|
*
|
|
6
|
+
* It carries a second, weaker rule class as well — the namespace-tool rule
|
|
7
|
+
* below. Read that rule's own comment before touching it: it is hygiene and it
|
|
8
|
+
* is **not** a boundary, and the two classes in this file are not the same kind
|
|
9
|
+
* of thing.
|
|
10
|
+
*
|
|
6
11
|
* ONE definition of the rules, used by both enforcement points:
|
|
7
12
|
*
|
|
8
13
|
* - `apps/celilo/src/policy/no-hand-built-ssh.test.ts` — every in-repo module,
|
|
@@ -63,6 +68,33 @@ const PATTERN_RULES: Array<{ rule: string; re: RegExp; hint: string }> = [
|
|
|
63
68
|
re: /(?:from|require\()\s*['"]ssh2['"]/,
|
|
64
69
|
hint: 'Modules never open their own SSH connection — use the primitives. See MODULE_PRIMITIVES.md.',
|
|
65
70
|
},
|
|
71
|
+
/**
|
|
72
|
+
* HYGIENE, NOT A MITIGATION (openspec/changes/hook-process-boundary, task 4.2e,
|
|
73
|
+
* design D9). The boundary is the jail; this line is a courtesy to an honest
|
|
74
|
+
* author, and describing it as a security control would be a lie in three
|
|
75
|
+
* separate ways:
|
|
76
|
+
*
|
|
77
|
+
* - a hook runs arbitrary code, so anything this pattern catches can be
|
|
78
|
+
* spelled another way by anyone who wants to;
|
|
79
|
+
* - the scan walks in-repo `scripts/` and staged `.netapp` payloads, so it
|
|
80
|
+
* never sees a registry module or an out-of-repo one at all;
|
|
81
|
+
* - celilo#1014 is a live example of an exemption in this very file reading
|
|
82
|
+
* wider than its author intended.
|
|
83
|
+
*
|
|
84
|
+
* What it is for: a module that shells out to `bwrap`/`unshare`/`nsenter`
|
|
85
|
+
* gets a namespace it is uid 0 in, and under the jail that either fails or
|
|
86
|
+
* nests badly. Telling the author at package time beats a confusing runtime
|
|
87
|
+
* failure on the fleet. Task 4.2d is the related-but-different guarantee, and
|
|
88
|
+
* it IS load-bearing: `bwrap` is never in the derived mount set.
|
|
89
|
+
*/
|
|
90
|
+
{
|
|
91
|
+
rule: 'namespace escape (bwrap/unshare/nsenter)',
|
|
92
|
+
re: /\b(?:bwrap|unshare|nsenter)\b|CLONE_NEWUSER/,
|
|
93
|
+
hint:
|
|
94
|
+
'Hooks already run inside a namespace celilo builds; a module creating its own is ' +
|
|
95
|
+
'not supported and will not survive the jail. This is a hygiene check, not a security ' +
|
|
96
|
+
'boundary. If you need isolation the framework does not give you, say what for.',
|
|
97
|
+
},
|
|
66
98
|
];
|
|
67
99
|
|
|
68
100
|
/** A `runAppCommand(` / `runAppCommandWithSecret(` CALL — not the import. */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observed health per module, derived from live alerts and enabled monitors.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `module list` because it now has a second consumer. The web
|
|
5
|
+
* console shows the same column, and celilo has already decided how this is
|
|
6
|
+
* computed: `nextModuleState()` documents why observation must NOT drive a
|
|
7
|
+
* module's lifecycle state, since letting a recurring check write it would
|
|
8
|
+
* redefine VERIFIED from "someone verified this" to "it was up recently" and
|
|
9
|
+
* churn it on every transient failure.
|
|
10
|
+
*
|
|
11
|
+
* So this stays a derivation and never a stored field, and both consumers read
|
|
12
|
+
* it here rather than each growing a copy.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { eq } from 'drizzle-orm';
|
|
16
|
+
import type { DbClient } from '../../db/client';
|
|
17
|
+
import { modules as modulesTable, monitors } from '../../db/schema';
|
|
18
|
+
import { moduleHealthCell } from './format';
|
|
19
|
+
import { loadAllLiveAlerts, summariseByModule } from './store';
|
|
20
|
+
|
|
21
|
+
/** The structured form. `cell` is the display string the CLI column prints. */
|
|
22
|
+
export interface ObservedHealth {
|
|
23
|
+
cell: string;
|
|
24
|
+
monitored: boolean;
|
|
25
|
+
firingCount: number;
|
|
26
|
+
suppressed: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Observed health per module: `ok`, `N firing`, `suppressed`, or
|
|
31
|
+
* `not observed`.
|
|
32
|
+
*
|
|
33
|
+
* Only computed for DEPLOYED modules — a module still being imported has
|
|
34
|
+
* nothing to observe, and reporting it as unwatched would be noise rather
|
|
35
|
+
* than a finding.
|
|
36
|
+
*/
|
|
37
|
+
export function loadObservedHealth(db: DbClient): Map<string, string> {
|
|
38
|
+
return new Map([...loadObservedHealthDetail(db)].map(([id, h]) => [id, h.cell]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The same derivation, with the inputs kept rather than collapsed into a
|
|
43
|
+
* string. The console needs the firing COUNT and the not-observed case as
|
|
44
|
+
* separate facts: it renders them differently, and parsing them back out of a
|
|
45
|
+
* terminal column would be a second, worse implementation.
|
|
46
|
+
*/
|
|
47
|
+
export function loadObservedHealthDetail(db: DbClient): Map<string, ObservedHealth> {
|
|
48
|
+
const monitored = new Set(
|
|
49
|
+
db
|
|
50
|
+
.select({ target: monitors.target })
|
|
51
|
+
.from(monitors)
|
|
52
|
+
.where(eq(monitors.enabled, true))
|
|
53
|
+
.all()
|
|
54
|
+
.map((m) => m.target),
|
|
55
|
+
);
|
|
56
|
+
const byModule = summariseByModule(loadAllLiveAlerts(db));
|
|
57
|
+
|
|
58
|
+
const result = new Map<string, ObservedHealth>();
|
|
59
|
+
for (const module of db.select().from(modulesTable).all()) {
|
|
60
|
+
if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
|
|
61
|
+
|
|
62
|
+
const summary = byModule.get(module.id) ?? [];
|
|
63
|
+
const input = {
|
|
64
|
+
monitored: monitored.has(module.id),
|
|
65
|
+
firingCount: summary.filter((s) => !s.suppressed).length,
|
|
66
|
+
suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
|
|
67
|
+
};
|
|
68
|
+
result.set(module.id, { ...input, cell: moduleHealthCell(input) });
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `control_plane_api`, and the one property that has to hold forever.
|
|
3
|
+
*
|
|
4
|
+
* The console reads celilo's own state. What it must never acquire is the
|
|
5
|
+
* ability to change it — and the way that would happen is not somebody adding
|
|
6
|
+
* `module:deploy` on purpose. It is a new verb appearing in the registry and
|
|
7
|
+
* quietly falling on the wrong side of a grant list nobody re-reads.
|
|
8
|
+
*
|
|
9
|
+
* So the grants are DERIVED, and these assert the derivation rather than a
|
|
10
|
+
* snapshot of its output. A test that pinned today's list would pass forever
|
|
11
|
+
* while the thing it protects rotted.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { COMMANDS, readOnlyGrants } from '@celilo/core';
|
|
18
|
+
import { closeDb } from '../db/client';
|
|
19
|
+
import { getPrincipalByName, isAuthorized } from './api-access';
|
|
20
|
+
import {
|
|
21
|
+
buildControlPlaneApi,
|
|
22
|
+
controlPlaneReadGrants,
|
|
23
|
+
enrolControlPlanePrincipal,
|
|
24
|
+
revokeControlPlanePrincipal,
|
|
25
|
+
} from './api-principal-enrolment';
|
|
26
|
+
|
|
27
|
+
/** A syntactically real ed25519 public key. Nothing here ever sees a private one. */
|
|
28
|
+
const PUBLIC_KEY =
|
|
29
|
+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB7cWlXqPn+aGaNKm2Rt1cwj6cVy0hL0hVXHhWJKtLZ9 celilo-web-console';
|
|
30
|
+
const OTHER_KEY =
|
|
31
|
+
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILlqL4YQ1MDe0J0X2ZQ4pXFqNbXK9y7dQe2Vd1kZ8sWq rotated';
|
|
32
|
+
|
|
33
|
+
describe('the grant set', () => {
|
|
34
|
+
test('is exactly the read-only ops the classifier derives', () => {
|
|
35
|
+
// Not a snapshot. If this were a literal list it would pass forever while a
|
|
36
|
+
// newer celilo added verbs it no longer covered.
|
|
37
|
+
expect(controlPlaneReadGrants()).toEqual(readOnlyGrants(COMMANDS));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('is not empty, so the derivation is doing something', () => {
|
|
41
|
+
// The failure mode a derived list has that a literal one does not: a
|
|
42
|
+
// classifier change makes it return nothing and every read starts failing
|
|
43
|
+
// closed. That is safe and completely broken, so it is asserted.
|
|
44
|
+
expect(controlPlaneReadGrants().length).toBeGreaterThan(10);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('contains no obviously mutating op', () => {
|
|
48
|
+
// A coarse net on purpose. The classifier is the real gate; this catches the
|
|
49
|
+
// case where the classifier itself is changed to admit writes, which no test
|
|
50
|
+
// of the classifier's own output would notice.
|
|
51
|
+
const mutating = /:(deploy|remove|delete|create|set|add|uninstall|restore|grant|revoke|pause)$/;
|
|
52
|
+
expect(controlPlaneReadGrants().filter((op) => mutating.test(op))).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('enrolment', () => {
|
|
57
|
+
let dir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
61
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
62
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
63
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
64
|
+
closeDb();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
closeDb();
|
|
69
|
+
rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('creates a principal carrying the derived grants', async () => {
|
|
73
|
+
const result = await enrolControlPlanePrincipal({
|
|
74
|
+
name: 'celilo-web-console',
|
|
75
|
+
publicKey: PUBLIC_KEY,
|
|
76
|
+
});
|
|
77
|
+
expect(result.created).toBe(true);
|
|
78
|
+
expect(result.grants).toEqual(controlPlaneReadGrants());
|
|
79
|
+
|
|
80
|
+
const stored = await getPrincipalByName('celilo-web-console');
|
|
81
|
+
expect(stored?.publicKey).toBe(PUBLIC_KEY);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('is idempotent, because a redeploy presents the same name again', async () => {
|
|
85
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
86
|
+
const second = await enrolControlPlanePrincipal({
|
|
87
|
+
name: 'celilo-web-console',
|
|
88
|
+
publicKey: PUBLIC_KEY,
|
|
89
|
+
});
|
|
90
|
+
expect(second.created).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a new key for an existing name ROTATES rather than duplicating', async () => {
|
|
94
|
+
// What a module that regenerated its keypair needs. Refusing it would leave
|
|
95
|
+
// a console that cannot authenticate and no way to fix it from the module.
|
|
96
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
97
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: OTHER_KEY });
|
|
98
|
+
expect((await getPrincipalByName('celilo-web-console'))?.publicKey).toBe(OTHER_KEY);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a malformed public key is refused here, not at login', async () => {
|
|
102
|
+
// Otherwise it lands in authorized_keys and fails later with a message
|
|
103
|
+
// about SSH rather than about the thing that was wrong.
|
|
104
|
+
await expect(
|
|
105
|
+
enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: 'not-a-key' }),
|
|
106
|
+
).rejects.toThrow();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('what the enrolled principal may actually do', () => {
|
|
111
|
+
let dir: string;
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
115
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
116
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
117
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
118
|
+
closeDb();
|
|
119
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
closeDb();
|
|
124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('may run the console reads it exists for', async () => {
|
|
128
|
+
expect(await isAuthorized('celilo-web-console', 'console', 'status')).toBe(true);
|
|
129
|
+
expect(await isAuthorized('celilo-web-console', 'console', 'get')).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('may run the other reads the console polls', async () => {
|
|
133
|
+
// `alerts:list`, not bare `alerts`. The API authorises command:subcommand,
|
|
134
|
+
// so a caller that omits the subcommand is refused — which is what the
|
|
135
|
+
// console server did until this test caught it.
|
|
136
|
+
expect(await isAuthorized('celilo-web-console', 'alerts', 'list')).toBe(true);
|
|
137
|
+
expect(await isAuthorized('celilo-web-console', 'alerts')).toBe(false);
|
|
138
|
+
expect(await isAuthorized('celilo-web-console', 'backup', 'list')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('may NOT deploy a module', async () => {
|
|
142
|
+
// The whole point. Everything above is convenience; this is the boundary.
|
|
143
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('may NOT uninstall, restore, or grant', async () => {
|
|
147
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'remove')).toBe(false);
|
|
148
|
+
expect(await isAuthorized('celilo-web-console', 'backup', 'restore')).toBe(false);
|
|
149
|
+
expect(await isAuthorized('celilo-web-console', 'api', 'grant')).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('revocation', () => {
|
|
154
|
+
let dir: string;
|
|
155
|
+
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
// The service reaches for the global `getDb()`, so isolation is by
|
|
158
|
+
// CELILO_DB_PATH rather than by passing a client around.
|
|
159
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
160
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
161
|
+
closeDb();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(() => {
|
|
165
|
+
closeDb();
|
|
166
|
+
rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('removes the principal, so an uninstalled console leaves no working key', async () => {
|
|
170
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
171
|
+
expect(await revokeControlPlanePrincipal('celilo-web-console')).toBe(true);
|
|
172
|
+
expect(await getPrincipalByName('celilo-web-console')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('revoking one that is already gone is false, not a throw', async () => {
|
|
176
|
+
// An uninstall that fails on tidy-up is worse than one finding nothing to
|
|
177
|
+
// tidy, and a hook that throws here leaves the module half-removed.
|
|
178
|
+
expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('the capability handed to a consuming module', () => {
|
|
183
|
+
let dir: string;
|
|
184
|
+
|
|
185
|
+
beforeEach(async () => {
|
|
186
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
|
|
187
|
+
process.env.CELILO_DB_PATH = join(dir, 'test.db');
|
|
188
|
+
closeDb();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
closeDb();
|
|
193
|
+
rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('enrols the caller and reports the grants it may use', async () => {
|
|
197
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
198
|
+
const result = await api.enrol_principal({
|
|
199
|
+
name: 'celilo-web-console',
|
|
200
|
+
publicKey: PUBLIC_KEY,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.created).toBe(true);
|
|
204
|
+
expect(result.grants).toEqual(controlPlaneReadGrants());
|
|
205
|
+
expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('a module may NOT enrol under a neighbour’s name', async () => {
|
|
209
|
+
// Otherwise any module holding this capability rotates the console's key by
|
|
210
|
+
// presenting its own public half under the console's name — the console then
|
|
211
|
+
// holds a principal whose authorized_keys entry it cannot log in with, and
|
|
212
|
+
// the attacker holds one that works.
|
|
213
|
+
const api = buildControlPlaneApi('hello-foo');
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }),
|
|
217
|
+
).rejects.toThrow(/only enrol its own principal/);
|
|
218
|
+
expect(await getPrincipalByName('celilo-web-console')).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a module may NOT revoke a neighbour’s principal', async () => {
|
|
222
|
+
// The denial-of-service half of the same hole: revocation is idempotent and
|
|
223
|
+
// returns false for a name that never existed, so an unguarded revoke gives
|
|
224
|
+
// no signal at all that a module just deleted someone else's access.
|
|
225
|
+
await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
226
|
+
const api = buildControlPlaneApi('hello-foo');
|
|
227
|
+
|
|
228
|
+
await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
|
|
229
|
+
/only revoke its own principal/,
|
|
230
|
+
);
|
|
231
|
+
expect(await getPrincipalByName('celilo-web-console')).not.toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('withdraws its own principal, and finding nothing is not an error', async () => {
|
|
235
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
236
|
+
await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
237
|
+
|
|
238
|
+
expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: true });
|
|
239
|
+
expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: false });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('what it enrols cannot write, whatever the caller asked for', async () => {
|
|
243
|
+
// The grant set is not a parameter, so there is nothing to ask for. This
|
|
244
|
+
// asserts the consequence rather than the absence: the principal the
|
|
245
|
+
// capability produces is refused a deploy.
|
|
246
|
+
const api = buildControlPlaneApi('celilo-web-console');
|
|
247
|
+
await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
|
|
248
|
+
|
|
249
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'list')).toBe(true);
|
|
250
|
+
expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|