@celilo/cli 0.19.0 → 0.21.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 (57) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  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/api/remote-client.test.ts +34 -12
  11. package/src/api/serve.ts +234 -38
  12. package/src/api/sessions.test.ts +196 -0
  13. package/src/api/sessions.ts +278 -0
  14. package/src/cli/commands/backup-sweep.ts +25 -9
  15. package/src/cli/commands/dns.ts +8 -4
  16. package/src/cli/commands/events.ts +64 -5
  17. package/src/cli/commands/module-update.ts +34 -11
  18. package/src/cli/commands/system-audit.ts +15 -0
  19. package/src/cli/commands/system-migrate.test.ts +25 -4
  20. package/src/cli/commands/system-update.ts +5 -0
  21. package/src/cli/completion.ts +1 -0
  22. package/src/cli/index.ts +22 -2
  23. package/src/cli/tui/audit-state.ts +2 -0
  24. package/src/db/dns-registrations-migration.test.ts +205 -0
  25. package/src/db/schema.ts +77 -8
  26. package/src/hooks/define-hook.test.ts +3 -3
  27. package/src/hooks/executor.test.ts +58 -0
  28. package/src/hooks/executor.ts +67 -7
  29. package/src/hooks/run-named-hook.ts +7 -1
  30. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  31. package/src/module/packaging/build.ts +14 -0
  32. package/src/services/alerting/builtin-monitors.ts +3 -0
  33. package/src/services/alerting/builtin-source.ts +23 -0
  34. package/src/services/audit/index.test.ts +2 -0
  35. package/src/services/audit/index.ts +3 -0
  36. package/src/services/audit/public-dns-source.ts +55 -0
  37. package/src/services/audit/public-dns.test.ts +209 -0
  38. package/src/services/audit/public-dns.ts +286 -0
  39. package/src/services/audit/types.ts +1 -0
  40. package/src/services/backup-metadata.ts +17 -0
  41. package/src/services/backup-staging.test.ts +98 -0
  42. package/src/services/backup-staging.ts +73 -1
  43. package/src/services/backup-sweep.test.ts +15 -0
  44. package/src/services/backup-sweep.ts +17 -1
  45. package/src/services/bus-interview-park.test.ts +179 -0
  46. package/src/services/bus-interview.ts +13 -8
  47. package/src/services/dns-registrations.test.ts +78 -16
  48. package/src/services/dns-registrations.ts +107 -19
  49. package/src/services/fleet-checks.test.ts +47 -1
  50. package/src/services/fleet-checks.ts +36 -4
  51. package/src/services/interview-errors.ts +24 -7
  52. package/src/services/module-subscriptions.test.ts +9 -0
  53. package/src/services/public-dns-probe.test.ts +81 -0
  54. package/src/services/public-dns-probe.ts +156 -0
  55. package/src/services/remote-responder.test.ts +33 -20
  56. package/src/services/remote-responder.ts +10 -6
  57. package/src/services/update/orchestrator.test.ts +2 -0
@@ -4,36 +4,63 @@
4
4
  *
5
5
  * The capability loader records every successful
6
6
  * dns_registrar.registerHost here; the run-hook path reads the ledger
