@tikoci/quickchr 0.3.1 → 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
@@ -19,7 +19,7 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
19
19
  ```
20
20
 
21
21
  - **CLI** — git-style subcommands + interactive wizard. Thin layer over the library.
22
- - **Library** — `QuickCHR` class with static methods: `start()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `monitor()`, etc.
22
+ - **Library** — `QuickCHR` class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `exec()`, `monitor()`, etc.
23
23
  - **Modules** — Pure functions for QEMU arg building, image download, port allocation, state persistence.
24
24
 
25
25
  ### Key Design Decisions
@@ -36,6 +36,24 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
36
36
 
37
37
  6. **Class-based API** — `QuickCHR` is a class with static methods for clean namespacing. `ChrInstance` is an interface implemented as a plain object with closures.
38
38
 
39
+ 7. **Running-only connection descriptors** — `ChrInstance.descriptor()`, `quickchr inspect`, and `quickchr env` are live connection handoff surfaces, not stale state readers. They intentionally fail with `MACHINE_STOPPED` when the VM is not running, because ports/auth/status are only safe to consume when the machine is active. Descriptor/env output includes auth material by design for subprocess handoff; callers must treat it as credential-bearing output.
40
+
41
+ 8. **Boot respawn-once on hardware accel** — When `QuickCHR.start()` boots a fresh machine in background mode and `waitForBoot` exhausts its budget under `kvm`/`hvf`, it stops the QEMU process, clears its `server=on` socket files, and respawns **once** before raising `BOOT_TIMEOUT` (see `start()` in `src/lib/quickchr.ts`, gated on `accel`). This targets an observed CI flake: on GitHub's nested-KVM runner a single boot among many occasionally never reaches REST while siblings boot in ~30-45s — a *wedged* process that a longer timeout would not rescue, only a fresh one. Gated to hardware accel because TCG boots are legitimately long and doubling buys nothing.
42
+
43
+ ⚠️ **Watch-item / scope caveat.** The root cause is **unconfirmed** — the respawn is a pragmatic mitigation that keeps CI green, not a proven diagnosis; the trigger could be something else (runner CPU-steal, a SLiRP stall, image-specific timing). Two deliberate limits to revisit if it recurs:
44
+ - **Not applied to `_launchExisting`** (the restart-existing-machine path) — that path also spawns + `waitForBoot`s but has never been observed to flake. Extend the same respawn there only with evidence.
45
+ - **Timeout factor left at 1.5×** (`accelTimeoutFactor`, `src/lib/platform.ts`) rather than inflated further — the respawn is the recovery mechanism, not a bigger ceiling.
46
+
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
+
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
+
39
57
  ## Port Layout
40
58
 
41
59
  | Offset | Service | Guest Port |
@@ -87,37 +105,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
87
105
 
88
106
  ## CI System
89
107
 
90
- **Workflow**: `.github/workflows/ci.yml`
108
+ **Workflows**: `.github/workflows/{ci,verify-extended,publish}.yml`. Full artifact map and
109
+ failure-diagnosis guide live in `.github/instructions/ci.instructions.md` — this section is
110
+ the high-level rationale only.
91
111
 
92
- Three jobs — lint and unit-tests run in parallel; integration is gated on both:
112
+ **`ci.yml`** — core quality gate on every push/PR to `main`:
93
113
 
114
+ ```text
115
+ lint (ubuntu) ──┐
116
+ unit-tests (ubuntu, --coverage) ──┴→ integration-x86 (ubuntu) → windows-unit-tests (windows-latest)
94
117
  ```
