@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.
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0020_dns_registrations_drop_ip.sql +25 -0
- package/drizzle/0021_dns_registration_consumers.sql +63 -0
- package/drizzle/0022_dns_registrations_companion.sql +15 -0
- package/drizzle/0023_public_dns_evidence.sql +19 -0
- package/drizzle/meta/_journal.json +29 -1
- package/package.json +4 -4
- package/schemas/system_config.json +22 -11
- package/src/cli/commands/dns.ts +8 -4
- package/src/cli/commands/events.test.ts +66 -0
- package/src/cli/commands/events.ts +76 -1
- package/src/cli/commands/system-audit.ts +15 -0
- package/src/cli/commands/system-migrate.test.ts +25 -4
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/dns-registrations-migration.test.ts +205 -0
- package/src/db/schema.ts +77 -8
- package/src/hooks/define-hook.test.ts +3 -3
- package/src/hooks/executor.test.ts +58 -0
- package/src/hooks/executor.ts +67 -7
- package/src/hooks/run-named-hook.ts +7 -1
- package/src/hooks/test-fixtures/silent-hook.ts +20 -0
- package/src/module/packaging/build.ts +14 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +23 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/public-dns-source.ts +55 -0
- package/src/services/audit/public-dns.test.ts +209 -0
- package/src/services/audit/public-dns.ts +286 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/dns-registrations.test.ts +78 -16
- package/src/services/dns-registrations.ts +119 -19
- package/src/services/fleet-checks.test.ts +93 -1
- package/src/services/fleet-checks.ts +51 -10
- package/src/services/module-subscriptions.test.ts +9 -0
- package/src/services/public-dns-probe.test.ts +81 -0
- package/src/services/public-dns-probe.ts +156 -0
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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: '
|
|
88
|
-
|
|
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'
|
|
110
|
-
await wrapped.registerHost({ fqdn: 'fail.example.net'
|
|
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
|
|
115
|
-
|
|
154
|
+
const fqdns = listDnsRegistrations(db)
|
|
155
|
+
.map((r) => r.fqdn)
|
|
156
|
+
.sort();
|
|
116
157
|
expect(fqdns).toEqual(['auto.example.net', 'www.example.net']);
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
companion: registration.companion ?? false,
|
|
62
124
|
registeredAt: new Date(),
|
|
63
125
|
})
|
|
64
126
|
.onConflictDoUpdate({
|
|
65
127
|
target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
},
|