7
- * back to feed a provider's `refresh_registrations` hook, and `celilo
8
- * dns registrations` lists it for the operator. Row lifecycle is FK
9
- * cascade — registrations die with their provider or consumer module.
7
+ * back to feed a provider's `refresh_registrations` hook, the
8
+ * `public_dns` check resolves every row from off-fleet, and `celilo dns
9
+ * registrations` lists it for the operator.
10
+ *
11
+ * The ledger records WHICH MODULE ASKED FOR WHICH NAME. It stores no
12
+ * address — see the schema comment and design.md D1.
13
+ *
14
+ * Lifecycle: a row dies with its provider by FK cascade, and with its
15
+ * LAST consumer via the `dns_registration_consumers` set (design.md D5).
10
16
  */
11
17
 
12
18
  import type { DnsRegistrarCapability, HookResult } from '@celilo/capabilities';
13
- import { eq } from 'drizzle-orm';
19
+ import { and, eq, sql } from 'drizzle-orm';
14
20
  import type { DbClient } from '../db/client';
15
- import { dnsRegistrations } from '../db/schema';
21
+ import { dnsRegistrationConsumers, dnsRegistrations } from '../db/schema';
16
22
 
17
23
  export interface DnsRegistrationRow {
18
24
  fqdn: string;
19
- /** null = the provider auto-detected the request's source IP. */
20
- ip: string | null;
21
25
  providerModuleId: string;
26
+ /** The module that introduced the name — the earliest consumer. */
22
27
  consumerModuleId: string;
28
+ /** Every module currently depending on the name, introducer first. */
29
+ consumerModuleIds: string[];
30
+ /** Claimed by celilo as the companion of a declared name, not asked for. */
31
+ companion: boolean;
23
32
  registeredAt: Date;
24
33
  refreshedAt: Date | null;
25
34
  }
26
35
 
36
+ /**
37
+ * Drop registrations whose last consumer module is gone.
38
+ *
39
+ * ponytail: pruned on read rather than by trigger — SQLite fires delete
40
+ * triggers for FK-cascaded deletes only when `recursive_triggers` is on,
41
+ * and reads are the only thing that consumes the ledger. Move it into a
42
+ * trigger if something ever reads these rows without going through here.
43
+ */
44
+ function pruneOrphanedRegistrations(db: DbClient): void {
45
+ db.run(
46
+ sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
47
+ SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
48
+ )`,
49
+ );
50
+ }
51
+
27
52
  export function listDnsRegistrations(
28
53
  db: DbClient,
29
54
  options: { providerModuleId?: string } = {},
30
55
  ): DnsRegistrationRow[] {
56
+ pruneOrphanedRegistrations(db);
57
+
31
58
  const query = db
32
59
  .select({
60
+ id: dnsRegistrations.id,
33
61
  fqdn: dnsRegistrations.fqdn,
34
- ip: dnsRegistrations.ip,
35
62
  providerModuleId: dnsRegistrations.providerModuleId,
36
- consumerModuleId: dnsRegistrations.consumerModuleId,
63
+ companion: dnsRegistrations.companion,
37
64
  registeredAt: dnsRegistrations.registeredAt,
38
65
  refreshedAt: dnsRegistrations.refreshedAt,
39
66
  })
@@ -41,7 +68,31 @@ export function listDnsRegistrations(
41
68
  const rows = options.providerModuleId
42
69
  ? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
43
70
  : query.all();
44
- return rows;
71
+
72
+ const consumers = db
73
+ .select({
74
+ registrationId: dnsRegistrationConsumers.registrationId,
75
+ moduleId: dnsRegistrationConsumers.moduleId,
76
+ })
77
+ .from(dnsRegistrationConsumers)
78
+ .orderBy(dnsRegistrationConsumers.id)
79
+ .all();
80
+
81
+ const byRegistration = new Map<number, string[]>();
82
+ for (const c of consumers) {
83
+ const list = byRegistration.get(c.registrationId);
84
+ if (list) list.push(c.moduleId);
85
+ else byRegistration.set(c.registrationId, [c.moduleId]);
86
+ }
87
+
88
+ return rows.map(({ id, ...row }) => {
89
+ const consumerModuleIds = byRegistration.get(id) ?? [];
90
+ return {
91
+ ...row,
92
+ consumerModuleIds,
93
+ consumerModuleId: consumerModuleIds[0] ?? '',
94
+ };
95
+ });
45
96
  }
46
97
 
47
98
  export function recordDnsRegistration(
@@ -50,26 +101,46 @@ export function recordDnsRegistration(
50
101
  providerModuleId: string;
51
102
  consumerModuleId: string;
52
103
  fqdn: string;
53
- ip: string | null;
104
+ companion?: boolean;
54
105
  },
55
106
  ): void {
56
107
  db.insert(dnsRegistrations)
57
108
  .values({
58
109
  providerModuleId: registration.providerModuleId,
59
- consumerModuleId: registration.consumerModuleId,
60
110
  fqdn: registration.fqdn,
61
- ip: registration.ip,
111
+ companion: registration.companion ?? false,
62
112
  registeredAt: new Date(),
63
113
  })
64
114
  .onConflictDoUpdate({
65
115
  target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
66
- set: {
67
- consumerModuleId: registration.consumerModuleId,
68
- ip: registration.ip,
69
- registeredAt: new Date(),
70
- },
116
+ // `companion` is not re-asserted: once a module declares a name
117
+ // outright it stops being something celilo claimed on its behalf.
118
+ set: { registeredAt: new Date() },
71
119
  })
72
120
  .run();
121
+
122
+ const row = db
123
+ .select({ id: dnsRegistrations.id })
124
+ .from(dnsRegistrations)
125
+ .where(
126
+ and(
127
+ eq(dnsRegistrations.providerModuleId, registration.providerModuleId),
128
+ eq(dnsRegistrations.fqdn, registration.fqdn),
129
+ ),
130
+ )
131
+ .get();
132
+ if (!row) return;
133
+
134
+ // A re-assert ADDS the asserting module rather than replacing whoever
135
+ // was there — the row must outlive any single one of them (D5).
136
+ db.insert(dnsRegistrationConsumers)
137
+ .values({
138
+ registrationId: row.id,
139
+ moduleId: registration.consumerModuleId,
140
+ firstSeenAt: new Date(),
141
+ })
142
+ .onConflictDoNothing()
143
+ .run();
73
144
  }
74
145
 
75
146
  /** Stamp every row of a provider as refreshed (successful refresh hook). */
@@ -85,6 +156,15 @@ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: s
85
156
  * recorded in the ledger. Failures and MissingProviderInputError
86
157
  * interview throws pass through untouched — only a confirmed
87
158
  * registration earns a row.
159
+ *
160
+ * A provider that ATTEMPTED a companion name (`www.<domain>` ↔
161
+ * `<domain>`) reports it as `outputs.companion_fqdn`, and it gets its own
162
+ * row whether or not the attempt reported success. The row is not a claim
163
+ * that the name is published — it is what puts the name under the
164
+ * `public_dns` check, which is the only thing that can tell. Namecheap
165
+ * returns `ErrCount 0` for a `www` update it silently does not apply, so
166
+ * a companion recorded only on "success" would be a name celilo believes
167
+ * it owns and never looks at again (design.md D3).
88
168
  */
89
169
  export function withDnsRegistrationLedger(
90
170
  registrar: DnsRegistrarCapability,
@@ -99,8 +179,16 @@ export function withDnsRegistrationLedger(
99
179
  providerModuleId: ctx.providerModuleId,
100
180
  consumerModuleId: ctx.consumerModuleId,
101
181
  fqdn: request.fqdn,
102
- ip: request.ip ?? null,
103
182
  });
183
+ const companion = result.outputs?.companion_fqdn;
184
+ if (typeof companion === 'string' && companion.length > 0) {
185
+ recordDnsRegistration(ctx.db, {
186
+ providerModuleId: ctx.providerModuleId,
187
+ consumerModuleId: ctx.consumerModuleId,
188
+ fqdn: companion,
189
+ companion: true,
190
+ });
191
+ }
104
192
  }
