@celilo/cli 2.1.0 → 2.2.1
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/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/schemas/system_config.json +7 -1
- package/src/ansible/inventory.test.ts +2 -1
- package/src/api/sessions.test.ts +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/backup-rename.test.ts +2 -1
- package/src/cli/cli.test.ts +2 -1
- package/src/cli/commands/console-get-chain.test.ts +2 -1
- package/src/cli/commands/firewall-interface-list.test.ts +158 -8
- package/src/cli/commands/firewall-interface-list.ts +73 -7
- package/src/cli/commands/machine-add.ts +12 -55
- package/src/cli/commands/module-config.test.ts +22 -2
- package/src/cli/commands/module-deploy.ts +8 -2
- package/src/cli/commands/module-generate.test.ts +53 -0
- package/src/cli/commands/module-generate.ts +31 -26
- package/src/cli/commands/module-import-aspect.test.ts +2 -1
- package/src/cli/commands/module-import-registry.test.ts +2 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-operations.test.ts +2 -1
- package/src/cli/commands/module-publish.test.ts +5 -12
- package/src/cli/commands/module-update.test.ts +87 -4
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/module-upgrade.test.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +54 -2
- package/src/cli/commands/module-verify.test.ts +2 -3
- package/src/cli/commands/module-verify.ts +0 -1
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/notify-config.test.ts +5 -3
- package/src/cli/commands/registry-owner.test.ts +2 -1
- package/src/cli/commands/registry-token.test.ts +2 -1
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
- package/src/cli/commands/system-config.test.ts +148 -0
- package/src/cli/commands/system-config.ts +26 -1
- package/src/cli/commands/system-doctor.test.ts +71 -0
- package/src/cli/commands/system-doctor.ts +110 -24
- package/src/cli/commands/system-init-deprecation.test.ts +6 -3
- package/src/cli/commands/system-migrate.test.ts +2 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +6 -4
- package/src/cli/restore-command.test.ts +2 -1
- package/src/cli/restore-migration-failure.test.ts +160 -0
- package/src/config/paths.test.ts +3 -3
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +62 -135
- package/src/db/migrate.ts +17 -12
- package/src/db/schema.ts +10 -0
- package/src/hooks/broker.test.ts +106 -2
- package/src/hooks/broker.ts +91 -1
- package/src/hooks/capability-loader-firewall.test.ts +37 -0
- package/src/hooks/capability-loader.test.ts +78 -0
- package/src/hooks/capability-loader.ts +52 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +106 -19
- package/src/hooks/executor.ts +120 -13
- package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
- package/src/hooks/hook-jail-unreachability.test.ts +4 -3
- package/src/hooks/hook-protocol.ts +46 -1
- package/src/hooks/hook-runner.ts +36 -0
- package/src/hooks/hook-store-proxy.test.ts +109 -0
- package/src/hooks/hook-store-proxy.ts +85 -0
- package/src/hooks/hook-store.test.ts +168 -0
- package/src/hooks/hook-store.ts +290 -0
- package/src/hooks/hook-timeout.test.ts +3 -2
- package/src/hooks/hook-trespass.test.ts +39 -5
- package/src/hooks/jail.test.ts +62 -3
- package/src/hooks/jail.ts +67 -8
- package/src/hooks/mount-set.test.ts +208 -0
- package/src/hooks/mount-set.ts +62 -14
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
- package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
- package/src/hooks/test-fixtures/store-backed.ts +47 -0
- package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
- package/src/hooks/unjailed-lint.test.ts +22 -6
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/audit.ts +9 -26
- package/src/module/packaging/build-paths.test.ts +127 -0
- package/src/module/packaging/build-paths.ts +175 -0
- package/src/module/packaging/build.test.ts +71 -1
- package/src/module/packaging/build.ts +130 -2
- package/src/module/packaging/extract.ts +1 -5
- package/src/module/web-root.ts +17 -1
- package/src/policy/fixture-capability-coverage.test.ts +322 -0
- package/src/policy/module-script-scan.test.ts +42 -1
- package/src/policy/module-script-scan.ts +235 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +264 -0
- package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
- package/src/registry/client.test.ts +149 -0
- package/src/registry/client.ts +203 -11
- package/src/services/alerting/ack.test.ts +2 -1
- package/src/services/alerting/cadence-migration.test.ts +3 -2
- package/src/services/alerting/coverage-source.test.ts +2 -1
- package/src/services/alerting/deferral.test.ts +2 -1
- package/src/services/alerting/delivery-loop.test.ts +2 -1
- package/src/services/alerting/deploy-hooks.test.ts +2 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/inbound-poller.test.ts +2 -1
- package/src/services/alerting/inbound.test.ts +2 -1
- package/src/services/alerting/notification-responder.test.ts +2 -1
- package/src/services/alerting/run-monitor.test.ts +2 -1
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/alerting/store.test.ts +2 -1
- package/src/services/alerting/sweep-runner.test.ts +2 -1
- package/src/services/alerting/tokens.test.ts +2 -1
- package/src/services/aspect-approvals.test.ts +2 -1
- package/src/services/aspect-reconcile.test.ts +4 -3
- package/src/services/aspect-runner.test.ts +2 -1
- package/src/services/audit/module-integrity.test.ts +0 -21
- package/src/services/audit/module-integrity.ts +0 -14
- package/src/services/backup-age-agreement.test.ts +2 -1
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +47 -3
- package/src/services/backup-in-flight-refusal.test.ts +2 -1
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-ensure-flow.test.ts +2 -1
- package/src/services/bus-interview-park.test.ts +2 -1
- package/src/services/bus-interview.ts +37 -14
- package/src/services/bus-secret-flow.test.ts +2 -1
- package/src/services/capability-table-rows.test.ts +2 -1
- package/src/services/config-provenance.ts +4 -0
- package/src/services/consumer-cleanup.test.ts +3 -2
- package/src/services/container-service.test.ts +2 -1
- package/src/services/control-plane-bootstrap.test.ts +123 -2
- package/src/services/control-plane-bootstrap.ts +51 -4
- package/src/services/cross-module-read.test.ts +2 -1
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +25 -2
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/dns-discovery.test.ts +54 -0
- package/src/services/dns-discovery.ts +47 -5
- package/src/services/dns-internal-records.test.ts +3 -2
- package/src/services/dns-provider-backfill.test.ts +2 -1
- package/src/services/dns-registrations.test.ts +2 -1
- package/src/services/ensure-interview.test.ts +3 -2
- package/src/services/fleet-checks.test.ts +3 -2
- package/src/services/fleet-key.test.ts +68 -3
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +2 -0
- package/src/services/infrastructure-selector.test.ts +2 -1
- package/src/services/infrastructure-variable-resolver.test.ts +2 -1
- package/src/services/machine-pool.test.ts +2 -1
- package/src/services/module-config.test.ts +2 -1
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +188 -0
- package/src/services/module-deploy.ts +199 -21
- package/src/services/module-operations.test.ts +2 -1
- package/src/services/module-subscriptions.test.ts +2 -1
- package/src/services/module-validator/git-hygiene.test.ts +122 -3
- package/src/services/module-validator/git-hygiene.ts +83 -14
- package/src/services/port-forwards.test.ts +2 -1
- package/src/services/programmatic-responder.aspect.test.ts +2 -1
- package/src/services/proxmox-reconcile.test.ts +2 -1
- package/src/services/restore-from-file.test.ts +23 -2
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/restore-preflight.test.ts +2 -1
- package/src/services/secret-schema-loader.test.ts +2 -1
- package/src/services/ssh-key-manager.test.ts +2 -1
- package/src/services/static-content-converge.test.ts +144 -5
- package/src/services/static-content-converge.ts +87 -17
- package/src/services/system-config-schema-types.ts +1 -1
- package/src/services/system-config-validator.test.ts +36 -0
- package/src/services/system-config-validator.ts +11 -0
- package/src/services/system-state-stage.test.ts +2 -1
- package/src/services/trusted-sources.test.ts +33 -2
- package/src/services/trusted-sources.ts +47 -10
- package/src/services/zone-detector.test.ts +2 -1
- package/src/templates/generator.ts +9 -2
- package/src/test-utils/bus-responder.ts +5 -3
- package/src/test-utils/db-path.ts +25 -0
- package/src/test-utils/integration.ts +7 -0
- package/src/test-utils/module-fixtures.ts +5 -6
- package/src/variables/context.ts +16 -5
- package/src/module/packaging/generated-plane.test.ts +0 -79
- package/src/module/packaging/generated-plane.ts +0 -134
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Who owns a `module_configs` row (hook-owned-state design D7).
|
|
2
|
+
--
|
|
3
|
+
-- One column, two changes: hook-owned-state needs it to enforce "only a hook
|
|
4
|
+
-- writes source: hook", and `derived-value-recomputation` needs it to tell an
|
|
5
|
+
-- operator row from a derived-seeded one. Whichever change landed first owns
|
|
6
|
+
-- the migration; this is it.
|
|
7
|
+
--
|
|
8
|
+
-- Purely additive and NULLABLE on purpose. NULL means "written before the
|
|
9
|
+
-- column existed" — operator intent, interview writes, legacy derived seeds,
|
|
10
|
+
-- framework keys alike — and stays the value for operator writes until
|
|
11
|
+
-- derived-value-recomputation lands its own classification. There is no
|
|
12
|
+
-- backfill: nothing in an existing row can prove who wrote it, and a guess
|
|
13
|
+
-- backfilled as fact is worse than an honest NULL.
|
|
14
|
+
--
|
|
15
|
+
-- The first writer of a non-NULL value is the hook config store
|
|
16
|
+
-- (`hooks/hook-store.ts`), whose `set` writes 'hook' through
|
|
17
|
+
-- `upsertModuleConfig`. The manifest refuses `module config set` on any
|
|
18
|
+
-- `source: hook` variable, so only a hook can produce the value.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE `module_configs` ADD `source` text;
|
|
@@ -218,6 +218,13 @@
|
|
|
218
218
|
"when": 1784400000000,
|
|
219
219
|
"tag": "0030_drop_module_builds_environment",
|
|
220
220
|
"breakpoints": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"idx": 31,
|
|
224
|
+
"version": "6",
|
|
225
|
+
"when": 1788768000000,
|
|
226
|
+
"tag": "0031_module_config_source",
|
|
227
|
+
"breakpoints": true
|
|
221
228
|
}
|
|
222
229
|
]
|
|
223
|
-
}
|
|
230
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^4.
|
|
61
|
+
"@celilo/capabilities": "^4.2.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.11.
|
|
63
|
+
"@celilo/core": "^0.11.1",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
},
|
|
103
103
|
"firewall.trusted_subnets": {
|
|
104
104
|
"type": "string",
|
|
105
|
-
"
|
|
105
|
+
"format": "cidr-list",
|
|
106
|
+
"description": "Operator-declared trusted source subnets permitted to reach every managed zone, as a comma-separated CIDR list (10.0.0.0/24,10.1.0.0/24) or a JSON array ([\"10.0.0.0/24\"]). ADDITIVE to the control-plane subnet celilo derives and to the sources modules register \u2014 it cannot drop control-plane trust, which would block the SSH every hook and converge runs over."
|
|
106
107
|
},
|
|
107
108
|
"dns.primary": {
|
|
108
109
|
"type": "string",
|
|
@@ -117,6 +118,11 @@
|
|
|
117
118
|
"type": "string",
|
|
118
119
|
"description": "SSH public key for container access"
|
|
119
120
|
},
|
|
121
|
+
"hooks.jail_policy": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"pattern": "^(auto|off|required)$",
|
|
124
|
+
"description": "Hook jail policy: 'auto' (jail when a backend is available), 'required' (an unavailable jail is a hard failure), or 'off' (hooks run unjailed). Precedence: the CELILO_HOOK_JAIL environment variable wins over this stored value. Run 'celilo system doctor' to see the effective policy and which source it came from."
|
|
125
|
+
},
|
|
120
126
|
"public_dns.resolver": {
|
|
121
127
|
"type": "string",
|
|
122
128
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
|
|
@@ -4,6 +4,7 @@ import { rm } from 'node:fs/promises';
|
|
|
4
4
|
import { type DbClient, createDbClient } from '../db/client';
|
|
5
5
|
import { systemConfig } from '../db/schema';
|
|
6
6
|
import { upsertModuleConfig } from '../services/module-config';
|
|
7
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
7
8
|
import {
|
|
8
9
|
buildHostVars,
|
|
9
10
|
buildSystemVars,
|
|
@@ -286,7 +287,7 @@ describe('Database integration', () => {
|
|
|
286
287
|
afterEach(async () => {
|
|
287
288
|
db.$client.close();
|
|
288
289
|
// Clean up environment variable
|
|
289
|
-
|
|
290
|
+
resetTestDbPath();
|
|
290
291
|
if (existsSync(TEST_DB_PATH)) {
|
|
291
292
|
await rm(TEST_DB_PATH);
|
|
292
293
|
}
|
package/src/api/sessions.test.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { handleModuleUpdate } from '../cli/commands/module-update';
|
|
|
32
32
|
import { getDb } from '../db/client';
|
|
33
33
|
import { modules } from '../db/schema';
|
|
34
34
|
import { RESPONDER_PROBE_EVENT } from '../services/responder-probe';
|
|
35
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
35
36
|
import { SessionWriter, listSessions, reapExpiredSessions } from './sessions';
|
|
36
37
|
|
|
37
38
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -99,7 +100,7 @@ beforeEach(() => {
|
|
|
99
100
|
afterEach(() => {
|
|
100
101
|
server.stop(true);
|
|
101
102
|
rmSync(dir, { recursive: true, force: true });
|
|
102
|
-
|
|
103
|
+
resetTestDbPath();
|
|
103
104
|
process.env.CELILO_DATA_DIR = undefined;
|
|
104
105
|
process.env.CELILO_ORIGINAL_CWD = undefined;
|
|
105
106
|
process.env.EVENT_BUS_DB = undefined;
|
|
@@ -273,6 +273,67 @@ describe('publishStaticSite — clientConfig injection', () => {
|
|
|
273
273
|
});
|
|
274
274
|
});
|
|
275
275
|
|
|
276
|
+
describe('the state web overlay (celilo#1265)', () => {
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
afterEach(() => {
|
|
282
|
+
execSyncSpy.mockRestore();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// A jailed hook cannot write into its own module tree, so generated site
|
|
286
|
+
// files land in `<module>/state/site`. The upload's content hash must see
|
|
287
|
+
// them, or the release directory never refreshes when the overlay changes.
|
|
288
|
+
test('the upload hash and file count include the state overlay', async () => {
|
|
289
|
+
const sourceDir = mkdtempSync(join(tmpdir(), 'publish-overlay-'));
|
|
290
|
+
const overlayDir = join(mkdtempSync(join(tmpdir(), 'publish-overlay-state-')));
|
|
291
|
+
writeFileSync(join(sourceDir, 'index.html'), '<html></html>', 'utf-8');
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const { ops, routes } = makeRouteOps();
|
|
295
|
+
const cap = createPublicWeb({
|
|
296
|
+
webRoot: sourceDir,
|
|
297
|
+
stateWebRoot: overlayDir,
|
|
298
|
+
moduleId: 'lunacycle',
|
|
299
|
+
logger: noopLogger,
|
|
300
|
+
config: {
|
|
301
|
+
target_ip: '10.0.10.20/24',
|
|
302
|
+
hostname: 'www',
|
|
303
|
+
primary_domain: 'example.com',
|
|
304
|
+
email: 'admin@example.com',
|
|
305
|
+
},
|
|
306
|
+
secrets: {},
|
|
307
|
+
convergeStaticContent: async () => {},
|
|
308
|
+
routeOps: ops,
|
|
309
|
+
hostnames: ['www.example.com'],
|
|
310
|
+
caddyModuleId: 'caddy',
|
|
311
|
+
dnsManagedDomains: ['www.example.com'],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const hashOf = () =>
|
|
315
|
+
routes.find((r) => r.moduleId === 'lunacycle' && r.path === '/lunacycle')?.contentHash;
|
|
316
|
+
|
|
317
|
+
// No overlay yet: one file, and the hash covers only the web root.
|
|
318
|
+
const before = await cap.publishStaticSite({ path: '/lunacycle' });
|
|
319
|
+
const hashBefore = hashOf();
|
|
320
|
+
expect(before.filesUploaded).toBe(1);
|
|
321
|
+
expect(hashBefore).toBeTruthy();
|
|
322
|
+
|
|
323
|
+
// A generated file in state must move the hash, or a rebuilt host's
|
|
324
|
+
// converge would re-serve the old release and the file would never land.
|
|
325
|
+
writeFileSync(join(overlayDir, 'ca.crt'), '---CERT---', 'utf-8');
|
|
326
|
+
const after = await cap.publishStaticSite({ path: '/lunacycle' });
|
|
327
|
+
expect(after.filesUploaded).toBe(2);
|
|
328
|
+
expect(hashOf()).toBeTruthy();
|
|
329
|
+
expect(hashOf()).not.toBe(hashBefore);
|
|
330
|
+
} finally {
|
|
331
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
332
|
+
rmSync(overlayDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
276
337
|
describe('registerReverseProxy', () => {
|
|
277
338
|
beforeEach(() => {
|
|
278
339
|
execSyncSpy = spyOn(childProcess, 'execSync').mockReturnValue(Buffer.from(''));
|
|
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { closeDb } from '../db/client';
|
|
18
18
|
import { runMigrations } from '../db/migrate';
|
|
19
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
19
20
|
import { runCli } from './index';
|
|
20
21
|
|
|
21
22
|
describe('celilo module backup (renamed surface)', () => {
|
|
@@ -48,7 +49,7 @@ describe('celilo module backup (renamed surface)', () => {
|
|
|
48
49
|
warnSpy = null;
|
|
49
50
|
}
|
|
50
51
|
closeDb();
|
|
51
|
-
|
|
52
|
+
resetTestDbPath();
|
|
52
53
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
53
54
|
process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
|
|
54
55
|
try {
|
package/src/cli/cli.test.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { moduleConfigs, modules, secrets } from '../db/schema';
|
|
|
8
8
|
import { encryptSecret } from '../secrets/encryption';
|
|
9
9
|
import { generateMasterKey, writeMasterKey } from '../secrets/master-key';
|
|
10
10
|
import { upsertModuleConfig } from '../services/module-config';
|
|
11
|
+
import { resetTestDbPath } from '../test-utils/db-path';
|
|
11
12
|
import { runCli } from './index';
|
|
12
13
|
|
|
13
14
|
const TEST_DB_PATH = `./test-cli-${Date.now()}-${Math.random()}.db`;
|
|
@@ -147,7 +148,7 @@ description: Test module for CLI
|
|
|
147
148
|
|
|
148
149
|
// Clear environment variables
|
|
149
150
|
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
150
|
-
|
|
151
|
+
resetTestDbPath();
|
|
151
152
|
});
|
|
152
153
|
|
|
153
154
|
describe('help', () => {
|
|
@@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
|
15
15
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
18
19
|
|
|
19
20
|
let testDir: string;
|
|
20
21
|
|
|
@@ -26,7 +27,7 @@ beforeEach(() => {
|
|
|
26
27
|
|
|
27
28
|
afterEach(() => {
|
|
28
29
|
rmSync(testDir, { recursive: true, force: true });
|
|
29
|
-
|
|
30
|
+
resetTestDbPath();
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
/** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
|
|
@@ -8,10 +8,39 @@
|
|
|
8
8
|
* was never a lack of information but a lack of celilo saying it.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
12
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
16
|
+
|
|
17
|
+
// The command reaches boxes ONLY through these two modules, and both are
|
|
18
|
+
// mocked below so the tests can drive the live-read and fallback paths without
|
|
19
|
+
// a real machine. Spreading the actual module keeps every other export intact
|
|
20
|
+
// for anything else that loads them.
|
|
21
|
+
const actualDetector = await import('../../services/machine-detector');
|
|
22
|
+
const actualKeyManager = await import('../../services/ssh-key-manager');
|
|
23
|
+
|
|
24
|
+
type Detected = {
|
|
25
|
+
interfaces: Array<{ name: string; ipAddress: string; zone?: string }>;
|
|
26
|
+
role: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let liveRead: (ip: string, user: string, keyPath: string) => Promise<Detected>;
|
|
30
|
+
let localRead: () => Promise<Detected>;
|
|
31
|
+
let tempKeyWriter: (machineId: string) => Promise<string>;
|
|
32
|
+
|
|
33
|
+
mock.module('../../services/machine-detector', () => ({
|
|
34
|
+
...actualDetector,
|
|
35
|
+
detectNetworkInterfaces: (ip: string, user: string, keyPath: string) =>
|
|
36
|
+
liveRead(ip, user, keyPath),
|
|
37
|
+
detectNetworkInterfacesLocal: () => localRead(),
|
|
38
|
+
}));
|
|
39
|
+
mock.module('../../services/ssh-key-manager', () => ({
|
|
40
|
+
...actualKeyManager,
|
|
41
|
+
writeTemporarySshKey: (machineId: string) => tempKeyWriter(machineId),
|
|
42
|
+
deleteTemporarySshKey: () => {},
|
|
43
|
+
}));
|
|
15
44
|
|
|
16
45
|
let testDir: string;
|
|
17
46
|
let logs: string[];
|
|
@@ -29,13 +58,19 @@ beforeEach(() => {
|
|
|
29
58
|
testDir = mkdtempSync(join(tmpdir(), 'celilo-fw-iface-'));
|
|
30
59
|
process.env.CELILO_DB_PATH = join(testDir, 'test.db');
|
|
31
60
|
process.env.CELILO_DATA_DIR = testDir;
|
|
61
|
+
// Key decryption (getMachineSshKey) needs a master key.
|
|
62
|
+
const masterKeyPath = join(testDir, 'master.key');
|
|
63
|
+
writeFileSync(masterKeyPath, 'a'.repeat(64));
|
|
64
|
+
process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
|
|
32
65
|
captureLogs();
|
|
33
66
|
});
|
|
34
67
|
|
|
35
68
|
afterEach(() => {
|
|
36
69
|
console.log = originalLog;
|
|
37
70
|
rmSync(testDir, { recursive: true, force: true });
|
|
38
|
-
|
|
71
|
+
resetTestDbPath();
|
|
72
|
+
process.env.CELILO_DATA_DIR = undefined;
|
|
73
|
+
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
39
74
|
});
|
|
40
75
|
|
|
41
76
|
describe('celilo firewall interface list', () => {
|
|
@@ -71,15 +106,130 @@ describe('celilo firewall interface list', () => {
|
|
|
71
106
|
* me — it would pass no matter what they said. The property below is different:
|
|
72
107
|
* it is a claim about what the command may DO, and it can genuinely fail.
|
|
73
108
|
*/
|
|
74
|
-
describe('
|
|
75
|
-
test('
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
109
|
+
describe('no side effects on the box', () => {
|
|
110
|
+
test('reaches a box only through the read-only detectors', async () => {
|
|
111
|
+
// #1287's CLI half came from the stored-read-only version of this command:
|
|
112
|
+
// a snapshot taken at machine add kept reading ALIEN after the interface
|
|
113
|
+
// was gone, so the command contradicted the converge it claims to preview.
|
|
114
|
+
// The fix reads the box live through the same detectors machine add uses —
|
|
115
|
+
// read-only by construction. What is still forbidden is any MUTATING
|
|
116
|
+
// remote primitive, and any hand-built ssh that could grow one silently.
|
|
79
117
|
const src = await Bun.file(join(import.meta.dir, 'firewall-interface-list.ts')).text();
|
|
80
118
|
|
|
81
119
|
expect(src).not.toContain('runAppCommand');
|
|
82
120
|
expect(src).not.toContain('execFileSync');
|
|
83
121
|
expect(src).not.toContain('ssh ');
|
|
122
|
+
// The live read must come from the shared detector, not a private path.
|
|
123
|
+
expect(src).toContain('detectNetworkInterfaces');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The live-read contract, against a machine whose STORED snapshot is stale —
|
|
129
|
+
* the #1287 shape: the alien cable was attached when `machine add` recorded
|
|
130
|
+
* the interfaces, and nothing refreshes that snapshot afterwards.
|
|
131
|
+
*/
|
|
132
|
+
describe('reads the box live', () => {
|
|
133
|
+
beforeEach(async () => {
|
|
134
|
+
liveRead = async () => {
|
|
135
|
+
throw new Error('liveRead not configured for this test');
|
|
136
|
+
};
|
|
137
|
+
localRead = async () => {
|
|
138
|
+
throw new Error('localRead not configured for this test');
|
|
139
|
+
};
|
|
140
|
+
tempKeyWriter = async () => {
|
|
141
|
+
throw new Error('tempKeyWriter not configured for this test');
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const { getDb } = await import('../../db/client');
|
|
145
|
+
const { systemConfig } = await import('../../db/schema');
|
|
146
|
+
const { addMachine } = await import('../../services/machine-pool');
|
|
147
|
+
|
|
148
|
+
await getDb()
|
|
149
|
+
.insert(systemConfig)
|
|
150
|
+
.values([
|
|
151
|
+
{ key: 'network.internal.subnet', value: '10.226.1.0/24' },
|
|
152
|
+
{ key: 'network.app.subnet', value: '10.226.20.0/24' },
|
|
153
|
+
{ key: 'network.dmz.subnet', value: '10.226.10.0/24' },
|
|
154
|
+
{ key: 'network.secure.subnet', value: '10.226.30.0/24' },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
await addMachine({
|
|
158
|
+
hostname: 'fw-main',
|
|
159
|
+
zone: 'internal',
|
|
160
|
+
ipAddress: '10.226.1.254',
|
|
161
|
+
sshUser: 'root',
|
|
162
|
+
sshKey: 'test-key-material',
|
|
163
|
+
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
|
|
164
|
+
role: 'host',
|
|
165
|
+
interfaces: [
|
|
166
|
+
{ name: 'eth0', ipAddress: '10.226.20.1', zone: 'app' },
|
|
167
|
+
{ name: 'eth1', ipAddress: '10.226.10.1', zone: 'dmz' },
|
|
168
|
+
{ name: 'eth2', ipAddress: '10.226.1.254', zone: 'internal' },
|
|
169
|
+
{ name: 'eth3', ipAddress: '10.226.30.1', zone: 'secure' },
|
|
170
|
+
// The cable nobody documented, recorded because it was attached when
|
|
171
|
+
// the machine was added. It left the box afterwards; the snapshot
|
|
172
|
+
// never heard about it.
|
|
173
|
+
{ name: 'eth4', ipAddress: '172.31.99.2', zone: 'unknown' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('an interface removed after machine add no longer reads ALIEN', async () => {
|
|
179
|
+
let keyWritten = false;
|
|
180
|
+
tempKeyWriter = async () => {
|
|
181
|
+
keyWritten = true;
|
|
182
|
+
return '/tmp/celilo-fw-iface-test.key';
|
|
183
|
+
};
|
|
184
|
+
liveRead = async (ip, user) => {
|
|
185
|
+
// The read went to the box named in the machine record, with its stored
|
|
186
|
+
// credentials — the same targeting the converge uses.
|
|
187
|
+
expect(ip).toBe('10.226.1.254');
|
|
188
|
+
expect(user).toBe('root');
|
|
189
|
+
return {
|
|
190
|
+
interfaces: [
|
|
191
|
+
{ name: 'eth0', ipAddress: '10.226.20.1' },
|
|
192
|
+
{ name: 'eth1', ipAddress: '10.226.10.1' },
|
|
193
|
+
{ name: 'eth2', ipAddress: '10.226.1.254' },
|
|
194
|
+
{ name: 'eth3', ipAddress: '10.226.30.1' },
|
|
195
|
+
],
|
|
196
|
+
role: 'router',
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
|
|
201
|
+
const result = await handleFirewallInterfaceList(['fw-main'], {});
|
|
202
|
+
|
|
203
|
+
expect(result.success).toBe(true);
|
|
204
|
+
expect(keyWritten).toBe(true);
|
|
205
|
+
const out = logs.join('\n');
|
|
206
|
+
// eth4 is gone from the box, so the answer says so — even though the
|
|
207
|
+
// stored snapshot still carries it.
|
|
208
|
+
expect(out).not.toContain('172.31.99.2');
|
|
209
|
+
expect(out).not.toContain('ALIEN');
|
|
210
|
+
for (const zone of ['app', 'dmz', 'internal', 'secure']) {
|
|
211
|
+
expect(out).toContain(`zone:${zone}`);
|
|
212
|
+
}
|
|
213
|
+
expect(out).not.toContain('could not read the box live');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a box that cannot be reached says so, and labels the snapshot as stale', async () => {
|
|
217
|
+
tempKeyWriter = async () => '/tmp/celilo-fw-iface-test.key';
|
|
218
|
+
liveRead = async () => {
|
|
219
|
+
throw new Error('SSH command failed after 3 attempts: connection refused');
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
|
|
223
|
+
const result = await handleFirewallInterfaceList(['fw-main'], {});
|
|
224
|
+
|
|
225
|
+
// Unreachable is not a classification result, but the stored snapshot is
|
|
226
|
+
// still the best available answer — as long as it says what it is.
|
|
227
|
+
expect(result.success).toBe(true);
|
|
228
|
+
const out = logs.join('\n');
|
|
229
|
+
expect(out).toContain('could not read the box live');
|
|
230
|
+
expect(out).toContain('may be stale');
|
|
231
|
+
// The stale eth4 is reported, under the label that keeps it from being
|
|
232
|
+
// mistaken for a current finding.
|
|
233
|
+
expect(out).toContain('ALIEN');
|
|
84
234
|
});
|
|
85
235
|
});
|
|
@@ -9,15 +9,34 @@
|
|
|
9
9
|
* The condition that produced `fw-keeper.sh` was not that celilo lacked the
|
|
10
10
|
* information; it was that celilo never said it.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* No side effects: it reads the LIVE interface table the same way the next
|
|
13
|
+
* converge will — classification is recomputed, never read from a stored copy
|
|
14
|
+
* (design D4: a stored classification goes stale) — plus the declarations, and
|
|
15
|
+
* classifies in memory. The only remote operations are the read-only interface
|
|
16
|
+
* detectors `machine add` uses. When the box cannot be reached it says so, and
|
|
17
|
+
* falls back to the interface snapshot recorded at machine add, labelled as
|
|
18
|
+
* what it is.
|
|
19
|
+
*
|
|
20
|
+
* The stored-read-only version of this command was the whole of the CLI half
|
|
21
|
+
* of #1287: the snapshot is taken at machine add, so an interface removed after
|
|
22
|
+
* it was recorded kept reading ALIEN forever, and an operator who did exactly
|
|
23
|
+
* what the converge refusal told them to do was told their fix had not worked.
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { classifyInterfaces, isPubliclyRoutable } from '@celilo/capabilities';
|
|
18
27
|
import { getDb } from '../../db/client';
|
|
19
28
|
import { listFirewallIps, readDeclaredNetworks } from '../../hooks/capability-loader';
|
|
29
|
+
import {
|
|
30
|
+
detectNetworkInterfaces,
|
|
31
|
+
detectNetworkInterfacesLocal,
|
|
32
|
+
} from '../../services/machine-detector';
|
|
20
33
|
import { listMachines } from '../../services/machine-pool';
|
|
34
|
+
import {
|
|
35
|
+
LOCAL_MACHINE_IP,
|
|
36
|
+
deleteTemporarySshKey,
|
|
37
|
+
writeTemporarySshKey,
|
|
38
|
+
} from '../../services/ssh-key-manager';
|
|
39
|
+
import type { Machine } from '../../types/infrastructure';
|
|
21
40
|
import { celiloIntro } from '../prompts';
|
|
22
41
|
import type { CommandResult } from '../types';
|
|
23
42
|
|
|
@@ -36,6 +55,41 @@ async function declaredZones(): Promise<Array<{ zone: string; subnet: string }>>
|
|
|
36
55
|
return readDeclaredNetworks(getDb());
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/** What the classifier consumes, whatever the source. */
|
|
59
|
+
interface NamedInterface {
|
|
60
|
+
name: string;
|
|
61
|
+
ip: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The interface table as the box holds it RIGHT NOW, via the same read-only
|
|
66
|
+
* detectors `machine add` runs. The converge reads the box live too, so this
|
|
67
|
+
* is the input its classification will see.
|
|
68
|
+
*/
|
|
69
|
+
async function readLiveInterfaces(machine: Machine): Promise<NamedInterface[]> {
|
|
70
|
+
const toNamed = (list: Array<{ name: string; ipAddress: string }>): NamedInterface[] =>
|
|
71
|
+
list.map(({ name, ipAddress }) => ({ name, ip: ipAddress }));
|
|
72
|
+
|
|
73
|
+
// The management box registers itself as 127.0.0.1 with no stored SSH key
|
|
74
|
+
// and is reached locally, the same way machine add reaches it.
|
|
75
|
+
if (machine.ipAddress === LOCAL_MACHINE_IP) {
|
|
76
|
+
const { interfaces } = await detectNetworkInterfacesLocal();
|
|
77
|
+
return toNamed(interfaces);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const keyPath = await writeTemporarySshKey(machine.id);
|
|
81
|
+
try {
|
|
82
|
+
const { interfaces } = await detectNetworkInterfaces(
|
|
83
|
+
machine.ipAddress,
|
|
84
|
+
machine.sshUser,
|
|
85
|
+
keyPath,
|
|
86
|
+
);
|
|
87
|
+
return toNamed(interfaces);
|
|
88
|
+
} finally {
|
|
89
|
+
deleteTemporarySshKey(machine.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
39
93
|
/** One line per interface, explaining the role rather than just naming it. */
|
|
40
94
|
function describe(role: string, zone: string | undefined, ip: string): string {
|
|
41
95
|
if (role === 'zone') return `zone:${zone}`;
|
|
@@ -88,10 +142,22 @@ export async function handleFirewallInterfaceList(
|
|
|
88
142
|
for (const machine of targets) {
|
|
89
143
|
console.log(`\n${machine.hostname} (${machine.ipAddress})`);
|
|
90
144
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
})
|
|
145
|
+
let interfaces: NamedInterface[];
|
|
146
|
+
try {
|
|
147
|
+
interfaces = await readLiveInterfaces(machine);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// An unreachable box is not a classification result. Name the fallback
|
|
150
|
+
// for what it is, so a stale ALIEN is never mistaken for a current one.
|
|
151
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
152
|
+
console.log(
|
|
153
|
+
' could not read the box live — classifying the interface snapshot recorded at machine add, which may be stale:',
|
|
154
|
+
);
|
|
155
|
+
console.log(` ${reason}`);
|
|
156
|
+
interfaces = (machine.interfaces ?? []).map((i) => ({
|
|
157
|
+
name: i.name,
|
|
158
|
+
ip: i.ipAddress,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
95
161
|
if (interfaces.length === 0) {
|
|
96
162
|
console.log(' no interfaces recorded — re-run `celilo machine add` to detect them');
|
|
97
163
|
continue;
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
8
|
import { isPubliclyRoutable } from '@celilo/capabilities';
|
|
10
9
|
import { getDb } from '../../db/client';
|
|
11
10
|
import type { NetworkZone } from '../../db/schema';
|
|
12
11
|
import { askText, withInterviewSession } from '../../services/bus-interview';
|
|
12
|
+
import { findFleetPrivateKey, fleetKeySearchDirs } from '../../services/fleet-key';
|
|
13
13
|
import {
|
|
14
14
|
detectMachineInfo,
|
|
15
15
|
detectMachineInfoLocal,
|
|
@@ -30,60 +30,18 @@ import { celiloIntro, celiloOutro } from '../prompts';
|
|
|
30
30
|
import type { CommandResult } from '../types';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* The private key matching `ssh.public_key`, or null.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Delegates the search to `findFleetPrivateKey`, which lives beside the mint
|
|
36
|
+
* so the two cannot name different directories. This function used to derive
|
|
37
|
+
* its own `join(HOME, '.ssh')`, which meant it could not see the key
|
|
38
|
+
* `ensureFleetKey` writes into celilo's data directory, so a fleet whose key
|
|
39
|
+
* celilo minted refused every `machine add` (celilo#1240).
|
|
38
40
|
*/
|
|
39
41
|
function findSshPrivateKey(): string | null {
|
|
40
42
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const publicKey = config['ssh.public_key'];
|
|
44
|
-
|
|
45
|
-
if (!publicKey) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Extract key type from public key (e.g., "ssh-ed25519", "ssh-rsa")
|
|
50
|
-
const keyType = publicKey.split(' ')[0];
|
|
51
|
-
|
|
52
|
-
const sshDir = join(process.env.HOME || '~', '.ssh');
|
|
53
|
-
if (!existsSync(sshDir)) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Common private key filenames based on type
|
|
58
|
-
const keyFileMap: Record<string, string[]> = {
|
|
59
|
-
'ssh-ed25519': ['id_ed25519'],
|
|
60
|
-
'ssh-rsa': ['id_rsa'],
|
|
61
|
-
'ecdsa-sha2-nistp256': ['id_ecdsa'],
|
|
62
|
-
'ecdsa-sha2-nistp384': ['id_ecdsa'],
|
|
63
|
-
'ecdsa-sha2-nistp521': ['id_ecdsa'],
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const candidateFiles = keyFileMap[keyType] || ['id_rsa', 'id_ed25519', 'id_ecdsa'];
|
|
67
|
-
|
|
68
|
-
// Try common key files
|
|
69
|
-
for (const keyFile of candidateFiles) {
|
|
70
|
-
const keyPath = join(sshDir, keyFile);
|
|
71
|
-
if (existsSync(keyPath)) {
|
|
72
|
-
// Verify this is the matching private key by checking if public key exists
|
|
73
|
-
const pubKeyPath = `${keyPath}.pub`;
|
|
74
|
-
if (existsSync(pubKeyPath)) {
|
|
75
|
-
const pubKeyContent = readFileSync(pubKeyPath, 'utf8').trim();
|
|
76
|
-
// Check if public keys match (compare key type and key data, ignore comment)
|
|
77
|
-
const [sysType, sysKey] = publicKey.split(' ');
|
|
78
|
-
const [fileType, fileKey] = pubKeyContent.split(' ');
|
|
79
|
-
if (sysType === fileType && sysKey === fileKey) {
|
|
80
|
-
return keyPath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return null;
|
|
43
|
+
const publicKey = loadExistingConfiguration(getDb())['ssh.public_key'];
|
|
44
|
+
return publicKey ? findFleetPrivateKey(publicKey) : null;
|
|
87
45
|
} catch {
|
|
88
46
|
return null;
|
|
89
47
|
}
|
|
@@ -200,10 +158,9 @@ export async function handleMachineAdd(
|
|
|
200
158
|
if (!detectedKeyPath) {
|
|
201
159
|
return {
|
|
202
160
|
success: false,
|
|
203
|
-
error:
|
|
204
|
-
|
|
205
|
-
'
|
|
206
|
-
'Specify manually with: --ssh-key-file ~/.ssh/id_ed25519',
|
|
161
|
+
error: `Cannot find SSH private key.\n\nThe ssh.public_key system config is set, but no private key matching it was found in:\n${fleetKeySearchDirs()
|
|
162
|
+
.map((dir) => ` ${dir}`)
|
|
163
|
+
.join('\n')}\n\nSpecify manually with: --ssh-key-file <path>`,
|
|
207
164
|
};
|
|
208
165
|
}
|
|
209
166
|
sshKeyPath = detectedKeyPath;
|