95
- lint unit-tests windows-unit-tests
96
- Biome + tsc --noEmit bun test test/unit/ bun test test/unit/
97
- --coverage (windows-latest, always)
98
- ↘ ↙
99
- integration (matrix)
100
- linux/x86_64 (always)
101
- linux/aarch64 (always)
102
- macos/arm64 (workflow_dispatch: macos=true)
103
- macos/x86_64 (workflow_dispatch: macos=true)
104
- ```
105
118
 
106
- **Integration matrix**: Each runner boots a CHR matching its native arch. `detectAccel()`
107
- selects KVM/HVF/TCG automatically — no per-runner overrides needed.
119
+ `lint` and `unit-tests` run in parallel. `integration-x86` boots an x86 CHR (KVM if
120
+ available, else TCG) and gates on both. `windows-unit-tests` runs last — if the core is
121
+ broken, Windows runner minutes add no signal. Only x86 integration runs on every push;
122
+ cross-arch and macOS are too slow/fragile for the per-push gate.
123
+
124
+ **`verify-extended.yml`** — `workflow_dispatch` only. Independent jobs (no cross-`needs:`)
125
+ for the platforms kept out of the per-push gate: linux/aarch64, macOS (arm64 + x86), and
126
+ Windows. Dispatch inputs `arm64` / `macos` / `windows` select platforms; `test-filter`
127
+ narrows to specific test files for fast iteration. Each runner boots a CHR matching its
128
+ native arch — `detectAccel()` selects KVM/HVF/TCG automatically, no per-runner overrides.
108
129
 
109
- **Coverage**: `bun test test/unit/ --coverage` output is parsed and compared against
110
- thresholds (default 75% functions, 60% lines). Failures emit `::warning::` annotations
111
- but do NOT block merges (`continue-on-error: true`). Thresholds are overridable via
112
- dispatch inputs `min-funcs` / `min-lines`.
130
+ **`publish.yml`** — triggers on `v*` tags (or dispatch). Runs lint + unit + x86 integration +
131
+ windows-unit before `npm publish` (`--tag next` for odd/pre-release minors, `--tag latest`
132
+ for even/stable). See "Release Process" in `ci.instructions.md`.
113
133
 
114
- **Artifacts** (details in `.github/instructions/ci.instructions.md`):
134
+ **Coverage**: `unit-tests` parses `bun test --coverage` output and compares against thresholds
135
+ (default 75% functions, 60% lines). Failures emit `::warning::` annotations but do NOT block
136
+ merges (`continue-on-error: true`). Thresholds are overridable via dispatch inputs
137
+ `min-funcs` / `min-lines`.
138
+
139
+ **Artifacts**:
115
140
  - `coverage-report` — full per-file coverage table (14 days)
116
141
  - `integration-logs-{platform}` — bun test output + machine.json + qemu.log (7 days)
117
142
 
118
- **Publish workflow** (`.github/workflows/publish.yml`): triggers on `v*` tags;
119
- runs lint + typecheck + unit tests before npm publish.
120
-
121
143
  ## Design Principles
122
144
 
123
145
  ### Scope Boundary — QEMU Expert, Not Orchestrator
@@ -144,6 +166,23 @@ The `default-route-distance=2` ensures the shared route is backup — SLiRP ethe
144
166
 
145
167
  **TCG hazard:** SLiRP half-open connections (TCP connect succeeds, data never flows) burn the full per-probe HTTP timeout in `waitForBoot`. Under cross-arch TCG where TCP round-trips are slow, this compounds badly. Lab: `test/lab/slirp-hostfwd/`.
146
168
 
169
+ #### Host-Side L2 Capture (MNDP, MAC-Telnet)
170
+
171
+ A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
172
+ broadcast) being the first use case — without root or a native helper, using the
173
+ TCP `socket` netdev: the host runs a TCP server, the CHR gets a `socket-connect`
174
+ NIC, and QEMU streams every guest frame to the host length-prefixed (4-byte BE
175
+ length + raw Ethernet). Loopback-only, cross-platform. Writing a frame back over
176
+ the same connection injects L2 into the guest (the MAC-Telnet primitive). Recipe:
177
+ `docs/mndp.md`; example: `examples/mndp/`.
178
+
179
+ **Discovered constraint (2026-06-06):** the `socket-mcast` netdev — the documented
180
+ multi-VM L2 path — is **broken on macOS**. QEMU's mcast socket sets only
181
+ `SO_REUSEADDR`, while macOS/BSD need `SO_REUSEPORT` on every socket sharing a
182
+ multicast port; two CHRs on one group don't discover each other and host capture
183
+ gets nothing. mcast still works on Linux/CI. Prefer `socket-connect` for host
184
+ capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
185
+
147
186
  ### Platform Priority
148
187
 
149
188
  macOS → Linux → Windows. Mac and Linux share most code paths with minor `#ifdef`-style branches. Windows is tracked but lower priority — larger RouterOS admin audience there, but fewer recipes and harder to test. Windows CI runner planned after the existing macOS/Linux matrix is stable.