105
193
  return result;
106
194
  },
@@ -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
 
@@ -274,10 +277,53 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
274
277
 
275
278
  it('warns about a stale subscriber with no deployed module', () => {
276
279
  // No modules deployed, but a leftover subscriber lingers.
277
- bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
280
+ bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo', registeredBy: 'ghost' });
278
281
  const f = checkSubscribers(bus, db);
279
282
  expect(f.status).toBe('warn');
280
283
  expect(f.detail.join(' ')).toContain('ghost.sub');
284
+ // `resync-subscriptions` never deletes, so it cannot clear this (#624).
285
+ expect(f.autoFixable).toBe(false);
286
+ expect(f.remediation).toContain('subscribers remove');
287
+ });
288
+
289
+ it('ignores core-registered subscribers that no manifest declares (#624)', () => {
290
+ ensureSweepSubscriber(bus);
291
+ ensureInboundSubscriber(bus);
292
+ ensureBackupSweepSubscriber(bus);
293
+ ensureOperationsSweepSubscriber(bus);
294
+ const f = checkSubscribers(bus, db);
295
+ expect(f.status).toBe('ok');
296
+ expect(f.detail.join(' ')).not.toContain('celilo-');
297
+ });
298
+
299
+ // The guard for the invariant `checkSubscribers` classifies by, asserted
300
+ // at the sites that ESTABLISH it rather than the one that consumes it —
301
+ // so it keeps holding if the predicate is ever rewritten. Goes red the
302
+ // moment a core registrar names its row under its own registrar id
303
+ // (`celilo-alerting` registering `celilo-alerting.digest`), which would
304
+ // read as a module row, find no module of that name, and be reported
305
+ // stale — #624 again, and baffling to whoever hit it.
306
+ //
307
+ // ponytail: enumerates the registrars by hand because celilo has no
308
+ // registry of them. A fifth one is not covered until it is added here;
309
+ // if that ever bites, the upgrade is a shared `registerCoreSubscriber`
310
+ // helper that every core site goes through, tested once.
311
+ it('no core registrar produces a module-shaped subscriber name', () => {
312
+ ensureSweepSubscriber(bus);
313
+ ensureInboundSubscriber(bus);
314
+ ensureBackupSweepSubscriber(bus);
315
+ ensureOperationsSweepSubscriber(bus);
316
+
317
+ const rows = bus.db
318
+ .query<{ name: string; registered_by: string | null }, []>(
319
+ 'SELECT name, registered_by FROM subscribers',
320
+ )
321
+ .all();
322
+ expect(rows.length).toBeGreaterThan(0);
323
+ for (const row of rows) {
324
+ expect(row.registered_by).not.toBeNull();
325
+ expect(row.name.startsWith(`${row.registered_by}.`)).toBe(false);
326
+ }
281
327
  });
