@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
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Public-DNS reachability check.
3
+ *
4
+ * Every check celilo had reported healthy throughout celilo#626, during which
5
+ * five public names — including the apt repo and the module registry — resolved
6
+ * to an address that answered nothing for nine days. None of them was wrong:
7
+ * they all looked from *inside*, where the split-horizon resolver deliberately
8
+ * answers with an address that is reachable in-zone. celilo simply had no
9
+ * notion of a check whose vantage point is outside itself.
10
+ *
11
+ * This is that check. For every name in the DNS registration ledger it asks an
12
+ * OFF-FLEET resolver what the internet resolves, and compares that against the
13
+ * address the fleet actually appears to come from according to an independent
14
+ * echo service.
15
+ *
16
+ * Three properties are load-bearing, and each exists because of a specific way
17
+ * this check could quietly become useless:
18
+ *
19
+ * - **The resolver must not be the fleet's.** A `public_dns` check that used
20
+ * the configured resolver would pass forever, exactly like caddy's `dig`
21
+ * does today. That is the original bug one layer up, so the probe asserts
22
+ * it rather than leaving it to a comment (see `public-dns-probe.ts`).
23
+ * - **The expectation must not come from the registrar.** Comparing what was
24
+ * published against what we asked to publish is self-agreement — and worse
25
+ * than useless here, because Namecheap's DDNS API returns `ErrCount 0` with
26
+ * the requested address echoed back for a `www` update it silently does not
27
+ * apply (design.md D3).
28
+ * - **Missing evidence is not a pass.** The one genuine external probe the
29
+ * fleet had (isitup.org, in celilo-website's health check) was itself
30
+ * unreachable during the outage, and recorded that as *undetermined* with no
31
+ * check item at all. So a real outage produced complete silence. A single
32
+ * undetermined result stays quiet — a prober blip must not page — but
33
+ * consecutive ones become their own finding, distinct from "unreachable".
34
+ *
35
+ * Pure over an injected probe and the previous run's evidence counters: no
36
+ * network, no DB. See design.md D2.
37
+ */
38
+
39
+ import type { DriftFinding } from './types';
40
+
41
+ /** What an off-fleet resolver answered for one name. */
42
+ export type PublicResolution =
43
+ | { kind: 'answer'; ip: string; ttlSeconds: number }
44
+ /** The resolver answered authoritatively that there is no A record. */
45
+ | { kind: 'no_record' }
46
+ /** The resolver could not be reached, or failed. Not a pass, not a failure. */
47
+ | { kind: 'undetermined'; reason: string };
48
+
49
+ /** The address the fleet appears to come from, per an independent third party. */
50
+ export type IngressObservation =
51
+ | { kind: 'observed'; ip: string }
52
+ | { kind: 'undetermined'; reason: string };
53
+
54
+ export interface PublicDnsProbe {
55
+ /** Named so findings can say where the answer came from, and so a test can assert it is off-fleet. */
56
+ readonly resolver: string;
57
+ /** Named for the same reason — an echo service outage must be attributable. */
58
+ readonly echoService: string;
59
+ observeIngress(): Promise<IngressObservation>;
60
+ resolve(fqdn: string): Promise<PublicResolution>;
61
+ }
62
+
63
+ /** One ledger name to check. */
64
+ export interface PublicDnsRecord {
65
+ fqdn: string;
66
+ /**
67
+ * Claimed by celilo as the companion of a declared name rather than asked
68
+ * for by a module. Best effort at claim time, so its remediation is the
69
+ * manual registrar step rather than "fix the registrar and redeploy".
70
+ */
71
+ companion: boolean;
72
+ /**
73
+ * When the fleet last asserted this name (the provider's last refresh, or
74
+ * the registration). Divergence inside one TTL of an assert is propagation,
75
+ * not a fault — see the hysteresis note below.
76
+ */
77
+ lastAssertedAt: Date;
78
+ }
79
+
80
+ /**
81
+ * Per-subject counter of consecutive runs that produced no evidence. Carried
82
+ * across runs by the caller; the audit itself neither reads nor writes storage.
83
+ */
84
+ export interface PublicDnsEvidence {
85
+ subject: string;
86
+ undeterminedRuns: number;
87
+ }
88
+
89
+ export interface PublicDnsAuditDeps {
90
+ records: PublicDnsRecord[];
91
+ probe: PublicDnsProbe;
92
+ /** Counters from the previous run. Empty on a first run. */
93
+ evidence?: PublicDnsEvidence[];
94
+ /** Sink for the updated counters. Omitted in unit tests. */
95
+ saveEvidence?: (evidence: PublicDnsEvidence[]) => void;
96
+ now?: Date;
97
+ /** Consecutive undetermined runs before the absence of evidence is itself a finding. */
98
+ undeterminedThreshold?: number;
99
+ }
100
+
101
+ /** The echo service's own subject, so its outage is counted like any other. */
102
+ const ECHO_SUBJECT = 'system';
103
+
104
+ const DEFAULT_UNDETERMINED_THRESHOLD = 3;
105
+
106
+ /**
107
+ * A record that diverges within one TTL of the last assert is still
108
+ * propagating. Fleet records measure 180s, so alerting on first divergence
109
+ * would page on every ISP re-lease. Used when the resolver gave no TTL.
110
+ */
111
+ const FALLBACK_TTL_SECONDS = 300;
112
+
113
+ function companionRemediation(fqdn: string): string {
114
+ const label = fqdn.startsWith('www.') ? 'www' : '@';
115
+ return [
116
+ `celilo claims ${fqdn} alongside the name the module declared, but the`,
117
+ 'registrar could not publish it. A DDNS API can update an existing record',
118
+ 'and not create one, and Namecheap reports success for a `www` update it',
119
+ 'silently does not apply — so this has to be fixed at the registrar:',
120
+ '',
121
+ ` 1. Registrar → DNS for this domain → add an A record with Host="${label}"`,
122
+ ' (any value — celilo overwrites it), and enable Dynamic DNS.',
123
+ ' 2. celilo claims it on the next assert; no redeploy needed.',
124
+ ].join('\n');
125
+ }
126
+
127
+ /**
128
+ * For callers that legitimately have nothing to check — `system update` runs a
129
+ * partial audit over what it already has and does not reach the network. It
130
+ * throws rather than answering, so a caller that grows records later cannot
131
+ * silently keep an inert vantage point.
132
+ */
133
+ export const unusedPublicDnsProbe: PublicDnsProbe = {
134
+ resolver: '(not used)',
135
+ echoService: '(not used)',
136
+ observeIngress() {
137
+ throw new Error('public_dns probe used by a caller that declared no records');
138
+ },
139
+ resolve() {
140
+ throw new Error('public_dns probe used by a caller that declared no records');
141
+ },
142
+ };
143
+
144
+ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFinding[]> {
145
+ // No names asserted publicly means nothing to verify — and no reason to
146
+ // reach the network or advance an undetermined counter.
147
+ if (deps.records.length === 0) return [];
148
+
149
+ const now = deps.now ?? new Date();
150
+ const threshold = deps.undeterminedThreshold ?? DEFAULT_UNDETERMINED_THRESHOLD;
151
+ const previous = new Map((deps.evidence ?? []).map((e) => [e.subject, e.undeterminedRuns]));
152
+ const next = new Map<string, number>();
153
+ const findings: DriftFinding[] = [];
154
+
155
+ /**
156
+ * Count an absence of evidence. The first few are silent — one prober blip
157
+ * must not page — but they are never forgotten, which is the difference
158
+ * between "we were not told" and "we could not have been told".
159
+ */
160
+ const countUndetermined = (subject: string): number => {
161
+ const runs = (previous.get(subject) ?? 0) + 1;
162
+ next.set(subject, runs);
163
+ return runs;
164
+ };
165
+
166
+ const ingress = await deps.probe.observeIngress();
167
+
168
+ if (ingress.kind === 'undetermined') {
169
+ const runs = countUndetermined(ECHO_SUBJECT);
170
+ // Every name is unverifiable when the expectation is, so the records are
171
+ // left uncounted rather than each accruing a duplicate of the same outage.
172
+ if (runs >= threshold) {
173
+ findings.push({
174
+ category: 'public_dns',
175
+ severity: 'drift',
176
+ code: 'public_dns_unverifiable',
177
+ message: `Public DNS has been unverifiable for ${runs} consecutive checks`,
178
+ details: `The echo service (${deps.probe.echoService}) could not be reached, so there is no\nexpectation to compare public DNS against. Latest reason: ${ingress.reason}.\n\nThis is NOT a report that the fleet is reachable, and not a report that it\nis unreachable — it is a report that nothing is currently checking. The\noutage this check exists for ran for nine days behind exactly this silence.`,
179
+ remediation: [
180
+ 'Check outbound connectivity from the management host, then:',
181
+ ` celilo system config set public_dns.echo_url <url> # current: ${deps.probe.echoService}`,
182
+ ].join('\n'),
183
+ actionable: false,
184
+ subject: ECHO_SUBJECT,
185
+ });
186
+ }
187
+ deps.saveEvidence?.(toEvidence(next));
188
+ return findings;
189
+ }
190
+
191
+ for (const record of deps.records) {
192
+ const resolution = await deps.probe.resolve(record.fqdn);
193
+
194
+ if (resolution.kind === 'undetermined') {
195
+ const runs = countUndetermined(record.fqdn);
196
+ if (runs >= threshold) {
197
+ findings.push({
198
+ category: 'public_dns',
199
+ severity: 'drift',
200
+ code: 'public_dns_unverifiable',
201
+ message: `${record.fqdn}: public reachability unverifiable for ${runs} consecutive checks`,
202
+ details: `${deps.probe.resolver} did not answer for this name. Latest reason: ${resolution.reason}.\nWhether the name resolves publicly is currently unknown — which is not the\nsame as it being fine.`,
203
+ remediation: `Resolve it by hand to see what the internet gets:\n dig @${deps.probe.resolver} ${record.fqdn} A`,
204
+ actionable: false,
205
+ subject: record.fqdn,
206
+ });
207
+ }
208
+ continue;
209
+ }
210
+
211
+ if (resolution.kind === 'no_record') {
212
+ findings.push(
213
+ record.companion
214
+ ? {
215
+ category: 'public_dns',
216
+ severity: 'drift',
217
+ code: 'public_dns_companion_unclaimed',
218
+ message: `${record.fqdn}: not published (companion of a name celilo serves)`,
219
+ details:
220
+ 'This name has no public A record, so it does not reach the fleet at all.\n' +
221
+ 'A working www beside a dead apex (or the reverse) is the same defect as\n' +
222
+ 'celilo#626 in miniature: publicly broken, locally invisible.',
223
+ remediation: companionRemediation(record.fqdn),
224
+ actionable: false,
225
+ subject: record.fqdn,
226
+ }
227
+ : {
228
+ category: 'public_dns',
229
+ severity: 'drift',
230
+ code: 'public_dns_missing',
231
+ message: `${record.fqdn}: no public A record`,
232
+ details: `celilo registered this name but ${deps.probe.resolver} resolves no A record for\nit. The registrar reported success; a provider's success response is a claim\nabout an API call, not evidence that the record was published.`,
233
+ remediation: `Re-assert it, then re-check:\n celilo module run-hook <registrar> refresh_registrations\n dig @${deps.probe.resolver} ${record.fqdn} A`,
234
+ actionable: false,
235
+ subject: record.fqdn,
236
+ },
237
+ );
238
+ continue;
239
+ }
240
+
241
+ if (resolution.ip === ingress.ip) continue;
242
+
243
+ // Hysteresis. A legitimate address change produces a genuinely divergent
244
+ // public record until the old answer's TTL expires, so a divergence is only
245
+ // a finding once it has outlived the TTL of the record we last asserted.
246
+ // Without this the check pages on every ISP re-lease.
247
+ const ttlSeconds = resolution.ttlSeconds > 0 ? resolution.ttlSeconds : FALLBACK_TTL_SECONDS;
248
+ const settledAt = record.lastAssertedAt.getTime() + ttlSeconds * 1000;
249
+ if (now.getTime() < settledAt) continue;
250
+
251
+ findings.push(
252
+ record.companion
253
+ ? {
254
+ category: 'public_dns',
255
+ severity: 'drift',
256
+ code: 'public_dns_companion_unclaimed',
257
+ message: `${record.fqdn}: publicly resolves to ${resolution.ip}, not ${ingress.ip}`,
258
+ details: `${deps.probe.resolver} serves ${resolution.ip} for this companion name while the fleet is\nreachable at ${ingress.ip} (per ${deps.probe.echoService}). A parked or redirect record is\nthe usual cause: Namecheap renders those as A records the DDNS endpoint\naccepts updates for and does not apply, reporting ErrCount 0 (design.md D3).`,
259
+ remediation: companionRemediation(record.fqdn),
260
+ actionable: false,
261
+ subject: record.fqdn,
262
+ }
263
+ : {
264
+ category: 'public_dns',
265
+ severity: 'drift',
266
+ code: 'public_dns_stale',
267
+ message: `${record.fqdn}: publicly resolves to ${resolution.ip}, not ${ingress.ip}`,
268
+ details: `${deps.probe.resolver} serves ${resolution.ip} for this name while the fleet is reachable at\n${ingress.ip} (per ${deps.probe.echoService}). The divergence has outlived the record's own\nTTL (${ttlSeconds}s since the last assert), so this is not propagation.\n\nNothing inside the fleet can see this: the split-horizon resolver answers\nwith an address that IS reachable in-zone, which is correct for its purpose\nand says nothing about the public internet.`,
269
+ remediation: `Re-assert the record, then re-check:\n celilo module run-hook <registrar> refresh_registrations\n dig @${deps.probe.resolver} ${record.fqdn} A`,
270
+ actionable: false,
271
+ subject: record.fqdn,
272
+ },
273
+ );
274
+ }
275
+
276
+ deps.saveEvidence?.(toEvidence(next));
277
+ return findings;
278
+ }
279
+
280
+ /**
281
+ * Only subjects that were undetermined THIS run survive. A subject that
282
+ * answered has no counter, which is what makes the count "consecutive".
283
+ */
284
+ function toEvidence(next: Map<string, number>): PublicDnsEvidence[] {
285
+ return [...next].map(([subject, undeterminedRuns]) => ({ subject, undeterminedRuns }));
286
+ }
@@ -37,6 +37,7 @@ export type DriftCategory =
37
37
  | 'secrets_decryptable'
38
38
  | 'services_reachable'
39
39
  | 'machines_reachable'
40
+ | 'public_dns'
40
41
  | 'disk_space'
41
42
  | 'transport_reads'
42
43
  | 'trusted_sources';
@@ -2,7 +2,8 @@
2
2
  * Unit tests for the DNS registration ledger
3
3
  * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2): upsert semantics,