package/README.md CHANGED
@@ -93,6 +93,11 @@ quickchr list
93
93
  # Detailed status for one instance
94
94
  quickchr status my-chr
95
95
 
96
+ # Stable connection descriptor/env for a running instance
97
+ quickchr inspect my-chr
98
+ quickchr env my-chr
99
+ quickchr env my-chr --json
100
+
96
101
  # Stop an instance
97
102
  quickchr stop my-chr
98
103
 
@@ -157,6 +162,7 @@ quickchr doctor
157
162
  | `--boot-disk-format <f>` | Boot disk format: qcow2\|raw | qcow2 |
158
163
  | `--boot-size <size>` | Resize boot disk (e.g., 512M, 2G). Requires `qemu-img`. | |
159
164
  | `--add-disk <size>` | Attach an extra blank qcow2 disk. Repeatable. Requires `qemu-img`. | |
165
+ | `--forward <spec>` | Add or pin a QEMU SLiRP hostfwd mapping. Repeatable. | |
160
166
  | `--bg` / `--background` | Run in background (default) | true |
161
167
  | `--fg` / `--foreground` | Run in foreground — serial console on stdio | |
162
168
  | `--add-package <pkg>` | Extra package to install (repeatable) | |
@@ -261,6 +267,39 @@ Each instance gets a block of 10 ports. With the default base of 9100, the first
261
267
 
262
268
  The second instance gets 9110–9119, and so on.
263
269
 
270
+ ### Custom Port Forwards and Fixed WinBox
271
+
272
+ Use `--forward <spec>` on `add` or create-and-start `start` commands to add
273
+ extra QEMU SLiRP `hostfwd` mappings. The shorthand uses the built-in service
274
+ registry when it knows the guest port:
275
+
276
+ ```bash
277
+ quickchr add lab --forward smb # auto host port → guest 445/tcp
278
+ quickchr add dude-lab --forward winbox:8291 # host 8291 → guest WinBox 8291/tcp
279
+ quickchr add app-lab --forward myapp:9200:7777/udp
280
+ ```
281
+
282
+ Reusing a built-in service name such as `winbox` pins/replaces that service's
283
+ host port for the machine. Existing machines keep the mapping stored in
284
+ `machine.json`; recreate the machine to change fixed service ports cleanly.
285
+
286
+ ### Machine Descriptors and Subprocess Environments
287
+
288
+ `quickchr inspect <name> [--json]` emits a stable JSON descriptor for a
289
+ **running** machine: status, ports, URLs, auth, env vars, and the machine
290
+ directory. `--json` is accepted for parity; inspect output is always JSON.
291
+
292
+ `quickchr env <name> [--json]` prints the same subprocess environment as
293
+ `ChrInstance.subprocessEnv()`: shell `KEY=value` lines by default, or a JSON
294
+ map with `--json`.
295
+
296
+ > **Credential caveat:** descriptor/env output includes connection secrets
297
+ > (`auth.password`, `auth.basic`, `auth.header`, `QUICKCHR_AUTH`, `BASICAUTH`)
298
+ > so child processes can connect without reading quickchr's secret store. Treat
299
+ > it like a password: do not commit it, paste it into public issues, or leave it
300
+ > in CI logs. Stopped machines fail with `MACHINE_STOPPED`; start the machine
301
+ > before requesting a descriptor or env map.
302
+
264
303
  ### Library Usage
265
304
 
266
305
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.3.1",
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",
@@ -20,11 +20,12 @@
20
20
  "DESIGN.md"
21
21
  ],
