@celilo/cli 0.20.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 (39) 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 +2 -2
  9. package/schemas/system_config.json +22 -11
  10. package/src/cli/commands/dns.ts +8 -4
  11. package/src/cli/commands/events.ts +4 -1
  12. package/src/cli/commands/system-audit.ts +15 -0
  13. package/src/cli/commands/system-migrate.test.ts +25 -4
  14. package/src/cli/commands/system-update.ts +5 -0
  15. package/src/cli/tui/audit-state.ts +2 -0
  16. package/src/db/dns-registrations-migration.test.ts +205 -0
  17. package/src/db/schema.ts +77 -8
  18. package/src/hooks/define-hook.test.ts +3 -3
  19. package/src/hooks/executor.test.ts +58 -0
  20. package/src/hooks/executor.ts +67 -7
  21. package/src/hooks/run-named-hook.ts +7 -1
  22. package/src/hooks/test-fixtures/silent-hook.ts +20 -0
  23. package/src/module/packaging/build.ts +14 -0
  24. package/src/services/alerting/builtin-monitors.ts +3 -0
  25. package/src/services/alerting/builtin-source.ts +23 -0
  26. package/src/services/audit/index.test.ts +2 -0
  27. package/src/services/audit/index.ts +3 -0
  28. package/src/services/audit/public-dns-source.ts +55 -0
  29. package/src/services/audit/public-dns.test.ts +209 -0
  30. package/src/services/audit/public-dns.ts +286 -0
  31. package/src/services/audit/types.ts +1 -0
  32. package/src/services/dns-registrations.test.ts +78 -16
  33. package/src/services/dns-registrations.ts +107 -19
  34. package/src/services/fleet-checks.test.ts +47 -1
  35. package/src/services/fleet-checks.ts +36 -4
  36. package/src/services/module-subscriptions.test.ts +9 -0
  37. package/src/services/public-dns-probe.test.ts +81 -0
  38. package/src/services/public-dns-probe.ts +156 -0
  39. package/src/services/update/orchestrator.test.ts +2 -0
@@ -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', () => {