@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.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- 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-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- 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 +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -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 +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -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/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -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/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- 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)
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
|
287
|
-
: await executeBuildScript(modulePath, script as string
|
|
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,
|
|
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,
|
|
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
|
+
});
|