@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
|
@@ -33,6 +33,12 @@ export interface RunNamedHookOptions {
|
|
|
33
33
|
debug?: boolean;
|
|
34
34
|
/** Hook inputs (key=value pairs from the CLI, etc.). Default: empty. */
|
|
35
35
|
inputs?: Record<string, unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* Total timeout for this invocation (ms). A bus delivery passes the
|
|
38
|
+
* subscription's declared `timeout_ms` (celilo#622); omitted, the manifest
|
|
39
|
+
* hook's own `timeout` or the executor default applies.
|
|
40
|
+
*/
|
|
41
|
+
timeoutMs?: number;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface RunNamedHookResult extends HookResult {
|
|
@@ -138,7 +144,6 @@ export async function runNamedHook(
|
|
|
138
144
|
if (hookName === 'refresh_registrations') {
|
|
139
145
|
const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({
|
|
140
146
|
fqdn: r.fqdn,
|
|
141
|
-
ip: r.ip,
|
|
142
147
|
}));
|
|
143
148
|
inputs = { ...inputs, registrations };
|
|
144
149
|
}
|
|
@@ -157,6 +162,7 @@ export async function runNamedHook(
|
|
|
157
162
|
capabilities: capabilityFunctions,
|
|
158
163
|
requiredCapabilities,
|
|
159
164
|
systems: getModuleSystems(moduleId, db),
|
|
165
|
+
timeoutMs: options.timeoutMs,
|
|
160
166
|
},
|
|
161
167
|
);
|
|
162
168
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture: a hook that logs once and then works in silence, the shape
|
|
3
|
+
* that celilo#622 is about (namecheap's ddns refresh logs per name, so one
|
|
4
|
+
* slow upstream response is a long gap between log lines).
|
|
5
|
+
*
|
|
6
|
+
* Sleeps `ctx.config.silent_ms` so a test can put the silence on either side
|
|
7
|
+
* of an idle bound.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineHook } from '@celilo/capabilities';
|
|
11
|
+
|
|
12
|
+
export default defineHook({
|
|
13
|
+
hook: 'container_created',
|
|
14
|
+
requires: [],
|
|
15
|
+
handler: async (ctx) => {
|
|
16
|
+
ctx.logger.info('Starting, then going quiet');
|
|
17
|
+
await new Promise((r) => setTimeout(r, Number(ctx.config.silent_ms ?? 0)));
|
|
18
|
+
return {};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -210,6 +210,20 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
|
|
|
210
210
|
console.log('Staging source for build (no package.json, using file copy)...');
|
|
211
211
|
cpSync(sourceDir, buildDir, {
|
|
212
212
|
recursive: true,
|
|
213
|
+
// MATERIALISE symlinks. `modules/*/scripts` depends on
|
|
214
|
+
// `@celilo/capabilities` by `file:` path so an in-repo module builds
|
|
215
|
+
// against the LIVE workspace rather than the last published tarball —
|
|
216
|
+
// without which a module cannot consume a capability added in the same
|
|
217
|
+
// PR, and `check:modules` typechecks every module against a version
|
|
218
|
+
// that no longer matches the source it ships beside.
|
|
219
|
+
//
|
|
220
|
+
// bun installs a `file:` dep as a tree of symlinks into the monorepo.
|
|
221
|
+
// Copied as symlinks they would point at a path that does not exist on
|
|
222
|
+
// the target, and because `scripts/node_modules` would still LOOK
|
|
223
|
+
// present, `installScriptDependencies` skips its `bun install` and the
|
|
224
|
+
// hooks fail at runtime with unresolvable imports. Dereferencing here
|
|
225
|
+
// is what keeps the shipped closure a real, self-contained tree.
|
|
226
|
+
dereference: true,
|
|
213
227
|
filter: (src) => !shouldExclude(relative(sourceDir, src)),
|
|
214
228
|
});
|
|
215
229
|
}
|
|
@@ -25,6 +25,9 @@ import { type FailingKey, builtinAlertKey } from './keys';
|
|
|
25
25
|
const TARGET_KIND_BY_CATEGORY: Partial<Record<DriftCategory, string>> = {
|
|
26
26
|
machines_reachable: 'machine',
|
|
27
27
|
disk_space: 'machine',
|
|
28
|
+
// Subject is an FQDN, which is neither a machine nor a module: a served
|
|
29
|
+
// name can outlive any single module that asked for it.
|
|
30
|
+
public_dns: 'hostname',
|
|
28
31
|
transport_reads: 'module',
|
|
29
32
|
services_reachable: 'service',
|
|
30
33
|
services_credentials: 'service',
|
|
@@ -18,9 +18,16 @@ import { loadBackupAuditInfo } from '../audit/backup-source';
|
|
|
18
18
|
import { auditBackups } from '../audit/backups';
|
|
19
19
|
import { auditDiskSpace } from '../audit/disk-space';
|
|
20
20
|
import { auditMachinesReachable } from '../audit/machines-reachable';
|
|
21
|
+
import { auditPublicDns } from '../audit/public-dns';
|
|
22
|
+
import {
|
|
23
|
+
loadPublicDnsEvidence,
|
|
24
|
+
loadPublicDnsRecords,
|
|
25
|
+
savePublicDnsEvidence,
|
|
26
|
+
} from '../audit/public-dns-source';
|
|
21
27
|
import type { DriftCategory, DriftFinding } from '../audit/types';
|
|
22
28
|
import { probeDiskUsage } from '../disk-probe';
|
|
23
29
|
import { probeMachines } from '../machine-probe';
|
|
30
|
+
import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../public-dns-probe';
|
|
24
31
|
|
|
25
32
|
/** Categories a monitor can currently schedule. */
|
|
26
33
|
export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
|
|
@@ -28,6 +35,7 @@ export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
|
|
|
28
35
|
'backups',
|
|
29
36
|
'disk_space',
|
|
30
37
|
'abandoned_operations',
|
|
38
|
+
'public_dns',
|
|
31
39
|
];
|
|
32
40
|
|
|
33
41
|
export function isSchedulableBuiltin(category: string): category is DriftCategory {
|
|
@@ -58,6 +66,21 @@ export async function runBuiltinCheckForMonitor(
|
|
|
58
66
|
return auditAbandonedOperations({ records: loadAbandonedOperations(db) });
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
// One DNS query per ledger name plus one HTTP echo, all bounded — cheap
|
|
70
|
+
// enough for a scheduled check, and the only one of these that looks at the
|
|
71
|
+
// fleet from OUTSIDE. It is the only place `undetermined` counters advance,
|
|
72
|
+
// which is why the check has to be scheduled and not merely available to
|
|
73
|
+
// `celilo system audit`: consecutive absence of evidence is a finding, and
|
|
74
|
+
// nothing is consecutive if it only runs when an operator asks.
|
|
75
|
+
if (category === 'public_dns') {
|
|
76
|
+
return auditPublicDns({
|
|
77
|
+
records: loadPublicDnsRecords(db),
|
|
78
|
+
probe: createPublicDnsProbe(loadPublicDnsProbeSettings(db)),
|
|
79
|
+
evidence: loadPublicDnsEvidence(db),
|
|
80
|
+
saveEvidence: (evidence) => savePublicDnsEvidence(db, evidence),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
throw new Error(
|
|
62
85
|
`Built-in check "${category}" is not schedulable yet. Schedulable: ${SCHEDULABLE_BUILTIN_CHECKS.join(', ')}.`,
|
|
63
86
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import type { DbClient } from '../../db/client';
|
|
3
3
|
import { runAudit } from './index';
|
|
4
|
+
import { unusedPublicDnsProbe } from './public-dns';
|
|
4
5
|
|
|
5
6
|
const fakeDb = {} as DbClient;
|
|
6
7
|
|
|
@@ -35,6 +36,7 @@ const emptyDeps = {
|
|
|
35
36
|
machinesReachable: { results: [] },
|
|
36
37
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
37
38
|
trustedSources: { firewalls: [] },
|
|
39
|
+
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
describe('runAudit', () => {
|
|
@@ -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
|
+
});
|