282
328
  });
283
329
 
@@ -386,6 +386,9 @@ function loadDeployedModules(db: DbClient): DeployedModule[] {
386
386
  * manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
387
387
  * every reactive subscription silently vanishes until a resync or a
388
388
  * redeploy. Missing rows fail; stale rows (a since-removed module) warn.
389
+ *
390
+ * Scoped to MODULE-owned rows — core's own subscribers share this bus and are
391
+ * declared by no manifest.
389
392
  */
390
393
  export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
391
394
  const deployed = loadDeployedModules(db);
@@ -402,7 +405,9 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
402
405
  }
403
406
 
404
407
  const actualRows = bus.db
405
- .query<{ name: string; pattern: string }, []>('SELECT name, pattern FROM subscribers')
408
+ .query<{ name: string; pattern: string; registered_by: string | null }, []>(
409
+ 'SELECT name, pattern, registered_by FROM subscribers',
410
+ )
406
411
  .all();
407
412
  const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
408
413
 
@@ -413,7 +418,25 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
413
418
  if (have === undefined) missing.push(name);
414
419
  else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
415
420
  }
416
- const stale = actualRows.map((r) => r.name).filter((name) => !expected.has(name));
421
+ // Only module-owned rows can be stale. celilo core registers subscribers on
422
+ // the same bus (the alerting/backup/operations sweeps) that no manifest
423
+ // declares, so judging every row against the manifest-derived map reported
424
+ // them as drift on every install, forever (#624).
425
+ //
426
+ // INVARIANT this relies on: a module row is named `<registered_by>.<sub>` —
427
+ // `resolveSubscription` (services/module-subscriptions.ts) derives both the
428
+ // scoped name and registered_by from the same module id. A core row is NOT
429
+ // scoped under its registrar's id (`celilo-alerting` registers
430
+ // `celilo-alerting-sweep`, not `celilo-alerting.sweep`). That's what lets
431
+ // provenance separate them with no allowlist to keep in sync.
432
+ //
433
+ // To break it: have a core registrar name a row under its own id — a future
434
+ // `celilo-alerting.digest` would read as module-owned, find no deployed
435
+ // module named `celilo-alerting`, and be reported stale. Which is #624 again.
436
+ const stale = actualRows
437
+ .filter((r) => r.registered_by !== null && r.name.startsWith(`${r.registered_by}.`))
438
+ .map((r) => r.name)
439
+ .filter((name) => !expected.has(name));
417
440
 
