@celilo/cli 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +4 -4
- package/schemas/system_config.json +22 -11
- package/src/api/remote-client.test.ts +34 -12
- package/src/api/serve.ts +234 -38
- package/src/api/sessions.test.ts +196 -0
- package/src/api/sessions.ts +278 -0
- package/src/cli/commands/backup-sweep.ts +25 -9
- package/src/cli/commands/dns.ts +8 -4
- package/src/cli/commands/events.ts +64 -5
- package/src/cli/commands/module-update.ts +34 -11
- 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 +22 -2
- 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/backup-metadata.ts +17 -0
- package/src/services/backup-staging.test.ts +98 -0
- package/src/services/backup-staging.ts +73 -1
- package/src/services/backup-sweep.test.ts +15 -0
- package/src/services/backup-sweep.ts +17 -1
- package/src/services/bus-interview-park.test.ts +179 -0
- package/src/services/bus-interview.ts +13 -8
- 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/interview-errors.ts +24 -7
- 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/remote-responder.test.ts +33 -20
- package/src/services/remote-responder.ts +10 -6
- package/src/services/update/orchestrator.test.ts +2 -0
|
@@ -4,36 +4,63 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The capability loader records every successful
|
|
6
6
|
* dns_registrar.registerHost here; the run-hook path reads the ledger
|
|
7
|
-
* back to feed a provider's `refresh_registrations` hook,
|
|
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
|
+
function pruneOrphanedRegistrations(db: DbClient): void {
|
|
45
|
+
db.run(
|
|
46
|
+
sql`DELETE FROM dns_registrations WHERE NOT EXISTS (
|
|
47
|
+
SELECT 1 FROM dns_registration_consumers c WHERE c.registration_id = dns_registrations.id
|
|
48
|
+
)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
export function listDnsRegistrations(
|
|
28
53
|
db: DbClient,
|
|
29
54
|
options: { providerModuleId?: string } = {},
|
|
30
55
|
): DnsRegistrationRow[] {
|
|
56
|
+
pruneOrphanedRegistrations(db);
|
|
57
|
+
|
|
31
58
|
const query = db
|
|
32
59
|
.select({
|
|
60
|
+
id: dnsRegistrations.id,
|
|
33
61
|
fqdn: dnsRegistrations.fqdn,
|
|
34
|
-
ip: dnsRegistrations.ip,
|
|
35
62
|
providerModuleId: dnsRegistrations.providerModuleId,
|
|
36
|
-
|
|
63
|
+
companion: dnsRegistrations.companion,
|
|
37
64
|
registeredAt: dnsRegistrations.registeredAt,
|
|
38
65
|
refreshedAt: dnsRegistrations.refreshedAt,
|
|
39
66
|
})
|
|
@@ -41,7 +68,31 @@ export function listDnsRegistrations(
|
|
|
41
68
|
const rows = options.providerModuleId
|
|
42
69
|
? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
|
|
43
70
|
: query.all();
|
|
44
|
-
|
|
71
|
+
|
|
72
|
+
const consumers = db
|
|
73
|
+
.select({
|
|
74
|
+
registrationId: dnsRegistrationConsumers.registrationId,
|
|
75
|
+
moduleId: dnsRegistrationConsumers.moduleId,
|
|
76
|
+
})
|
|
77
|
+
.from(dnsRegistrationConsumers)
|
|
78
|
+
.orderBy(dnsRegistrationConsumers.id)
|
|
79
|
+
.all();
|
|
80
|
+
|
|
81
|
+
const byRegistration = new Map<number, string[]>();
|
|
82
|
+
for (const c of consumers) {
|
|
83
|
+
const list = byRegistration.get(c.registrationId);
|
|
84
|
+
if (list) list.push(c.moduleId);
|
|
85
|
+
else byRegistration.set(c.registrationId, [c.moduleId]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return rows.map(({ id, ...row }) => {
|
|
89
|
+
const consumerModuleIds = byRegistration.get(id) ?? [];
|
|
90
|
+
return {
|
|
91
|
+
...row,
|
|
92
|
+
consumerModuleIds,
|
|
93
|
+
consumerModuleId: consumerModuleIds[0] ?? '',
|
|
94
|
+
};
|
|
95
|
+
});
|
|
45
96
|
}
|
|
46
97
|
|
|
47
98
|
export function recordDnsRegistration(
|
|
@@ -50,26 +101,46 @@ export function recordDnsRegistration(
|
|
|
50
101
|
providerModuleId: string;
|
|
51
102
|
consumerModuleId: string;
|
|
52
103
|
fqdn: string;
|
|
53
|
-
|
|
104
|
+
companion?: boolean;
|
|
54
105
|
},
|
|
55
106
|
): void {
|
|
56
107
|
db.insert(dnsRegistrations)
|
|
57
108
|
.values({
|
|
58
109
|
providerModuleId: registration.providerModuleId,
|
|
59
|
-
consumerModuleId: registration.consumerModuleId,
|
|
60
110
|
fqdn: registration.fqdn,
|
|
61
|
-
|
|
111
|
+
companion: registration.companion ?? false,
|
|
62
112
|
registeredAt: new Date(),
|
|
63
113
|
})
|
|
64
114
|
.onConflictDoUpdate({
|
|
65
115
|
target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
registeredAt: new Date(),
|
|
70
|
-
},
|
|
116
|
+
// `companion` is not re-asserted: once a module declares a name
|
|
117
|
+
// outright it stops being something celilo claimed on its behalf.
|
|
118
|
+
set: { registeredAt: new Date() },
|
|
71
119
|
})
|
|
72
120
|
.run();
|
|
121
|
+
|
|
122
|
+
const row = db
|
|
123
|
+
.select({ id: dnsRegistrations.id })
|
|
124
|
+
.from(dnsRegistrations)
|
|
125
|
+
.where(
|
|
126
|
+
and(
|
|
127
|
+
eq(dnsRegistrations.providerModuleId, registration.providerModuleId),
|
|
128
|
+
eq(dnsRegistrations.fqdn, registration.fqdn),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.get();
|
|
132
|
+
if (!row) return;
|
|
133
|
+
|
|
134
|
+
// A re-assert ADDS the asserting module rather than replacing whoever
|
|
135
|
+
// was there — the row must outlive any single one of them (D5).
|
|
136
|
+
db.insert(dnsRegistrationConsumers)
|
|
137
|
+
.values({
|
|
138
|
+
registrationId: row.id,
|
|
139
|
+
moduleId: registration.consumerModuleId,
|
|
140
|
+
firstSeenAt: new Date(),
|
|
141
|
+
})
|
|
142
|
+
.onConflictDoNothing()
|
|
143
|
+
.run();
|
|
73
144
|
}
|
|
74
145
|
|
|
75
146
|
/** Stamp every row of a provider as refreshed (successful refresh hook). */
|
|
@@ -85,6 +156,15 @@ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: s
|
|
|
85
156
|
* recorded in the ledger. Failures and MissingProviderInputError
|
|
86
157
|
* interview throws pass through untouched — only a confirmed
|
|
87
158
|
* registration earns a row.
|
|
159
|
+
*
|
|
160
|
+
* A provider that ATTEMPTED a companion name (`www.<domain>` ↔
|
|
161
|
+
* `<domain>`) reports it as `outputs.companion_fqdn`, and it gets its own
|
|
162
|
+
* row whether or not the attempt reported success. The row is not a claim
|
|
163
|
+
* that the name is published — it is what puts the name under the
|
|
164
|
+
* `public_dns` check, which is the only thing that can tell. Namecheap
|
|
165
|
+
* returns `ErrCount 0` for a `www` update it silently does not apply, so
|
|
166
|
+
* a companion recorded only on "success" would be a name celilo believes
|
|
167
|
+
* it owns and never looks at again (design.md D3).
|
|
88
168
|
*/
|
|
89
169
|
export function withDnsRegistrationLedger(
|
|
90
170
|
registrar: DnsRegistrarCapability,
|
|
@@ -99,8 +179,16 @@ export function withDnsRegistrationLedger(
|
|
|
99
179
|
providerModuleId: ctx.providerModuleId,
|
|
100
180
|
consumerModuleId: ctx.consumerModuleId,
|
|
101
181
|
fqdn: request.fqdn,
|
|
102
|
-
ip: request.ip ?? null,
|
|
103
182
|
});
|
|
183
|
+
const companion = result.outputs?.companion_fqdn;
|
|
184
|
+
if (typeof companion === 'string' && companion.length > 0) {
|
|
185
|
+
recordDnsRegistration(ctx.db, {
|
|
186
|
+
providerModuleId: ctx.providerModuleId,
|
|
187
|
+
consumerModuleId: ctx.consumerModuleId,
|
|
188
|
+
fqdn: companion,
|
|
189
|
+
companion: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
104
192
|
}
|
|
105
193
|
return result;
|
|
106
194
|
},
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from '../db/schema';
|
|
15
15
|
import type { ModuleManifest } from '../manifest/schema';
|
|
16
16
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
17
|
+
import { ensureInboundSubscriber, ensureSweepSubscriber } from './alerting/monitors';
|
|
18
|
+
import { ensureBackupSweepSubscriber } from './backup-sweep';
|
|
17
19
|
import { getDaemonUnitPath } from './events-daemon';
|
|
18
20
|
import {
|
|
19
21
|
checkCapabilityProviders,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
describeCapabilityProblem,
|
|
26
28
|
findBrokenCapabilityDerivations,
|
|
27
29
|
} from './fleet-checks';
|
|
30
|
+
import { ensureOperationsSweepSubscriber } from './module-operations';
|
|
28
31
|
|
|
29
32
|
const MINUTE = 60_000;
|
|
30
33
|
|
|
@@ -274,10 +277,53 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
274
277
|
|
|
275
278
|
it('warns about a stale subscriber with no deployed module', () => {
|
|
276
279
|
// No modules deployed, but a leftover subscriber lingers.
|
|
277
|
-
bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
|
|
280
|
+
bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo', registeredBy: 'ghost' });
|
|
278
281
|
const f = checkSubscribers(bus, db);
|
|
279
282
|
expect(f.status).toBe('warn');
|
|
280
283
|
expect(f.detail.join(' ')).toContain('ghost.sub');
|
|
284
|
+
// `resync-subscriptions` never deletes, so it cannot clear this (#624).
|
|
285
|
+
expect(f.autoFixable).toBe(false);
|
|
286
|
+
expect(f.remediation).toContain('subscribers remove');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('ignores core-registered subscribers that no manifest declares (#624)', () => {
|
|
290
|
+
ensureSweepSubscriber(bus);
|
|
291
|
+
ensureInboundSubscriber(bus);
|
|
292
|
+
ensureBackupSweepSubscriber(bus);
|
|
293
|
+
ensureOperationsSweepSubscriber(bus);
|
|
294
|
+
const f = checkSubscribers(bus, db);
|
|
295
|
+
expect(f.status).toBe('ok');
|
|
296
|
+
expect(f.detail.join(' ')).not.toContain('celilo-');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// The guard for the invariant `checkSubscribers` classifies by, asserted
|
|
300
|
+
// at the sites that ESTABLISH it rather than the one that consumes it —
|
|
301
|
+
// so it keeps holding if the predicate is ever rewritten. Goes red the
|
|
302
|
+
// moment a core registrar names its row under its own registrar id
|
|
303
|
+
// (`celilo-alerting` registering `celilo-alerting.digest`), which would
|
|
304
|
+
// read as a module row, find no module of that name, and be reported
|
|
305
|
+
// stale — #624 again, and baffling to whoever hit it.
|
|
306
|
+
//
|
|
307
|
+
// ponytail: enumerates the registrars by hand because celilo has no
|
|
308
|
+
// registry of them. A fifth one is not covered until it is added here;
|
|
309
|
+
// if that ever bites, the upgrade is a shared `registerCoreSubscriber`
|
|
310
|
+
// helper that every core site goes through, tested once.
|
|
311
|
+
it('no core registrar produces a module-shaped subscriber name', () => {
|
|
312
|
+
ensureSweepSubscriber(bus);
|
|
313
|
+
ensureInboundSubscriber(bus);
|
|
314
|
+
ensureBackupSweepSubscriber(bus);
|
|
315
|
+
ensureOperationsSweepSubscriber(bus);
|
|
316
|
+
|
|
317
|
+
const rows = bus.db
|
|
318
|
+
.query<{ name: string; registered_by: string | null }, []>(
|
|
319
|
+
'SELECT name, registered_by FROM subscribers',
|
|
320
|
+
)
|
|
321
|
+
.all();
|
|
322
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
expect(row.registered_by).not.toBeNull();
|
|
325
|
+
expect(row.name.startsWith(`${row.registered_by}.`)).toBe(false);
|
|
326
|
+
}
|
|
281
327
|
});
|
|
282
328
|
});
|
|
283
329
|
|
|
@@ -386,6 +386,9 @@ function loadDeployedModules(db: DbClient): DeployedModule[] {
|
|
|
386
386
|
* manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
|
|
387
387
|
* every reactive subscription silently vanishes until a resync or a
|
|
388
388
|
* redeploy. Missing rows fail; stale rows (a since-removed module) warn.
|
|
389
|
+
*
|
|
390
|
+
* Scoped to MODULE-owned rows — core's own subscribers share this bus and are
|
|
391
|
+
* declared by no manifest.
|
|
389
392
|
*/
|
|
390
393
|
export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
391
394
|
const deployed = loadDeployedModules(db);
|
|
@@ -402,7 +405,9 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
402
405
|
}
|
|
403
406
|
|
|
404
407
|
const actualRows = bus.db
|
|
405
|
-
.query<{ name: string; pattern: string }, []>(
|
|
408
|
+
.query<{ name: string; pattern: string; registered_by: string | null }, []>(
|
|
409
|
+
'SELECT name, pattern, registered_by FROM subscribers',
|
|
410
|
+
)
|
|
406
411
|
.all();
|
|
407
412
|
const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
|
|
408
413
|
|
|
@@ -413,7 +418,25 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
413
418
|
if (have === undefined) missing.push(name);
|
|
414
419
|
else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
|
|
415
420
|
}
|
|
416
|
-
|
|
421
|
+
// Only module-owned rows can be stale. celilo core registers subscribers on
|
|
422
|
+
// the same bus (the alerting/backup/operations sweeps) that no manifest
|
|
423
|
+
// declares, so judging every row against the manifest-derived map reported
|
|
424
|
+
// them as drift on every install, forever (#624).
|
|
425
|
+
//
|
|
426
|
+
// INVARIANT this relies on: a module row is named `<registered_by>.<sub>` —
|
|
427
|
+
// `resolveSubscription` (services/module-subscriptions.ts) derives both the
|
|
428
|
+
// scoped name and registered_by from the same module id. A core row is NOT
|
|
429
|
+
// scoped under its registrar's id (`celilo-alerting` registers
|
|
430
|
+
// `celilo-alerting-sweep`, not `celilo-alerting.sweep`). That's what lets
|
|
431
|
+
// provenance separate them with no allowlist to keep in sync.
|
|
432
|
+
//
|
|
433
|
+
// To break it: have a core registrar name a row under its own id — a future
|
|
434
|
+
// `celilo-alerting.digest` would read as module-owned, find no deployed
|
|
435
|
+
// module named `celilo-alerting`, and be reported stale. Which is #624 again.
|
|
436
|
+
const stale = actualRows
|
|
437
|
+
.filter((r) => r.registered_by !== null && r.name.startsWith(`${r.registered_by}.`))
|
|
438
|
+
.map((r) => r.name)
|
|
439
|
+
.filter((name) => !expected.has(name));
|
|
417
440
|
|
|
418
441
|
const detail: string[] = [];
|
|
419
442
|
const statuses: FleetFindingStatus[] = [];
|
|
@@ -436,6 +459,15 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
436
459
|
);
|
|
437
460
|
}
|
|
438
461
|
|
|
462
|
+
// `resync-subscriptions` only upserts from manifests — it never deletes, so
|
|
463
|
+
// it cannot clear a stale row. Advertising that as the remedy (and as
|
|
464
|
+
// auto-fixable) made `--fix` run a guaranteed no-op and call it a fix.
|
|
465
|
+
const resyncable = missing.length > 0 || mismatched.length > 0;
|
|
466
|
+
const remediations: string[] = [];
|
|
467
|
+
if (resyncable) remediations.push('`celilo events resync-subscriptions` (safe, idempotent)');
|
|
468
|
+
if (stale.length > 0)
|
|
469
|
+
remediations.push(`\`celilo subscribers remove <name>\` for: ${stale.join(', ')}`);
|
|
470
|
+
|
|
439
471
|
const status = worst(statuses);
|
|
440
472
|
return {
|
|
441
473
|
id: 'subscribers',
|
|
@@ -446,8 +478,8 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
446
478
|
? `${expected.size} subscription(s) match the deployed fleet`
|
|
447
479
|
: 'bus subscribers drifted from the deployed fleet',
|
|
448
480
|
detail,
|
|
449
|
-
remediation:
|
|
450
|
-
autoFixable:
|
|
481
|
+
remediation: remediations.length > 0 ? remediations.join('; ') : null,
|
|
482
|
+
autoFixable: resyncable,
|
|
451
483
|
};
|
|
452
484
|
}
|
|
453
485
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The two error types that mean "this interview question was not decided".
|
|
3
|
+
* Their own module so `responder-probe` (nobody is listening at all) and the
|
|
4
|
+
* session reaper (a parked question expired) can both throw without importing
|
|
5
|
+
* each other.
|
|
6
6
|
*
|
|
7
|
-
* Callers catch
|
|
8
|
-
*
|
|
9
|
-
* update as "operator declined" that no operator had ever seen.
|
|
7
|
+
* Callers catch these to distinguish a question that was never decided from one
|
|
8
|
+
* that was answered — the distinction `module update` conflated when it reported
|
|
9
|
+
* a breaking update as "operator declined" that no operator had ever seen. The
|
|
10
|
+
* two are not interchangeable: *unanswered* means no responder could even be
|
|
11
|
+
* found, *abandoned* means a responder existed, the question stood, and the
|
|
12
|
+
* deadline passed with nobody deciding.
|
|
10
13
|
*/
|
|
11
14
|
export class InterviewUnansweredError extends Error {
|
|
12
15
|
constructor(
|
|
@@ -18,3 +21,17 @@ export class InterviewUnansweredError extends Error {
|
|
|
18
21
|
this.name = 'InterviewUnansweredError';
|
|
19
22
|
}
|
|
20
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The question was posted, stood unanswered past its session's TTL, and the
|
|
27
|
+
* reaper answered it `abandoned` to release what the parked command held.
|
|
28
|
+
*/
|
|
29
|
+
export class InterviewAbandonedError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
readonly queryType: string,
|
|
32
|
+
message: string,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'InterviewAbandonedError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -46,6 +46,15 @@ describe('resolveSubscription', () => {
|
|
|
46
46
|
expect(resolved.registeredBy).toBe('lunacycle');
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
// The other half of the invariant `checkSubscribers` classifies by (#624):
|
|
50
|
+
// core rows are not scoped under their registrar id, module rows always are.
|
|
51
|
+
// Stop scoping the name here and every module row reads as core-owned — the
|
|
52
|
+
// stale check would go silently blind rather than noisily wrong.
|
|
53
|
+
it('always scopes the name under the id it stamps in registeredBy', () => {
|
|
54
|
+
const resolved = resolveSubscription({ name: 'a', pattern: 'x', handler: 'echo' }, 'foo', '/p');
|
|
55
|
+
expect(resolved.name.startsWith(`${resolved.registeredBy}.`)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
49
58
|
it('only substitutes $self when followed by . or end-of-string', () => {
|
|
50
59
|
// `$selfish` would not be a real pattern but we want to ensure the
|
|
51
60
|
// substitution doesn't accidentally rewrite identifier-like names.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate for the original bug, one layer up.
|
|
3
|
+
*
|
|
4
|
+
* A `public_dns` check that quietly resolved through the fleet's own resolver
|
|
5
|
+
* would pass forever whatever the internet sees — exactly like caddy's `dig`
|
|
6
|
+
* check does today, and exactly the blindness that let celilo#626 run for nine
|
|
7
|
+
* days. So "the resolver is off-fleet" is asserted rather than left to a
|
|
8
|
+
* comment, and the assertion lives where the probe is built.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_PUBLIC_RESOLVER,
|
|
14
|
+
FleetResolverAsPublicVantageError,
|
|
15
|
+
assertOffFleetResolver,
|
|
16
|
+
parseFleetResolvers,
|
|
17
|
+
} from './public-dns-probe';
|
|
18
|
+
|
|
19
|
+
const FLEET = [
|
|
20
|
+
{ role: 'dns.primary', ip: '10.0.20.5' },
|
|
21
|
+
{ role: 'dns.fallback', ip: '8.8.8.8' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
describe('assertOffFleetResolver', () => {
|
|
25
|
+
test("refuses the fleet's own resolver", () => {
|
|
26
|
+
expect(() => assertOffFleetResolver('10.0.20.5', FLEET)).toThrow(
|
|
27
|
+
FleetResolverAsPublicVantageError,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('refuses it even when the fleet resolver is a public address', () => {
|
|
32
|
+
// The failure is "the fleet asks this resolver too", not "the address is
|
|
33
|
+
// private": a fleet configured to forward to 8.8.8.8 gets the same
|
|
34
|
+
// split-horizon answers back through it.
|
|
35
|
+
expect(() => assertOffFleetResolver('8.8.8.8', FLEET)).toThrow(
|
|
36
|
+
FleetResolverAsPublicVantageError,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('accepts a resolver the fleet does not use', () => {
|
|
41
|
+
expect(() => assertOffFleetResolver(DEFAULT_PUBLIC_RESOLVER, FLEET)).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the refusal says how to fix it', () => {
|
|
45
|
+
try {
|
|
46
|
+
assertOffFleetResolver('10.0.20.5', FLEET);
|
|
47
|
+
throw new Error('expected a refusal');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
expect((error as Error).message).toContain('celilo system config set public_dns.resolver');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('parseFleetResolvers', () => {
|
|
55
|
+
test('a comma-separated dns.fallback yields one entry per address', () => {
|
|
56
|
+
// `system init` writes `dns.fallback=1.0.0.1,8.8.8.8`. Compared whole, the
|
|
57
|
+
// guard matched neither address and would have accepted 8.8.8.8 as the
|
|
58
|
+
// "off-fleet" vantage point of a fleet that forwards to 8.8.8.8.
|
|
59
|
+
expect(
|
|
60
|
+
parseFleetResolvers([
|
|
61
|
+
{ role: 'dns.primary', value: '10.0.20.5' },
|
|
62
|
+
{ role: 'dns.fallback', value: '1.0.0.1, 8.8.8.8' },
|
|
63
|
+
]),
|
|
64
|
+
).toEqual([
|
|
65
|
+
{ role: 'dns.primary', ip: '10.0.20.5' },
|
|
66
|
+
{ role: 'dns.fallback', ip: '1.0.0.1' },
|
|
67
|
+
{ role: 'dns.fallback', ip: '8.8.8.8' },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('an unset key contributes nothing', () => {
|
|
72
|
+
expect(parseFleetResolvers([{ role: 'dns.primary', value: undefined }])).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a multi-entry fallback is caught by the guard', () => {
|
|
76
|
+
const fleet = parseFleetResolvers([{ role: 'dns.fallback', value: '1.0.0.1,8.8.8.8' }]);
|
|
77
|
+
expect(() => assertOffFleetResolver('8.8.8.8', fleet)).toThrow(
|
|
78
|
+
FleetResolverAsPublicVantageError,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The off-fleet vantage point for the `public_dns` check.
|
|
3
|
+
*
|
|
4
|
+
* Two independent third parties, deliberately:
|
|
5
|
+
*
|
|
6
|
+
* - a **resolver that is not the fleet's**, asked what the internet resolves
|
|
7
|
+
* for each name. The fleet's own resolver runs split-horizon and answers
|
|
8
|
+
* with an address that is reachable in-zone — correct for its purpose, and
|
|
9
|
+
* not evidence about the public internet. A `public_dns` check that quietly
|
|
10
|
+
* used it would pass forever, which is the original defect one layer up, so
|
|
11
|
+
* `assertOffFleetResolver` refuses rather than trusting a code comment.
|
|
12
|
+
* - an **echo service**, for what the fleet's public ingress address actually
|
|
13
|
+
* is. Not the registrar's response: comparing what was published against
|
|
14
|
+
* what we asked to publish is self-agreement, and Namecheap answers
|
|
15
|
+
* `ErrCount 0` for updates it does not apply (design.md D2/D3).
|
|
16
|
+
*
|
|
17
|
+
* Both are configurable, and both are named in every finding they produce.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Resolver } from 'node:dns/promises';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import type { DbClient } from '../db/client';
|
|
23
|
+
import { systemConfig } from '../db/schema';
|
|
24
|
+
import type { IngressObservation, PublicDnsProbe, PublicResolution } from './audit/public-dns';
|
|
25
|
+
|
|
26
|
+
/** Cloudflare. Overridable — the requirement is that it is not the fleet's. */
|
|
27
|
+
export const DEFAULT_PUBLIC_RESOLVER = '1.1.1.1';
|
|
28
|
+
export const DEFAULT_ECHO_URL = 'https://api.ipify.org';
|
|
29
|
+
|
|
30
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
31
|
+
|
|
32
|
+
export class FleetResolverAsPublicVantageError extends Error {
|
|
33
|
+
constructor(resolver: string, role: string) {
|
|
34
|
+
super(
|
|
35
|
+
`Refusing to check public DNS through ${resolver}: it is the fleet's own resolver (${role}).\nThe fleet resolver runs split-horizon and answers with an in-zone address, so a\ncheck that used it would pass whatever the internet sees — which is exactly how\ncelilo#626 stayed invisible for nine days.\n\nSet an off-fleet resolver:\n celilo system config set public_dns.resolver 1.1.1.1`,
|
|
36
|
+
);
|
|
37
|
+
this.name = 'FleetResolverAsPublicVantageError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reject a resolver the fleet itself uses. Pure so the gate is unit-testable
|
|
43
|
+
* without a database — it is the assertion §5.2 asks for.
|
|
44
|
+
*/
|
|
45
|
+
export function assertOffFleetResolver(
|
|
46
|
+
resolver: string,
|
|
47
|
+
fleetResolvers: { role: string; ip: string }[],
|
|
48
|
+
): void {
|
|
49
|
+
const match = fleetResolvers.find((r) => r.ip === resolver);
|
|
50
|
+
if (match) throw new FleetResolverAsPublicVantageError(resolver, match.role);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function configValue(db: DbClient, key: string): string | undefined {
|
|
54
|
+
const row = db.select().from(systemConfig).where(eq(systemConfig.key, key)).get();
|
|
55
|
+
const value = row?.value?.trim();
|
|
56
|
+
return value && value.length > 0 ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every resolver address the fleet is configured to use for its own lookups.
|
|
61
|
+
*
|
|
62
|
+
* `dns.fallback` holds a COMMA-SEPARATED list (`1.0.0.1,8.8.8.8` is what
|
|
63
|
+
* `system init` writes), so it is split rather than compared whole. Treating it
|
|
64
|
+
* as one string made the guard below miss every fallback but a single-entry
|
|
65
|
+
* one — a fleet forwarding to 8.8.8.8 could have been handed 8.8.8.8 as its
|
|
66
|
+
* "off-fleet" vantage point and the check would have agreed with itself
|
|
67
|
+
* forever, which is precisely the failure this guard exists to prevent.
|
|
68
|
+
*/
|
|
69
|
+
export function fleetResolvers(db: DbClient): { role: string; ip: string }[] {
|
|
70
|
+
return parseFleetResolvers(
|
|
71
|
+
['dns.primary', 'dns.fallback'].map((role) => ({ role, value: configValue(db, role) })),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The parse, split from the read so the comma handling is testable on its own. */
|
|
76
|
+
export function parseFleetResolvers(
|
|
77
|
+
entries: { role: string; value: string | undefined }[],
|
|
78
|
+
): { role: string; ip: string }[] {
|
|
79
|
+
const resolvers: { role: string; ip: string }[] = [];
|
|
80
|
+
for (const { role, value } of entries) {
|
|
81
|
+
for (const ip of (value ?? '').split(',')) {
|
|
82
|
+
const trimmed = ip.trim();
|
|
83
|
+
if (trimmed) resolvers.push({ role, ip: trimmed });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return resolvers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PublicDnsProbeSettings {
|
|
90
|
+
resolver: string;
|
|
91
|
+
echoUrl: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loadPublicDnsProbeSettings(db: DbClient): PublicDnsProbeSettings {
|
|
95
|
+
const resolver = configValue(db, 'public_dns.resolver') ?? DEFAULT_PUBLIC_RESOLVER;
|
|
96
|
+
assertOffFleetResolver(resolver, fleetResolvers(db));
|
|
97
|
+
return { resolver, echoUrl: configValue(db, 'public_dns.echo_url') ?? DEFAULT_ECHO_URL };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `fetch` with a bound, so an unanswered echo request cannot hang a scheduled
|
|
102
|
+
* check (the shape celilo#622 fixed for DDNS).
|
|
103
|
+
*/
|
|
104
|
+
async function fetchIngress(echoUrl: string): Promise<IngressObservation> {
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(echoUrl, {
|
|
107
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
return { kind: 'undetermined', reason: `HTTP ${response.status}` };
|
|
111
|
+
}
|
|
112
|
+
const ip = (await response.text()).trim();
|
|
113
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
|
114
|
+
return { kind: 'undetermined', reason: `unparseable answer: ${ip.slice(0, 40)}` };
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'observed', ip };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'undetermined',
|
|
120
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createPublicDnsProbe(settings: PublicDnsProbeSettings): PublicDnsProbe {
|
|
126
|
+
const resolver = new Resolver({ timeout: PROBE_TIMEOUT_MS, tries: 2 });
|
|
127
|
+
resolver.setServers([settings.resolver]);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
resolver: settings.resolver,
|
|
131
|
+
echoService: settings.echoUrl,
|
|
132
|
+
|
|
133
|
+
observeIngress: () => fetchIngress(settings.echoUrl),
|
|
134
|
+
|
|
135
|
+
async resolve(fqdn: string): Promise<PublicResolution> {
|
|
136
|
+
try {
|
|
137
|
+
// `ttl: true` is why this uses node:dns rather than shelling out to
|
|
138
|
+
// dig: the TTL is what the hysteresis window is measured in.
|
|
139
|
+
const answers = await resolver.resolve4(fqdn, { ttl: true });
|
|
140
|
+
const first = answers[0];
|
|
141
|
+
if (!first) return { kind: 'no_record' };
|
|
142
|
+
return { kind: 'answer', ip: first.address, ttlSeconds: first.ttl };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
145
|
+
// NXDOMAIN / NODATA are authoritative answers: the name really has no
|
|
146
|
+
// A record. Everything else (timeout, SERVFAIL, refused) means the
|
|
147
|
+
// probe could not look, which is never a pass.
|
|
148
|
+
if (code === 'ENOTFOUND' || code === 'ENODATA') return { kind: 'no_record' };
|
|
149
|
+
return {
|
|
150
|
+
kind: 'undetermined',
|
|
151
|
+
reason: code ?? (error instanceof Error ? error.message : String(error)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|