@celilo/cli 0.20.0 → 0.22.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 (42) hide show
  1. package/CELILO_CORE_MODULES.md +7 -6
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
  4. package/drizzle/0021_dns_registration_consumers.sql +63 -0
  5. package/drizzle/0022_dns_registrations_companion.sql +15 -0
  6. package/drizzle/0023_public_dns_evidence.sql +19 -0
  7. package/drizzle/meta/_journal.json +29 -1
  8. package/package.json +4 -4
  9. package/schemas/system_config.json +22 -11
  10. package/src/cli/commands/dns.ts +8 -4
  11. package/src/cli/commands/events.test.ts +66 -0
  12. package/src/cli/commands/events.ts +76 -1
  13. package/src/cli/commands/system-audit.ts +15 -0
  14. package/src/cli/commands/system-migrate.test.ts +25 -4
  15. package/src/cli/commands/system-update.ts +5 -0
  16. package/src/cli/completion.ts +1 -0
  17. package/src/cli/index.ts +4 -0
  18. package/src/cli/tui/audit-state.ts +2 -0
  19. package/src/db/dns-registrations-migration.test.ts +205 -0
  20. package/src/db/schema.ts +77 -8
  21. package/src/hooks/define-hook.test.ts +3 -3
  22. package/src/hooks/executor.test.ts +58 -0
  23. package/src/hooks/executor.ts +67 -7
  24. package/src/hooks/run-named-hook.ts +7 -1
  25. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  26. package/src/module/packaging/build.ts +14 -0
  27. package/src/services/alerting/builtin-monitors.ts +3 -0
  28. package/src/services/alerting/builtin-source.ts +23 -0
  29. package/src/services/audit/index.test.ts +2 -0
  30. package/src/services/audit/index.ts +3 -0
  31. package/src/services/audit/public-dns-source.ts +55 -0
  32. package/src/services/audit/public-dns.test.ts +209 -0
  33. package/src/services/audit/public-dns.ts +286 -0
  34. package/src/services/audit/types.ts +1 -0
  35. package/src/services/dns-registrations.test.ts +78 -16
  36. package/src/services/dns-registrations.ts +119 -19
  37. package/src/services/fleet-checks.test.ts +93 -1
  38. package/src/services/fleet-checks.ts +51 -10
  39. package/src/services/module-subscriptions.test.ts +9 -0
  40. package/src/services/public-dns-probe.test.ts +81 -0
  41. package/src/services/public-dns-probe.ts +156 -0
  42. package/src/services/update/orchestrator.test.ts +2 -0
@@ -14,6 +14,8 @@ import {
14
14
  } from '../db/schema';
15
15
  import type { ModuleManifest } from '../manifest/schema';
16
16
  import { setupTestDatabase } from '../test-utils/setup-test-db';
17
+ import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monitors';
18
+ import { ensureBackupSweepSubscriber } from './backup-sweep';
17
19
  import { getDaemonUnitPath } from './events-daemon';
18
20
  import {
19
21
  checkCapabilityProviders,
@@ -25,6 +27,7 @@ import {
25
27
  describeCapabilityProblem,
26
28
  findBrokenCapabilityDerivations,
27
29
  } from './fleet-checks';
30
+ import { ensureOperationsSweepSubscriber } from './module-operations';
28
31
 
29
32
  const MINUTE = 60_000;
30
33
 
@@ -39,6 +42,19 @@ function seedHeartbeat(
39
42
  );
40
43
  }
41
44
 
