@celilo/cli 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -1,12 +1,21 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
3
11
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
12
+ import { join, resolve } from 'node:path';
5
13
  import { eq } from 'drizzle-orm';
6
14
  import type { DbClient } from '../db/client';
7
15
  import { moduleConfigs, modules, webRoutes } from '../db/schema';
8
16
  import { setupTestDatabaseAt } from '../test-utils/database';
9
17
  import {
18
+ ansibleTreeMentions,
10
19
  buildStaticContentPlan,
11
20
  planStaticContent,
12
21
  resolveStaticContentRetention,
@@ -246,6 +255,82 @@ describe('staticContentVarsYaml — deterministic desired state', () => {
246
255
  });
247
256
  });
248
257
 
258
+ describe('the state web overlay (celilo#1265)', () => {
259
+ let dir: string;
260
+ let db: DbClient;
261
+
262
+ beforeEach(async () => {
263
+ dir = mkdtempSync(join(tmpdir(), 'static-converge-'));
264
+ const dbPath = join(dir, 'celilo.db');
265
+ process.env.CELILO_DB_PATH = dbPath;
266
+ db = await setupTestDatabaseAt(dbPath);
267
+ writeSite('lunacycle', '<html>luna</html>');
268
+ db.insert(modules)
269
+ .values({
270
+ id: 'lunacycle',
271
+ name: 'Lunacycle',
272
+ version: '1.0.0',
273
+ manifestData: {},
274
+ sourcePath: join(dir, 'modules', 'lunacycle'),
275
+ })
276
+ .onConflictDoNothing()
277
+ .run();
278
+ });
279
+
280
+ afterEach(() => {
281
+ db.$client.close();
282
+ process.env.CELILO_DB_PATH = undefined;
283
+ try {
284
+ rmSync(dir, { recursive: true, force: true });
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ });
289
+
290
+ function writeSite(moduleId: string, html: string): string {
291
+ const webRoot = join(dir, 'modules', moduleId, 'site', 'dist');
292
+ mkdirSync(webRoot, { recursive: true });
293
+ writeFileSync(join(webRoot, 'index.html'), html, 'utf-8');
294
+ return webRoot;
295
+ }
296
+
297
+ function insertRoute(route: { slug: string; moduleId: string; hostname: string }) {
298
+ db.insert(webRoutes)
299
+ .values({
300
+ slug: route.slug,
301
+ moduleId: route.moduleId,
302
+ type: 'static',
303
+ path: '/',
304
+ hostname: route.hostname,
305
+ websocket: false,
306
+ contentHash: HASH1,
307
+ })
308
+ .run();
309
+ }
310
+
311
+ it('a release with a state overlay carries its directory; one without does not', () => {
312
+ insertRoute({ slug: 'lunacycle', moduleId: 'lunacycle', hostname: 'lunacycle.net' });
313
+
314
+ // No overlay in state: the release names no overlay_dir, and the converge
315
+ // role has nothing second to copy.
316
+ const bare = planStaticContent(db);
317
+ expect(bare).toHaveLength(1);
318
+ expect(bare[0]?.overlayDir).toBeUndefined();
319
+ expect(staticContentVarsYaml({ retention: 1, releases: bare })).not.toContain('overlay_dir');
320
+
321
+ // The hook wrote generated files into `<module>/state/site`: the converge
322
+ // must copy them over the release, or a rebuilt host loses the file.
323
+ const overlay = join(dir, 'modules', 'lunacycle', 'state', 'site');
324
+ mkdirSync(overlay, { recursive: true });
325
+ writeFileSync(join(overlay, 'ca.crt'), '---CERT---', 'utf-8');
326
+
327
+ const overlaid = planStaticContent(db);
328
+ expect(overlaid[0]?.overlayDir).toBe(overlay);
329
+ const yaml = staticContentVarsYaml({ retention: 1, releases: overlaid });
330
+ expect(yaml).toContain('overlay_dir:');
331
+ });
332
+ });
333
+
249
334
  describe('writeStaticContentVars — lands in the generated inventory', () => {
250
335
  let dir: string;
251
336
  let db: DbClient;
@@ -336,3 +421,56 @@ describe('writeStaticContentVars — lands in the generated inventory', () => {
336
421
  expect(plan.releases).toHaveLength(1);
337
422
  });
338
423
  });
424
+
425
+ describe('the stale-project guard reaches the whole generated tree (celilo#1248)', () => {
426
+ let dir: string;
427
+
428
+ beforeEach(() => {
429
+ dir = mkdtempSync(join(tmpdir(), 'static-guard-reach-'));
430
+ });
431
+ afterEach(() => {
432
+ rmSync(dir, { recursive: true, force: true });
433
+ });
434
+
435
+ it('sees a converge that lives in a role task file, not in playbook.yml', () => {
436
+ // Mirror DERIVED from the real module, never hand-written: a hand-built
437
+ // tree would give confident reach data about a world that does not exist,
438
+ // which is the same bug one level up.
439
+ const real = resolve(import.meta.dir, '../../../..', 'modules/caddy/ansible');
440
+ cpSync(real, join(dir, 'ansible'), { recursive: true });
441
+
442
+ // The premise this guards. caddy's playbook is a thin hosts/vars/roles
443
+ // stanza; if it ever DOES name static_releases, this test stops proving
444
+ // anything and should be re-derived rather than deleted.
445
+ const playbook = readFileSync(join(dir, 'ansible/playbook.yml.tpl'), 'utf-8');
446
+ expect(playbook).not.toContain('static_releases');
447
+
448
+ expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
449
+ });
450
+
451
+ it('still refuses a project that mentions the var nowhere', () => {
452
+ // A pre-converge generated project. Hand-built deliberately, and it has to
453
+ // be: no deploy on today's code can produce a tree without the role tasks,
454
+ // so this legacy shape is the fixture and not a shortcut.
455
+ mkdirSync(join(dir, 'ansible/roles/caddy/tasks'), { recursive: true });
456
+ writeFileSync(
457
+ join(dir, 'ansible/playbook.yml'),
458
+ '---\n- hosts: caddy\n roles:\n - caddy\n',
459
+ );
460
+ writeFileSync(
461
+ join(dir, 'ansible/roles/caddy/tasks/main.yml'),
462
+ '---\n- name: install\n debug: {}\n',
463
+ );
464
+
465
+ expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(false);
466
+ });
467
+
468
+ it('ignores non-text files rather than reading assets as utf-8', () => {
469
+ mkdirSync(join(dir, 'ansible/files'), { recursive: true });
470
+ writeFileSync(join(dir, 'ansible/files/logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
471
+ writeFileSync(join(dir, 'ansible/site.yml'), 'vars:\n static_releases: []\n');
472
+
473
+ expect(ansibleTreeMentions(join(dir, 'ansible'), 'static_releases')).toBe(true);
474
+ expect(ansibleTreeMentions(join(dir, 'ansible'), 'PNG')).toBe(false);
475
+ });
476
+ });
@@ -23,14 +23,14 @@
23
23
  * `module verify` (celilo#1018, deliberately left to that thread).
24
24
  */
25
25
 
26
- import { existsSync, readFileSync } from 'node:fs';
26
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
27
27
  import { mkdir, writeFile } from 'node:fs/promises';
28
28
  import { join } from 'node:path';
29
29
  import { and, eq, isNotNull } from 'drizzle-orm';
30
30
  import { stringify as stringifyYaml } from 'yaml';
31
31
  import type { DbClient } from '../db/client';
32
32
  import { moduleConfigs, modules, webRoutes } from '../db/schema';
33
- import { resolveModuleWebRoot } from '../module/web-root';
33
+ import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
34
34
  import { parseStoredConfigValue } from './module-config';
35
35
 
36
36
  /** One content-hashed release the provider host must serve. */
@@ -43,6 +43,13 @@ export interface StaticRelease {
43
43
  hostnames: string[];
44
44
  /** Absolute celilo-mgr path core derived: `<module source_path>/site/dist`. */
45
45
  sourceDir: string;
46
+ /**
47
+ * Absolute path to `<module source_path>/state/site` (celilo#1265), present
48
+ * ONLY when that directory exists. The role copies it over the release
49
+ * after the web root, so a hook-generated file wins over a built one. A
50
+ * module with no generated site content has no overlay and no row.
51
+ */
52
+ overlayDir?: string;
46
53
  }
47
54
 
48
55
  export interface StaticContentPlan {
@@ -172,11 +179,17 @@ export function planStaticContent(db: DbClient): StaticRelease[] {
172
179
  );
173
180
  }
174
181
 
182
+ // Present only when the module's hook actually wrote an overlay —
183
+ // otherwise the vars file would name a directory that does not exist
184
+ // and the role's copy task would have nothing to copy from.
185
+ const stateWebRoot = resolveModuleStateWebRoot(moduleId, db);
186
+ const overlayDir = stateWebRoot && existsSync(stateWebRoot) ? stateWebRoot : undefined;
175
187
  releases.push({
176
188
  slug,
177
189
  contentHash,
178
190
  hostnames: [...new Set(group.map((r) => r.hostname))].sort(),
179
191
  sourceDir,
192
+ ...(overlayDir ? { overlayDir } : {}),
180
193
  });
181
194
  }
182
195
 
@@ -208,6 +221,9 @@ export function staticContentVarsYaml(plan: StaticContentPlan): string {
208
221
  content_hash: r.contentHash,
209
222
  hostnames: r.hostnames,
210
223
  source_dir: r.sourceDir,
224
+ // Absent when there is no overlay, so the role's `when:` guard reads a
225
+ // clean variable — not an empty string a copy task would choke on.
226
+ ...(r.overlayDir ? { overlay_dir: r.overlayDir } : {}),
211
227
  })),
212
228
  };
