@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0031_module_config_source.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +2 -2
- package/schemas/system_config.json +2 -1
- package/src/capabilities/public-web-publish.test.ts +61 -0
- package/src/cli/commands/firewall-interface-list.test.ts +156 -7
- 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 +20 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-update.test.ts +82 -0
- package/src/cli/commands/module-update.ts +14 -4
- package/src/cli/commands/monitor.ts +2 -10
- package/src/cli/commands/restore.ts +16 -6
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +4 -3
- package/src/cli/restore-migration-failure.test.ts +159 -0
- package/src/db/client.ts +5 -0
- package/src/db/migrate.test.ts +61 -135
- package/src/db/migrate.ts +7 -2
- 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.ts +16 -3
- package/src/hooks/define-hook.test.ts +4 -3
- package/src/hooks/executor.test.ts +19 -18
- package/src/hooks/executor.ts +88 -11
- package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
- package/src/hooks/hook-jail-unreachability.test.ts +55 -29
- 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 +162 -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 +94 -14
- package/src/hooks/jail.test.ts +1 -1
- package/src/hooks/jail.ts +194 -32
- package/src/hooks/mount-set.test.ts +296 -1
- package/src/hooks/mount-set.ts +216 -13
- 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 +27 -8
- package/src/manifest/schema.ts +1 -0
- package/src/module/packaging/build.ts +70 -2
- 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 +275 -5
- package/src/policy/no-hand-built-ssh.test.ts +34 -1
- package/src/policy/no-swallowed-refusal.test.ts +265 -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/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/format.test.ts +57 -0
- package/src/services/alerting/format.ts +24 -0
- package/src/services/alerting/run-monitor.ts +2 -2
- package/src/services/backup-create.ts +7 -7
- package/src/services/backup-envelope-roundtrip.test.ts +45 -2
- package/src/services/backup-restore.ts +8 -4
- package/src/services/bus-interview.ts +37 -14
- package/src/services/config-provenance.ts +4 -0
- package/src/services/control-plane-bootstrap.test.ts +297 -0
- package/src/services/control-plane-bootstrap.ts +223 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-preflight.ts +8 -2
- package/src/services/deploy-validation.test.ts +22 -0
- package/src/services/deploy-validation.ts +8 -0
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +147 -0
- package/src/services/dns-discovery.ts +134 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/fleet-key.test.ts +66 -2
- package/src/services/fleet-key.ts +54 -0
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-config.ts +20 -2
- package/src/services/module-deploy.dns-repoint.test.ts +187 -0
- package/src/services/module-deploy.ts +214 -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/remote-access.test.ts +88 -4
- package/src/services/remote-access.ts +52 -1
- package/src/services/restore-from-file.test.ts +20 -0
- package/src/services/restore-from-file.ts +21 -6
- package/src/services/static-content-converge.test.ts +140 -2
- package/src/services/static-content-converge.ts +55 -8
- 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/trusted-sources.test.ts +30 -0
- package/src/services/trusted-sources.ts +47 -10
- package/src/templates/generator.ts +9 -2
- package/src/variables/context.ts +16 -5
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import {
|
|
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
|
|
275
|
-
const
|
|
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.`,
|
|
@@ -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,
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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) =>
|
|
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) {
|
package/src/variables/context.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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, ...
|
|
667
|
-
:
|
|
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(' ');
|