418
441
  const detail: string[] = [];
419
442
  const statuses: FleetFindingStatus[] = [];
@@ -436,6 +459,15 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
436
459
  );
437
460
  }
438
461
 
462
+ // `resync-subscriptions` only upserts from manifests — it never deletes, so
463
+ // it cannot clear a stale row. Advertising that as the remedy (and as
464
+ // auto-fixable) made `--fix` run a guaranteed no-op and call it a fix.
465
+ const resyncable = missing.length > 0 || mismatched.length > 0;
466
+ const remediations: string[] = [];
467
+ if (resyncable) remediations.push('`celilo events resync-subscriptions` (safe, idempotent)');
468
+ if (stale.length > 0)
469
+ remediations.push(`\`celilo subscribers remove <name>\` for: ${stale.join(', ')}`);
470
+
439
471
  const status = worst(statuses);
440
472
  return {
441
473
  id: 'subscribers',
@@ -446,8 +478,8 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
446
478
  ? `${expected.size} subscription(s) match the deployed fleet`
447
479
  : 'bus subscribers drifted from the deployed fleet',
448
480
  detail,
449
- remediation: status === 'ok' ? null : '`celilo events resync-subscriptions` (safe, idempotent)',
450
- autoFixable: status !== 'ok',
481
+ remediation: remediations.length > 0 ? remediations.join('; ') : null,
482
+ autoFixable: resyncable,
451
483
  };
452
484
  }
453
485
 
@@ -1,12 +1,15 @@
1
1
  /**
2
- * The one error type that means "this interview question could not be
3
- * answered". Its own module so `responder-probe` (no responder listening) and
4
- * `bus-interview` (a responder replied that it couldn't decide) can both throw
5
- * it without importing each other.
2
+ * The two error types that mean "this interview question was not decided".
3
+ * Their own module so `responder-probe` (nobody is listening at all) and the
4
+ * session reaper (a parked question expired) can both throw without importing
5
+ * each other.
6
6
  *
7
- * Callers catch this to distinguish an *unanswered* question from an answered
8
- * one — the distinction `module update` conflated when it reported a breaking
9
- * update as "operator declined" that no operator had ever seen.
7
+ * Callers catch these to distinguish a question that was never decided from one
8
+ * that was answered — the distinction `module update` conflated when it reported
9
+ * a breaking update as "operator declined" that no operator had ever seen. The
10
+ * two are not interchangeable: *unanswered* means no responder could even be
11
+ * found, *abandoned* means a responder existed, the question stood, and the
12
+ * deadline passed with nobody deciding.
10
13
  */
11
14
  export class InterviewUnansweredError extends Error {
12
15
  constructor(
@@ -18,3 +21,17 @@ export class InterviewUnansweredError extends Error {
18
21
  this.name = 'InterviewUnansweredError';
19
22
  }
20
23
  }
24
+
25
+ /**
26
+ * The question was posted, stood unanswered past its session's TTL, and the
27
+ * reaper answered it `abandoned` to release what the parked command held.
28
+ */
29
+ export class InterviewAbandonedError extends Error {
30
+ constructor(
31
+ readonly queryType: string,
32
+ message: string,
33
+ ) {
34
+ super(message);
35
+ this.name = 'InterviewAbandonedError';
36
+ }
37
+ }
@@ -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
+ }