213
229
  const header =
@@ -249,6 +265,40 @@ export async function writeStaticContentVars(
249
265
  * never happen, so the playbook is checked for the converge's own variable
250
266
  * first and a stale project fails loudly with the redeploy that fixes it.
251
267
  */
268
+ /**
269
+ * Whether any text file in a generated Ansible tree mentions `needle`.
270
+ *
271
+ * Scans the TREE rather than `playbook.yml`, because a provider's converge
272
+ * belongs in one of its role's task files and a generated `playbook.yml` is a
273
+ * thin hosts/vars/roles stanza that names no variable at all. caddy is the
274
+ * worked example: `static_releases` appears in
275
+ * `roles/caddy/tasks/static-content.yml`, `.../main.yml.tpl` and
276
+ * `.../converge-release.yml`, and zero times in its playbook.
277
+ *
278
+ * @psbanka - 2026-09: this used to read `ansible/playbook.yml` alone, which
279
+ * could not reach the string it was looking for. The guard below therefore
280
+ * refused EVERY provider that had the converge support, and its remedy told the
281
+ * operator to redeploy — which regenerates the same thin playbook and cannot
282
+ * help. Guard and role tasks landed in the same commit (1fd3595e), so it had
283
+ * never once passed, and it held three e2e suites red where nobody was looking.
284
+ * celilo#1248.
285
+ */
286
+ export function ansibleTreeMentions(dir: string, needle: string): boolean {
287
+ if (!existsSync(dir)) return false;
288
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
289
+ const full = join(dir, entry.name);
290
+ if (entry.isDirectory()) {
291
+ if (ansibleTreeMentions(full, needle)) return true;
292
+ continue;
293
+ }
294
+ // Text only. A generated tree can carry static assets, and reading those
295
+ // as utf-8 to look for a variable name is waste at best.
296
+ if (!/\.(ya?ml|j2|tpl|cfg|ini|conf)$/.test(entry.name)) continue;
297
+ if (readFileSync(full, 'utf-8').includes(needle)) return true;
298
+ }
299
+ return false;
300
+ }
301
+
252
302
  export async function convergeStaticContent(
253
303
  db: DbClient,
254
304
  providerModuleId: string,
@@ -271,12 +321,9 @@ export async function convergeStaticContent(
271
321
 
272
322
  // A generated project that predates the converge role tasks would read the
273
323
  // new vars file and converge nothing. `static_releases` appearing nowhere in
274
- // the playbook is the tell. Generic string check — core names no module.
275
- const playbookPath = join(generatedPath, 'ansible', 'playbook.yml');
276
- if (
277
- existsSync(playbookPath) &&
278
- !readFileSync(playbookPath, 'utf-8').includes('static_releases')
279
- ) {
324
+ // the project is the tell. Generic string check — core names no module.
325
+ const ansiblePath = join(generatedPath, 'ansible');
326
+ if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'static_releases')) {
280
327
  return {
281
328
  success: false,
282
329
  error: `The generated project for '${providerModuleId}' predates the static-content converge (its playbook never reads static_releases). Redeploy the provider (\`celilo module deploy ${providerModuleId}\`) to regenerate it, then retry the publish.`,
@@ -14,7 +14,7 @@ export interface SystemConfigProperty {
14
14
  maximum?: number;
15
15
  default?: string | number | boolean;
16
16
  description?: string;
17
- format?: 'email';
17
+ format?: 'email' | 'cidr-list';
18
18
  }
19
19
 
20
20
  /**
@@ -119,6 +119,42 @@ describe('validateValue', () => {
119
119
  expect(result.valid).toBe(false);
120
120
  });
121
121
  });
122
+
123
+ describe('cidr-list format validation', () => {
124
+ const property: SystemConfigProperty = {
125
+ type: 'string',
126
+ format: 'cidr-list',
127
+ description: 'Trusted source subnets',
128
+ };
129
+
130
+ it('accepts both stored forms: comma-separated CIDRs and a JSON array', () => {
131
+ expect(validateValue('firewall.trusted_subnets', '10.0.0.0/24', property).valid).toBe(true);
132
+ expect(
133
+ validateValue('firewall.trusted_subnets', '10.0.0.0/24, 10.1.0.0/24', property).valid,
134
+ ).toBe(true);
135
+ expect(validateValue('firewall.trusted_subnets', '["10.226.120.0/24"]', property).valid).toBe(
136
+ true,
137
+ );
138
+ });
139
+
140
+ it('rejects a token that is not a CIDR, naming it, at write time', () => {
141
+ const result = validateValue(
142
+ 'firewall.trusted_subnets',
143
+ '["10.226.120.0/24", oops]',
144
+ property,
145
+ );
146
+ expect(result.valid).toBe(false);
147
+ if (result.valid) return;
148
+ expect(result.error).toContain('looks like JSON but does not parse');
149
+ });
150
+
151
+ it('rejects a JSON-looking value outright rather than mangling it into a subnet name', () => {
152
+ const result = validateValue('firewall.trusted_subnets', 'nonsense', property);
153
+ expect(result.valid).toBe(false);
154
+ if (result.valid) return;
155
+ expect(result.error).toContain('nonsense');
156
+ });
157
+ });
122
158
  });
123
159
 
124
160
  describe('validateKey', () => {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { SystemConfigProperty, SystemConfigSchema } from './system-config-schema-types';
8
+ import { parseOperatorTrustedSubnets } from './trusted-sources';
8
9
 
9
10
  /**
10
11
  * Validation result (discriminated union)
@@ -55,6 +56,16 @@ export function validateValue(
55
56
  }
56
57
  }
57
58
 
59
+ // CIDR lists validate through the key's own parser, so the write-time check
60
+ // and the converge-time read can never disagree about what is valid.
61
+ if (property.format === 'cidr-list') {
62
+ try {
63
+ parseOperatorTrustedSubnets(value);
64
+ } catch (e) {
65
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
66
+ }
67
+ }
68
+
58
69
  return { valid: true };
59
70
  }
60
71
 
@@ -256,5 +256,35 @@ describe('parsing the operator override', () => {
256
256
  it('an unset key contributes nothing', () => {
257
257
  expect(parseOperatorTrustedSubnets(undefined)).toEqual([]);
258
258
  expect(parseOperatorTrustedSubnets('')).toEqual([]);
259
+ expect(parseOperatorTrustedSubnets(' ')).toEqual([]);
260
+ });
261
+
262
+ it('parses the JSON-array form every sibling config key uses', () => {
263
+ expect(parseOperatorTrustedSubnets('["10.226.120.0/24"]')).toEqual(['10.226.120.0/24']);
264
+ expect(parseOperatorTrustedSubnets('[ "10.1.0.0/24" , "10.2.0.0/24" ]')).toEqual([
265
+ '10.1.0.0/24',
266
+ '10.2.0.0/24',
267
+ ]);
268
+ });
269
+
270
+ it('refuses a JSON-looking value that does not parse to an array of strings', () => {
271
+ // The exact e2e failure (ce-yaqa): a JSON array read as ONE subnet named
272
+ // ["10.226.120.0/24"], rendered verbatim into rules.v4, and
273
+ // iptables-restore failed the converge with "Bad argument `]'".
274
+ expect(() => parseOperatorTrustedSubnets('{"a":1}')).toThrow(/JSON array of CIDR strings/);
275
+ expect(() => parseOperatorTrustedSubnets('["10.0.0.0/24", 5]')).toThrow(
276
+ /JSON array of CIDR strings/,
277
+ );
278
+ expect(() => parseOperatorTrustedSubnets('[10.0.0.0/24')).toThrow(
279
+ /looks like JSON but does not parse/,
280
+ );
281
+ });
282
+
283
+ it('refuses a token that is not an IPv4 CIDR, naming it', () => {
284
+ expect(() => parseOperatorTrustedSubnets('10.0.0.1')).toThrow(/10\.0\.0\.1/);
285
+ expect(() => parseOperatorTrustedSubnets('10.0.0.1')).toThrow(/not an IPv4 CIDR/);
286
+ expect(() => parseOperatorTrustedSubnets('10.0.0.0/24, hostname.local')).toThrow(
287
+ /hostname\.local/,
288
+ );
259
289
  });
260
290
  });
@@ -14,6 +14,7 @@ import type {
14
14
  TrustedSource,
15
15
  TrustedSourceStore,
16
16
  } from '@celilo/capabilities';
17
+ import { isCidr } from '@celilo/capabilities';
17
18
  import { and, eq } from 'drizzle-orm';
18
19
  import type { DbClient } from '../db/client';
19
20
  import { trustedSources } from '../db/schema';
@@ -80,20 +81,56 @@ export interface TrustedSubnetEntry {
80
81
  }
81
82
 
82
83
  /**
83
- * The operator's explicit trusted sources, as a comma-separated CIDR list in
84
- * system config. ADDITIVE rather than replacing the derived control-plane
85
- * subnet: dropping control-plane trust would block the SSH celilo uses to run
86
- * every hook and converge, so an operator cannot strand celilo's own management
87
- * by naming a subnet here.
84
+ * The operator's explicit trusted sources, in system config. Accepts the two
85
+ * forms a config value takes in this area: a comma-separated CIDR list and a
86
+ * JSON array of CIDRs (the form every sibling key uses, e.g. ddns_passwords).
87
+ *
88
+ * Every token must be a CIDR. The parser is the single choke point between the
89
+ * operator's value and the ruleset renderer, which emits each subnet verbatim
90
+ * into rules.v4 — a token that is not a CIDR used to ride through as a literal
91
+ * subnet name and iptables-restore failed the whole converge with
92
+ * "Bad argument `]'". Failing here names the token instead.
93
+ *
94
+ * ADDITIVE rather than replacing the derived control-plane subnet: dropping
95
+ * control-plane trust would block the SSH celilo uses to run every hook and
96
+ * converge, so an operator cannot strand celilo's own management by naming a
97
+ * subnet here.
88
98
  */
89
99
  export const TRUSTED_SUBNETS_CONFIG_KEY = 'firewall.trusted_subnets';
90
100
 
91
101
  export function parseOperatorTrustedSubnets(raw: string | undefined): string[] {
92
- if (!raw) return [];
93
- return raw
94
- .split(',')
95
- .map((s) => s.trim())
96
- .filter((s) => s.length > 0);
102
+ if (!raw || raw.trim().length === 0) return [];
103
+
104
+ let tokens: string[];
105
+ const trimmed = raw.trim();
106
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(trimmed);
110
+ } catch {
111
+ throw new Error(
112
+ `${TRUSTED_SUBNETS_CONFIG_KEY} looks like JSON but does not parse: ${raw}. Expected a JSON array of CIDRs, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`,
113
+ );
114
+ }
115
+ if (!Array.isArray(parsed) || parsed.some((t) => typeof t !== 'string')) {
116
+ throw new Error(
117
+ `${TRUSTED_SUBNETS_CONFIG_KEY} must be a JSON array of CIDR strings, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`,
118
+ );
119
+ }
120
+ tokens = parsed;
121
+ } else {
122
+ tokens = trimmed.split(',');
123
+ }
124
+
125
+ const subnets = tokens.map((s) => s.trim()).filter((s) => s.length > 0);
126
+ for (const subnet of subnets) {
127
+ if (!isCidr(subnet)) {
128
+ throw new Error(
129
+ `${TRUSTED_SUBNETS_CONFIG_KEY} contains "${subnet}", which is not an IPv4 CIDR. Expected e.g. 10.0.0.0/24 (comma-separated) or ["10.0.0.0/24"] (JSON array).`,
130
+ );
131
+ }
132
+ }
133
+ return subnets;
97
134
  }
98
135
 
99
136
  /**
@@ -817,9 +817,16 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
817
817
  const configMap = new Map(configs.map((c) => [c.key, c.value || c.valueJson]));
818
818
 
819
819
  // Check for missing required variables
820
- // Skip infrastructure and terraform variables - they're auto-populated during/after generation
820
+ // Skip infrastructure and terraform variables - they're auto-populated during/after generation.
821
+ // Skip hook-owned variables too: they are discovered by the module's own
822
+ // hooks at runtime (hook-owned-state D2), so a hook that has not run yet
823
+ // is not a configuration error.
821
824
  const missingVars = requiredVars.filter(
822
- (v) => v.source !== 'infrastructure' && v.source !== 'terraform' && !configMap.has(v.name),
825
+ (v) =>
826
+ v.source !== 'infrastructure' &&
827
+ v.source !== 'terraform' &&
828
+ v.source !== 'hook' &&
829
+ !configMap.has(v.name),
823
830
  );
824
831
 
825
832
  if (missingVars.length > 0) {
@@ -577,19 +577,30 @@ async function assembleResolutionContext(
577
577
  // LXC boots with a working resolver — before Ansible's apt update, and
578
578
  // independent of which Proxmox node it lands on (the nodes' DNS defaults
579
579
  // diverge): the internal dns_internal resolver first (it recurses, so it
580
- // also answers external names), then the public resolvers as fallback.
580
+ // also answers external names), then the configured fleet resolvers as
581
+ // fallback.
581
582
  // Bootstrap (no dns_internal provider registered yet — e.g. the DNS module's
582
583
  // own LXC) → public only, so apt still works. Injected as
583
584
  // $self:lxc_nameserver, mirroring how inventory.* are auto-derived; the
584
585
  // proxmox_lxc terraform template drops it straight into `nameserver`.
585
586
  {
586
- const publicDns: string[] = [];
587
+ // Named for what the keys hold, not for what a reader hopes they hold:
588
+ // `dns.primary` / `dns.fallback` mean "the resolvers systems should use",
589
+ // and an operator pointing the fleet at its own resolver (a deployed
590
+ // dns_internal provider's address) is established, deliberate practice
591
+ // (celilo#1239). These entries may therefore be private. The guard against
592
+ // a resolver celilo installed ending up here lives where the keys are
593
+ // WRITTEN from host state — discovery refuses a deployed provider's
594
+ // address (dns-discovery.ts) — not in a privateness check here, which
595
+ // would break that practice while fixing nothing: in an uncovered zone
596
+ // dropping the entries takes DNS away rather than tightening anything.
597
+ const configuredDns: string[] = [];
587
598
  for (const key of ['dns.primary', 'dns.fallback']) {
588
599
  const raw = systemConfigMap[key];
589
600
  if (raw) {
590
601
  for (const part of raw.split(',')) {
591
602
  const ip = part.trim();
592
- if (ip) publicDns.push(ip);
603
+ if (ip) configuredDns.push(ip);
593
604
  }
594
605
  }
595
606
  }
@@ -663,8 +674,8 @@ async function assembleResolutionContext(
663
674
  const nameservers = dropPublicResolvers
664
675
  ? [primaryIp, secondaryIp]
665
676
  : primaryIp
666
- ? [primaryIp, ...publicDns]
667
- : publicDns;
677
+ ? [primaryIp, ...configuredDns]
678
+ : configuredDns;
668
679
  const unique = [...new Set(nameservers)];
669
680
  if (unique.length > 0) {
670
681
  selfConfig.lxc_nameserver = unique.join(' ');