45
+ /** Abandon `count` deliveries to one subscriber, oldest first. */
46
+ function seedFailed(bus: Bus, count: number): void {
47
+ const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
48
+ for (let i = 0; i < count; i++) {
49
+ const event = bus.emitRaw('ddns.refresh', { n: i });
50
+ bus.markFailed(
51
+ { eventId: event.id, subscriberId: sub.id },
52
+ new Error('handler timed out after 30000ms'),
53
+ { abandoned: true },
54
+ );
55
+ }
56
+ }
57
+
42
58
  /** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
43
59
  function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
44
60
  const path = getDaemonUnitPath('linux', home, scope, systemRoot);
@@ -196,6 +212,39 @@ describe('checkDispatcher', () => {
196
212
  expect(f.status).toBe('warn');
197
213
  expect(f.detail.join(' ')).toContain('not emitting on schedule');
198
214
  });
215
+
216
+ // celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
217
+ // so on celilo-mgr it printed a literal `50` that meant "at least 50" and
218
+ // read as an exact count. 137 > any limit anyone would pick.
219
+ it('reports the TRUE total of failed deliveries, not the read limit', () => {
220
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
221
+ installFakeUnit(home);
222
+ seedFailed(bus, 137);
223
+
224
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
225
+ expect(f.status).toBe('warn');
226
+ expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
227
+ expect(f.detail.join(' ')).not.toContain('50 failed');
228
+ });
229
+
230
+ // The stored error is double-wrapped for every row written before the
231
+ // serializeError fix; those live 90 days, so doctor must unwrap them.
232
+ it('renders the sample error text, not {"message":"[object Object]"}', () => {
233
+ seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
234
+ installFakeUnit(home);
235
+ seedFailed(bus, 1);
236
+ bus.db.run('UPDATE deliveries SET last_error = ?', [
237
+ JSON.stringify({
238
+ message: '[object Object]',
239
+ value: { message: 'handler exited with code 1' },
240
+ }),
241
+ ]);
242
+
243
+ const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
244
+ const detail = f.detail.join(' ');
245
+ expect(detail).toContain('handler exited with code 1');
246
+ expect(detail).not.toContain('[object Object]');
247
+ });
199
248
  });
200
249
 
201
250
  describe('checkSubscribers + checkCapabilityProviders', () => {
@@ -274,10 +323,53 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
274
323
 
275
324
  it('warns about a stale subscriber with no deployed module', () => {
276
325
  // No modules deployed, but a leftover subscriber lingers.
277
- bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
326
+ bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo', registeredBy: 'ghost' });
278
327
  const f = checkSubscribers(bus, db);
279
328
  expect(f.status).toBe('warn');
280
329
  expect(f.detail.join(' ')).toContain('ghost.sub');
330
+ // `resync-subscriptions` never deletes, so it cannot clear this (#624).
331
+ expect(f.autoFixable).toBe(false);
332
+ expect(f.remediation).toContain('subscribers remove');
333
+ });
334
+
335
+ it('ignores core-registered subscribers that no manifest declares (#624)', () => {
336
+ ensureSweepSubscriber(bus);
337
+ ensureInboundSubscriber(bus);
338
+ ensureBackupSweepSubscriber(bus);
339
+ ensureOperationsSweepSubscriber(bus);
340
+ const f = checkSubscribers(bus, db);
341
+ expect(f.status).toBe('ok');
342
+ expect(f.detail.join(' ')).not.toContain('celilo-');
343
+ });
344
+
345
+ // The guard for the invariant `checkSubscribers` classifies by, asserted
346
+ // at the sites that ESTABLISH it rather than the one that consumes it —
347
+ // so it keeps holding if the predicate is ever rewritten. Goes red the
348
+ // moment a core registrar names its row under its own registrar id
349
+ // (`celilo-alerting` registering `celilo-alerting.digest`), which would
350
+ // read as a module row, find no module of that name, and be reported
351
+ // stale — #624 again, and baffling to whoever hit it.
352
+ //
353
+ // ponytail: enumerates the registrars by hand because celilo has no
354
+ // registry of them. A fifth one is not covered until it is added here;
355
+ // if that ever bites, the upgrade is a shared `registerCoreSubscriber`
356
+ // helper that every core site goes through, tested once.
357
+ it('no core registrar produces a module-shaped subscriber name', () => {
358
+ ensureSweepSubscriber(bus);
359
+ ensureInboundSubscriber(bus);
360
+ ensureBackupSweepSubscriber(bus);
361
+ ensureOperationsSweepSubscriber(bus);
362
+
363
+ const rows = bus.db
364
+ .query<{ name: string; registered_by: string | null }, []>(
365
+ 'SELECT name, registered_by FROM subscribers',
366
+ )
367
+ .all();
368
+ expect(rows.length).toBeGreaterThan(0);
369
+ for (const row of rows) {
370
+ expect(row.registered_by).not.toBeNull();
371
+ expect(row.name.startsWith(`${row.registered_by}.`)).toBe(false);
372
+ }
281
373
  });
282
374
  });
283
375
 
@@ -17,7 +17,7 @@
17
17
  * rendering + `--fix` orchestration lives in the doctor command.
18
18
  */