22
22
  "scripts": {
23
- "check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown",
23
+ "check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell",
24
24
  "lint": "biome check . && tsc --noEmit",
25
25
  "lint:biome": "biome check .",
26
26
  "lint:typecheck": "tsc --noEmit",
27
27
  "lint:markdown": "bunx markdownlint-cli2 '**/*.md'",
28
+ "lint:cspell": "cspell lint . --no-progress",
28
29
  "test": "bun test test/unit/",
29
30
  "test:examples": "bun test examples/",
30
31
  "test:integration": "QUICKCHR_INTEGRATION=1 bun test test/integration/",
@@ -36,6 +37,7 @@
36
37
  "devDependencies": {
37
38
  "@biomejs/biome": "^2.4.10",
38
39
  "@types/bun": "^1.3.11",
40
+ "cspell": "^10.0.0",
39
41
  "markdownlint-cli2": "^0.21.0"
40
42
  },
41
43
  "dependencies": {
package/src/cli/index.ts CHANGED
@@ -152,6 +152,12 @@ async function main() {
152
152
  case "get":
153
153
  await cmdGet(args.slice(1));
154
154
  break;
155
+ case "inspect":
156
+ await cmdInspect(args.slice(1));
157
+ break;
158
+ case "env":
159
+ await cmdEnv(args.slice(1));
160
+ break;
155
161
  case "license":
156
162
  console.error("Note: 'quickchr license' is deprecated. Use 'quickchr set <name> --license' instead.");
157
163
  await cmdLicense(args.slice(1));
@@ -1432,6 +1438,50 @@ async function cmdList(argv: string[] = []) {
1432
1438
  console.log(table(headers, rows));
1433
1439
  }
1434
1440
 
1441
+ function shellQuoteEnv(value: string): string {
1442
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
1443
+ return `'${value.replaceAll("'", "'\\''")}'`;
1444
+ }
1445
+
1446
+ async function getRunningDescriptor(name: string) {
1447
+ const { QuickCHR } = await import("../lib/quickchr.ts");
1448
+ const { machineNotFoundMessage } = await import("./format.ts");
1449
+ const instance = QuickCHR.get(name);
1450
+ if (!instance) {
1451
+ console.error(machineNotFoundMessage(name));
1452
+ process.exit(1);
1453
+ }
1454
+ return instance.descriptor();
1455
+ }
1456
+
1457
+ async function cmdInspect(argv: string[] = []) {
1458
+ const { positional } = parseFlags(argv);
1459
+ const name = positional[0];
1460
+ if (!name) {
1461
+ console.error("Usage: quickchr inspect <name> [--json]");
1462
+ process.exit(1);
1463
+ }
1464
+ const descriptor = await getRunningDescriptor(name);
1465
+ console.log(JSON.stringify(descriptor, null, 2));
1466
+ }
1467
+
1468
+ async function cmdEnv(argv: string[] = []) {
1469
+ const { flags, positional } = parseFlags(argv);
1470
+ const name = positional[0];
1471
+ const asJson = flagBool(flags, "json");
1472
+ if (!name) {
1473
+ console.error("Usage: quickchr env <name> [--json]");
1474
+ process.exit(1);
1475
+ }
1476
+ const descriptor = await getRunningDescriptor(name);
1477
+ if (asJson) {
1478
+ console.log(JSON.stringify(descriptor.env, null, 2));
1479
+ return;
1480
+ }
1481
+ for (const [key, value] of Object.entries(descriptor.env)) {
1482
+ console.log(`${key}=${shellQuoteEnv(value)}`);
1483
+ }
1484
+ }
1435
1485
 
1436
1486
  async function cmdGet(argv: string[]) {
1437
1487
  const { flags, positional } = parseFlags(argv);
@@ -2457,6 +2507,8 @@ Commands:
2457
2507
  stop [<name>|--all] Stop instance(s) — print list if no name
2458
2508
  list [<name>] [--json] List all instances, or detail for one
2459
2509
  status Alias for list
2510
+ inspect <name> [--json] Print running machine descriptor as JSON
2511
+ env <name> [--json] Print subprocess environment for a running machine
2460
2512
  console <name> Attach to serial console of a running instance
2461
2513
  exec <name> <command> Run a RouterOS CLI command on a running instance
2462
2514
  remove [<name>|--all] Remove instance(s) and disk
@@ -2637,6 +2689,23 @@ List all CHR instances or show detailed info for one.
2637
2689
 
2638
2690
  'quickchr status' is an alias for this command.`);
2639
2691
  break;
2692
+ case "inspect":
2693
+ console.log(`quickchr inspect <name> [--json]
2694
+
2695
+ Print a stable JSON descriptor for a running CHR instance: ports, URLs,
2696
+ credentials, status, and subprocess env vars.
2697
+
2698
+ <name> Name of a running CHR instance.
2699
+ --json Accepted for parity; output is always JSON.`);
2700
+ break;
2701
+ case "env":
2702
+ console.log(`quickchr env <name> [--json]
2703
+
2704
+ Print environment variables for subprocesses that connect to a running CHR.
2705
+
2706
+ <name> Name of a running CHR instance.
2707
+ --json Output the env map as JSON instead of KEY=value lines.`);
2708
+ break;
2640
2709
  case "doctor":
2641
2710
  console.log(`quickchr doctor
2642
2711
 
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * - {@link QuickCHR} — Main class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`
18
18
  * - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `upload()`, `download()`, `snapshot`, etc.
19
19
  * - {@link StartOptions} — All options for creating/starting a CHR instance
20
- * - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `TIMEOUT`, `EXEC_FAILED`
20
+ * - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `BOOT_TIMEOUT`, `EXEC_FAILED` (full list: {@link ErrorCode})
21
21
  *
22
22
  * @packageDocumentation
23
23
  */
@@ -44,6 +44,7 @@ export type {
44
44
  LicenseLevel,
45
45
  LicenseOptions,
46
46
  MachineConfig,
47
+ MachineDescriptor,
47
48
  MachineState,
48
49
  NetworkConfig,
49
50
  NetworkMode,
package/src/lib/auth.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { MachineState } from "./types.ts";
11
- import { getInstanceCredentials } from "./credentials.ts";
11
+ import { getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
12
12
 
13
13
  export interface ResolvedAuth {
14
14
  /** HTTP Basic Authorization header value. */
@@ -48,9 +48,16 @@ export function resolveAuth(
48
48
  };
49
49
  }
50
50
 
51
- // state.user is the canonical provisioned user written to machine.json.
52
- // Check this first — no I/O, no keychain, no blocking.
53
51
  if (state.user) {
52
+ if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
53
+ const stored = getInstanceCredentials(state.name);
54
+ if (stored) {
55
+ return {
56
+ header: `Basic ${btoa(`${stored.user}:${stored.password}`)}`,
57
+ user: stored.user,
58
+ };
59
+ }
60
+ }
54
61
  return {
55
62
  header: `Basic ${btoa(`${state.user.name}:${state.user.password}`)}`,
56
63
  user: state.user.name,
@@ -82,7 +89,13 @@ export function resolveCreds(
82
89
  password?: string,
83
90
  ): ResolvedCreds {
84
91
  if (user !== undefined) return { user, password: password ?? "" };
85
- if (state.user) return { user: state.user.name, password: state.user.password };
92
+ if (state.user) {
93
+ if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
94
+ const stored = getInstanceCredentials(state.name);
95
+ if (stored) return { user: stored.user, password: stored.password };
96
+ }
97
+ return { user: state.user.name, password: state.user.password };
98
+ }
86
99
  const stored = getInstanceCredentials(state.name);
87
100
  if (stored) return { user: stored.user, password: stored.password };
88
101
  return { user: "admin", password: "" };
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Communication channels — QEMU monitor, serial console, QGA (QEMU Guest Agent).
3
3
  *
4
- * Uses Unix domain sockets stored in the machine directory.
4
+ * Uses Unix domain sockets stored in the machine directory on POSIX. On Windows,
5
+ * launched machines use TCP localhost endpoints derived from portBase; named pipes
6
+ * remain only as the no-portBase fallback path.
5
7
  * QGA protocol implementation lives in qga.ts — this module provides
6
8
  * the high-level qgaCommand() wrapper with arch guards and path resolution.
7
9
  */
@@ -328,7 +328,7 @@ _quickchr() {
328
328
  cword=${D}COMP_CWORD
329
329
  }
330
330
 
331
- local commands="add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
331
+ local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -337,7 +337,7 @@ _quickchr() {
337
337
 
338
338
  local cmd="${D}{words[1]}"
339
339
  case "${D}cmd" in
340
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
340
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
341
341
  local machines
342
342
  machines=$(quickchr completions --machines 2>/dev/null)
343
343
  COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
@@ -387,6 +387,8 @@ _quickchr() {
387
387
  'list:List all instances'
388
388
  'ls:List all instances (alias)'
389
389
  'status:Detailed status'
390
+ 'inspect:Print running machine descriptor'
391
+ 'env:Print subprocess environment'
390
392
  'remove:Remove instance(s) and disk'
391
393
  'rm:Remove instance(s) (alias)'
392
394
  'clean:Reset instance disk to fresh image'
@@ -410,7 +412,7 @@ _quickchr() {
410
412
  args)
411
413
  local cmd="${D}{words[2]}"
412
414
  case ${D}cmd in
413
- start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
415
+ start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
414
416
  local machines
415
417
  machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
416
418
  _describe 'machine' machines
@@ -432,7 +434,7 @@ _quickchr() {
432
434
  _describe 'option' opts
433
435
  ;;
434
436
  help)
435
- local commands=(add start stop list status remove clean console exec qga license disk snapshot networks doctor setup completions version)
437
+ local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
436
438
  _describe 'command' commands
437
439
  ;;
438
440
  esac
@@ -448,7 +450,7 @@ function generateFishCompletion(): string {
448
450
  return `# quickchr fish completion
449
451
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
450
452
 
451
- set -l quickchr_commands add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
453
+ set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
452
454
 
453
455
  # Disable file completions globally for quickchr
454
456
  complete -c quickchr -f
@@ -464,6 +466,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
464
466
  -a "list ls" -d "List all instances"
465
467
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
466
468
  -a "status" -d "Detailed status"
469
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
470
+ -a "inspect" -d "Print running machine descriptor"
471
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
472
+ -a "env" -d "Print subprocess environment"
467
473
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
468
474
  -a "remove rm" -d "Remove instance(s) and disk"
469
475
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -499,7 +505,7 @@ complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
499
505
  -d "machine"
500
506
 
501
507
  # Machine name completions for commands that take any machine
502
- complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status" \\
508
+ complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
503
509
  -a "(quickchr completions --machines 2>/dev/null)" \\
504
510
  -d "machine"
505
511
 
@@ -26,6 +26,8 @@ export interface InstanceCredentials {
26
26
  password: string;
27
27
  }
28
28
 
29
+ export const STORED_IN_SECRETS_PASSWORD = "(stored in secrets)";
30
+
29
31
  const WEB_SERVICE = "com.quickchr.mikrotik-web";
30
32
  const INSTANCE_SERVICE = "com.quickchr.instance";
31
33
 
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/qemu.ts CHANGED
@@ -437,23 +437,23 @@ export async function stopQemu(pid: number): Promise<boolean> {
437
437
  return true;
438
438
  }
439
439
 
440
- /** Stop a machine by name. */
441
- export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
442
- if (!state.pid) return false;
443
- const stopped = await stopQemu(state.pid);
444
-
445
- // Clean up socket files
446
- const machineDir = state.machineDir;
440
+ /** Remove the listening unix-socket files QEMU binds (monitor/serial/qga).
441
+ * A SIGKILLed QEMU leaves these behind; a respawn with `server=on` then fails
442
+ * to bind ("Address already in use"). Call before re-spawning into the same dir. */
443
+ export function cleanupQemuSockets(machineDir: string): void {
447
444
  for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
448
445
  const path = join(machineDir, sock);
449
446
  try {
450
- if (existsSync(path)) {
451
- const { unlinkSync } = await import("node:fs");
452
- unlinkSync(path);
453
- }
447
+ if (existsSync(path)) unlinkSync(path);
454
448
  } catch { /* ignore */ }
455
449
  }
450
+ }
456
451
 
452
+ /** Stop a machine by name. */
453
+ export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
454
+ if (!state.pid) return false;
455
+ const stopped = await stopQemu(state.pid);
456
+ cleanupQemuSockets(state.machineDir);
457
457
  return stopped;
458
458
  }
459
459
 
@@ -13,6 +13,7 @@ import type {
13
13
  ExecResult,
14
14
  LicenseInput,
15
15
  LicenseLevel,
16
+ MachineDescriptor,
16
17
  MachineState,
17
18
  QgaCommand,
18
19
  SnapshotInfo,
@@ -45,7 +46,7 @@ import {
45
46
  } from "./state.ts";
46
47
  import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
47
48
  import { autoPruneIfOverCap } from "./cache.ts";
48
- import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
49
+ import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
49
50
  import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
50
51
  import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
51
52
  import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
@@ -53,7 +54,7 @@ import { provision } from "./provision.ts";
53
54
  import { renewLicense, getLicenseInfo } from "./license.ts";
54
55
  import { resolveAuth, resolveCreds } from "./auth.ts";
55
56
  import { scpPush, scpPull } from "./scp.ts";
56
- import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
57
+ import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
57
58
  import { restExecute } from "./exec.ts";
58
59
  import { qgaExec } from "./qga.ts";
59
60
  import { consoleExec } from "./console.ts";
@@ -224,6 +225,63 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
224
225
  function createInstance(state: MachineState): ChrInstance {
225
226
  const ports = toChrPorts(state.ports);
226
227
  const restUrl = `http://127.0.0.1:${ports.http}`;
228
+ const buildEnv = (): Record<string, string> => {
229
+ const creds = resolveCreds(state);
230
+ const rawCreds = `${creds.user}:${creds.password}`;
231
+ const restBase = `${restUrl}/rest`;
232
+ return {
233
+ QUICKCHR_NAME: state.name,
234
+ QUICKCHR_REST_URL: restUrl,
235
+ QUICKCHR_REST_BASE: restBase,
236
+ QUICKCHR_SSH_PORT: String(ports.ssh),
237
+ QUICKCHR_AUTH: rawCreds,
238
+ // Legacy compat keys used by restraml and similar consumers.
239
+ URLBASE: restBase,
240
+ BASICAUTH: rawCreds,
241
+ };
242
+ };
243
+ const buildDescriptor = (): MachineDescriptor => {
244
+ if (state.status !== "running") {
245
+ throw new QuickCHRError(
246
+ "MACHINE_STOPPED",
247
+ `Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
248
+ );
249
+ }
250
+ const auth = resolveAuth(state);
251
+ const env = buildEnv();
252
+ const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`;
253
+ return {
254
+ name: state.name,
255
+ status: "running",
256
+ version: state.version,
257
+ arch: state.arch,
258
+ cpu: state.cpu,
259
+ mem: state.mem,
260
+ pid: state.pid ?? null,
261
+ ports,
262
+ portMappings: state.ports,
263
+ urls: {
264
+ http: restUrl,
265
+ rest: `${restUrl}/rest`,
266
+ restBase: `${restUrl}/rest`,
267
+ https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
268
+ ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
269
+ api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
270
+ apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
271
+ winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
272
+ },
273
+ auth: {
274
+ user: auth.user,
275
+ password: basic.slice(auth.user.length + 1),
276
+ basic,
277
+ header: auth.header,
278
+ },
279
+ env,
280
+ machineDir: state.machineDir,
281
+ createdAt: state.createdAt,
282
+ lastStartedAt: state.lastStartedAt ?? null,
283
+ };
284
+ };
227
285
 
228
286
  return {
229
287
  name: state.name,
@@ -571,22 +629,11 @@ function createInstance(state: MachineState): ChrInstance {
571
629
  },
572
630
 
573
631
  async subprocessEnv(): Promise<Record<string, string>> {
574
- const auth = resolveAuth(state);
575
- // auth.header is "Basic <base64>" — extract the raw user:pass.
576
- const rawCreds = auth.header.startsWith("Basic ")
577
- ? Buffer.from(auth.header.slice(6), "base64").toString()
578
- : `${auth.user}:`;
579
- const restBase = `${restUrl}/rest`;
580
- return {
581
- QUICKCHR_NAME: state.name,
582
- QUICKCHR_REST_URL: restUrl,
583
- QUICKCHR_REST_BASE: restBase,
584
- QUICKCHR_SSH_PORT: String(ports.ssh),
585
- QUICKCHR_AUTH: rawCreds,
586
- // Legacy compat keys used by restraml and similar consumers.
587
- URLBASE: restBase,
588
- BASICAUTH: rawCreds,
589
- };
632
+ return buildEnv();
633
+ },
634
+
635
+ async descriptor(): Promise<MachineDescriptor> {
636
+ return buildDescriptor();
590
637
  },
591
638
 
592
639
  async queryLoad(): Promise<ChrLoadSample | null> {
@@ -1355,7 +1402,7 @@ export class QuickCHR {
1355
1402
 
1356
1403
  const qemuArgs = await buildQemuArgs(launchConfig);
1357
1404
  const wrapper = extractWrapper(resolvedNetworks);
1358
- const { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1405
+ let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
1359
1406
 
1360
1407
  // Foreground (no provisioning): spawnQemu blocks until QEMU exits
1361
1408
  if (!background && !hasProvisioning) {
@@ -1375,7 +1422,26 @@ export class QuickCHR {
1375
1422
  const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
1376
1423
 
1377
1424
  // Always wait for boot in background mode — the JSDoc promises "REST-ready".
1378
- const booted = await instance.waitForBoot(bootTimeout);
1425
+ let booted = await instance.waitForBoot(bootTimeout);
1426
+
1427
+ // Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
1428
+ // runners) occasionally produce a single QEMU process that boots but never
1429
+ // reaches REST, while sibling boots on the same host succeed in ~30-45s. The
1430
+ // process is wedged, not merely slow — so a fresh respawn recovers reliably
1431
+ // where a longer timeout would only stretch the failure. Retry once, gated to
1432
+ // hardware accel (TCG boots are legitimately long; doubling buys nothing there).
1433
+ if (!booted && (accel === "kvm" || accel === "hvf")) {
1434
+ logger.warn(
1435
+ `CHR "${name}" did not reach REST within ${bootTimeout / 1000}s (accel=${accel}) — respawning QEMU once.`,
1436
+ );
1437
+ try { await stopQemu(pid); } catch { /* ignore */ }
1438
+ cleanupQemuSockets(machineDir);
1439
+ ({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
1440
+ state.pid = pid;
1441
+ saveMachine(state);
1442
+ booted = await instance.waitForBoot(bootTimeout);
1443
+ }
1444
+
1379
1445
  if (!booted) {
1380
1446
  let qemuLogTail = "";
1381
1447
  try {
@@ -1536,7 +1602,7 @@ export class QuickCHR {
1536
1602
  // Persist user info in state (password placeholder — real password in secret store)
1537
1603
  const current = loadMachine(machineState.name);
1538
1604
  if (current) {
1539
- current.user = { name: result.user.name, password: "(stored in secrets)" };
1605
+ current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
1540
1606
  saveMachine(current);
1541
1607
  }
1542
1608
  machineState.user = { name: result.user.name, password: result.user.password };
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";
package/src/lib/types.ts CHANGED
@@ -290,6 +290,38 @@ export interface ChrPorts {
290
290
  [key: string]: number;
291
291
  }
292
292
 
293
+ export interface MachineDescriptor {
294
+ name: string;
295
+ status: "running";
296
+ version: string;
297
+ arch: Arch;
298
+ cpu: number;
299
+ mem: number;
300
+ pid: number | null;
301
+ ports: ChrPorts;
302
+ portMappings: Record<string, PortMapping>;
303
+ urls: {
304
+ http: string;
305
+ rest: string;
306
+ restBase: string;
307
+ https?: string;
308
+ ssh?: string;
309
+ api?: string;
310
+ apiSsl?: string;
311
+ winbox?: string;
312
+ };
313
+ auth: {
314
+ user: string;
315
+ password: string;
316
+ basic: string;
317
+ header: string;
318
+ };
319
+ env: Record<string, string>;
320
+ machineDir: string;
321
+ createdAt: string;
322
+ lastStartedAt: string | null;
323
+ }
324
+
293
325
  /** Return value of ChrInstance.queryLoad(). */
294
326
  export interface ChrLoadSample {
295
327
  /** Host CPU percent across all virtual CPUs (0–100 × vCPU count). */
@@ -437,6 +469,8 @@ export interface ChrInstance {
437
469
  * Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
438
470
  */
439
471
  subprocessEnv(): Promise<Record<string, string>>;
472
+ /** Build a stable machine-readable connection descriptor for a running instance. */
473
+ descriptor(): Promise<MachineDescriptor>;
440
474
 
441
475
  /** Sample QEMU guest load from the monitor. Returns null when the monitor is
442
476
  * unavailable (machine stopped or running in foreground mode). */
@@ -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",