4
4
  * the registerHost recording wrapper (success-only), refresh stamping,
5
- * and FK-cascade cleanup when a module is removed.
5
+ * companion rows, and the consumer-set lifecycle (design.md D5) a row
6
+ * survives every consumer but the last.
6
7
  */
7
8
 
8
9
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
@@ -28,7 +29,7 @@ describe('dns_registrations ledger', () => {
28
29
  tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsreg-'));
29
30
  process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
30
31
  db = getDb();
31
- for (const id of ['namecheap', 'caddy']) {
32
+ for (const id of ['namecheap', 'caddy', 'authentik']) {
32
33
  db.insert(modules)
33
34
  .values({
34
35
  id,
@@ -46,25 +47,23 @@ describe('dns_registrations ledger', () => {
46
47
  process.env.CELILO_DB_PATH = undefined;
47
48
  });
48
49
 
49
- test('record + list roundtrip; upsert replaces ip and consumer', () => {
50
+ test('record + list roundtrip; a re-assert is an upsert, not a second row', () => {
50
51
  recordDnsRegistration(db, {
51
52
  providerModuleId: 'namecheap',
52
53
  consumerModuleId: 'caddy',
53
54
  fqdn: 'www.example.net',
54
- ip: '198.51.100.7',
55
55
  });
56
56
  recordDnsRegistration(db, {
57
57
  providerModuleId: 'namecheap',
58
58
  consumerModuleId: 'caddy',
59
59
  fqdn: 'www.example.net',
60
- ip: '198.51.100.8',
61
60
  });
62
61
 
63
62
  const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
64
63
  expect(rows.length).toBe(1);
65
64
  expect(rows[0].fqdn).toBe('www.example.net');
66
- expect(rows[0].ip).toBe('198.51.100.8');
67
65
  expect(rows[0].consumerModuleId).toBe('caddy');
66
+ expect(rows[0].companion).toBe(false);
68
67
  expect(rows[0].refreshedAt).toBeNull();
69
68
  });
70
69
 
@@ -73,21 +72,62 @@ describe('dns_registrations ledger', () => {
73
72
  providerModuleId: 'namecheap',
74
73
  consumerModuleId: 'caddy',
75
74
  fqdn: 'git.example.net',
76
- ip: null,
77
75
  });
78
76
  stampDnsRegistrationsRefreshed(db, 'namecheap');
79
77
  const [row] = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
80
78
  expect(row.refreshedAt).not.toBeNull();
81
79
  });
82
80
 
83
- test('rows die with the consumer module (FK cascade)', () => {
81
+ // ── design.md D5: attribution is a set, and it is load-bearing ─────────────
82
+ test('a blanket re-assert ADDS a consumer instead of overwriting the introducer', () => {
83
+ recordDnsRegistration(db, {
84
+ providerModuleId: 'namecheap',
85
+ consumerModuleId: 'authentik',
86
+ fqdn: 'auth.example.net',
87
+ });
88
+ // What `run-hook caddy on_install` does to every served name.
84
89
  recordDnsRegistration(db, {
85
90
  providerModuleId: 'namecheap',
86
91
  consumerModuleId: 'caddy',
87
- fqdn: 'www.example.net',
88
- ip: '198.51.100.7',
92
+ fqdn: 'auth.example.net',
93
+ });
94
+
95
+ const [row] = listDnsRegistrations(db);
96
+ expect(row.consumerModuleIds).toEqual(['authentik', 'caddy']);
97
+ // The module that introduced the name is still identifiable.
98
+ expect(row.consumerModuleId).toBe('authentik');
99
+ });
100
+
101
+ test('the row survives a consumer removal and dies with the last one', () => {
102
+ recordDnsRegistration(db, {
103
+ providerModuleId: 'namecheap',
104
+ consumerModuleId: 'authentik',
105
+ fqdn: 'auth.example.net',
89
106
  });
107
+ recordDnsRegistration(db, {
108
+ providerModuleId: 'namecheap',
109
+ consumerModuleId: 'caddy',
110
+ fqdn: 'auth.example.net',
111
+ });
112
+
113
+ // Removing caddy on the live fleet would, under the single-value column,
114
+ // have cascade-deleted a name authentik still serves.
90
115
  db.delete(modules).where(eq(modules.id, 'caddy')).run();
116
+ const [row] = listDnsRegistrations(db);
117
+ expect(row.fqdn).toBe('auth.example.net');
118
+ expect(row.consumerModuleIds).toEqual(['authentik']);
119
+
120
+ db.delete(modules).where(eq(modules.id, 'authentik')).run();
121
+ expect(listDnsRegistrations(db).length).toBe(0);
122
+ });
123
+
124
+ test('rows die with the provider module (FK cascade)', () => {
125
+ recordDnsRegistration(db, {
126
+ providerModuleId: 'namecheap',
127
+ consumerModuleId: 'caddy',
128
+ fqdn: 'www.example.net',
129
+ });
130
+ db.delete(modules).where(eq(modules.id, 'namecheap')).run();
91
131
  expect(listDnsRegistrations(db).length).toBe(0);
92
132
  });
93
133
 
@@ -106,15 +146,37 @@ describe('dns_registrations ledger', () => {
106
146
  consumerModuleId: 'caddy',
107
147
  });
108
148
 
109
- await wrapped.registerHost({ fqdn: 'www.example.net', ip: '198.51.100.7' });
110
- await wrapped.registerHost({ fqdn: 'fail.example.net', ip: '198.51.100.7' });
149
+ await wrapped.registerHost({ fqdn: 'www.example.net' });
150
+ await wrapped.registerHost({ fqdn: 'fail.example.net' });
111
151
  await wrapped.registerHost({ fqdn: 'auto.example.net' });
112
152
 
113
153
  expect(calls.length).toBe(3);
114
- const rows = listDnsRegistrations(db);
115
- const fqdns = rows.map((r) => r.fqdn).sort();
154
+ const fqdns = listDnsRegistrations(db)
155
+ .map((r) => r.fqdn)
156
+ .sort();
116
157
  expect(fqdns).toEqual(['auto.example.net', 'www.example.net']);
117
- const auto = rows.find((r) => r.fqdn === 'auto.example.net');
118
- expect(auto?.ip).toBeNull();
158
+ });
159
+
160
+ test('a companion the provider attempted gets its own watched row', async () => {
161
+ const fake: DnsRegistrarCapability = {
162
+ async registerHost(request) {
163
+ return {
164
+ success: true,
165
+ outputs: { companion_fqdn: `www.${request.fqdn}` },
166
+ duration: 1,
167
+ };
168
+ },
169
+ };
170
+ const wrapped = withDnsRegistrationLedger(fake, {
171
+ db,
172
+ providerModuleId: 'namecheap',
173
+ consumerModuleId: 'caddy',
174
+ });
175
+
176
+ await wrapped.registerHost({ fqdn: 'example.net' });
177
+
178
+ const rows = listDnsRegistrations(db).sort((a, b) => a.fqdn.localeCompare(b.fqdn));
179
+ expect(rows.map((r) => r.fqdn)).toEqual(['example.net', 'www.example.net']);
180
+ expect(rows.map((r) => r.companion)).toEqual([false, true]);
119
181
  });
120
182
  });
@@ -4,36 +4,75 @@
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
+ * It CHECKS before deleting, so the overwhelmingly common case (nothing
45
+ * orphaned) stays a pure read. The first version ran the DELETE
46
+ * unconditionally, which took a write lock on every list — including the
47
+ * refresh hook's, and `celilo dns registrations`. A read that quietly writes
48
+ * is a surprise on its own, and on SQLite it is a surprise that serialises
49
+ * against every other writer for no benefit.
50
+ */
51
+ function pruneOrphanedRegistrations(db: DbClient): void {
52
+ const orphaned = sql`SELECT 1 FROM dns_registrations WHERE NOT EXISTS (
53
+ SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
54
+ ) LIMIT 1`;
55
+ if (!db.get(orphaned)) return;
56
+
57
+ db.run(
58
+ sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
59
+ SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
60
+ )`,
61
+ );
62
+ }
63
+
27
64
  export function listDnsRegistrations(
28
65
  db: DbClient,
29
66
  options: { providerModuleId?: string } = {},
30
67
  ): DnsRegistrationRow[] {
68
+ pruneOrphanedRegistrations(db);
69
+
31
70
  const query = db
32
71
  .select({
72
+ id: dnsRegistrations.id,
33
73
  fqdn: dnsRegistrations.fqdn,
34
- ip: dnsRegistrations.ip,
35
74
  providerModuleId: dnsRegistrations.providerModuleId,
36
- consumerModuleId: dnsRegistrations.consumerModuleId,
75
+ companion: dnsRegistrations.companion,
37
76
  registeredAt: dnsRegistrations.registeredAt,
38
77
  refreshedAt: dnsRegistrations.refreshedAt,
39
78
  })
@@ -41,7 +80,31 @@ export function listDnsRegistrations(
41
80
  const rows = options.providerModuleId
42
81
  ? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
43
82
  : query.all();
44
- return rows;
83
+
84
+ const consumers = db
85
+ .select({
86
+ registrationId: dnsRegistrationConsumers.registrationId,
87
+ moduleId: dnsRegistrationConsumers.moduleId,
88
+ })
89
+ .from(dnsRegistrationConsumers)
90
+ .orderBy(dnsRegistrationConsumers.id)
91
+ .all();
92
+
93
+ const byRegistration = new Map<number, string[]>();
94
+ for (const c of consumers) {
95
+ const list = byRegistration.get(c.registrationId);
96
+ if (list) list.push(c.moduleId);
97
+ else byRegistration.set(c.registrationId, [c.moduleId]);
98
+ }
99
+
100
+ return rows.map(({ id, ...row }) => {
101
+ const consumerModuleIds = byRegistration.get(id) ?? [];
102
+ return {
103
+ ...row,
104
+ consumerModuleIds,
105
+ consumerModuleId: consumerModuleIds[0] ?? '',
106
+ };
107
+ });
45
108
  }
46
109
 
47
110
  export function recordDnsRegistration(
@@ -50,25 +113,45 @@ export function recordDnsRegistration(
50
113
  providerModuleId: string;
51
114
  consumerModuleId: string;
52
115
  fqdn: string;
53
- ip: string | null;
116
+ companion?: boolean;
54
117
  },
55
118
  ): void {
56
119
  db.insert(dnsRegistrations)
57
120
  .values({
58
121
  providerModuleId: registration.providerModuleId,
59
- consumerModuleId: registration.consumerModuleId,
60
122
  fqdn: registration.fqdn,
61
- ip: registration.ip,
123
+ companion: registration.companion ?? false,
62
124
  registeredAt: new Date(),
63
125
  })
64
126
  .onConflictDoUpdate({
65
127
  target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
66
- set: {
67
- consumerModuleId: registration.consumerModuleId,
68
- ip: registration.ip,
69
- registeredAt: new Date(),
70
- },
128
+ // `companion` is not re-asserted: once a module declares a name
129
+ // outright it stops being something celilo claimed on its behalf.
130
+ set: { registeredAt: new Date() },
131
+ })
132
+ .run();
133
+
134
+ const row = db
135
+ .select({ id: dnsRegistrations.id })
136
+ .from(dnsRegistrations)
137
+ .where(
138
+ and(
139
+ eq(dnsRegistrations.providerModuleId, registration.providerModuleId),
140
+ eq(dnsRegistrations.fqdn, registration.fqdn),
141
+ ),
142
+ )
143
+ .get();
144
+ if (!row) return;
145
+
146
+ // A re-assert ADDS the asserting module rather than replacing whoever
147
+ // was there — the row must outlive any single one of them (D5).
148
+ db.insert(dnsRegistrationConsumers)
149
+ .values({
150
+ registrationId: row.id,
151
+ moduleId: registration.consumerModuleId,
152
+ firstSeenAt: new Date(),
71
153
  })
154
+ .onConflictDoNothing()
72
155
  .run();
73
156
  }
74
157
 
@@ -85,6 +168,15 @@ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: s
85
168
  * recorded in the ledger. Failures and MissingProviderInputError
86
169
  * interview throws pass through untouched — only a confirmed
87
170
  * registration earns a row.
171
+ *
172
+ * A provider that ATTEMPTED a companion name (`www.<domain>` ↔
173
+ * `<domain>`) reports it as `outputs.companion_fqdn`, and it gets its own
174
+ * row whether or not the attempt reported success. The row is not a claim
175
+ * that the name is published — it is what puts the name under the
176
+ * `public_dns` check, which is the only thing that can tell. Namecheap
177
+ * returns `ErrCount 0` for a `www` update it silently does not apply, so
178
+ * a companion recorded only on "success" would be a name celilo believes
179
+ * it owns and never looks at again (design.md D3).
88
180
  */
89
181
  export function withDnsRegistrationLedger(
90
182
  registrar: DnsRegistrarCapability,
@@ -99,8 +191,16 @@ export function withDnsRegistrationLedger(
99
191
  providerModuleId: ctx.providerModuleId,
100
192
  consumerModuleId: ctx.consumerModuleId,
101
193
  fqdn: request.fqdn,
102
- ip: request.ip ?? null,
103
194
  });
195
+ const companion = result.outputs?.companion_fqdn;
196
+ if (typeof companion === 'string' && companion.length > 0) {
197
+ recordDnsRegistration(ctx.db, {
198
+ providerModuleId: ctx.providerModuleId,
199
+ consumerModuleId: ctx.consumerModuleId,
200
+ fqdn: companion,
201
+ companion: true,
202
+ });
203
+ }
104
204
  }
105
205
  return result;
106
206
  },