@tikoci/quickchr 0.4.0 → 0.4.1

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/DESIGN.md CHANGED
@@ -46,6 +46,14 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
46
46
 
47
47
  How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked in `BACKLOG.md` (boot-respawn watch-item).
48
48
 
49
+ 9. **Resilient downloads via public DNS** — All fetches to MikroTik's `upgrade`/`download` hosts go through `fetchResilient()` (`src/lib/net.ts`), never bare `fetch()`. The real failure mode, confirmed by an on-runner probe (2026-06-16), is that **GitHub-hosted runners' system resolver returns `ESERVFAIL` for `*.mikrotik.com`** — slowly (2–26 s) — via *both* `getaddrinfo` and c-ares-over-`resolv.conf`. So a plain `fetch` either times out resolving or (when the stub hands back only the unreachable AAAA) fails with Bun's `errno: 0` `ConnectionRefused` / `FailedToOpenSocket`. A **direct query to a public resolver (1.1.1.1 / 8.8.8.8) answers in ~10 ms**, and the hosts have a reachable IPv4. The earlier "IPv6 happy-eyeballs" theory was a red herring — the primary fault is DNS resolution at the runner's stub, not IPv6 egress.
50
+
51
+ Measured on the CI runner: `lookup({family:4})` → `ESERVFAIL` ~9 s; `lookup({all})` → `ESERVFAIL`/`ETIMEOUT` 22–26 s; `resolve4` (resolv.conf) → `ESERVFAIL` 2–22 s; `Resolver([1.1.1.1,8.8.8.8]).resolve4` → **OK ~10 ms**.
52
+
53
+ `fetchResilient` resolves the A record by querying public DNS **directly** (a `dns.Resolver` with `setServers([1.1.1.1, 8.8.8.8, 1.0.0.1])`, bounded by a 3 s timeout so a blocked resolver doesn't stall), then connects to the IPv4 literal preserving the `Host` header and TLS SNI so certificate validation still passes. It falls back to a normal `fetch` (system resolver, dual-stack) only when public DNS is unavailable — e.g. a network that blocks public resolvers — so it stays correct off CI. HTTP responses and aborts/timeouts pass through unchanged, never retried. `dns.resolve4`/`getaddrinfo` are deliberately **not** used for the primary path because they inherit the runner's broken stub.
54
+
55
+ ⚠️ **Sister-project routing.** A download/version-resolve failure that only appears on CI (slow `ESERVFAIL`, or `errno: 0` from `resolveVersion`) is a **quickchr** concern, not the consuming project's — do not paper over it downstream with `/etc/hosts` pins or IPv6 toggles in the sister repo's workflow. The historical `getent ahostsv4` `/etc/hosts` workaround was both in the wrong layer and non-functional (it hit the same broken stub resolver, returning empty). Covered by `test/unit/net.test.ts`.
56
+
49
57
  ## Port Layout
50
58
 
51
59
  | Offset | Service | Guest Port |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/lib/images.ts CHANGED
@@ -7,6 +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
11
  import { getCacheDir, ensureDir } from "./state.ts";
11
12
  import { createLogger, type ProgressLogger } from "./log.ts";
12
13
  import { extractZip } from "./zip.ts";
@@ -46,7 +47,7 @@ export async function downloadImage(
46
47
 
47
48
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
48
49
  try {
49
- const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
50
+ const response = await fetchResilient(url, { signal: AbortSignal.timeout(120_000) });
50
51
  // Non-retriable: client errors except 408 (request timeout) and 429
51
52
  // (rate limited), which can be transient on CDNs.
52
53
  if (response.status >= 400 && response.status < 500
package/src/lib/net.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Resilient HTTP fetch for MikroTik's download hosts.
3
+ *
4
+ * On GitHub-hosted CI runners the system DNS resolver returns `ESERVFAIL`
5
+ * (slowly — 2 to 26 s) for `upgrade.mikrotik.com` / `download.mikrotik.com` via
6
+ * both `getaddrinfo` and c-ares-over-resolv.conf, so a plain `fetch` either
7
+ * times out or (when the stub hands back only the unreachable AAAA) fails with
8
+ * Bun's `errno: 0` ConnectionRefused / FailedToOpenSocket. A direct query to a
9
+ * public resolver answers in ~10 ms, and the hosts are dual-stack with a
10
+ * reachable IPv4. quickchr is expected to run unattended in CI, so it resolves
11
+ * these hosts itself rather than trusting the runner's broken resolver.
12
+ *
13
+ * Strategy: resolve the A record by querying a public DNS server directly
14
+ * (bypassing the host's resolv.conf), then connect to the IPv4 literal,
15
+ * preserving the `Host` header and TLS SNI so certificate validation still
16
+ * passes. Fall back to a normal `fetch` (system resolver, dual-stack) when the
17
+ * public-DNS path is unavailable — e.g. a network that blocks public resolvers
18
+ * — so this stays correct off CI. HTTP responses and aborts/timeouts pass
19
+ * through unchanged; they are never retried.
20
+ */
21
+
22
+ import { promises as dns } from "node:dns";
23
+
24
+ const PUBLIC_DNS_SERVERS = ["1.1.1.1", "8.8.8.8", "1.0.0.1"];
25
+ const DNS_TIMEOUT_MS = 3000;
26
+
27
+ /**
28
+ * True for the connection-class failures raised when a socket cannot be opened
29
+ * (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
30
+ * ConnectionRefused / FailedToOpenSocket against an unreachable address), plus
31
+ * the standard connect errors. Excludes aborts/timeouts and HTTP-level outcomes
32
+ * (those carry a Response and never throw here). Used to decide whether to fall
33
+ * back from the IPv4 attempt to a normal dual-stack fetch.
34
+ */
35
+ export function isConnectionFailure(err: unknown): boolean {
36
+ if (!err || typeof err !== "object") return false;
37
+ const e = err as { name?: string; code?: string; cause?: { code?: string } };
38
+ if (e.name === "AbortError") return false;
39
+ const code = e.code ?? e.cause?.code;
40
+ if (
41
+ code === "ConnectionRefused" ||
42
+ code === "FailedToOpenSocket" ||
43
+ code === "ConnectionClosed" ||
44
+ code === "ECONNREFUSED" ||
45
+ code === "ECONNRESET" ||
46
+ code === "EHOSTUNREACH" ||
47
+ code === "ENETUNREACH" ||
48
+ code === "ETIMEDOUT"
49
+ ) {
50
+ return true;
51
+ }
52
+ // Bun wraps low-level connect failures as a bare TypeError ("Unable to
53
+ // connect…"). Treat those as retriable too; a wrong guess only costs one
54
+ // normal fetch, which then surfaces the real error if it also fails.
55
+ return e.name === "TypeError";
56
+ }
57
+
58
+ /** Rewrite a URL to target an explicit IPv4 literal, preserving port/path/query. */
59
+ export function toIpv4Url(url: string, address: string): string {
60
+ const u = new URL(url);
61
+ u.hostname = address;
62
+ return u.toString();
63
+ }
64
+
65
+ /**
66
+ * Resolve a host's A record via public DNS servers directly, bypassing the
67
+ * host's resolv.conf (which is broken for these names on CI runners). Returns
68
+ * undefined — never throws — when public DNS is unreachable or has no answer,
69
+ * so the caller can fall back to a normal fetch. Bounded by DNS_TIMEOUT_MS so a
70
+ * blocked resolver does not stall the request.
71
+ */
72
+ async function resolveIpv4(host: string): Promise<string | undefined> {
73
+ const resolver = new dns.Resolver();
74
+ resolver.setServers(PUBLIC_DNS_SERVERS);
75
+ let timer: ReturnType<typeof setTimeout> | undefined;
76
+ try {
77
+ const addresses = await Promise.race([
78
+ resolver.resolve4(host),
79
+ new Promise<string[]>((_, reject) => {
80
+ timer = setTimeout(() => reject(new Error("public DNS timeout")), DNS_TIMEOUT_MS);
81
+ }),
82
+ ]);
83
+ return addresses[0];
84
+ } catch {
85
+ return undefined;
86
+ } finally {
87
+ if (timer) clearTimeout(timer);
88
+ }
89
+ }
90
+
91
+ async function fetchOverIpv4(
92
+ url: string,
93
+ address: string,
94
+ init?: BunFetchRequestInit,
95
+ ): Promise<Response> {
96
+ const host = new URL(url).hostname;
97
+ const headers = new Headers(init?.headers);
98
+ headers.set("Host", host);
99
+ return fetch(toIpv4Url(url, address), {
100
+ ...init,
101
+ headers,
102
+ tls: { serverName: host, ...init?.tls },
103
+ });
104
+ }
105
+
106
+ /**
107
+ * `fetch()` that resolves dual-stack hosts via public DNS and connects over
108
+ * IPv4, so it survives a broken/slow system resolver (e.g. GitHub-hosted CI
109
+ * runners). Falls back to a normal `fetch` when public DNS is unavailable.
110
+ * Behaves like `fetch` for HTTP responses and aborts.
111
+ */
112
+ export async function fetchResilient(url: string, init?: BunFetchRequestInit): Promise<Response> {
113
+ const address = await resolveIpv4(new URL(url).hostname);
114
+ if (address) {
115
+ try {
116
+ return await fetchOverIpv4(url, address, init);
117
+ } catch (err) {
118
+ if (!isConnectionFailure(err)) throw err;
119
+ // IPv4 unreachable (e.g. IPv6-only egress) — fall through to a normal fetch.
120
+ }
121
+ }
122
+ return fetch(url, init);
123
+ }
@@ -7,6 +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 { packagesDownloadUrl } from "./versions.ts";
10
+ import { fetchResilient } from "./net.ts";
10
11
  import { getCacheDir, ensureDir } from "./state.ts";
11
12
  import { createLogger, type ProgressLogger } from "./log.ts";
12
13
  import { restPost } from "./rest.ts";
@@ -38,7 +39,7 @@ export async function downloadPackages(
38
39
  if (!existsSync(zipPath)) {
39
40
  const log = logger ?? createLogger();
40
41
  log.status(`Downloading packages for ${version} (${arch})...`);
41
- const response = await fetch(url);
42
+ const response = await fetchResilient(url);
42
43
  if (!response.ok) {
43
44
  throw new QuickCHRError(
44
45
  "DOWNLOAD_FAILED",
package/src/lib/rest.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  * because Bun's node:http req.destroy() does not reliably emit the
11
11
  * "error" event, which would leave the Promise pending forever.
12
12
  *
13
- * External URLs (mikrotik.com, upgrade server) use fetch() — no pool
14
- * issue there. This module is ONLY for CHR REST calls (127.0.0.1).
13
+ * External URLs (mikrotik.com, upgrade server) use fetchResilient() (net.ts) —
14
+ * no pool issue there. This module is ONLY for CHR REST calls (127.0.0.1).
15
15
  */
16
16
 
17
17
  import { request as nodeRequest } from "node:http";
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Arch, Channel } from "./types.ts";
6
6
  import { QuickCHRError } from "./types.ts";
7
+ import { fetchResilient } from "./net.ts";
7
8
 
8
9
  const UPGRADE_BASE = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
9
10
  const DOWNLOAD_BASE = "https://download.mikrotik.com/routeros";
@@ -68,7 +69,7 @@ export function provisioningSupportHint(minimumVersion = MIN_PROVISION_VERSION):
68
69
  export async function resolveVersion(channel: Channel): Promise<string> {
69
70
  const url = `${UPGRADE_BASE}.${channel}`;
70
71
 
71
- const response = await fetch(url);
72
+ const response = await fetchResilient(url);
72
73
  if (!response.ok) {
73
74
  throw new QuickCHRError(
74
75
  "DOWNLOAD_FAILED",