@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.
- package/CELILO_CORE_MODULES.md +2 -2
- 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 +2 -2
- package/schemas/system_config.json +22 -11
- package/src/cli/commands/dns.ts +8 -4
- package/src/cli/commands/events.ts +4 -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/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 +107 -19
- package/src/services/fleet-checks.test.ts +47 -1
- package/src/services/fleet-checks.ts +36 -4
- 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
|
@@ -21,6 +21,7 @@ import { type HealthAuditDeps, auditHealth } from './health';
|
|
|
21
21
|
import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
|
|
22
22
|
import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
|
|
23
23
|
import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
|
|
24
|
+
import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
|
|
24
25
|
import { type SchemaAuditDeps, auditSchema } from './schema';
|
|
25
26
|
import { type SecretsDecryptableAuditDeps, auditSecretsDecryptable } from './secrets-decryptable';
|
|
26
27
|
import {
|
|
@@ -59,6 +60,7 @@ export interface AuditDeps {
|
|
|
59
60
|
secretsDecryptable: SecretsDecryptableAuditDeps;
|
|
60
61
|
servicesReachable: ServicesReachableAuditDeps;
|
|
61
62
|
machinesReachable: MachinesReachableAuditDeps;
|
|
63
|
+
publicDns: PublicDnsAuditDeps;
|
|
62
64
|
transportReads: TransportReadsAuditDeps;
|
|
63
65
|
trustedSources: TrustedSourcesAuditDeps;
|
|
64
66
|
/** Defaults to `Date.now()`-based ISO string. */
|
|
@@ -114,6 +116,7 @@ export async function runAudit(
|
|
|
114
116
|
wrap('secrets_decryptable', auditSecretsDecryptable(deps.secretsDecryptable)),
|
|
115
117
|
wrap('services_reachable', auditServicesReachable(deps.servicesReachable)),
|
|
116
118
|
wrap('machines_reachable', auditMachinesReachable(deps.machinesReachable)),
|
|
119
|
+
wrap('public_dns', auditPublicDns(deps.publicDns)),
|
|
117
120
|
wrap('transport_reads', auditTransportReads(deps.transportReads)),
|
|
118
121
|
wrap('trusted_sources', auditTrustedSources(deps.trustedSources)),
|
|
119
122
|
]);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB-facing half of the `public_dns` check: the ledger names it watches and
|
|
3
|
+
* the consecutive-undetermined counters it carries between runs.
|
|
4
|
+
*
|
|
5
|
+
* Split from `public-dns.ts` so the audit itself stays a pure function over an
|
|
6
|
+
* injected probe — the counters are the only state it has, and a check that
|
|
7
|
+
* owned its own storage could not be tested without one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DbClient } from '../../db/client';
|
|
11
|
+
import { publicDnsEvidence } from '../../db/schema';
|
|
12
|
+
import { listDnsRegistrations } from '../dns-registrations';
|
|
13
|
+
import type { PublicDnsEvidence, PublicDnsRecord } from './public-dns';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every name celilo has asserted publicly. `lastAssertedAt` is when the fleet
|
|
17
|
+
* last told the registrar about it — the point the record's own TTL is
|
|
18
|
+
* measured from, so a divergence inside one TTL reads as propagation.
|
|
19
|
+
*/
|
|
20
|
+
export function loadPublicDnsRecords(db: DbClient): PublicDnsRecord[] {
|
|
21
|
+
return listDnsRegistrations(db).map((row) => ({
|
|
22
|
+
fqdn: row.fqdn,
|
|
23
|
+
companion: row.companion,
|
|
24
|
+
lastAssertedAt: row.refreshedAt ?? row.registeredAt,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadPublicDnsEvidence(db: DbClient): PublicDnsEvidence[] {
|
|
29
|
+
return db
|
|
30
|
+
.select({
|
|
31
|
+
subject: publicDnsEvidence.subject,
|
|
32
|
+
undeterminedRuns: publicDnsEvidence.undeterminedRuns,
|
|
33
|
+
})
|
|
34
|
+
.from(publicDnsEvidence)
|
|
35
|
+
.all();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Replace the whole set. A subject that produced evidence this run is absent
|
|
40
|
+
* from `evidence` and its counter goes with it — that is what makes the count
|
|
41
|
+
* consecutive rather than cumulative.
|
|
42
|
+
*/
|
|
43
|
+
export function savePublicDnsEvidence(db: DbClient, evidence: PublicDnsEvidence[]): void {
|
|
44
|
+
db.delete(publicDnsEvidence).run();
|
|
45
|
+
if (evidence.length === 0) return;
|
|
46
|
+
db.insert(publicDnsEvidence)
|
|
47
|
+
.values(
|
|
48
|
+
evidence.map((e) => ({
|
|
49
|
+
subject: e.subject,
|
|
50
|
+
undeterminedRuns: e.undeterminedRuns,
|
|
51
|
+
lastCheckedAt: new Date(),
|
|
52
|
+
})),
|
|
53
|
+
)
|
|
54
|
+
.run();
|
|
55
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate celilo#626 asks for, at the layer it can actually be
|
|
3
|
+
* asserted: feed the check a public record that diverges from where the fleet
|
|
4
|
+
* really is, and it must say so.
|
|
5
|
+
*
|
|
6
|
+
* Every check celilo had answered "healthy" for the nine days five public
|
|
7
|
+
* names were dark. This is the one that could not have.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import {
|
|
12
|
+
type IngressObservation,
|
|
13
|
+
type PublicDnsEvidence,
|
|
14
|
+
type PublicDnsProbe,
|
|
15
|
+
type PublicResolution,
|
|
16
|
+
auditPublicDns,
|
|
17
|
+
} from './public-dns';
|
|
18
|
+
|
|
19
|
+
const FLEET_INGRESS = '71.36.123.107';
|
|
20
|
+
/** The address the fleet moved off, and kept publishing for nine days. */
|
|
21
|
+
const DEAD_ADDRESS = '71.36.112.98';
|
|
22
|
+
|
|
23
|
+
const LONG_AGO = new Date('2026-08-01T00:00:00Z');
|
|
24
|
+
const NOW = new Date('2026-08-06T00:00:00Z');
|
|
25
|
+
|
|
26
|
+
function probe(options: {
|
|
27
|
+
ingress?: IngressObservation;
|
|
28
|
+
answers?: Record<string, PublicResolution>;
|
|
29
|
+
fallback?: PublicResolution;
|
|
30
|
+
}): PublicDnsProbe {
|
|
31
|
+
return {
|
|
32
|
+
resolver: '1.1.1.1',
|
|
33
|
+
echoService: 'https://echo.example',
|
|
34
|
+
async observeIngress() {
|
|
35
|
+
return options.ingress ?? { kind: 'observed', ip: FLEET_INGRESS };
|
|
36
|
+
},
|
|
37
|
+
async resolve(fqdn) {
|
|
38
|
+
return (
|
|
39
|
+
options.answers?.[fqdn] ??
|
|
40
|
+
options.fallback ?? { kind: 'answer', ip: FLEET_INGRESS, ttlSeconds: 180 }
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function declared(fqdn: string, lastAssertedAt = LONG_AGO) {
|
|
47
|
+
return { fqdn, companion: false, lastAssertedAt };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('auditPublicDns', () => {
|
|
51
|
+
test('a record pointing at an address the fleet no longer holds is a finding', async () => {
|
|
52
|
+
const findings = await auditPublicDns({
|
|
53
|
+
records: [declared('apt.celilo.computer')],
|
|
54
|
+
probe: probe({
|
|
55
|
+
answers: {
|
|
56
|
+
'apt.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
now: NOW,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(findings.length).toBe(1);
|
|
63
|
+
expect(findings[0].code).toBe('public_dns_stale');
|
|
64
|
+
expect(findings[0].subject).toBe('apt.celilo.computer');
|
|
65
|
+
// The finding has to name both addresses; "DNS is wrong" is not actionable.
|
|
66
|
+
expect(findings[0].message).toContain(DEAD_ADDRESS);
|
|
67
|
+
expect(findings[0].message).toContain(FLEET_INGRESS);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a record that agrees with the fleet is silent', async () => {
|
|
71
|
+
const findings = await auditPublicDns({
|
|
72
|
+
records: [declared('git.celilo.computer')],
|
|
73
|
+
probe: probe({}),
|
|
74
|
+
now: NOW,
|
|
75
|
+
});
|
|
76
|
+
expect(findings).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── Hysteresis: propagation is not a fault ────────────────────────────────
|
|
80
|
+
test('divergence inside the record TTL is propagation, not a finding', async () => {
|
|
81
|
+
const assertedSecondsAgo = 60;
|
|
82
|
+
const findings = await auditPublicDns({
|
|
83
|
+
records: [
|
|
84
|
+
declared('git.celilo.computer', new Date(NOW.getTime() - assertedSecondsAgo * 1000)),
|
|
85
|
+
],
|
|
86
|
+
probe: probe({
|
|
87
|
+
answers: {
|
|
88
|
+
'git.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
now: NOW,
|
|
92
|
+
});
|
|
93
|
+
// An ISP re-lease produces exactly this for up to one TTL. Alerting here
|
|
94
|
+
// would page on every re-lease, and a check nobody trusts is not a check.
|
|
95
|
+
expect(findings).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the same divergence once the TTL has elapsed IS a finding', async () => {
|
|
99
|
+
const findings = await auditPublicDns({
|
|
100
|
+
records: [declared('git.celilo.computer', new Date(NOW.getTime() - 181 * 1000))],
|
|
101
|
+
probe: probe({
|
|
102
|
+
answers: {
|
|
103
|
+
'git.celilo.computer': { kind: 'answer', ip: DEAD_ADDRESS, ttlSeconds: 180 },
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
now: NOW,
|
|
107
|
+
});
|
|
108
|
+
expect(findings.map((f) => f.code)).toEqual(['public_dns_stale']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Missing evidence is louder than absent evidence ───────────────────────
|
|
112
|
+
test('one undetermined run stays quiet', async () => {
|
|
113
|
+
const findings = await auditPublicDns({
|
|
114
|
+
records: [declared('git.celilo.computer')],
|
|
115
|
+
probe: probe({ ingress: { kind: 'undetermined', reason: 'ECONNREFUSED' } }),
|
|
116
|
+
now: NOW,
|
|
117
|
+
});
|
|
118
|
+
expect(findings).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('consecutive undetermined runs become their own finding', async () => {
|
|
122
|
+
let evidence: PublicDnsEvidence[] = [];
|
|
123
|
+
const run = () =>
|
|
124
|
+
auditPublicDns({
|
|
125
|
+
records: [declared('git.celilo.computer')],
|
|
126
|
+
probe: probe({ ingress: { kind: 'undetermined', reason: 'ECONNREFUSED' } }),
|
|
127
|
+
evidence,
|
|
128
|
+
saveEvidence: (next) => {
|
|
129
|
+
evidence = next;
|
|
130
|
+
},
|
|
131
|
+
now: NOW,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(await run()).toEqual([]);
|
|
135
|
+
expect(await run()).toEqual([]);
|
|
136
|
+
const findings = await run();
|
|
137
|
+
|
|
138
|
+
expect(findings.map((f) => f.code)).toEqual(['public_dns_unverifiable']);
|
|
139
|
+
// Distinguishable from "the site is unreachable" — this says nothing is
|
|
140
|
+
// currently checking, which is the state the outage hid behind.
|
|
141
|
+
expect(findings[0].message).toContain('unverifiable');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a run that obtained evidence resets the count', async () => {
|
|
145
|
+
let evidence: PublicDnsEvidence[] = [];
|
|
146
|
+
const save = (next: PublicDnsEvidence[]) => {
|
|
147
|
+
evidence = next;
|
|
148
|
+
};
|
|
149
|
+
const args = (ingress?: IngressObservation) => ({
|
|
150
|
+
records: [declared('git.celilo.computer')],
|
|
151
|
+
probe: probe({ ingress }),
|
|
152
|
+
evidence,
|
|
153
|
+
saveEvidence: save,
|
|
154
|
+
now: NOW,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }));
|
|
158
|
+
await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }));
|
|
159
|
+
await auditPublicDns(args());
|
|
160
|
+
expect(evidence).toEqual([]);
|
|
161
|
+
|
|
162
|
+
expect(await auditPublicDns(args({ kind: 'undetermined', reason: 'ECONNREFUSED' }))).toEqual(
|
|
163
|
+
[],
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── Companions: best effort must not mean silent ──────────────────────────
|
|
168
|
+
test('an unclaimed companion is reported with the manual registrar step', async () => {
|
|
169
|
+
const findings = await auditPublicDns({
|
|
170
|
+
records: [{ fqdn: 'www.peterbanka.org', companion: true, lastAssertedAt: LONG_AGO }],
|
|
171
|
+
probe: probe({
|
|
172
|
+
// The parked placeholder Namecheap keeps serving after reporting
|
|
173
|
+
// ErrCount 0 for an update it never applied (design.md D3).
|
|
174
|
+
answers: { 'www.peterbanka.org': { kind: 'answer', ip: '1.2.23.4', ttlSeconds: 1799 } },
|
|
175
|
+
}),
|
|
176
|
+
now: NOW,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(findings.map((f) => f.code)).toEqual(['public_dns_companion_unclaimed']);
|
|
180
|
+
expect(findings[0].remediation).toContain('Dynamic DNS');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a name with no public record at all is a finding', async () => {
|
|
184
|
+
const findings = await auditPublicDns({
|
|
185
|
+
records: [declared('nexus.lunacycle.net')],
|
|
186
|
+
probe: probe({ fallback: { kind: 'no_record' } }),
|
|
187
|
+
now: NOW,
|
|
188
|
+
});
|
|
189
|
+
expect(findings.map((f) => f.code)).toEqual(['public_dns_missing']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('an empty ledger never touches the network', async () => {
|
|
193
|
+
const findings = await auditPublicDns({
|
|
194
|
+
records: [],
|
|
195
|
+
probe: {
|
|
196
|
+
resolver: 'x',
|
|
197
|
+
echoService: 'y',
|
|
198
|
+
observeIngress() {
|
|
199
|
+
throw new Error('probed with nothing to check');
|
|
200
|
+
},
|
|
201
|
+
resolve() {
|
|
202
|
+
throw new Error('probed with nothing to check');
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
now: NOW,
|
|
206
|
+
});
|
|
207
|
+
expect(findings).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -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
|
+
}
|