19
19
 
20
- import type { Bus } from '@celilo/event-bus';
20
+ import { type Bus, describeError } from '@celilo/event-bus';
21
21
  import { inArray } from 'drizzle-orm';
22
22
  import { getModuleStoragePath } from '../config/paths';
23
23
  import { type DbClient, findMigrationsFolder } from '../db/client';
@@ -343,12 +343,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
343
343
  }
344
344
  }
345
345
 
346
- const failed = bus.failedDeliveries({ limit: 50 });
347
- if (failed.length > 0) {
346
+ // A TRUE total, not `failedDeliveries().length` that saturates at its LIMIT
347
+ // and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
348
+ // The newest row dates the backlog: a big total whose newest entry is days old
349
+ // is drained history, not active bleeding.
350
+ const { total: failedTotal } = bus.failedDeliveryTotals();
351
+ if (failedTotal > 0) {
348
352
  statuses.push('warn');
349
- const sample = failed[0]?.lastError ? ` (e.g. ${failed[0].lastError.split('\n')[0]})` : '';
350
- detail.push(`${failed.length} failed/abandoned delivery(ies)${sample}`);
351
- remediations.push('inspect failed deliveries and re-emit/repair as needed');
353
+ const newest = bus.failedDeliveries({ limit: 1 })[0];
354
+ const age = newest?.finishedAt
355
+ ? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
356
+ : '';
357
+ detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
358
+ const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
359
+ if (sample) detail.push(` example (newest, not the only one): ${sample}`);
360
+ remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
352
361
  }
353
362
 
354
363
  const status = worst(statuses);
@@ -386,6 +395,9 @@ function loadDeployedModules(db: DbClient): DeployedModule[] {
386
395
  * manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
387
396
  * every reactive subscription silently vanishes until a resync or a
388
397
  * redeploy. Missing rows fail; stale rows (a since-removed module) warn.
398
+ *
399
+ * Scoped to MODULE-owned rows — core's own subscribers share this bus and are
400
+ * declared by no manifest.
389
401
  */
390
402
  export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
391
403
  const deployed = loadDeployedModules(db);
@@ -402,7 +414,9 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
402
414
  }
403
415
 
404
416
  const actualRows = bus.db
405
- .query<{ name: string; pattern: string }, []>('SELECT name, pattern FROM subscribers')
417
+ .query<{ name: string; pattern: string; registered_by: string | null }, []>(
418
+ 'SELECT name, pattern, registered_by FROM subscribers',
419
+ )
406
420
  .all();
407
421
  const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
408
422
 
@@ -413,7 +427,25 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
413
427
  if (have === undefined) missing.push(name);
414
428
  else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
415
429
  }
