@celilo/cli 1.14.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. package/src/services/static-content-converge.ts +299 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Module Build Service
3
3
  *
4
- * Executes module build scripts (Ansible playbooks) in Nix environments
4
+ * Executes module build scripts (Ansible playbooks)
5
5
  * to compile custom software binaries before deployment.
6
6
  *
7
7
  * Example: Caddy with RFC2136 DNS provider
@@ -40,65 +40,26 @@ export interface BuildStatusRecord {
40
40
  buildLog?: string;
41
41
  }
42
42
 
43
- /**
44
- * Detect if module uses Nix environment
45
- * Policy function - checks for flake.nix existence
46
- *
47
- * @param modulePath - Path to module directory
48
- * @returns True if flake.nix exists
49
- */
50
- function detectNixEnvironment(modulePath: string): boolean {
51
- const flakePath = `${modulePath}/flake.nix`;
52
- return existsSync(flakePath);
53
- }
54
-
55
- /**
56
- * Check if nix command is available
57
- * Policy function - validates nix availability
58
- *
59
- * @returns True if nix command can be executed
60
- */
61
- async function isNixAvailable(): Promise<boolean> {
62
- const { spawn } = await import('node:child_process');
63
-
64
- return new Promise((resolve) => {
65
- const child = spawn('nix', ['--version'], {
66
- stdio: 'ignore',
67
- });
68
-
69
- child.on('close', (code) => {
70
- resolve(code === 0);
71
- });
72
-
73
- child.on('error', () => {
74
- resolve(false);
75
- });
76
- });
77
- }
78
-
79
43
  /**
80
44
  * Execute build playbook with streaming progress
81
45
  * Execution function - runs ansible-playbook with fuel-gauge progress indicator
82
46
  *
83
47
  * @param modulePath - Path to module directory
84
- * @param playbookPath - Path to build playbook (relative to module)
85
- * @param useNix - Whether to run inside Nix environment
48
+ * @param commandStr - Build command to execute
86
49
  * @returns Build result with output
87
50
  */
