cursedops 0.4.0 → 0.5.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/README.md +138 -5
- package/package.json +41 -4
- package/src/d1Import.ts +124 -0
- package/src/edgeFetch.ts +152 -0
- package/src/launchd.ts +40 -0
- package/src/paths.ts +173 -0
- package/src/roots.ts +126 -0
- package/src/smoke.ts +476 -4
- package/src/workerDeploy.ts +268 -0
- package/src/workerRollback.ts +133 -0
- package/src/workerSecrets.ts +195 -0
package/src/smoke.ts
CHANGED
|
@@ -31,9 +31,19 @@
|
|
|
31
31
|
* |---|---|---|
|
|
32
32
|
* | `0` | every check passed | keep the release |
|
|
33
33
|
* | `1` | the APP is wrong | roll back |
|
|
34
|
-
* | `2` | the
|
|
34
|
+
* | `2` | NOT the app — the edge (DNS, tunnel, hostname, policy), the NETWORK the smoke ran on, or the smoke's OWN environment | do **not** roll back |
|
|
35
35
|
*
|
|
36
36
|
* Two is the load-bearing one and it is why {@link Smoke.edgeFault} exists.
|
|
37
|
+
*
|
|
38
|
+
* 🔴 **0.5.0 widened what a `2` can mean and did NOT add a code**, deliberately. Every deploy
|
|
39
|
+
* script in the fleet reads this contract as `0 → keep`, `2 → stop without reverting`, anything
|
|
40
|
+
* else → `git revert` (`apps/collections/scripts/deploy.ts:413-424` and its four siblings). A new
|
|
41
|
+
* `3` for "the smoke's credential is missing" would fall into the revert branch of every one of
|
|
42
|
+
* them — the exact failure it exists to prevent — so the distinction lives in {@link Smoke.verdict}
|
|
43
|
+
* and in the sentence {@link Smoke.report} prints, and the CODE stays one every reader already
|
|
44
|
+
* handles. The one direction that is never excused: a dead ORIGIN ({@link probeOrigin}) is `1`
|
|
45
|
+
* whatever else the run saw, because a live edge over a dead origin is precisely the green lie
|
|
46
|
+
* (incident 5 above) this module exists to refuse.
|
|
37
47
|
* Reverting good application code cannot restore a DNS record, a tunnel route or
|
|
38
48
|
* an access policy: a rollback there discards work while leaving the real fault
|
|
39
49
|
* exactly where it is. {@link Smoke.finish} is the single place that code is
|
|
@@ -71,12 +81,29 @@
|
|
|
71
81
|
*/
|
|
72
82
|
|
|
73
83
|
import { spawnSync } from "node:child_process";
|
|
84
|
+
import { lookup } from "node:dns/promises";
|
|
85
|
+
import { isIP } from "node:net";
|
|
74
86
|
|
|
75
87
|
export interface Failure {
|
|
76
88
|
check: string;
|
|
77
89
|
detail: string;
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
/**
|
|
93
|
+
* What a finished smoke concluded, in the order {@link Smoke.report} decides it:
|
|
94
|
+
*
|
|
95
|
+
* · `origin` — the process behind the edge did not answer on loopback. Exit 1, ALWAYS: a cache
|
|
96
|
+
* in front of a dead origin is the one thing no other verdict may excuse.
|
|
97
|
+
* · `environment` — the smoke could not judge the app, because something IT needs (a
|
|
98
|
+
* credential, a file) is missing here. Exit 2: nothing about the app was measured.
|
|
99
|
+
* · `network` — the public checks failed and this machine's resolver is substituting answers
|
|
100
|
+
* for a Cloudflare-proxied hostname. Exit 2: the app is not at fault.
|
|
101
|
+
* · `edge` — something in front of the app could not be reached or disagrees. Exit 2.
|
|
102
|
+
* · `app` — the app answered wrongly. Exit 1, roll back.
|
|
103
|
+
* · `pass` — exit 0.
|
|
104
|
+
*/
|
|
105
|
+
export type SmokeVerdict = "pass" | "app" | "origin" | "edge" | "network" | "environment";
|
|
106
|
+
|
|
80
107
|
export interface SmokeOptions {
|
|
81
108
|
/** The origin every relative path is asked against. A trailing slash is trimmed. */
|
|
82
109
|
base: string;
|
|
@@ -91,6 +118,8 @@ export interface SmokeOptions {
|
|
|
91
118
|
headers?: () => Record<string, string>;
|
|
92
119
|
/** Injected for tests. Resolves a hostname against a public resolver. */
|
|
93
120
|
resolve?: (host: string) => string;
|
|
121
|
+
/** Injected for tests. How {@link Smoke.checkNetwork} asks the resolvers — see {@link diagnoseNetwork}. */
|
|
122
|
+
network?: NetworkProbe;
|
|
94
123
|
log?: (line: string) => void;
|
|
95
124
|
error?: (line: string) => void;
|
|
96
125
|
}
|
|
@@ -109,12 +138,47 @@ export interface Smoke {
|
|
|
109
138
|
anonymous(path: string, init?: RequestInit): Promise<Response>;
|
|
110
139
|
/** Mark the run as an edge fault: reached nothing, so code 2 rather than 1. */
|
|
111
140
|
markEdgeFault(): void;
|
|
141
|
+
/**
|
|
142
|
+
* Record that the ORIGIN is dead — code 1 whatever else happened. {@link probeOrigin} is the
|
|
143
|
+
* one caller that should need this; it is public so an app with its own origin check can say so.
|
|
144
|
+
*/
|
|
145
|
+
markOriginFault(check: string, detail: string): void;
|
|
146
|
+
/** True once {@link markOriginFault} has been called. */
|
|
147
|
+
readonly originFault: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Assert the SMOKE's own environment before asserting the app: every name in `needs` whose
|
|
150
|
+
* value is empty is refused with `how` (what to do about it), and the run can no longer pass
|
|
151
|
+
* or fail the app — it exits 2 with the sentence. Returns true when nothing was missing.
|
|
152
|
+
*
|
|
153
|
+
* 🔴 Measured 2026-09-17 on `desk`: `✗ FAILED — 5 of 6` about a deploy that was serving
|
|
154
|
+
* correctly, because only the deploy script loaded the Access token and the smoke, run by a
|
|
155
|
+
* person, hit the Access wall and charged it to the app.
|
|
156
|
+
*/
|
|
157
|
+
requireEnvironment(needs: Record<string, string | null | undefined>, how: string): boolean;
|
|
158
|
+
/** One free-form environment refusal — for a need that is not a variable (a file, a socket). */
|
|
159
|
+
refuseEnvironment(what: string, how: string): void;
|
|
160
|
+
/** Every environment refusal so far. */
|
|
161
|
+
readonly refusals: readonly Failure[];
|
|
162
|
+
/**
|
|
163
|
+
* Ask whether this machine's NETWORK is lying about {@link base}'s hostname — see
|
|
164
|
+
* {@link diagnoseNetwork}. When it is, the run is marked a network fault (exit 2) and the
|
|
165
|
+
* lines say where the fault lives. Returns the diagnosis.
|
|
166
|
+
*/
|
|
167
|
+
checkNetwork(): Promise<NetworkDiagnosis>;
|
|
168
|
+
/** The conclusion {@link report} will print, without printing it. */
|
|
169
|
+
verdict(): SmokeVerdict;
|
|
112
170
|
/** A sentence to append to a fetch failure, or `""`. See {@link createSmoke}. */
|
|
113
171
|
dnsHint(error: Error): string;
|
|
114
172
|
/** Print the ledger and return the exit code. Does not exit. */
|
|
115
173
|
report(): number;
|
|
116
174
|
/** {@link report}, then `process.exit` with it. */
|
|
117
175
|
finish(): never;
|
|
176
|
+
/**
|
|
177
|
+
* {@link checkNetwork} when the run failed for a reason other than a dead origin, then
|
|
178
|
+
* {@link report}. Returns the exit code; does not exit. The recommended ending of a smoke
|
|
179
|
+
* that asks a public hostname: `process.exit(await smoke.settle())`.
|
|
180
|
+
*/
|
|
181
|
+
settle(): Promise<number>;
|
|
118
182
|
}
|
|
119
183
|
|
|
120
184
|
/**
|
|
@@ -147,7 +211,10 @@ export function createSmoke(options: SmokeOptions): Smoke {
|
|
|
147
211
|
|
|
148
212
|
const passed: string[] = [];
|
|
149
213
|
const failures: Failure[] = [];
|
|
214
|
+
const refusals: Failure[] = [];
|
|
150
215
|
let edgeFault = false;
|
|
216
|
+
let originFault = false;
|
|
217
|
+
let network: NetworkDiagnosis | null = null;
|
|
151
218
|
|
|
152
219
|
const smoke: Smoke = {
|
|
153
220
|
base,
|
|
@@ -157,9 +224,15 @@ export function createSmoke(options: SmokeOptions): Smoke {
|
|
|
157
224
|
get failures() {
|
|
158
225
|
return failures;
|
|
159
226
|
},
|
|
227
|
+
get refusals() {
|
|
228
|
+
return refusals;
|
|
229
|
+
},
|
|
160
230
|
get edgeFault() {
|
|
161
231
|
return edgeFault;
|
|
162
232
|
},
|
|
233
|
+
get originFault() {
|
|
234
|
+
return originFault;
|
|
235
|
+
},
|
|
163
236
|
|
|
164
237
|
record(check, ok, detail) {
|
|
165
238
|
if (ok) passed.push(`${check} — ${detail}`);
|
|
@@ -187,6 +260,44 @@ export function createSmoke(options: SmokeOptions): Smoke {
|
|
|
187
260
|
edgeFault = true;
|
|
188
261
|
},
|
|
189
262
|
|
|
263
|
+
markOriginFault(check, detail) {
|
|
264
|
+
originFault = true;
|
|
265
|
+
failures.push({ check, detail });
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
requireEnvironment(needs, how) {
|
|
269
|
+
const missing = Object.entries(needs)
|
|
270
|
+
.filter(([, value]) => !value?.trim())
|
|
271
|
+
.map(([name]) => name);
|
|
272
|
+
if (missing.length === 0) return true;
|
|
273
|
+
smoke.refuseEnvironment(`${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not set for this smoke`, how);
|
|
274
|
+
return false;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
refuseEnvironment(what, how) {
|
|
278
|
+
refusals.push({ check: what, detail: how });
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async checkNetwork() {
|
|
282
|
+
let host: string;
|
|
283
|
+
try {
|
|
284
|
+
host = new URL(base).hostname;
|
|
285
|
+
} catch {
|
|
286
|
+
return { verdict: "not-applicable", lines: [`${base} is not a URL`] };
|
|
287
|
+
}
|
|
288
|
+
network = await diagnoseNetwork(host, options.network);
|
|
289
|
+
return network;
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
verdict() {
|
|
293
|
+
if (originFault) return "origin";
|
|
294
|
+
if (refusals.length > 0) return "environment";
|
|
295
|
+
if (failures.length === 0) return "pass";
|
|
296
|
+
if (network?.verdict === "intercepting") return "network";
|
|
297
|
+
if (edgeFault) return "edge";
|
|
298
|
+
return "app";
|
|
299
|
+
},
|
|
300
|
+
|
|
190
301
|
dnsHint(failed: Error) {
|
|
191
302
|
if (!LOOKS_LIKE_DNS.test(failed.message)) return "";
|
|
192
303
|
const host = new URL(base).hostname;
|
|
@@ -204,19 +315,50 @@ export function createSmoke(options: SmokeOptions): Smoke {
|
|
|
204
315
|
report() {
|
|
205
316
|
for (const line of passed) log(` ✓ ${line}`);
|
|
206
317
|
for (const failure of failures) error(` ✗ ${failure.check} — ${failure.detail}`);
|
|
318
|
+
for (const refusal of refusals) error(` ⚠ ${refusal.check} — ${refusal.detail}`);
|
|
207
319
|
const total = passed.length + failures.length;
|
|
208
|
-
|
|
320
|
+
const verdict = smoke.verdict();
|
|
321
|
+
if (verdict === "pass") {
|
|
209
322
|
log(`\n✓ deploy smoke passed — ${total} checks against ${base}`);
|
|
210
323
|
return 0;
|
|
211
324
|
}
|
|
325
|
+
if (verdict === "environment") {
|
|
326
|
+
error(`\n⚠ this smoke could not judge the app at ${base} — its OWN environment is missing something (above).`);
|
|
327
|
+
error(" (exit 2 — not an app failure: nothing about the app was measured. Rolling back would not change it.)");
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
212
330
|
error(`\n✗ deploy smoke FAILED — ${failures.length} of ${total} checks against ${base}`);
|
|
213
|
-
if (
|
|
214
|
-
|
|
331
|
+
if (verdict === "origin") {
|
|
332
|
+
error(" (exit 1 — the ORIGIN is dead. Whatever the public hostname said came from something in front of it.)");
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
if (verdict === "network") {
|
|
336
|
+
error("\n🛜 this network is intercepting DNS — the app is not at fault:");
|
|
337
|
+
for (const line of network?.lines ?? []) error(` ${line}`);
|
|
338
|
+
error(" (exit 2 — the NETWORK this ran on. Re-run from another network, or over encrypted DNS.)");
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
if (verdict === "edge") {
|
|
342
|
+
error(" (exit 2 — an EDGE fault. Rolling the app back would not change it.)");
|
|
343
|
+
return 2;
|
|
344
|
+
}
|
|
345
|
+
return 1;
|
|
215
346
|
},
|
|
216
347
|
|
|
217
348
|
finish() {
|
|
218
349
|
process.exit(smoke.report());
|
|
219
350
|
},
|
|
351
|
+
|
|
352
|
+
async settle() {
|
|
353
|
+
if (failures.length > 0 && !originFault && refusals.length === 0) {
|
|
354
|
+
try {
|
|
355
|
+
await smoke.checkNetwork();
|
|
356
|
+
} catch {
|
|
357
|
+
// A diagnosis that could not run is not a verdict; the ledger stands as it is.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return smoke.report();
|
|
361
|
+
},
|
|
220
362
|
};
|
|
221
363
|
|
|
222
364
|
return smoke;
|
|
@@ -421,3 +563,333 @@ export async function sameDeployedShell(smoke: Smoke, addresses: string[], optio
|
|
|
421
563
|
});
|
|
422
564
|
return agreed;
|
|
423
565
|
}
|
|
566
|
+
|
|
567
|
+
// ── The ORIGIN, asked directly ────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
/** The origin probe's name in the ledger. */
|
|
570
|
+
export const ORIGIN_CHECK = "the origin answers on loopback";
|
|
571
|
+
|
|
572
|
+
export interface OriginOptions {
|
|
573
|
+
/** What the port was read from, for the sentence when there is none — usually the launchd label. */
|
|
574
|
+
label?: string;
|
|
575
|
+
/** The path to ask. `/healthz` unless the app's liveness answer lives elsewhere. */
|
|
576
|
+
path?: string;
|
|
577
|
+
/** What counts as alive. Default: a `200`. */
|
|
578
|
+
accept?: (response: Response) => boolean | Promise<boolean>;
|
|
579
|
+
timeoutMs?: number;
|
|
580
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
581
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Ask the process itself, on `127.0.0.1:<port>`, and record a dead one as an ORIGIN fault —
|
|
586
|
+
* exit 1 whatever the public hostname said.
|
|
587
|
+
*
|
|
588
|
+
* 🔴 **A deployed smoke that only asks the public hostname is asking Cloudflare, not the app.**
|
|
589
|
+
* Measured 2026-09-15/17: `patterns` and `collections` each served a cached `200` over a `503`
|
|
590
|
+
* origin with every check green, and four apps' smokes (`patterns`, `roms`, `family`, `desk`)
|
|
591
|
+
* made no loopback request at all. Loopback cannot be cached by anything, so this is the one
|
|
592
|
+
* question whose answer the edge cannot forge.
|
|
593
|
+
*
|
|
594
|
+
* Pass the port from `livePort(LABEL)` in `cursedops/launchd` — the LIVE declaration, never
|
|
595
|
+
* `package.json`, which advertised the wrong port in eight of eight apps. A `null` port is
|
|
596
|
+
* recorded as the fault it is: no job, no origin, and a public `200` can only be a cache.
|
|
597
|
+
*/
|
|
598
|
+
export async function probeOrigin(smoke: Smoke, port: number | null, options: OriginOptions = {}): Promise<boolean> {
|
|
599
|
+
const path = options.path ?? "/healthz";
|
|
600
|
+
const accept = options.accept ?? ((response: Response) => response.status === 200);
|
|
601
|
+
const send = options.fetch ?? ((url: string, init: RequestInit) => fetch(url, init));
|
|
602
|
+
const where = options.label ? ` (${options.label})` : "";
|
|
603
|
+
if (port === null) {
|
|
604
|
+
smoke.markOriginFault(
|
|
605
|
+
ORIGIN_CHECK,
|
|
606
|
+
`there is no origin to ask — no live port${where}. The job is not loaded or declares no PORT, so any 200 the public hostname gave came from a cache.`,
|
|
607
|
+
);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
const url = `http://127.0.0.1:${port}${path}${path.includes("?") ? "&" : "?"}cb=${Date.now().toString(36)}`;
|
|
611
|
+
try {
|
|
612
|
+
const response = await send(url, { redirect: "manual", signal: AbortSignal.timeout(options.timeoutMs ?? 10_000) });
|
|
613
|
+
if (await accept(response)) return smoke.record(ORIGIN_CHECK, true, `GET 127.0.0.1:${port}${path} → ${response.status}`);
|
|
614
|
+
smoke.markOriginFault(ORIGIN_CHECK, `GET 127.0.0.1:${port}${path} → ${response.status}${where} — the process behind the edge is not healthy`);
|
|
615
|
+
} catch (thrown) {
|
|
616
|
+
smoke.markOriginFault(ORIGIN_CHECK, `GET 127.0.0.1:${port}${path}${where} — ${(thrown as Error).message}. Nothing is serving behind the edge.`);
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── The NETWORK the smoke ran on ──────────────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Cloudflare's published proxy ranges (`https://www.cloudflare.com/ips-v4` and `/ips-v6`), as of
|
|
625
|
+
* 2026-09-19. The fallback, never the first answer: {@link diagnoseNetwork} asks for the live
|
|
626
|
+
* list first, and uses this only when it cannot be fetched — which is most likely on the very
|
|
627
|
+
* network this exists to describe.
|
|
628
|
+
*/
|
|
629
|
+
export const CLOUDFLARE_RANGES_SNAPSHOT: readonly string[] = [
|
|
630
|
+
"173.245.48.0/20",
|
|
631
|
+
"103.21.244.0/22",
|
|
632
|
+
"103.22.200.0/22",
|
|
633
|
+
"103.31.4.0/22",
|
|
634
|
+
"141.101.64.0/18",
|
|
635
|
+
"108.162.192.0/18",
|
|
636
|
+
"190.93.240.0/20",
|
|
637
|
+
"188.114.96.0/20",
|
|
638
|
+
"197.234.240.0/22",
|
|
639
|
+
"198.41.128.0/17",
|
|
640
|
+
"162.158.0.0/15",
|
|
641
|
+
"104.16.0.0/13",
|
|
642
|
+
"104.24.0.0/14",
|
|
643
|
+
"172.64.0.0/13",
|
|
644
|
+
"131.0.72.0/22",
|
|
645
|
+
"2400:cb00::/32",
|
|
646
|
+
"2606:4700::/32",
|
|
647
|
+
"2803:f800::/32",
|
|
648
|
+
"2405:b500::/32",
|
|
649
|
+
"2405:8100::/32",
|
|
650
|
+
"2a06:98c0::/29",
|
|
651
|
+
"2c0f:f248::/32",
|
|
652
|
+
];
|
|
653
|
+
|
|
654
|
+
function ipToBig(ip: string): bigint | null {
|
|
655
|
+
if (isIP(ip) === 4) return ip.split(".").reduce((a, o) => (a << 8n) + BigInt(Number(o)), 0n);
|
|
656
|
+
if (isIP(ip) !== 6) return null;
|
|
657
|
+
const bare = ip.split("%")[0] as string;
|
|
658
|
+
// `::ffff:1.2.3.4` — the tail is dotted and is two groups, not one.
|
|
659
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(bare);
|
|
660
|
+
const text = dotted
|
|
661
|
+
? bare.replace(dotted[1] as string, (() => {
|
|
662
|
+
const n = ipToBig(dotted[1] as string) as bigint;
|
|
663
|
+
return `${(n >> 16n).toString(16)}:${(n & 0xffffn).toString(16)}`;
|
|
664
|
+
})())
|
|
665
|
+
: bare;
|
|
666
|
+
const [head, tail] = text.split("::");
|
|
667
|
+
const h = head ? head.split(":") : [];
|
|
668
|
+
const t = tail !== undefined ? (tail ? tail.split(":") : []) : [];
|
|
669
|
+
const mid = tail !== undefined ? Array(8 - h.length - t.length).fill("0") : [];
|
|
670
|
+
return [...h, ...mid, ...t].reduce((a, g) => (a << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Is `ip` inside one of `ranges` (CIDR strings)? Lifted from `tools/check-public-dns.ts`. */
|
|
674
|
+
export function inRanges(ip: string, ranges: readonly string[]): boolean {
|
|
675
|
+
const addr = ipToBig(ip);
|
|
676
|
+
if (addr === null) return false;
|
|
677
|
+
const v6 = isIP(ip) === 6;
|
|
678
|
+
for (const cidr of ranges) {
|
|
679
|
+
const [net, bitsRaw] = cidr.split("/");
|
|
680
|
+
if (!net || !bitsRaw || (isIP(net) === 6) !== v6) continue;
|
|
681
|
+
const base = ipToBig(net);
|
|
682
|
+
if (base === null) continue;
|
|
683
|
+
const width = v6 ? 128n : 32n;
|
|
684
|
+
const bits = BigInt(bitsRaw);
|
|
685
|
+
const mask = ((1n << bits) - 1n) << (width - bits);
|
|
686
|
+
if ((addr & mask) === (base & mask)) return true;
|
|
687
|
+
}
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** One independent resolver's answer; `null` when it could not be reached for this host. */
|
|
692
|
+
export interface IndependentAnswer {
|
|
693
|
+
name: string;
|
|
694
|
+
answers: string[] | null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** How {@link diagnoseNetwork} asks. Every member is injectable, so a test never touches a network. */
|
|
698
|
+
export interface NetworkProbe {
|
|
699
|
+
/** What THIS machine's resolver answers — the path a `fetch` really takes. Default: `dns.lookup`, all addresses. */
|
|
700
|
+
system?: (host: string) => Promise<string[]>;
|
|
701
|
+
/** What resolvers that cannot be silently substituted answer. Default: DoH to Cloudflare and Google. */
|
|
702
|
+
independent?: (host: string) => Promise<IndependentAnswer[]>;
|
|
703
|
+
/** Cloudflare's proxy ranges. Default: the published list, else {@link CLOUDFLARE_RANGES_SNAPSHOT}. */
|
|
704
|
+
ranges?: () => Promise<readonly string[]>;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface NetworkDiagnosis {
|
|
708
|
+
/**
|
|
709
|
+
* `intercepting` — this machine's answer for a Cloudflare-proxied hostname is outside every
|
|
710
|
+
* published range, or is one no independent resolver gives: the NETWORK is at fault.
|
|
711
|
+
* `clean` — the network is honest; whatever failed, it was not this. `not-proxied` — the
|
|
712
|
+
* hostname is not behind Cloudflare's proxy, so the range rule says nothing. `offline` —
|
|
713
|
+
* nothing resolved it at all. `not-applicable` — an IP literal or a loopback name.
|
|
714
|
+
*/
|
|
715
|
+
verdict: "intercepting" | "clean" | "not-proxied" | "offline" | "not-applicable";
|
|
716
|
+
lines: string[];
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function systemAnswers(host: string): Promise<string[]> {
|
|
720
|
+
try {
|
|
721
|
+
return (await lookup(host, { all: true })).map((entry) => entry.address);
|
|
722
|
+
} catch {
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const DOH_ENDPOINTS = [
|
|
728
|
+
["cloudflare-dns.com", "https://cloudflare-dns.com/dns-query"],
|
|
729
|
+
["dns.google", "https://dns.google/resolve"],
|
|
730
|
+
] as const;
|
|
731
|
+
|
|
732
|
+
async function dohAnswers(host: string): Promise<IndependentAnswer[]> {
|
|
733
|
+
const ask = async (endpoint: string, type: "A" | "AAAA"): Promise<string[] | null> => {
|
|
734
|
+
try {
|
|
735
|
+
const response = await fetch(`${endpoint}?name=${encodeURIComponent(host)}&type=${type}`, {
|
|
736
|
+
headers: { accept: "application/dns-json" },
|
|
737
|
+
signal: AbortSignal.timeout(8_000),
|
|
738
|
+
});
|
|
739
|
+
if (!response.ok) return null;
|
|
740
|
+
const body = (await response.json()) as { Answer?: Array<{ type: number; data: string }> };
|
|
741
|
+
return (body.Answer ?? []).filter((a) => a.type === 1 || a.type === 28).map((a) => a.data);
|
|
742
|
+
} catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
return await Promise.all(
|
|
747
|
+
DOH_ENDPOINTS.map(async ([name, endpoint]) => {
|
|
748
|
+
const both = await Promise.all([ask(endpoint, "A"), ask(endpoint, "AAAA")]);
|
|
749
|
+
// 🔴 Either family failing means this endpoint did not answer — a partial answer read as
|
|
750
|
+
// complete is how a check reports "everything resolved" while blind to v6.
|
|
751
|
+
return { name, answers: both.some((b) => b === null) ? null : both.flatMap((b) => b ?? []) };
|
|
752
|
+
}),
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function publishedRanges(): Promise<readonly string[]> {
|
|
757
|
+
try {
|
|
758
|
+
const out: string[] = [];
|
|
759
|
+
for (const url of ["https://www.cloudflare.com/ips-v4", "https://www.cloudflare.com/ips-v6"]) {
|
|
760
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8_000) });
|
|
761
|
+
if (!response.ok) return CLOUDFLARE_RANGES_SNAPSHOT;
|
|
762
|
+
out.push(...(await response.text()).split("\n").map((l) => l.trim()).filter((l) => l.includes("/")));
|
|
763
|
+
}
|
|
764
|
+
return out.length > 0 ? out : CLOUDFLARE_RANGES_SNAPSHOT;
|
|
765
|
+
} catch {
|
|
766
|
+
return CLOUDFLARE_RANGES_SNAPSHOT;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Is the network this smoke is running on LYING about `host`?
|
|
772
|
+
*
|
|
773
|
+
* 🔴 Measured 2026-09-19 over an airline's wifi: the aircraft's resolver answered
|
|
774
|
+
* `desk.cursedalchemy.com` with `1.1.1.1` — Cloudflare's public RESOLVER, not an edge that serves
|
|
775
|
+
* customer zones — and Cloudflare refused the SNI with `403 · Error 1034`. Nothing in the fleet
|
|
776
|
+
* was wrong and nothing in the fleet could say so; every deployed smoke run there charges that
|
|
777
|
+
* 403 to the app, because a smoke's verdict is a function of whatever resolver its machine has.
|
|
778
|
+
*
|
|
779
|
+
* The mechanism is `tools/check-public-dns.ts`'s, lifted: a Cloudflare-proxied hostname MUST
|
|
780
|
+
* resolve inside the ranges Cloudflare publishes, so an answer outside them, or one no
|
|
781
|
+
* independent resolver gives, is the network substituting answers — true everywhere, and
|
|
782
|
+
* checkable from the bad network itself. Whether the hostname is proxied at all is read off the
|
|
783
|
+
* independent answers, so a caller never has to say.
|
|
784
|
+
*
|
|
785
|
+
* 🔴 **Diagnosis only. It never retries and never pins an address** — the fault is that a
|
|
786
|
+
* network-caused red was indistinguishable from an app-caused one, and the deliverable is the
|
|
787
|
+
* distinction, not a way around the network.
|
|
788
|
+
*/
|
|
789
|
+
export async function diagnoseNetwork(host: string, probe: NetworkProbe = {}): Promise<NetworkDiagnosis> {
|
|
790
|
+
if (isIP(host) !== 0 || host === "localhost" || host.endsWith(".localhost")) {
|
|
791
|
+
return { verdict: "not-applicable", lines: [`${host} is not a public hostname`] };
|
|
792
|
+
}
|
|
793
|
+
const [system, independent, ranges] = await Promise.all([
|
|
794
|
+
(probe.system ?? systemAnswers)(host),
|
|
795
|
+
(probe.independent ?? dohAnswers)(host),
|
|
796
|
+
(probe.ranges ?? publishedRanges)(),
|
|
797
|
+
]);
|
|
798
|
+
const reached = independent.filter((entry) => entry.answers !== null);
|
|
799
|
+
const world = [...new Set(reached.flatMap((entry) => entry.answers ?? []))];
|
|
800
|
+
|
|
801
|
+
if (reached.length === 0) {
|
|
802
|
+
if (system.length === 0) return { verdict: "offline", lines: [`nothing resolved ${host} — not this machine, not any independent resolver`] };
|
|
803
|
+
const outside = system.filter((ip) => !inRanges(ip, ranges));
|
|
804
|
+
return {
|
|
805
|
+
verdict: "intercepting",
|
|
806
|
+
lines: [
|
|
807
|
+
`no independent resolver (${DOH_ENDPOINTS.map(([name]) => name).join(", ")}) could be reached, while this machine's resolver answers ${system.join(", ")}.`,
|
|
808
|
+
...(outside.length > 0 ? [`${outside.join(", ")} is OUTSIDE every range Cloudflare publishes.`] : []),
|
|
809
|
+
"A network that blocks both encrypted resolvers is already answering the question.",
|
|
810
|
+
],
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (world.length > 0 && world.every((ip) => !inRanges(ip, ranges))) {
|
|
814
|
+
return { verdict: "not-proxied", lines: [`${host} is not served through Cloudflare's proxy (${world.join(", ")}), so the range rule says nothing`] };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const lines: string[] = [];
|
|
818
|
+
for (const ip of system) {
|
|
819
|
+
if (inRanges(ip, ranges)) continue;
|
|
820
|
+
lines.push(
|
|
821
|
+
`${host} resolved to ${ip} via this machine's resolver — OUTSIDE every range Cloudflare publishes. Connecting there with this SNI is what returns "403 · Error 1034 · Edge IP Restricted".`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
const strangers = system.filter((ip) => inRanges(ip, ranges) && !world.includes(ip));
|
|
825
|
+
if (strangers.length > 0) {
|
|
826
|
+
lines.push(`this machine's resolver answers ${strangers.join(", ")}, which NO independent resolver returned (they say ${world.join(", ")}).`);
|
|
827
|
+
}
|
|
828
|
+
if (system.length === 0 && world.length > 0) {
|
|
829
|
+
lines.push(
|
|
830
|
+
`this machine cannot resolve ${host}, which the independent resolvers answer (${world.join(", ")}) — a resolver in the way, or this Mac's negative DNS cache.`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
return lines.length > 0 ? { verdict: "intercepting", lines } : { verdict: "clean", lines: [`${host}: this machine and ${reached.length} independent resolver(s) agree`] };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── A version that has SETTLED ────────────────────────────────────────────────────────────
|
|
837
|
+
|
|
838
|
+
export interface SteadyOptions {
|
|
839
|
+
/** Consecutive good reads before the answer is trusted. Default 3. */
|
|
840
|
+
reads?: number;
|
|
841
|
+
/** Reads before giving up and judging whatever answered. Default 40. */
|
|
842
|
+
tries?: number;
|
|
843
|
+
/** Pause after a GOOD read, before the next. Default 2 s. */
|
|
844
|
+
pauseMs?: number;
|
|
845
|
+
/** Pause after a BAD read. Default 5 s. */
|
|
846
|
+
retryMs?: number;
|
|
847
|
+
/** Injected for tests. */
|
|
848
|
+
sleep?: (ms: number) => Promise<void>;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export interface SteadyRead<T> {
|
|
852
|
+
/** The LAST response read, body unconsumed — judge this, never the wait. */
|
|
853
|
+
response: Response;
|
|
854
|
+
/** Its body as JSON, or `{}` when it was not JSON. */
|
|
855
|
+
body: T;
|
|
856
|
+
/** Consecutive good reads at the end. `>= reads` means it settled. */
|
|
857
|
+
steady: number;
|
|
858
|
+
/** How many times it asked. */
|
|
859
|
+
asked: number;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Ask until `ready` holds for `reads` reads IN A ROW, then hand back the last answer.
|
|
864
|
+
*
|
|
865
|
+
* 🔴 **Several reads, not one.** A deploy and each secret upload are separate Worker versions,
|
|
866
|
+
* and for some seconds consecutive requests land on different ones. Measured on `music`'s first
|
|
867
|
+
* deploy (2026-09-23, `a54ef71`): ONE good `/healthz` read was followed by thirteen gated routes
|
|
868
|
+
* answering `503` (the pre-secrets version) and `404`, because one isolate had the new version
|
|
869
|
+
* and the next did not. The copies in `vault` and `collections` still judged after one read.
|
|
870
|
+
*
|
|
871
|
+
* It never decides pass or fail: it returns what answered, and the caller judges that — a wait
|
|
872
|
+
* that swallowed its own timeout would be a check that cannot go red.
|
|
873
|
+
*/
|
|
874
|
+
export async function steadyHealth<T = Record<string, unknown>>(
|
|
875
|
+
ask: () => Promise<Response>,
|
|
876
|
+
ready: (body: T, response: Response) => boolean,
|
|
877
|
+
options: SteadyOptions = {},
|
|
878
|
+
): Promise<SteadyRead<T>> {
|
|
879
|
+
const reads = options.reads ?? 3;
|
|
880
|
+
const tries = options.tries ?? 40;
|
|
881
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
|
|
882
|
+
let steady = 0;
|
|
883
|
+
let asked = 0;
|
|
884
|
+
for (;;) {
|
|
885
|
+
const response = await ask();
|
|
886
|
+
asked++;
|
|
887
|
+
const body = (await response
|
|
888
|
+
.clone()
|
|
889
|
+
.json()
|
|
890
|
+
.catch(() => ({}))) as T;
|
|
891
|
+
steady = ready(body, response) ? steady + 1 : 0;
|
|
892
|
+
if (steady >= reads || asked >= tries) return { response, body, steady, asked };
|
|
893
|
+
await sleep(steady > 0 ? (options.pauseMs ?? 2_000) : (options.retryMs ?? 5_000));
|
|
894
|
+
}
|
|
895
|
+
}
|