416
- const stale = actualRows.map((r) => r.name).filter((name) => !expected.has(name));
430
+ // Only module-owned rows can be stale. celilo core registers subscribers on
431
+ // the same bus (the alerting/backup/operations sweeps) that no manifest
432
+ // declares, so judging every row against the manifest-derived map reported
433
+ // them as drift on every install, forever (#624).
434
+ //
435
+ // INVARIANT this relies on: a module row is named `<registered_by>.<sub>` —
436
+ // `resolveSubscription` (services/module-subscriptions.ts) derives both the
437
+ // scoped name and registered_by from the same module id. A core row is NOT
438
+ // scoped under its registrar's id (`celilo-alerting` registers
439
+ // `celilo-alerting-sweep`, not `celilo-alerting.sweep`). That's what lets
440
+ // provenance separate them with no allowlist to keep in sync.
441
+ //
442
+ // To break it: have a core registrar name a row under its own id — a future
443
+ // `celilo-alerting.digest` would read as module-owned, find no deployed
444
+ // module named `celilo-alerting`, and be reported stale. Which is #624 again.
445
+ const stale = actualRows
446
+ .filter((r) => r.registered_by !== null && r.name.startsWith(`${r.registered_by}.`))
447
+ .map((r) => r.name)
448
+ .filter((name) => !expected.has(name));
417
449
 
418
450
  const detail: string[] = [];
419
451
  const statuses: FleetFindingStatus[] = [];
@@ -436,6 +468,15 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
436
468
  );
437
469
  }
438
470
 
471
+ // `resync-subscriptions` only upserts from manifests — it never deletes, so
472
+ // it cannot clear a stale row. Advertising that as the remedy (and as
473
+ // auto-fixable) made `--fix` run a guaranteed no-op and call it a fix.
474
+ const resyncable = missing.length > 0 || mismatched.length > 0;
475
+ const remediations: string[] = [];
476
+ if (resyncable) remediations.push('`celilo events resync-subscriptions` (safe, idempotent)');
477
+ if (stale.length > 0)
478
+ remediations.push(`\`celilo subscribers remove <name>\` for: ${stale.join(', ')}`);
479
+
439
480
  const status = worst(statuses);
440
481
  return {
441
482
  id: 'subscribers',
@@ -446,8 +487,8 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
446
487
  ? `${expected.size} subscription(s) match the deployed fleet`
447
488
  : 'bus subscribers drifted from the deployed fleet',
448
489
  detail,
449
- remediation: status === 'ok' ? null : '`celilo events resync-subscriptions` (safe, idempotent)',
450
- autoFixable: status !== 'ok',
490
+ remediation: remediations.length > 0 ? remediations.join('; ') : null,
491
+ autoFixable: resyncable,
451
492
  };
452
493
  }
453
494
 
@@ -46,6 +46,15 @@ describe('resolveSubscription', () => {
46
46
  expect(resolved.registeredBy).toBe('lunacycle');
47
47
  });
48
48
 
