@tikoci/quickchr 0.4.6 → 0.4.7

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.
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Guest-side forensics over the serial console, for a CHR that never became
3
+ * REST-ready.
4
+ *
5
+ * The serial console stays reachable while REST is dead — verified against a
6
+ * synthetic stuck state (a `action=drop` filter rule on tcp/80 makes REST time
7
+ * out exactly like #79 while `consoleExec` still logs in and answers). That
8
+ * makes a guest-side snapshot at failure time the only way to see whether
9
+ * RouterOS itself is healthy when the host side says "silently dropped".
10
+ *
11
+ * Two capabilities, deliberately separated by how invasive they are:
12
+ *
13
+ * - {@link captureGuestSnapshot} — read-only. Default on wherever a serial
14
+ * channel exists.
15
+ * - {@link runCountingRuleProbe} — writes a firewall rule and flips
16
+ * connection tracking active as a side effect. Opt-in
17
+ * (`QUICKCHR_DEEP_BOOT_DIAGNOSTICS=1`), and skipped when the caller asked
18
+ * for the machine to be preserved, since that means someone wants the
19
+ * failure state untouched.
20
+ *
21
+ * Everything here is best-effort: a diagnostic must never turn a boot failure
22
+ * into a different thrown error.
23
+ *
24
+ * RouterOS console rules that this module encodes (all measured on 7.21.5 —
25
+ * see tikoci/quickchr#105):
26
+ *
27
+ * - **Always `:put [:serialize to=json [ ... print as-value]]`.** A bare
28
+ * `print` on `/log` or `/ip/firewall/connection/tracking` hits a paging
29
+ * prompt: it blocks for ~11 s and returns empty.
30
+ * - **De-wrap before parsing.** `:serialize` emits one line, so every newline
31
+ * in what comes back is a terminal wrap — and a wrap landing inside a JSON
32
+ * string breaks `JSON.parse`. See {@link parseSerializedJson}.
33
+ * - **`/tool/profile` is useless here** — it repaints instead of printing and
34
+ * returns empty in both `duration=` and `as-value` forms. Do not re-try it.
35
+ */
36
+
37
+ /** A console reply, with whether the reader could frame it. `framed:false` means
38
+ * the end-of-reply sentinel never arrived, so an empty `output` says "the reader
39
+ * could not delimit the reply", not "the guest printed nothing" (#109). */
40
+ export interface GuestReply {
41
+ output: string;
42
+ framed?: boolean;
43
+ }
44
+
45
+ /** Executes one RouterOS CLI command in the guest and returns its raw output.
46
+ * Injected so this module stays free of a console.ts/channels.ts import.
47
+ * A bare string is accepted for callers and fakes that cannot report framing. */
48
+ export type GuestExec = (command: string, timeoutMs: number) => Promise<string | GuestReply>;
49
+
50
+ function asReply(reply: string | GuestReply): GuestReply {
51
+ return typeof reply === "string" ? { output: reply } : reply;
52
+ }
53
+
54
+ /** Opens a TCP connection to a host port and returns a one-word outcome.
55
+ * Injected (diagnostics.probeTcpPort) to avoid an import cycle. */
56
+ export type HostPortProbe = (port: number, timeoutMs?: number) => Promise<string>;
57
+
58
+ /** Wrap a `print as-value` query so it comes back as single-line JSON. */
59
+ export function serializeJsonCommand(query: string): string {
60
+ return `:put [:serialize to=json [${query}]]`;
61
+ }
62
+
63
+ /** The read-only snapshot. Ordered cheapest-and-most-decisive first, because
64
+ * {@link captureGuestSnapshot} stops issuing queries once its budget is spent.
65
+ *
66
+ * `/log` leads: it is the only source that timestamps DHCP acquisition and
67
+ * service start. Note that a link flap does NOT show up under
68
+ * `topics~"interface"` — a healthy boot logs only `lo link up` and ether1's
69
+ * link-up is never recorded — it shows up as a `lost IP address` / `got IP
70
+ * address` DHCP pair, which is why the whole log is captured unfiltered. */
71
+ export const GUEST_SNAPSHOT_QUERIES: readonly { key: string; query: string; why: string }[] = [
72
+ { key: "log", query: "/log print as-value", why: "DHCP churn, service start, login failures" },
73
+ { key: "ipAddress", query: "/ip/address print as-value", why: "did the guest keep 10.0.2.15?" },
74
+ { key: "ipService", query: "/ip/service print as-value", why: "is www enabled and on port 80?" },
75
+ { key: "firewallFilter", query: "/ip/firewall/filter print as-value", why: "expected [] on a fresh CHR — confirm" },
76
+ { key: "systemResource", query: "/system/resource print as-value", why: "uptime, version, board" },
77
+ { key: "interfaceStats", query: "/interface print stats as-value", why: "rx-packet / rx-drop / rx-error" },
78
+ { key: "connectionTracking", query: "/ip/firewall/connection/tracking print as-value", why: "enabled + active-ipv4" },
79
+ ];
80
+
81
+ export interface GuestSnapshotEntry {
82
+ command: string;
83
+ ok: boolean;
84
+ elapsedMs: number;
85
+ /** Parsed JSON payload when the round-trip succeeded. */
86
+ value?: unknown;
87
+ /** Why this entry has no value: exec failure, parse failure, or budget. */
88
+ error?: string;
89
+ /** Raw console text, kept only when parsing failed so the payload is not lost. */
90
+ raw?: string;
91
+ /** False when the console reader could not delimit the reply — the failure
92
+ * mode is then the reader, not RouterOS. **Undefined means "not reported"**,
93
+ * which covers both an exec that never ran and one that returned a bare
94
+ * string (see {@link GuestExec}); do not read it as "no exec". */
95
+ framed?: boolean;
96
+ }
97
+
98
+ export interface GuestSnapshot {
99
+ /** True when at least one query round-tripped — i.e. RouterOS is alive and
100
+ * answering on the console even though REST is not. */
101
+ consoleReachable: boolean;
102
+ loginUser: string;
103
+ elapsedMs: number;
104
+ entries: Record<string, GuestSnapshotEntry>;
105
+ /** One-line, credential-free verdict safe to put in a thrown error. */
106
+ summary: string;
107
+ }
108
+
109
+ /** Cap on raw console text retained for a query that failed to parse. */
110
+ const MAX_RAW_BYTES = 32 * 1024;
111
+
112
+ /**
113
+ * De-wrap and parse a `:serialize to=json` reply.
114
+ *
115
+ * The payload is single-line by construction, so newlines are terminal wraps
116
+ * and are simply removed. The harder half is finding where the payload starts:
117
+ * RouterOS repaints the input line, so the echoed command can appear several
118
+ * times before the reply. Candidate starts are therefore tried last-first —
119
+ * the real payload is the last thing on the wire, and a candidate that starts
120
+ * mid-payload leaves unbalanced JSON that fails to parse.
121
+ */
122
+ export function parseSerializedJson(raw: string, framed?: boolean): { value?: unknown; error?: string } {
123
+ const cleaned = raw.replace(/\r/g, "");
124
+ const lines = cleaned.split("\n");
125
+ const starts: number[] = [];
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const trimmed = (lines[i] ?? "").trimStart();
128
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) starts.push(i);
129
+ }
130
+ if (starts.length === 0) {
131
+ if (cleaned.trim() !== "") return { error: "no JSON payload in console reply" };
132
+ // An empty reply has two very different causes, and #109 is the case where
133
+ // telling them apart is the whole point: a *framed* empty reply is RouterOS
134
+ // printing nothing, while an *unframed* one is the reader losing the payload.
135
+ // `undefined` is neither — the exec could not report framing at all, so the
136
+ // message must stay ambiguous rather than claim the guest was silent.
137
+ if (framed === true) return { error: "empty console reply — RouterOS printed nothing" };
138
+ if (framed === false) {
139
+ return { error: "empty console reply — the reader could not frame it (payload may have been lost)" };
140
+ }
141
+ return { error: "empty console reply — framing unknown, so a lost payload cannot be ruled out" };
142
+ }
143
+ let lastError = "unparsable console reply";
144
+ for (let i = starts.length - 1; i >= 0; i--) {
145
+ const payload = lines.slice(starts[i]).join("").trim();
146
+ try {
147
+ return { value: JSON.parse(payload) };
148
+ } catch (e) {
149
+ lastError = e instanceof Error ? e.message : String(e);
150
+ }
151
+ }
152
+ return { error: lastError };
153
+ }
154
+
155
+ /** Default per-command console timeout. Measured round-trips are ~0.5 s once
156
+ * paging is avoided; 15 s is slack for a loaded TCG guest, not an expectation.
157
+ * This covers the *command* only — see {@link GUEST_LOGIN_ALLOWANCE_MS}. */
158
+ export const GUEST_QUERY_TIMEOUT_MS = 15_000;
159
+
160
+ /**
161
+ * Extra time a query gets, on top of {@link GUEST_QUERY_TIMEOUT_MS}, to pay for
162
+ * the serial login the executor has to do before any command can run — granted
163
+ * until one query answers, then dropped for the rest of the snapshot.
164
+ *
165
+ * The query cap was sized on the ~0.3 s cost of a command on an already-open
166
+ * session (`CONSOLE_LOGIN_COST_MS` in console.ts has the measurements), which
167
+ * left the ~11.4 s login unbudgeted. With a multi-candidate executor —
168
+ * `bootFailureGuestExec()` tries stored credentials, then factory `admin`, then
169
+ * `state.user` — that budget was then *divided*, so no candidate ever got enough
170
+ * time to finish logging in. The first query failed, no credential was ever
171
+ * marked working, every later query repeated the same doomed split, and the
172
+ * snapshot spent its whole budget to report `consoleReachable: false` about a
173
+ * guest that was answering serial in 10 ms (tikoci/quickchr#69, B10 of #110).
174
+ *
175
+ * Sized so at least two candidates fit in one query: 15 s + 15 s = 30 s of the
176
+ * 60 s {@link GUEST_SNAPSHOT_BUDGET_MS}, leaving the remaining ~30 s for the
177
+ * other six queries, which cost ~0.3 s each once a working credential is known.
178
+ * Two candidates is the meaningful coverage — stored credentials and factory
179
+ * `admin:""`, the pair that between them answer any machine we produce;
180
+ * `state.user` is documented as the weakest case.
181
+ *
182
+ * Dropping it after the first answer is what keeps it bounded, and it costs a
183
+ * genuinely wedged console nothing extra: `budgetMs - elapsed` caps every query
184
+ * regardless, so an unreachable console spends the same total in fewer, longer
185
+ * waits rather than more.
186
+ */
187
+ export const GUEST_LOGIN_ALLOWANCE_MS = 15_000;
188
+
189
+ /** Default wall-clock cap for the whole read-only snapshot. It runs on an
190
+ * already-timed-out path, so it must be bounded rather than thorough. */
191
+ export const GUEST_SNAPSHOT_BUDGET_MS = 60_000;
192
+
193
+ /**
194
+ * Run the read-only guest snapshot. Never throws.
195
+ *
196
+ * @param exec Runs one command in the guest (serial console).
197
+ * @param loginUser Recorded in the result so a report shows which account was
198
+ * used — post-`clean()` machines are unprovisioned and only
199
+ * answer to `admin` with an empty password.
200
+ */
201
+ export async function captureGuestSnapshot(
202
+ exec: GuestExec,
203
+ loginUser = "admin",
204
+ opts: { queryTimeoutMs?: number; budgetMs?: number; loginAllowanceMs?: number } = {},
205
+ ): Promise<GuestSnapshot> {
206
+ const queryTimeoutMs = opts.queryTimeoutMs ?? GUEST_QUERY_TIMEOUT_MS;
207
+ const budgetMs = opts.budgetMs ?? GUEST_SNAPSHOT_BUDGET_MS;
208
+ const loginAllowanceMs = opts.loginAllowanceMs ?? GUEST_LOGIN_ALLOWANCE_MS;
209
+ const started = Date.now();
210
+ const entries: Record<string, GuestSnapshotEntry> = {};
211
+ let consoleReachable = false;
212
+
213
+ for (const { key, query } of GUEST_SNAPSHOT_QUERIES) {
214
+ const command = serializeJsonCommand(query);
215
+ const elapsed = Date.now() - started;
216
+ if (elapsed >= budgetMs) {
217
+ entries[key] = { command, ok: false, elapsedMs: 0, error: `skipped — snapshot budget ${budgetMs}ms spent` };
218
+ continue;
219
+ }
220
+ // Granted until a query actually answers, not just to the first one: while
221
+ // nothing has answered, no login has been established, so the next query
222
+ // still has to pay for one. Tying it to "first" instead would let a single
223
+ // hiccup cost the whole snapshot its only chance to log in. This cannot
224
+ // overrun, because `budgetMs - elapsed` bounds every query either way — an
225
+ // unreachable console spends the same total budget, in fewer, longer waits.
226
+ const allowance = consoleReachable ? 0 : loginAllowanceMs;
227
+ const queryStart = Date.now();
228
+ try {
229
+ const { output, framed } = asReply(await exec(command, Math.min(queryTimeoutMs + allowance, budgetMs - elapsed)));
230
+ const parsed = parseSerializedJson(output, framed);
231
+ consoleReachable = true;
232
+ entries[key] = parsed.error === undefined
233
+ ? { command, ok: true, elapsedMs: Date.now() - queryStart, value: parsed.value, framed }
234
+ : {
235
+ command,
236
+ ok: false,
237
+ elapsedMs: Date.now() - queryStart,
238
+ error: parsed.error,
239
+ raw: output.slice(-MAX_RAW_BYTES),
240
+ framed,
241
+ };
242
+ } catch (e) {
243
+ entries[key] = {
244
+ command,
245
+ ok: false,
246
+ elapsedMs: Date.now() - queryStart,
247
+ error: e instanceof Error ? e.message : String(e),
248
+ };
249
+ }
250
+ }
251
+
252
+ return {
253
+ consoleReachable,
254
+ loginUser,
255
+ elapsedMs: Date.now() - started,
256
+ entries,
257
+ summary: summarizeGuestSnapshot(consoleReachable, entries),
258
+ };
259
+ }
260
+
261
+ /** Count rows in a `print as-value` payload — arrays are the usual shape,
262
+ * a bare object (e.g. `/ip/firewall/connection/tracking`) counts as one. */
263
+ function rowCount(value: unknown): number | null {
264
+ if (Array.isArray(value)) return value.length;
265
+ if (value !== null && typeof value === "object") return 1;
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * One line, no payload contents.
271
+ *
272
+ * This string is appended to a thrown `QuickCHRError`, which CI echoes into a
273
+ * public job log. `/log` records "device changed by console" entries and the
274
+ * snapshot can echo credentials the same way `serial.log` can, so only shapes
275
+ * (reachability, row counts, a couple of booleans) go here — the values live in
276
+ * the JSON report, which lands in an access-controlled artifact.
277
+ */
278
+ function summarizeGuestSnapshot(consoleReachable: boolean, entries: Record<string, GuestSnapshotEntry>): string {
279
+ if (!consoleReachable) return "guest snapshot: console unreachable — RouterOS answered nothing over serial";
280
+ const parts: string[] = [];
281
+ const ok = Object.values(entries).filter((e) => e.ok).length;
282
+ parts.push(`${ok}/${Object.keys(entries).length} queries answered`);
283
+
284
+ const addresses = rowCount(entries.ipAddress?.value);
285
+ if (addresses !== null) parts.push(`${addresses} ip address(es)`);
286
+
287
+ const filters = rowCount(entries.firewallFilter?.value);
288
+ if (filters !== null) parts.push(`${filters} firewall filter rule(s)`);
289
+
290
+ const services = entries.ipService?.value;
291
+ if (Array.isArray(services)) {
292
+ const www = services.find((s) => (s as Record<string, unknown>)?.name === "www") as Record<string, unknown> | undefined;
293
+ if (www) parts.push(`www disabled=${String(www.disabled)} port=${String(www.port)}`);
294
+ }
295
+
296
+ const uptime = (entries.systemResource?.value as Record<string, unknown> | undefined)?.uptime
297
+ ?? (Array.isArray(entries.systemResource?.value)
298
+ ? (entries.systemResource?.value[0] as Record<string, unknown> | undefined)?.uptime
299
+ : undefined);
300
+ if (uptime !== undefined) parts.push(`uptime=${String(uptime)}`);
301
+
302
+ return `guest snapshot: ${parts.join(", ")}`;
303
+ }
304
+
305
+ // --- Counting-rule probe (opt-in, mutates the guest) ---
306
+
307
+ /** True when the invasive guest probes may run. */
308
+ export function deepBootDiagnosticsEnabled(): boolean {
309
+ return process.env.QUICKCHR_DEEP_BOOT_DIAGNOSTICS === "1";
310
+ }
311
+
312
+ /** Comment used to find the diagnostic rule again — and to make it obvious in a
313
+ * preserved machine where the rule came from. */
314
+ export const DIAGNOSTIC_RULE_COMMENT = "quickchr-boot-diagnostic";
315
+
316
+ /** How long to let the guest count retransmitted SYNs before re-reading. */
317
+ export const COUNTER_SETTLE_MS = 1_500;
318
+
319
+ export interface CountingRuleProbe {
320
+ ran: boolean;
321
+ /** Set when the probe declined to run; `ran` is then false. */
322
+ skipped?: string;
323
+ guestPort?: number;
324
+ hostPort?: number;
325
+ probesFired?: number;
326
+ packetsBefore?: number | null;
327
+ packetsAfter?: number | null;
328
+ /** `/ip/firewall/connection print as-value` right after the burst — `seen-reply`
329
+ * separates "arrived and something answered" from "arrived, nothing answered".
330
+ * Secondary only: conntrack records what was *accepted*, never what was dropped. */
331
+ connections?: unknown;
332
+ verdict?: "guest-received" | "not-delivered" | "inconclusive";
333
+ detail: string;
334
+ }
335
+
336
+ function firstNumber(value: unknown, field: string): number | null {
337
+ const row = Array.isArray(value) ? value[0] : value;
338
+ if (row === null || typeof row !== "object") return null;
339
+ const raw = (row as Record<string, unknown>)[field];
340
+ if (raw === undefined || raw === null) return null;
341
+ const n = Number(String(raw).replace(/\s/g, ""));
342
+ return Number.isFinite(n) ? n : null;
343
+ }
344
+
345
+ async function execJson(exec: GuestExec, query: string, timeoutMs: number): Promise<unknown> {
346
+ const { output, framed } = asReply(await exec(serializeJsonCommand(query), timeoutMs));
347
+ const parsed = parseSerializedJson(output, framed);
348
+ if (parsed.error !== undefined) throw new Error(parsed.error);
349
+ return parsed.value;
350
+ }
351
+
352
+ /**
353
+ * The discriminator for #79: **does the guest receive the SYNs?**
354
+ *
355
+ * Accumulating `TCP[SYN_SENT]` in `info usernet` proves the packets are being
356
+ * silently dropped, but a guest that received all of them and dropped them
357
+ * itself looks identical from the host — measured: with `action=drop`
358
+ * installed, 20 host probes advanced a rule counter by exactly 20 while slirp
359
+ * showed 21 `SYN_SENT` rows. A counting rule inside the guest is what separates
360
+ * the two:
361
+ *
362
+ * - counter advances → the packets arrive; the drop is RouterOS-side;
363
+ * - counter flat → they never reach RouterOS; the drop is in the slirp /
364
+ * virtio RX path.
365
+ *
366
+ * It must be a **mangle `chain=prerouting action=passthrough`** rule, not a
367
+ * filter `accept`. Two reasons, both measured on 7.23.2:
368
+ *
369
+ * - `passthrough` is non-terminating and prerouting runs before the filter
370
+ * chains, so it counts arrivals whatever else is installed — verified by
371
+ * counting 10/10 SYNs that a filter `drop` on the same port then killed. A
372
+ * filter `accept` appended after an existing `drop` counts **zero** and
373
+ * reports "not-delivered" for a guest that is receiving everything.
374
+ * - it does not change what happens to the packet, so the failure state under
375
+ * investigation survives the measurement.
376
+ *
377
+ * `place-before=0` is not the fix for the ordering problem: on the empty chain
378
+ * of a fresh CHR — #79's exact state — it fails with `no such item`.
379
+ *
380
+ * Read the delta as a boolean, never as a count: slirp retransmits a dropped
381
+ * SYN, so the counter can advance by several per probe.
382
+ *
383
+ * Conntrack cannot answer this. RouterOS ships `enabled=auto`, which means
384
+ * tracking is *off* while no filter/NAT/mangle rule exists — a fresh CHR (#79's
385
+ * exact state) reports `active-ipv4:false` and 0 connections after healthy
386
+ * probes — and even with tracking forced on, a dropped SYN leaves no entry at
387
+ * all. It is captured here only as corroboration, because installing the accept
388
+ * rule flips tracking active as a side effect.
389
+ */
390
+ export async function runCountingRuleProbe(
391
+ exec: GuestExec,
392
+ probeHostPort: HostPortProbe,
393
+ opts: { guestPort: number; hostPort: number; probes?: number; queryTimeoutMs?: number; settleMs?: number },
394
+ ): Promise<CountingRuleProbe> {
395
+ const probes = opts.probes ?? 10;
396
+ const timeout = opts.queryTimeoutMs ?? GUEST_QUERY_TIMEOUT_MS;
397
+ const base: CountingRuleProbe = { ran: false, guestPort: opts.guestPort, hostPort: opts.hostPort, detail: "" };
398
+
399
+ try {
400
+ await exec(
401
+ `/ip/firewall/mangle add chain=prerouting protocol=tcp dst-port=${opts.guestPort} action=passthrough comment="${DIAGNOSTIC_RULE_COMMENT}"`,
402
+ timeout,
403
+ );
404
+ } catch (e) {
405
+ return { ...base, detail: `could not install counting rule: ${e instanceof Error ? e.message : String(e)}` };
406
+ }
407
+
408
+ const readCounter = async (): Promise<number | null> => {
409
+ try {
410
+ // `print stats`, not plain `print`: measured on 7.23.2, the plain form
411
+ // returns the rule without `packets`/`bytes` at all, which reads as an
412
+ // unreadable counter rather than a flat one.
413
+ const value = await execJson(
414
+ exec,
415
+ `/ip/firewall/mangle print stats as-value where comment="${DIAGNOSTIC_RULE_COMMENT}"`,
416
+ timeout,
417
+ );
418
+ return firstNumber(value, "packets");
419
+ } catch {
420
+ return null;
421
+ }
422
+ };
423
+
424
+ const packetsBefore = await readCounter();
425
+ for (let i = 0; i < probes; i++) await probeHostPort(opts.hostPort, 2000);
426
+ // slirp retransmits a dropped SYN, so give the guest a moment to count what
427
+ // was already sent before sampling — otherwise a slow TCG guest reads flat.
428
+ // Overridable only so unit tests need not pay it; never shorten it in anger.
429
+ await Bun.sleep(opts.settleMs ?? COUNTER_SETTLE_MS);
430
+ const packetsAfter = await readCounter();
431
+
432
+ let connections: unknown;
433
+ try {
434
+ connections = await execJson(exec, "/ip/firewall/connection print as-value", timeout);
435
+ } catch { /* secondary signal — absence is itself expected on a fresh CHR */ }
436
+
437
+ // Best-effort cleanup: the machine is normally destroyed right after this,
438
+ // but the probe must not be the reason a preserved machine looks modified
439
+ // beyond the tracking flip it cannot undo.
440
+ try {
441
+ await exec(`/ip/firewall/mangle remove [find comment="${DIAGNOSTIC_RULE_COMMENT}"]`, timeout);
442
+ } catch { /* ignore */ }
443
+
444
+ // Only `packetsAfter` is load-bearing. The rule is created by *this* function a
445
+ // moment earlier, so its counter necessarily starts at 0 — which means an
446
+ // unreadable `packetsBefore` costs nothing and must not throw the verdict away.
447
+ // #109: a report read `packetsAfter: 2` and still said "inconclusive", even
448
+ // though a counter at 2 on a seconds-old rule already proves `guest-received`.
449
+ const baseline = packetsBefore ?? 0;
450
+ const delta = packetsAfter === null ? null : packetsAfter - baseline;
451
+ // A negative delta cannot happen on a rule this function created moments ago,
452
+ // so it means the rule was replaced or reset mid-probe — that is unmeasured,
453
+ // not evidence of non-delivery.
454
+ const verdict: CountingRuleProbe["verdict"] = delta === null || delta < 0
455
+ ? "inconclusive"
456
+ : delta > 0
457
+ ? "guest-received"
458
+ : "not-delivered";
459
+ const assumed = packetsBefore === null ? " (pre-probe read failed; a freshly added rule starts at 0)" : "";
460
+ let detail: string;
461
+ if (delta === null) {
462
+ detail = "could not read the rule counter after the probes — cannot localize the drop";
463
+ } else if (delta > 0) {
464
+ detail = `rule counter advanced by ${delta} over ${probes} probes${assumed} — the guest IS receiving the SYNs; the drop is RouterOS-side`;
465
+ } else if (delta < 0) {
466
+ // Should not happen on a rule this function created; report it as the
467
+ // anomaly it is rather than folding it into "flat".
468
+ detail = `rule counter went BACKWARDS by ${-delta} over ${probes} probes${assumed} — the rule was replaced or reset mid-probe; treat as not measured`;
469
+ } else {
470
+ detail = `rule counter flat over ${probes} probes${assumed} — the SYNs never reach RouterOS; the drop is in the slirp/virtio RX path`;
471
+ }
472
+
473
+ return { ...base, ran: true, probesFired: probes, packetsBefore, packetsAfter, connections, verdict, detail };
474
+ }
package/src/lib/images.ts CHANGED
@@ -7,7 +7,7 @@ import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
9
9
  import { chrDownloadUrl, chrImageBasename } from "./versions.ts";
10
- import { fetchResilient } from "./net.ts";
10
+ import { downloadToFile } from "./download.ts";
11
11
  import { getCacheDir, ensureDir } from "./state.ts";
12
12
  import { createLogger, type ProgressLogger } from "./log.ts";
13
13
  import { extractZip } from "./zip.ts";
@@ -42,44 +42,8 @@ export async function downloadImage(
42
42
  log.status(`Downloading CHR ${version} (${arch})...`);
43
43
  log.status(` ${url}`);
44
44
 
45
- const MAX_RETRIES = 3;
46
- let lastError: Error | undefined;
47
-
48
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
49
- try {
50
- const response = await fetchResilient(url, { signal: AbortSignal.timeout(120_000) });
51
- // Non-retriable: client errors except 408 (request timeout) and 429
52
- // (rate limited), which can be transient on CDNs.
53
- if (response.status >= 400 && response.status < 500
54
- && response.status !== 408 && response.status !== 429) {
55
- throw new QuickCHRError(
56
- "DOWNLOAD_FAILED",
57
- `Download failed: HTTP ${response.status} for ${url}`,
58
- );
59
- }
60
- if (!response.ok) {
61
- lastError = new Error(`HTTP ${response.status}`);
62
- } else {
63
- // Stream to disk via arrayBuffer (Bun.write with Response can hang on large files)
64
- const buf = await response.arrayBuffer();
65
- await Bun.write(zipPath, buf);
66
- log.status(` Saved (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB)`);
67
- return zipPath;
68
- }
69
- } catch (e) {
70
- if (e instanceof QuickCHRError) throw e;
71
- lastError = e instanceof Error ? e : new Error(String(e));
72
- }
73
- if (attempt < MAX_RETRIES) {
74
- log.status(` Download failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${attempt * 2}s...`);
75
- await Bun.sleep(attempt * 2000);
76
- }
77
- }
78
-
79
- throw new QuickCHRError(
80
- "DOWNLOAD_FAILED",
81
- `Download failed after ${MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
82
- );
45
+ await downloadToFile(url, zipPath, { logger: log });
46
+ return zipPath;
83
47
  }
84
48
 
85
49
  /** Extract the .img from the ZIP. Returns path to the raw .img file. */
package/src/lib/net.ts CHANGED
@@ -19,7 +19,9 @@
19
19
  * error), we retry by resolving the A record against a public DNS server
20
20
  * directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
21
21
  * IPv4 literal with the `Host` header and TLS SNI preserved so certificate
22
- * validation still passes.
22
+ * validation still passes. If that attempt fails too, both failures are reported
23
+ * together as a `ResilientFetchError` — the fallback's own error names only an IP
24
+ * literal, which on its own reads as a host nobody configured.
23
25
  *
24
26
  * Only connection-class failures trigger the fallback; HTTP responses (incl.
25
27
  * 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
@@ -32,6 +34,49 @@ import { promises as dns } from "node:dns";
32
34
  const PUBLIC_DNS_SERVERS = ["1.1.1.1", "8.8.8.8", "1.0.0.1"];
33
35
  const DNS_TIMEOUT_MS = 3000;
34
36
 
37
+ /** `CODE: message` when the error carries a code, else just the message. */
38
+ function describeError(err: unknown): string {
39
+ if (!err || typeof err !== "object") return String(err);
40
+ const e = err as { name?: string; message?: string; code?: string; cause?: { code?: string } };
41
+ const code = e.code ?? e.cause?.code;
42
+ const label = code ?? e.name;
43
+ const message = e.message ?? String(err);
44
+ return label ? `${label}: ${message}` : message;
45
+ }
46
+
47
+ /**
48
+ * Both transports failed: the direct fetch with a connection-class error, and
49
+ * then the public-DNS/IPv4 fallback too.
50
+ *
51
+ * It exists because the fallback's own error is unreadable on its own — it names
52
+ * an IP literal nobody configured and no hostname, so a log cannot distinguish
53
+ * "the system resolver was broken" from "the host refused us" from "public DNS
54
+ * handed back a bad address" (#121). The message carries the URL, both failures,
55
+ * and which address the fallback tried; the direct failure is also the `cause`.
56
+ */
57
+ export class ResilientFetchError extends Error {
58
+ /** The hostname both transports were aiming at. */
59
+ readonly host: string;
60
+ /** The IPv4 literal the fallback connected to, from public DNS. */
61
+ readonly address: string;
62
+ /** The fallback transport's failure. The direct attempt's is `cause`. */
63
+ readonly fallbackError: unknown;
64
+
65
+ constructor(url: string, address: string, directError: unknown, fallbackError: unknown) {
66
+ const host = new URL(url).hostname;
67
+ super(
68
+ `Fetch failed for ${url} on both transports: ` +
69
+ `system resolver — ${describeError(directError)}; ` +
70
+ `public DNS (${PUBLIC_DNS_SERVERS.join(", ")}) → ${address} — ${describeError(fallbackError)}`,
71
+ { cause: directError },
72
+ );
73
+ this.name = "ResilientFetchError";
74
+ this.host = host;
75
+ this.address = address;
76
+ this.fallbackError = fallbackError;
77
+ }
78
+ }
79
+
35
80
  /**
36
81
  * True for the connection-class failures raised when a socket cannot be opened
37
82
  * (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
@@ -39,9 +84,14 @@ const DNS_TIMEOUT_MS = 3000;
39
84
  * the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
40
85
  * timeouts) and HTTP-level outcomes (those carry a Response and never throw
41
86
  * here). Used to decide whether to fall back from the normal fetch to the
42
- * public-DNS IPv4 attempt.
87
+ * public-DNS IPv4 attempt, and by callers deciding whether a failure is worth
88
+ * another attempt.
43
89
  */
44
90
  export function isConnectionFailure(err: unknown): boolean {
91
+ // Connection-class by construction: fetchResilient raises this only after a
92
+ // direct failure that already passed this test. Callers retrying on a
93
+ // connection failure must keep retrying once the fallback also fails.
94
+ if (err instanceof ResilientFetchError) return true;
45
95
  if (!err || typeof err !== "object") return false;
46
96
  const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
47
97
  if (e.name === "AbortError") return false;
@@ -142,6 +192,12 @@ export async function fetchResilient(url: string, init?: BunFetchRequestInit): P
142
192
  const address = await resolveIpv4(new URL(url).hostname);
143
193
  // Public DNS can't help (unreachable / no answer) — surface the original failure.
144
194
  if (address === undefined) throw err;
145
- return fetchOverIpv4(url, address, init);
195
+ try {
196
+ return await fetchOverIpv4(url, address, init);
197
+ } catch (fallbackErr) {
198
+ // Never let the fallback's error stand alone: it names only an IP literal,
199
+ // which is the one thing the reader cannot map back to what was attempted.
200
+ throw new ResilientFetchError(url, address, err, fallbackErr);
201
+ }
146
202
  }
147
203
  }
@@ -5,9 +5,8 @@
5
5
  import { existsSync, readdirSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
- import { QuickCHRError } from "./types.ts";
9
8
  import { packagesDownloadUrl } from "./versions.ts";
10
- import { fetchResilient } from "./net.ts";
9
+ import { downloadToFile } from "./download.ts";
11
10
  import { getCacheDir, ensureDir } from "./state.ts";
12
11
  import { createLogger, type ProgressLogger } from "./log.ts";
13
12
  import { restPost } from "./rest.ts";
@@ -35,19 +34,14 @@ export async function downloadPackages(
35
34
  return extractDir;
36
35
  }
37
36
 
38
- // Download if needed
37
+ // Download if needed. Same bounded path as the image download (#116) — this
38
+ // call site previously had neither a deadline nor retries, so its only bound
39
+ // was whatever the calling test happened to impose.
39
40
  if (!existsSync(zipPath)) {
40
41
  const log = logger ?? createLogger();
41
42
  log.status(`Downloading packages for ${version} (${arch})...`);
42
- const response = await fetchResilient(url);
43
- if (!response.ok) {
44
- throw new QuickCHRError(
45
- "DOWNLOAD_FAILED",
46
- `Failed to download packages: HTTP ${response.status} for ${url}`,
47
- );
48
- }
49
- const buf = await response.arrayBuffer();
50
- await Bun.write(zipPath, buf);
43
+ log.status(` ${url}`);
44
+ await downloadToFile(url, zipPath, { logger: log });
51
45
  }
52
46
 
53
47
  // Extract