@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.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. 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.0",
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.1.0",
61
+ "@celilo/capabilities": "^4.2.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.11.0",
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
- "description": "Operator-declared trusted source subnets (comma-separated CIDRs) permitted to reach every managed zone. 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."
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
- process.env.CELILO_DB_PATH = undefined;
290
+ resetTestDbPath();
290
291
  if (existsSync(TEST_DB_PATH)) {
291
292
  await rm(TEST_DB_PATH);
292
293
  }
@@ -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
- process.env.CELILO_DB_PATH = undefined;
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
- process.env.CELILO_DB_PATH = undefined;
52
+ resetTestDbPath();
52
53
  process.env.CELILO_MASTER_KEY_PATH = undefined;
53
54
  process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
54
55
  try {
@@ -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
- process.env.CELILO_DB_PATH = undefined;
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
- process.env.CELILO_DB_PATH = undefined;
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
- process.env.CELILO_DB_PATH = undefined;
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('read-only by construction', () => {
75
- test('never touches the box', async () => {
76
- // Safe to run against a firewall whose converge is currently refusing —
77
- // which is exactly when an operator reaches for it. If this ever grows an
78
- // SSH call it is a different command and should be named like one.
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
- * Read-only by construction: it reads the stored interface table and the
13
- * declarations, and classifies in memory. It never touches the box, so it is
14
- * safe to run against a firewall that is currently refusing.
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
- const interfaces = (machine.interfaces ?? []).map((i) => ({
92
- name: i.name,
93
- ip: i.ipAddress,
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
- * Auto-detect SSH private key from system configuration
33
+ * The private key matching `ssh.public_key`, or null.
34
34
  *
35
- * Reads ssh.public_key from system config and finds matching private key in ~/.ssh/
36
- *
37
- * @returns Path to SSH private key, or null if not found
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 db = getDb();
42
- const config = loadExistingConfiguration(db);
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
- 'Cannot find SSH private key.\n\n' +
205
- 'The ssh.public_key system config is set, but no matching private key was found in ~/.ssh/\n\n' +
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;