49
+ // The other half of the invariant `checkSubscribers` classifies by (#624):
50
+ // core rows are not scoped under their registrar id, module rows always are.
51
+ // Stop scoping the name here and every module row reads as core-owned — the
52
+ // stale check would go silently blind rather than noisily wrong.
53
+ it('always scopes the name under the id it stamps in registeredBy', () => {
54
+ const resolved = resolveSubscription({ name: 'a', pattern: 'x', handler: 'echo' }, 'foo', '/p');
55
+ expect(resolved.name.startsWith(`${resolved.registeredBy}.`)).toBe(true);
56
+ });
57
+
49
58
  it('only substitutes $self when followed by . or end-of-string', () => {
50
59
  // `$selfish` would not be a real pattern but we want to ensure the
51
60
  // substitution doesn't accidentally rewrite identifier-like names.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The gate for the original bug, one layer up.
3
+ *
4
+ * A `public_dns` check that quietly resolved through the fleet's own resolver
5
+ * would pass forever whatever the internet sees — exactly like caddy's `dig`
6
+ * check does today, and exactly the blindness that let celilo#626 run for nine
7
+ * days. So "the resolver is off-fleet" is asserted rather than left to a
8
+ * comment, and the assertion lives where the probe is built.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import {
13
+ DEFAULT_PUBLIC_RESOLVER,
14
+ FleetResolverAsPublicVantageError,
15
+ assertOffFleetResolver,
16
+ parseFleetResolvers,
17
+ } from './public-dns-probe';
18
+
19
+ const FLEET = [
20
+ { role: 'dns.primary', ip: '10.0.20.5' },
21
+ { role: 'dns.fallback', ip: '8.8.8.8' },
22
+ ];
23
+
24
+ describe('assertOffFleetResolver', () => {
25
+ test("refuses the fleet's own resolver", () => {
26
+ expect(() => assertOffFleetResolver('10.0.20.5', FLEET)).toThrow(
27
+ FleetResolverAsPublicVantageError,
28
+ );
29
+ });
30
+
31
+ test('refuses it even when the fleet resolver is a public address', () => {
32
+ // The failure is "the fleet asks this resolver too", not "the address is
33
+ // private": a fleet configured to forward to 8.8.8.8 gets the same
34
+ // split-horizon answers back through it.
35
+ expect(() => assertOffFleetResolver('8.8.8.8', FLEET)).toThrow(
36
+ FleetResolverAsPublicVantageError,
37
+ );
38
+ });
39
+
40
+ test('accepts a resolver the fleet does not use', () => {
41
+ expect(() => assertOffFleetResolver(DEFAULT_PUBLIC_RESOLVER, FLEET)).not.toThrow();
42
+ });
43
+
44
+ test('the refusal says how to fix it', () => {
45
+ try {
46
+ assertOffFleetResolver('10.0.20.5', FLEET);
47
+ throw new Error('expected a refusal');
48
+ } catch (error) {
49
+ expect((error as Error).message).toContain('celilo system config set public_dns.resolver');
50
+ }
51
+ });
52
+ });
53
+
54
+ describe('parseFleetResolvers', () => {
55
+ test('a comma-separated dns.fallback yields one entry per address', () => {
56
+ // `system init` writes `dns.fallback=1.0.0.1,8.8.8.8`. Compared whole, the
57
+ // guard matched neither address and would have accepted 8.8.8.8 as the
58
+ // "off-fleet" vantage point of a fleet that forwards to 8.8.8.8.
59
+ expect(
60
+ parseFleetResolvers([
61
+ { role: 'dns.primary', value: '10.0.20.5' },
62
+ { role: 'dns.fallback', value: '1.0.0.1, 8.8.8.8' },
63
+ ]),
64
+ ).toEqual([
65
+ { role: 'dns.primary', ip: '10.0.20.5' },
66
+ { role: 'dns.fallback', ip: '1.0.0.1' },
67
+ { role: 'dns.fallback', ip: '8.8.8.8' },
68
+ ]);
69
+ });
70
+
71
+ test('an unset key contributes nothing', () => {
72
+ expect(parseFleetResolvers([{ role: 'dns.primary', value: undefined }])).toEqual([]);
73
+ });
74
+
75
+ test('a multi-entry fallback is caught by the guard', () => {
76
+ const fleet = parseFleetResolvers([{ role: 'dns.fallback', value: '1.0.0.1,8.8.8.8' }]);
77
+ expect(() => assertOffFleetResolver('8.8.8.8', fleet)).toThrow(
78
+ FleetResolverAsPublicVantageError,
79
+ );
80
+ });
81
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The off-fleet vantage point for the `public_dns` check.
3
+ *
4
+ * Two independent third parties, deliberately:
5
+ *
6
+ * - a **resolver that is not the fleet's**, asked what the internet resolves
7
+ * for each name. The fleet's own resolver runs split-horizon and answers
8
+ * with an address that is reachable in-zone — correct for its purpose, and
9
+ * not evidence about the public internet. A `public_dns` check that quietly
10
+ * used it would pass forever, which is the original defect one layer up, so
11
+ * `assertOffFleetResolver` refuses rather than trusting a code comment.
12
+ * - an **echo service**, for what the fleet's public ingress address actually
13
+ * is. Not the registrar's response: comparing what was published against
14
+ * what we asked to publish is self-agreement, and Namecheap answers
15
+ * `ErrCount 0` for updates it does not apply (design.md D2/D3).
16
+ *
17
+ * Both are configurable, and both are named in every finding they produce.
18
+ */
19
+
20
+ import { Resolver } from 'node:dns/promises';
21
+ import { eq } from 'drizzle-orm';
22
+ import type { DbClient } from '../db/client';
23
+ import { systemConfig } from '../db/schema';
24
+ import type { IngressObservation, PublicDnsProbe, PublicResolution } from './audit/public-dns';
25
+
26
+ /** Cloudflare. Overridable — the requirement is that it is not the fleet's. */
27
+ export const DEFAULT_PUBLIC_RESOLVER = '1.1.1.1';
28
+ export const DEFAULT_ECHO_URL = 'https://api.ipify.org';
29
+
30
+ const PROBE_TIMEOUT_MS = 5_000;
31
+
32
+ export class FleetResolverAsPublicVantageError extends Error {
33
+ constructor(resolver: string, role: string) {
34
+ super(
35
+ `Refusing to check public DNS through ${resolver}: it is the fleet's own resolver (${role}).\nThe fleet resolver runs split-horizon and answers with an in-zone address, so a\ncheck that used it would pass whatever the internet sees — which is exactly how\ncelilo#626 stayed invisible for nine days.\n\nSet an off-fleet resolver:\n celilo system config set public_dns.resolver 1.1.1.1`,
36
+ );
37
+ this.name = 'FleetResolverAsPublicVantageError';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Reject a resolver the fleet itself uses. Pure so the gate is unit-testable
43
+ * without a database — it is the assertion §5.2 asks for.
44
+ */
45
+ export function assertOffFleetResolver(
46
+ resolver: string,
47
+ fleetResolvers: { role: string; ip: string }[],
48
+ ): void {
49
+ const match = fleetResolvers.find((r) => r.ip === resolver);
50
+ if (match) throw new FleetResolverAsPublicVantageError(resolver, match.role);
51
+ }
52
+
53
+ function configValue(db: DbClient, key: string): string | undefined {
54
+ const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
55
+ const value = row?.value?.trim();
56
+ return value && value.length > 0 ? value : undefined;
57
+ }
58
+
59
+ /**
60
+ * Every resolver address the fleet is configured to use for its own lookups.
61
+ *
62
+ * `dns.fallback` holds a COMMA-SEPARATED list (`1.0.0.1,8.8.8.8` is what
63
+ * `system init` writes), so it is split rather than compared whole. Treating it
64
+ * as one string made the guard below miss every fallback but a single-entry
65
+ * one — a fleet forwarding to 8.8.8.8 could have been handed 8.8.8.8 as its
66
+ * "off-fleet" vantage point and the check would have agreed with itself
67
+ * forever, which is precisely the failure this guard exists to prevent.
68
+ */
69
+ export function fleetResolvers(db: DbClient): { role: string; ip: string }[] {
70
+ return parseFleetResolvers(
71
+ ['dns.primary', 'dns.fallback'].map((role) => ({ role, value: configValue(db, role) })),
72
+ );
73
+ }
74
+
75
+ /** The parse, split from the read so the comma handling is testable on its own. */
76
+ export function parseFleetResolvers(
77
+ entries: { role: string; value: string | undefined }[],
78
+ ): { role: string; ip: string }[] {
79
+ const resolvers: { role: string; ip: string }[] = [];
80
+ for (const { role, value } of entries) {
81
+ for (const ip of (value ?? '').split(',')) {
82
+ const trimmed = ip.trim();
83
+ if (trimmed) resolvers.push({ role, ip: trimmed });
84
+ }
85
+ }
86
+ return resolvers;
87
+ }
88
+
89
+ export interface PublicDnsProbeSettings {
90
+ resolver: string;
91
+ echoUrl: string;
92
+ }
93
+
94
+ export function loadPublicDnsProbeSettings(db: DbClient): PublicDnsProbeSettings {
95
+ const resolver = configValue(db, 'public_dns.resolver') ?? DEFAULT_PUBLIC_RESOLVER;
96
+ assertOffFleetResolver(resolver, fleetResolvers(db));
97
+ return { resolver, echoUrl: configValue(db, 'public_dns.echo_url') ?? DEFAULT_ECHO_URL };
98
+ }
99
+
100
+ /**
101
+ * `fetch` with a bound, so an unanswered echo request cannot hang a scheduled
102
+ * check (the shape celilo#622 fixed for DDNS).
103
+ */
104
+ async function fetchIngress(echoUrl: string): Promise<IngressObservation> {
105
+ try {
106
+ const response = await fetch(echoUrl, {
107
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
108
+ });
109
+ if (!response.ok) {
110
+ return { kind: 'undetermined', reason: `HTTP ${response.status}` };
111
+ }
112
+ const ip = (await response.text()).trim();
113
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
114
+ return { kind: 'undetermined', reason: `unparseable answer: ${ip.slice(0, 40)}` };
115
+ }
116
+ return { kind: 'observed', ip };
117
+ } catch (error) {
118
+ return {
119
+ kind: 'undetermined',
120
+ reason: error instanceof Error ? error.message : String(error),
121
+ };
122
+ }
123
+ }
124
+
125
+ export function createPublicDnsProbe(settings: PublicDnsProbeSettings): PublicDnsProbe {
126
+ const resolver = new Resolver({ timeout: PROBE_TIMEOUT_MS, tries: 2 });
127
+ resolver.setServers([settings.resolver]);
128
+
129
+ return {
130
+ resolver: settings.resolver,
131
+ echoService: settings.echoUrl,
132
+
133
+ observeIngress: () => fetchIngress(settings.echoUrl),
134
+
135
+ async resolve(fqdn: string): Promise<PublicResolution> {
136
+ try {
137
+ // `ttl: true` is why this uses node:dns rather than shelling out to
138
+ // dig: the TTL is what the hysteresis window is measured in.
139
+ const answers = await resolver.resolve4(fqdn, { ttl: true });
140
+ const first = answers[0];
141
+ if (!first) return { kind: 'no_record' };
142
+ return { kind: 'answer', ip: first.address, ttlSeconds: first.ttl };
143
+ } catch (error) {
144
+ const code = (error as NodeJS.ErrnoException).code;
145
+ // NXDOMAIN / NODATA are authoritative answers: the name really has no
146
+ // A record. Everything else (timeout, SERVFAIL, refused) means the
147
+ // probe could not look, which is never a pass.
148
+ if (code === 'ENOTFOUND' || code === 'ENODATA') return { kind: 'no_record' };
149
+ return {
150
+ kind: 'undetermined',
151
+ reason: code ?? (error instanceof Error ? error.message : String(error)),
152
+ };
153
+ }
154
+ },
155
+ };
156
+ }
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import type { DbClient } from '../../db/client';
3
3
  import type { ModuleManifest } from '../../manifest/schema';
4
4
  import type { AuditDeps } from '../audit';
5
+ import { unusedPublicDnsProbe } from '../audit/public-dns';
5
6
  import { buildModuleGraph } from './dep-graph';
6
7
  import {
7
8
  type ModuleSnapshot,
@@ -85,6 +86,7 @@ const cleanAudit: AuditDeps = {
85
86
  machinesReachable: { results: [] },
86
87
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
87
88
  trustedSources: { firewalls: [] },
89
+ publicDns: { records: [], probe: unusedPublicDnsProbe },
88
90
  };
89
91
 
90
92
  describe('consumerSkipReason', () => {