@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
|
@@ -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
|
|
|
@@ -39,6 +42,19 @@ function seedHeartbeat(
|
|
|
39
42
|
);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/** Abandon `count` deliveries to one subscriber, oldest first. */
|
|
46
|
+
function seedFailed(bus: Bus, count: number): void {
|
|
47
|
+
const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.refresh', handler: 'echo' });
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const event = bus.emitRaw('ddns.refresh', { n: i });
|
|
50
|
+
bus.markFailed(
|
|
51
|
+
{ eventId: event.id, subscriberId: sub.id },
|
|
52
|
+
new Error('handler timed out after 30000ms'),
|
|
53
|
+
{ abandoned: true },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
/** Write a supervisor unit file so readInstalledUnit(scope) sees it. */
|
|
43
59
|
function installFakeUnit(home: string, scope: 'user' | 'system' = 'user', systemRoot = '/'): void {
|
|
44
60
|
const path = getDaemonUnitPath('linux', home, scope, systemRoot);
|
|
@@ -196,6 +212,39 @@ describe('checkDispatcher', () => {
|
|
|
196
212
|
expect(f.status).toBe('warn');
|
|
197
213
|
expect(f.detail.join(' ')).toContain('not emitting on schedule');
|
|
198
214
|
});
|
|
215
|
+
|
|
216
|
+
// celilo#623 — the old check read `failedDeliveries({ limit: 50 }).length`,
|
|
217
|
+
// so on celilo-mgr it printed a literal `50` that meant "at least 50" and
|
|
218
|
+
// read as an exact count. 137 > any limit anyone would pick.
|
|
219
|
+
it('reports the TRUE total of failed deliveries, not the read limit', () => {
|
|
220
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
221
|
+
installFakeUnit(home);
|
|
222
|
+
seedFailed(bus, 137);
|
|
223
|
+
|
|
224
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
225
|
+
expect(f.status).toBe('warn');
|
|
226
|
+
expect(f.detail.join(' ')).toContain('137 failed/abandoned delivery(ies) total');
|
|
227
|
+
expect(f.detail.join(' ')).not.toContain('50 failed');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The stored error is double-wrapped for every row written before the
|
|
231
|
+
// serializeError fix; those live 90 days, so doctor must unwrap them.
|
|
232
|
+
it('renders the sample error text, not {"message":"[object Object]"}', () => {
|
|
233
|
+
seedHeartbeat(bus, { startedAt: now - MINUTE, lastHeartbeat: now - 1000 });
|
|
234
|
+
installFakeUnit(home);
|
|
235
|
+
seedFailed(bus, 1);
|
|
236
|
+
bus.db.run('UPDATE deliveries SET last_error = ?', [
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
message: '[object Object]',
|
|
239
|
+
value: { message: 'handler exited with code 1' },
|
|
240
|
+
}),
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
const f = checkDispatcher(bus, { now: now, home, platform: 'linux' });
|
|
244
|
+
const detail = f.detail.join(' ');
|
|
245
|
+
expect(detail).toContain('handler exited with code 1');
|
|
246
|
+
expect(detail).not.toContain('[object Object]');
|
|
247
|
+
});
|
|
199
248
|
});
|
|
200
249
|
|
|
201
250
|
describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
@@ -274,10 +323,53 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
274
323
|
|
|
275
324
|
it('warns about a stale subscriber with no deployed module', () => {
|
|
276
325
|
// No modules deployed, but a leftover subscriber lingers.
|
|
277
|
-
bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo' });
|
|
326
|
+
bus.subscribe({ name: 'ghost.sub', pattern: 'x', handler: 'echo', registeredBy: 'ghost' });
|
|
278
327
|
const f = checkSubscribers(bus, db);
|
|
279
328
|
expect(f.status).toBe('warn');
|
|
280
329
|
expect(f.detail.join(' ')).toContain('ghost.sub');
|
|
330
|
+
// `resync-subscriptions` never deletes, so it cannot clear this (#624).
|
|
331
|
+
expect(f.autoFixable).toBe(false);
|
|
332
|
+
expect(f.remediation).toContain('subscribers remove');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('ignores core-registered subscribers that no manifest declares (#624)', () => {
|
|
336
|
+
ensureSweepSubscriber(bus);
|
|
337
|
+
ensureInboundSubscriber(bus);
|
|
338
|
+
ensureBackupSweepSubscriber(bus);
|
|
339
|
+
ensureOperationsSweepSubscriber(bus);
|
|
340
|
+
const f = checkSubscribers(bus, db);
|
|
341
|
+
expect(f.status).toBe('ok');
|
|
342
|
+
expect(f.detail.join(' ')).not.toContain('celilo-');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// The guard for the invariant `checkSubscribers` classifies by, asserted
|
|
346
|
+
// at the sites that ESTABLISH it rather than the one that consumes it —
|
|
347
|
+
// so it keeps holding if the predicate is ever rewritten. Goes red the
|
|
348
|
+
// moment a core registrar names its row under its own registrar id
|
|
349
|
+
// (`celilo-alerting` registering `celilo-alerting.digest`), which would
|
|
350
|
+
// read as a module row, find no module of that name, and be reported
|
|
351
|
+
// stale — #624 again, and baffling to whoever hit it.
|
|
352
|
+
//
|
|
353
|
+
// ponytail: enumerates the registrars by hand because celilo has no
|
|
354
|
+
// registry of them. A fifth one is not covered until it is added here;
|
|
355
|
+
// if that ever bites, the upgrade is a shared `registerCoreSubscriber`
|
|
356
|
+
// helper that every core site goes through, tested once.
|
|
357
|
+
it('no core registrar produces a module-shaped subscriber name', () => {
|
|
358
|
+
ensureSweepSubscriber(bus);
|
|
359
|
+
ensureInboundSubscriber(bus);
|
|
360
|
+
ensureBackupSweepSubscriber(bus);
|
|
361
|
+
ensureOperationsSweepSubscriber(bus);
|
|
362
|
+
|
|
363
|
+
const rows = bus.db
|
|
364
|
+
.query<{ name: string; registered_by: string | null }, []>(
|
|
365
|
+
'SELECT name, registered_by FROM subscribers',
|
|
366
|
+
)
|
|
367
|
+
.all();
|
|
368
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
369
|
+
for (const row of rows) {
|
|
370
|
+
expect(row.registered_by).not.toBeNull();
|
|
371
|
+
expect(row.name.startsWith(`${row.registered_by}.`)).toBe(false);
|
|
372
|
+
}
|
|
281
373
|
});
|
|
282
374
|
});
|
|
283
375
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* rendering + `--fix` orchestration lives in the doctor command.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type
|
|
20
|
+
import { type Bus, describeError } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
22
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
23
|
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
@@ -343,12 +343,21 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
|
|
347
|
-
|
|
346
|
+
// A TRUE total, not `failedDeliveries().length` — that saturates at its LIMIT
|
|
347
|
+
// and printed a literal `50` on celilo-mgr that read as a count (celilo#623).
|
|
348
|
+
// The newest row dates the backlog: a big total whose newest entry is days old
|
|
349
|
+
// is drained history, not active bleeding.
|
|
350
|
+
const { total: failedTotal } = bus.failedDeliveryTotals();
|
|
351
|
+
if (failedTotal > 0) {
|
|
348
352
|
statuses.push('warn');
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
353
|
+
const newest = bus.failedDeliveries({ limit: 1 })[0];
|
|
354
|
+
const age = newest?.finishedAt
|
|
355
|
+
? `, most recent ${Math.round((now - newest.finishedAt) / 60000)}min ago`
|
|
356
|
+
: '';
|
|
357
|
+
detail.push(`${failedTotal} failed/abandoned delivery(ies) total${age}`);
|
|
358
|
+
const sample = describeError(newest?.lastError ?? null)?.split('\n')[0];
|
|
359
|
+
if (sample) detail.push(` example (newest, not the only one): ${sample}`);
|
|
360
|
+
remediations.push('`celilo events list-failed` to see them; re-emit/repair as needed');
|
|
352
361
|
}
|
|
353
362
|
|
|
354
363
|
const status = worst(statuses);
|
|
@@ -386,6 +395,9 @@ function loadDeployedModules(db: DbClient): DeployedModule[] {
|
|
|
386
395
|
* manifests declare. A restore/migration starts it EMPTY (ISS-0088), so
|
|
387
396
|
* every reactive subscription silently vanishes until a resync or a
|
|
388
397
|
* redeploy. Missing rows fail; stale rows (a since-removed module) warn.
|
|
398
|
+
*
|
|
399
|
+
* Scoped to MODULE-owned rows — core's own subscribers share this bus and are
|
|
400
|
+
* declared by no manifest.
|
|
389
401
|
*/
|
|
390
402
|
export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
391
403
|
const deployed = loadDeployedModules(db);
|
|
@@ -402,7 +414,9 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
402
414
|
}
|
|
403
415
|
|
|
404
416
|
const actualRows = bus.db
|
|
405
|
-
.query<{ name: string; pattern: string }, []>(
|
|
417
|
+
.query<{ name: string; pattern: string; registered_by: string | null }, []>(
|
|
418
|
+
'SELECT name, pattern, registered_by FROM subscribers',
|
|
419
|
+
)
|
|
406
420
|
.all();
|
|
407
421
|
const actual = new Map(actualRows.map((r) => [r.name, r.pattern]));
|
|
408
422
|
|
|
@@ -413,7 +427,25 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
413
427
|
if (have === undefined) missing.push(name);
|
|
414
428
|
else if (have !== pattern) mismatched.push(`${name} (manifest: ${pattern}, bus: ${have})`);
|
|
415
429
|
}
|
|
416
|
-
|
|
430
|
+
// Only module-owned rows can be stale. celilo core registers subscribers on
|
|
431
|
+
// the same bus (the alerting/backup/operations sweeps) that no manifest
|
|
432
|
+
// declares, so judging every row against the manifest-derived map reported
|
|
433
|
+
// them as drift on every install, forever (#624).
|
|
434
|
+
//
|
|
435
|
+
// INVARIANT this relies on: a module row is named `<registered_by>.<sub>` —
|
|
436
|
+
// `resolveSubscription` (services/module-subscriptions.ts) derives both the
|
|
437
|
+
// scoped name and registered_by from the same module id. A core row is NOT
|
|
438
|
+
// scoped under its registrar's id (`celilo-alerting` registers
|
|
439
|
+
// `celilo-alerting-sweep`, not `celilo-alerting.sweep`). That's what lets
|
|
440
|
+
// provenance separate them with no allowlist to keep in sync.
|
|
441
|
+
//
|
|
442
|
+
// To break it: have a core registrar name a row under its own id — a future
|
|
443
|
+
// `celilo-alerting.digest` would read as module-owned, find no deployed
|
|
444
|
+
// module named `celilo-alerting`, and be reported stale. Which is #624 again.
|
|
445
|
+
const stale = actualRows
|
|
446
|
+
.filter((r) => r.registered_by !== null && r.name.startsWith(`${r.registered_by}.`))
|
|
447
|
+
.map((r) => r.name)
|
|
448
|
+
.filter((name) => !expected.has(name));
|
|
417
449
|
|
|
418
450
|
const detail: string[] = [];
|
|
419
451
|
const statuses: FleetFindingStatus[] = [];
|
|
@@ -436,6 +468,15 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
436
468
|
);
|
|
437
469
|
}
|
|
438
470
|
|
|
471
|
+
// `resync-subscriptions` only upserts from manifests — it never deletes, so
|
|
472
|
+
// it cannot clear a stale row. Advertising that as the remedy (and as
|
|
473
|
+
// auto-fixable) made `--fix` run a guaranteed no-op and call it a fix.
|
|
474
|
+
const resyncable = missing.length > 0 || mismatched.length > 0;
|
|
475
|
+
const remediations: string[] = [];
|
|
476
|
+
if (resyncable) remediations.push('`celilo events resync-subscriptions` (safe, idempotent)');
|
|
477
|
+
if (stale.length > 0)
|
|
478
|
+
remediations.push(`\`celilo subscribers remove <name>\` for: ${stale.join(', ')}`);
|
|
479
|
+
|
|
439
480
|
const status = worst(statuses);
|
|
440
481
|
return {
|
|
441
482
|
id: 'subscribers',
|
|
@@ -446,8 +487,8 @@ export function checkSubscribers(bus: Bus, db: DbClient): FleetFinding {
|
|
|
446
487
|
? `${expected.size} subscription(s) match the deployed fleet`
|
|
447
488
|
: 'bus subscribers drifted from the deployed fleet',
|
|
448
489
|
detail,
|
|
449
|
-
remediation:
|
|
450
|
-
autoFixable:
|
|
490
|
+
remediation: remediations.length > 0 ? remediations.join('; ') : null,
|
|
491
|
+
autoFixable: resyncable,
|
|
451
492
|
};
|
|
452
493
|
}
|
|
453
494
|
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import type { DbClient } from '../../db/client';
|
|
3
3
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
4
4
|
import type { AuditDeps } from '../audit';
|
|
5
|
+
import { unusedPublicDnsProbe } from '../audit/public-dns';
|
|
5
6
|
import { buildModuleGraph } from './dep-graph';
|
|
6
7
|
import {
|
|
7
8
|
type ModuleSnapshot,
|
|
@@ -85,6 +86,7 @@ const cleanAudit: AuditDeps = {
|
|
|
85
86
|
machinesReachable: { results: [] },
|
|
86
87
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
87
88
|
trustedSources: { firewalls: [] },
|
|
89
|
+
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
describe('consumerSkipReason', () => {
|