@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 +8 -0
- package/package.json +1 -1
- package/src/lib/images.ts +2 -1
- package/src/lib/net.ts +123 -0
- package/src/lib/packages.ts +2 -1
- package/src/lib/rest.ts +2 -2
- package/src/lib/versions.ts +2 -1
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
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
|
|
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
|
+
}
|
package/src/lib/packages.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 { 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
|
|
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
|
|
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";
|
package/src/lib/versions.ts
CHANGED
|
@@ -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
|
|
72
|
+
const response = await fetchResilient(url);
|
|
72
73
|
if (!response.ok) {
|
|
73
74
|
throw new QuickCHRError(
|
|
74
75
|
"DOWNLOAD_FAILED",
|