88
51
  async function executeBuildCommand(
89
52
  modulePath: string,
90
53
  commandStr: string,
91
- useNix: boolean,
92
54
  ): Promise<{ success: boolean; output: string; error?: string }> {
93
55
  const { executeBuildWithProgress } = await import('./build-stream');
94
- const { shellEscape } = await import('../utils/shell');
95
56
 
96
57
  // build-stream passes through `shell: true`, so spawn re-wraps everything in
97
58
  // `/bin/sh -c`. If we sent ['bash', '-c', commandStr] as command+args, sh
98
59
  // would re-parse the join — `&&` outside the inner quotes splits the chain
99
60
  // and `bun install` runs in modulePath instead of the cd'd subdir. Pass the
100
61
  // full command as one string so sh -c gets a single, unsplit shell command.
101
- const command = useNix ? `nix develop --command bash -c ${shellEscape(commandStr)}` : commandStr;
62
+ const command = commandStr;
102
63
 
103
64
  const result = await executeBuildWithProgress({
104
65
  command,
@@ -117,7 +78,6 @@ async function executeBuildCommand(
117
78
  async function executeBuildScript(
118
79
  modulePath: string,
119
80
  scriptPath: string,
120
- useNix: boolean,
121
81
  ): Promise<{ success: boolean; output: string; error?: string }> {
122
82
  const fullScriptPath = `${modulePath}/${scriptPath}`;
123
83
 
@@ -132,11 +92,7 @@ async function executeBuildScript(
132
92
  let command: string;
133
93
  let args: string[];
134
94
 
135
- if (useNix) {
136
- const innerCommand = isShellScript ? 'bash' : 'ansible-playbook';
137
- command = 'nix';
138
- args = ['develop', '--command', innerCommand, scriptPath];
139
- } else if (isShellScript) {
95
+ if (isShellScript) {
140
96
  command = 'bash';
141
97
  args = [scriptPath];
142
98
  } else {
@@ -207,7 +163,6 @@ function formatBytes(bytes: number): string {
207
163
  * @param artifacts - List of artifact paths
208
164
  * @param status - Build status
209
165
  * @param buildLog - Build output log
210
- * @param environment - Build environment (nix or system)
211
166
  * @param db - Database client
212
167
  */
213
168
  async function recordBuildMetadata(
@@ -216,7 +171,6 @@ async function recordBuildMetadata(
216
171
  artifacts: string[],
217
172
  status: BuildStatus,
218
173
  buildLog: string,
219
- environment: 'nix' | 'system' | null,
220
174
  db: DbClient,
221
175
  ): Promise<void> {
222
176
  await db.insert(moduleBuilds).values({
@@ -225,7 +179,6 @@ async function recordBuildMetadata(
225
179
  artifacts,
226
180
  status,
227
181
  buildLog,
228
- environment,
229
182
  });
230
183
  }
231
184
 
@@ -268,38 +221,18 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
268
221
  };
269
222
  }
270
223
 
271
- // Detect Nix environment and verify availability
272
- const nixDetected = detectNixEnvironment(modulePath);
273
- const nixAvailable = nixDetected ? await isNixAvailable() : false;
274
- const useNix = nixDetected && nixAvailable;
275
- const environment: 'nix' | 'system' = useNix ? 'nix' : 'system';
276
-
277
- if (useNix) {
278
- log.info('Entering Nix environment (flake.nix detected)...');
279
- } else if (nixDetected && !nixAvailable) {
280
- log.warn('flake.nix detected but nix command not available - using system Ansible');
281
- }
282
-
283
224
  try {
284
225
  // Execute build script with streaming progress
285
226
  const buildResult = command
286
- ? await executeBuildCommand(modulePath, command, useNix)
287
- : await executeBuildScript(modulePath, script as string, useNix);
227
+ ? await executeBuildCommand(modulePath, command)
228
+ : await executeBuildScript(modulePath, script as string);
288
229
 
289
230
  // Check if build command failed
290
231
  if (!buildResult.success) {
291
232
  const errorMsg = buildResult.error || 'Build command failed';
292
233
 
293
234
  // Record failed build
294
- await recordBuildMetadata(
295
- moduleId,
296
- manifest.version,
297
- [],
298
- 'failed',
299
- buildResult.output,
300
- environment,
301
- db,
302
- );
235
+ await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildResult.output, db);
303
236
 
304
237
  return {
305
238
  success: false,
@@ -327,7 +260,6 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
327
260
  [],
328
261
  'failed',
329
262
  `${buildLog}\n\n${errorMsg}`,
330
- environment,
331
263
  db,
332
264
  );
333
265
 
@@ -346,15 +278,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
346
278
 
347
279
  // Record successful build
348
280
  const artifactPaths = verification.map((v) => v.path);
349
- await recordBuildMetadata(
350
- moduleId,
351
- manifest.version,
352
- artifactPaths,
353
- 'success',
354
- buildLog,
355
- environment,
356
- db,
357
- );
281
+ await recordBuildMetadata(moduleId, manifest.version, artifactPaths, 'success', buildLog, db);
358
282
 
359
283
  return {
360
284
  success: true,
@@ -364,7 +288,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
364
288
  }
365
289
 
366
290
  // No artifacts defined, just record success
367
- await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, environment, db);
291
+ await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, db);
368
292
 
369
293
  return {
370
294
  success: true,
@@ -376,7 +300,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
376
300
  const buildLog = `Build failed: ${errorMsg}`;
377
301
 
378
302
  // Record failed build
379
- await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, environment, db);
303
+ await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, db);
380
304
 
381
305
  return {
382
306
  success: false,
@@ -30,11 +30,13 @@ import {
30
30
  interviewForMissingSecrets,
31
31
  } from './config-interview';
32
32
  import { getContainerService } from './container-service';
33
+ import { bootstrapControlPlane } from './control-plane-bootstrap';
33
34
  import { executeAnsible } from './deploy-ansible';
34
35
  import { planDeployment } from './deploy-planner';
35
36
  import { waitForSSH } from './deploy-ssh';
36
37
  import { executeTerraform, parseTerraformOutputs } from './deploy-terraform';
37
38
  import { validateAndPrepareDeployment } from './deploy-validation';
39
+ import { CONTROL_PLANE_MODULE_ID } from './deployed-systems';
38
40
  import { getModuleSystems } from './deployed-systems';
39
41
  import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
40
42
  import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
@@ -46,6 +48,7 @@ import {
46
48
  runProviderBackfill,
47
49
  } from './provider-arrival';
48
50
  import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
51
+ import { remoteAccessPolicy } from './remote-access';
49
52
  import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
50
53
  import { buildTerraformEnvForService } from './terraform-env';
51
54
 
@@ -720,6 +723,7 @@ async function deployModuleImpl(
720
723
  capabilities: capabilityFunctions,
721
724
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
722
725
  systems: getModuleSystems(moduleId, db),
726
+ remoteAccess: remoteAccessPolicy(moduleId, db),
723
727
  },
724
728
  );
725
729
 
@@ -858,6 +862,7 @@ async function deployModuleImpl(
858
862
  capabilities: capFns,
859
863
  requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
860
864
  systems: getModuleSystems(moduleId, db),
865
+ remoteAccess: remoteAccessPolicy(moduleId, db),
861
866
  },
862
867
  };
863
868
  },
@@ -1159,6 +1164,7 @@ async function deployModuleImpl(
1159
1164
  capabilities: capFunctions,
1160
1165
  requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
1161
1166
  systems: getModuleSystems(capRecord.moduleId, db),
1167
+ remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
1162
1168
  },
1163
1169
  );
1164
1170
 
@@ -1308,6 +1314,21 @@ async function deployModuleImpl(
1308
1314
  }
1309
1315
  }
1310
1316
 
1317
+ // The provider's own deploy runs the static-content converge alongside
1318
+ // everything else (design D10, task 4.5): the release set is written into
1319
+ // the generated inventory, the playbook's static_content-tagged tasks
1320
+ // converge /srv/www, and a rebuilt host recovers with no consumer
1321
+ // involvement. Same plan + writer the publish-time converge uses — one
1322
+ // code path, two callers.
1323
+ if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
1324
+ const { writeStaticContentVars } = await import('./static-content-converge');
1325
+ const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
1326
+ if (!staticVars.success) {
1327
+ return { success: false, error: staticVars.error, phases };
1328
+ }
1329
+ log.success('Static-content release set written to the inventory');
1330
+ }
1331
+
1311
1332
  try {
1312
1333
  const ansibleResult = await executeAnsible(generatedPath, {
1313
1334
  noInteractive: !process.stdout.isTTY,
@@ -1332,6 +1353,55 @@ async function deployModuleImpl(
1332
1353
  // container ID/IP got assigned per module in the DB.
1333
1354
  }
1334
1355
 
1356
+ // celilo initialises its own box, in its own process.
1357
+ //
1358
+ // This was `celilo-mgmt`'s `on_install` hook until celilo#1225. The hook
1359
+ // reached every one of these operations by spawning the `celilo` CLI,
1360
+ // which a jailed hook cannot do — the mount set binds no `/usr/bin`, no
1361
+ // `/usr/local/bin` and no shell — so the deploy ran Ansible to completion
1362
+ // and then died in its own install hook on any host with a jail backend.
1363
+ //
1364
+ // It runs at the point the hook used to, for the reason the hook ran
1365
+ // there: the Ansible role has just installed celilo and started the
1366
+ // dispatcher, and none of this can be read before that.
1367
+ if (moduleId === CONTROL_PLANE_MODULE_ID) {
1368
+ log.success('Initializing celilo management state');
1369
+ try {
1370
+ const bootstrap = await bootstrapControlPlane({ db });
1371
+
1372
+ log.info(` DNS: primary ${bootstrap.dns.primary}, fallback ${bootstrap.dns.fallback}`);
1373
+ for (const applied of bootstrap.network.applied) {
1374
+ log.info(` Network: ${applied}`);
1375
+ }
1376
+ if (bootstrap.network.skipped) {
1377
+ log.warn(` Network not recorded: ${bootstrap.network.skipped}`);
1378
+ }
1379
+ // The operator authorises this key on every machine celilo manages,
1380
+ // so it is printed rather than merely stored. It was the loudest line
1381
+ // the hook produced and it stays the loudest line here.
1382
+ log.success(
1383
+ `celilo fleet SSH key — add this public key to machines celilo will manage:\n${bootstrap.fleetKey.publicKey}`,
1384
+ );
1385
+
1386
+ if (bootstrap.dispatcher.status === 'fail') {
1387
+ // A management plane with no dispatcher is broken: event-driven
1388
+ // reconciles never deliver. Fail the install, do not whisper.
1389
+ log.error(`Dispatcher check failed: ${bootstrap.dispatcher.summary}`);
1390
+ return {
1391
+ success: false,
1392
+ phases,
1393
+ error:
1394
+ `celilo-mgmt: ${bootstrap.dispatcher.summary}. ${bootstrap.dispatcher.remediation ?? ''}`.trim(),
1395
+ };
1396
+ }
1397
+ log.success(`Event dispatcher is live (${bootstrap.dispatcher.summary})`);
1398
+ } catch (err) {
1399
+ const detail = err instanceof Error ? err.message : String(err);
1400
+ log.error(`Initializing celilo management state failed: ${detail}`);
1401
+ return { success: false, phases, error: detail };
1402
+ }
1403
+ }
1404
+
1335
1405
  // Run on_install hook (post-deploy actions like port forwarding, DNS registration)
1336
1406
  if (manifest.hooks?.on_install) {
1337
1407
  const onInstallDef = manifest.hooks.on_install;
@@ -1412,6 +1482,7 @@ async function deployModuleImpl(
1412
1482
  capabilities: capFns,
1413
1483
  requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
1414
1484
  systems: getModuleSystems(moduleId, db),
1485
+ remoteAccess: remoteAccessPolicy(moduleId, db),
1415
1486
  },
1416
1487
  };
1417
1488
  },
@@ -0,0 +1,223 @@
1
+ /**
2
+ * D12's reachability policy (hook-process-boundary stage 3, tasks 5.1b/5.2).
3
+ *
4
+ * The rules under test, in the order they short-circuit:
5
+ * 1. a request on the module's OWN credential is allowed — the credential is
6
+ * the scope;
7
+ * 2. an explicit non-root user is allowed — the fleet key's authority is
8
+ * root on fleet systems, and a non-root account trusts it only where an
9
+ * operator installed it (the cPanel bootstrap);
10
+ * 3. the target is a system the module or one of its instances provisioned;
11
+ * 4. or it is a machine in celilo's pool that this module's own config names
12
+ * — the case a config-only manager module needs, since it provisions
13
+ * nothing and so can never satisfy rule 3 (celilo#1225);
14
+ *
15
+ * and otherwise a refusal names the module, the target, and the capability
16
+ * route.
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { existsSync } from 'node:fs';
21
+ import { rm } from 'node:fs/promises';
22
+ import { type DbClient, createDbClient } from '../db/client';
23
+ import {
24
+ capabilities,
25
+ machines,
26
+ moduleConfigs,
27
+ moduleInstances,
28
+ moduleSystems,
29
+ modules,
30
+ } from '../db/schema';
31
+ import { remoteAccessPolicy } from './remote-access';
32
+
33
+ const TEST_DB_PATH = './test-remote-access.db';
34
+
35
+ function seedModule(db: DbClient, id: string): void {
36
+ db.insert(modules)
37
+ .values({
38
+ id,
39
+ name: id,
40
+ version: '1.0.0',
41
+ manifestData: {},
42
+ sourcePath: `/tmp/${id}`,
43
+ state: 'VERIFIED',
44
+ })
45
+ .run();
46
+ }
47
+
48
+ function seedSystem(db: DbClient, moduleId: string, ipv4Address: string, hostname: string): void {
49
+ db.insert(moduleSystems)
50
+ .values({
51
+ moduleId,
52
+ name: 'main',
53
+ hostname,
54
+ ipv4Address,
55
+ zone: 'dmz',
56
+ infraType: 'machine',
57
+ updatedAt: new Date(),
58
+ })
59
+ .run();
60
+ }
61
+
62
+ /** A box the operator admitted to celilo's pool. */
63
+ function seedPoolMachine(db: DbClient, ipAddress: string, hostname: string): void {
64
+ db.insert(machines)
65
+ .values({
66
+ id: `machine-${hostname}`,
67
+ hostname,
68
+ zone: 'internal',
69
+ ipAddress,
70
+ sshUser: 'root',
71
+ sshKeyEncrypted: 'x',
72
+ hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 8 },
73
+ })
74
+ .run();
75
+ }
76
+
77
+ /** A config value the operator set on a module. */
78
+ function seedConfig(db: DbClient, moduleId: string, key: string, value: string): void {
79
+ db.insert(moduleConfigs).values({ moduleId, key, value }).run();
80
+ }
81
+
82
+ describe('remoteAccessPolicy', () => {
83
+ let db: DbClient;
84
+
85
+ beforeEach(() => {
86
+ db = createDbClient({ path: TEST_DB_PATH });
87
+ seedModule(db, 'caddy');
88
+ seedSystem(db, 'caddy', '10.0.10.10', 'www');
89
+ seedModule(db, 'homebridge');
90
+ seedSystem(db, 'homebridge', '10.0.30.7', 'homebridge');
91
+ db.insert(capabilities)
92
+ .values({ moduleId: 'caddy', capabilityName: 'public_web', version: '1.0.0', data: {} })
93
+ .run();
94
+ });
95
+
96
+ afterEach(async () => {
97
+ db.$client.close();
98
+ for (const suffix of ['', '-shm', '-wal']) {
99
+ const p = `${TEST_DB_PATH}${suffix}`;
100
+ if (existsSync(p)) await rm(p);
101
+ }
102
+ });
103
+
104
+ test("allows the module's own system", () => {
105
+ const policy = remoteAccessPolicy('caddy', db);
106
+ expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, false)).toEqual({ allowed: true });
107
+ });
108
+
109
+ test("allows an instance's system, one level deep", () => {
110
+ seedModule(db, 'forgejo-runner-abc123def456');
111
+ db.insert(moduleInstances)
112
+ .values({
113
+ moduleId: 'forgejo-runner-abc123def456',
114
+ parentId: 'homebridge',
115
+ submodule: 'runner',
116
+ instanceKey: 'lab',
117
+ state: 'ready',
118
+ })
119
+ .run();
120
+ seedSystem(db, 'forgejo-runner-abc123def456', '10.0.30.9', 'runner-lab');
121
+
122
+ const policy = remoteAccessPolicy('homebridge', db);
123
+ expect(policy.checkTarget({ ipv4_address: '10.0.30.9' }, false)).toEqual({ allowed: true });
124
+ });
125
+
126
+ test("refuses another module's system, naming module, target and the capability route", () => {
127
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
128
+ { ipv4_address: '10.0.10.10' },
129
+ false,
130
+ );
131
+ expect(verdict.allowed).toBe(false);
132
+ if (verdict.allowed) throw new Error('unreachable');
133
+ expect(verdict.message).toContain("Module 'homebridge'");
134
+ expect(verdict.message).toContain('10.0.10.10');
135
+ expect(verdict.message).toContain("belongs to module 'caddy'");
136
+ expect(verdict.message).toContain('public_web');
137
+ });
138
+
139
+ test('refuses an address no deployed system has, with the generic guidance', () => {
140
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
141
+ { ipv4_address: '203.0.113.50' },
142
+ false,
143
+ );
144
+ expect(verdict.allowed).toBe(false);
145
+ if (verdict.allowed) throw new Error('unreachable');
146
+ expect(verdict.message).toContain("Module 'homebridge'");
147
+ expect(verdict.message).toContain('203.0.113.50');
148
+ expect(verdict.message).toContain('capability');
149
+ });
150
+
151
+ test('allows any target when the request carries its own credential', () => {
152
+ const policy = remoteAccessPolicy('homebridge', db);
153
+ expect(policy.checkTarget({ ipv4_address: '10.0.10.10' }, true)).toEqual({ allowed: true });
154
+ });
155
+
156
+ test('allows an explicit non-root user (the off-fleet account case)', () => {
157
+ const policy = remoteAccessPolicy('generic-cpanel-hosting-provider', db);
158
+ expect(
159
+ policy.checkTarget({ ipv4_address: '198.51.100.7', user: 'peba-hosting' }, false),
160
+ ).toEqual({ allowed: true });
161
+ });
162
+
163
+ test("an explicit 'root' user gets no such pass", () => {
164
+ const verdict = remoteAccessPolicy('homebridge', db).checkTarget(
165
+ { ipv4_address: '10.0.10.10', user: 'root' },
166
+ false,
167
+ );
168
+ expect(verdict.allowed).toBe(false);
169
+ });
170
+
171
+ describe('a config-only module managing a pool machine (celilo#1225)', () => {
172
+ // `iptables` is the archetype: it configures a pre-existing firewall and is
173
+ // deployed onto nothing, so `module_systems` holds no row for it EVER. Rule
174
+ // 3 is unsatisfiable for it by construction, not by timing.
175
+ const FIREWALL = '10.226.1.254';
176
+
177
+ beforeEach(() => {
178
+ seedModule(db, 'iptables');
179
+ });
180
+
181
+ test("allows a pool machine this module's config names", () => {
182
+ seedPoolMachine(db, FIREWALL, 'fw-main');
183
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
184
+
185
+ expect(
186
+ remoteAccessPolicy('iptables', db).checkTarget({ ipv4_address: FIREWALL }, false),
187
+ ).toEqual({ allowed: true });
188
+ });
189
+
190
+ test('pool membership ALONE is not authorization', () => {
191
+ // Otherwise any module could reach every box celilo knows about, which is
192
+ // far wider than the rule this policy states.
193
+ seedPoolMachine(db, FIREWALL, 'fw-main');
194
+
195
+ const result = remoteAccessPolicy('iptables', db).checkTarget(
196
+ { ipv4_address: FIREWALL },
197
+ false,
198
+ );
199
+ expect(result.allowed).toBe(false);
200
+ });
201
+
202
+ test('config ALONE is not authorization', () => {
203
+ // A module names its own config keys. If naming an address were enough, a
204
+ // module could point itself at any box in the world.
205
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
206
+
207
+ const result = remoteAccessPolicy('iptables', db).checkTarget(
208
+ { ipv4_address: FIREWALL },
209
+ false,
210
+ );
211
+ expect(result.allowed).toBe(false);
212
+ });
213
+
214
+ test("one module's config does not unlock another module's box", () => {
215
+ seedPoolMachine(db, FIREWALL, 'fw-main');
216
+ seedConfig(db, 'iptables', 'firewall_ip', FIREWALL);
217
+
218
+ // caddy has its own systems and no config naming the firewall.
219
+ const result = remoteAccessPolicy('caddy', db).checkTarget({ ipv4_address: FIREWALL }, false);
220
+ expect(result.allowed).toBe(false);
221
+ });
222
+ });
223
+ });