@tikoci/quickchr 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { DeviceModeOptions } from "./types.ts";
6
6
  import { QuickCHRError } from "./types.ts";
7
+ import { shellQuote } from "./names.ts";
7
8
  import { restGet, restPost } from "./rest.ts";
8
9
 
9
10
  export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
@@ -63,6 +64,26 @@ function normalizeFeatureList(list: string[] | undefined): string[] {
63
64
  return [...new Set(values)];
64
65
  }
65
66
 
67
+ /** Keys `/system/device-mode` reports that are not feature flags. `mode` is the
68
+ * device-mode itself; the rest are status RouterOS keeps alongside it. */
69
+ export const DEVICE_MODE_NON_FEATURE_KEYS: ReadonlySet<string> = new Set([
70
+ "mode",
71
+ "allowed-versions",
72
+ "attempt-count",
73
+ "flagged",
74
+ "flagging-enabled",
75
+ ]);
76
+
77
+ /** Is this feature on, as RouterOS reports it?
78
+ *
79
+ * RouterOS answers `/system/device-mode` with **strings**: `"container": "true"`, not
80
+ * `true`. A reader that tests `value === true` therefore finds nothing enabled, ever —
81
+ * which is what `quickchr get <name> device-mode` did, printing a bare mode and no
82
+ * feature line however many were on. Normalize before deciding. */
83
+ export function isDeviceModeFeatureEnabled(value: unknown): boolean {
84
+ return toYesNo(value) === "yes";
85
+ }
86
+
66
87
  function toYesNo(value: unknown): string {
67
88
  if (typeof value === "boolean") return value ? "yes" : "no";
68
89
  if (typeof value === "number") return value > 0 ? "yes" : "no";
@@ -148,20 +169,61 @@ export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): s
148
169
  return parts.join(" ");
149
170
  }
150
171
 
151
- /** Wait until the device-mode REST endpoint is reachable with default admin auth. */
152
- export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number = 60_000): Promise<void> {
153
- const auth = `Basic ${btoa("admin:")}`;
172
+ /** Factory credentials — `admin` with no password, as a fresh CHR ships.
173
+ *
174
+ * The right auth for device-mode during *first-boot* provisioning, where it runs
175
+ * before any account has been created, and the wrong auth afterwards: a machine
176
+ * provisioned with `--disable-admin` answers 401. Post-boot callers resolve the
177
+ * machine's own credentials instead — see `applyDeviceMode`. */
178
+ export const FACTORY_AUTH_HEADER = `Basic ${btoa("admin:")}`;
179
+
180
+ /** Is this response body actually `/system/device-mode`, and not the post-boot race?
181
+ *
182
+ * RouterOS can answer a non-resource endpoint with resource-shaped or otherwise wrong
183
+ * data for a short window after boot — `provisioning.instructions.md` documents this
184
+ * for exactly this endpoint, and `readDeviceMode()` carries a guard for it. A 2xx is
185
+ * therefore not proof the endpoint is ready; a body with `mode` in it is. */
186
+ function isDeviceModeBody(body: string): boolean {
187
+ let data: unknown;
188
+ try { data = JSON.parse(body); } catch { return false; }
189
+ const record = Array.isArray(data) ? data[0] : data;
190
+ if (!record || typeof record !== "object") return false;
191
+ const keys = record as Record<string, unknown>;
192
+ if ("board-name" in keys || "architecture-name" in keys) return false;
193
+ return "mode" in keys;
194
+ }
195
+
196
+ /** Wait until the device-mode REST endpoint is reachable **and answering with
197
+ * device-mode data**.
198
+ *
199
+ * Returning on a bare 2xx was a race the next line then paid for: `readDeviceMode()`
200
+ * rejects a resource-shaped body outright, so a guest that came back mid-race failed
201
+ * the whole operation instead of being waited out for one more second. This is the
202
+ * poll-until-the-keys-are-there pattern `provisioning.instructions.md` prescribes for
203
+ * every non-resource endpoint, applied where the waiting already happens.
204
+ *
205
+ * `auth` defaults to factory admin, which is what a guest being provisioned for the
206
+ * first time has. A machine past that point needs its own credentials passed in. */
207
+ export async function waitForDeviceModeApi(
208
+ httpPort: number,
209
+ timeoutMs: number = 60_000,
210
+ auth: string = FACTORY_AUTH_HEADER,
211
+ ): Promise<void> {
154
212
  const deadline = Date.now() + timeoutMs;
213
+ let lastBody = "";
155
214
  while (Date.now() < deadline) {
156
215
  try {
157
- const { status } = await restGet(
216
+ const { status, body } = await restGet(
158
217
  `http://127.0.0.1:${httpPort}/rest/system/device-mode`,
159
218
  auth,
160
219
  3000,
161
220
  );
162
221
 
163
222
  if (status >= 200 && status < 300) {
164
- return;
223
+ if (isDeviceModeBody(body)) return;
224
+ // Answering, but not with device-mode yet — keep waiting rather than
225
+ // handing the caller a body it will throw on.
226
+ lastBody = body;
165
227
  }
166
228
 
167
229
  if (status === 401) {
@@ -176,7 +238,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
176
238
  await Bun.sleep(1000);
177
239
  }
178
240
 
179
- throw new QuickCHRError("BOOT_TIMEOUT", `Device-mode API did not become ready on port ${httpPort}`);
241
+ throw new QuickCHRError(
242
+ "BOOT_TIMEOUT",
243
+ `Device-mode API did not become ready on port ${httpPort}` +
244
+ (lastBody ? ` (last response was not device-mode data: ${lastBody.slice(0, 200)})` : ""),
245
+ );
180
246
  }
181
247
 
182
248
  /**
@@ -189,7 +255,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
189
255
  * Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
190
256
  * The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
191
257
  */
192
- export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceModeOptions): Promise<{ status: number; body: string }> {
258
+ export function startDeviceModeUpdate(
259
+ httpPort: number,
260
+ options: ResolvedDeviceModeOptions,
261
+ auth: string = FACTORY_AUTH_HEADER,
262
+ ): Promise<{ status: number; body: string }> {
193
263
  if (!shouldApplyDeviceMode(options)) {
194
264
  return Promise.resolve({ status: 200, body: "" });
195
265
  }
@@ -200,7 +270,6 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
200
270
  payload[name] = value;
201
271
  }
202
272
 
203
- const auth = `Basic ${btoa("admin:")}`;
204
273
  return restPost(
205
274
  `http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
206
275
  auth,
@@ -209,8 +278,10 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
209
278
  );
210
279
  }
211
280
 
212
- export async function readDeviceMode(httpPort: number): Promise<Record<string, string>> {
213
- const auth = `Basic ${btoa("admin:")}`;
281
+ export async function readDeviceMode(
282
+ httpPort: number,
283
+ auth: string = FACTORY_AUTH_HEADER,
284
+ ): Promise<Record<string, string>> {
214
285
  const { status, body } = await restGet(
215
286
  `http://127.0.0.1:${httpPort}/rest/system/device-mode`,
216
287
  auth,
@@ -281,3 +352,71 @@ export function verifyDeviceMode(
281
352
  actual,
282
353
  };
283
354
  }
355
+
356
+ /** The `quickchr set` flags that would reproduce this selection, shell-quoted.
357
+ *
358
+ * Rendered from the *resolved* options, not the raw ones, so the command it prints
359
+ * is the command that was actually going to run — `mode: "auto"` comes out as the
360
+ * `rose` it resolves to rather than as an alias the reader has to expand.
361
+ *
362
+ * Quoted because `resolveDeviceModeOptions()` deliberately passes unknown modes and
363
+ * features through for forward compatibility (it warns, RouterOS validates), so a
364
+ * value with a space or a `;` in it reaches this function intact. Unquoted, the
365
+ * advertised command would silently become a different command. Ordinary values are
366
+ * returned unquoted, so the common case still reads like something a person typed. */
367
+ export function formatDeviceModeFlags(options: ResolvedDeviceModeOptions): string {
368
+ if (options.skip) return "";
369
+ const parts: string[] = [];
370
+ if (options.mode) parts.push(`--device-mode ${shellQuote(options.mode)}`);
371
+ const enable = Object.entries(options.features).filter(([, v]) => v === "yes").map(([k]) => k);
372
+ const disable = Object.entries(options.features).filter(([, v]) => v === "no").map(([k]) => k);
373
+ if (enable.length > 0) parts.push(`--device-mode-enable ${shellQuote(enable.sort().join(","))}`);
374
+ if (disable.length > 0) parts.push(`--device-mode-disable ${shellQuote(disable.sort().join(","))}`);
375
+ return parts.join(" ");
376
+ }
377
+
378
+ /** What a device-mode update would change, read against the guest's current record.
379
+ *
380
+ * Only the differences — a change that costs a power cycle should say which settings
381
+ * it is buying, and `expected == actual` rows are noise. Empty means nothing would
382
+ * move, which is the caller's cue that no power cycle is needed. */
383
+ export function describeDeviceModeChange(
384
+ options: ResolvedDeviceModeOptions,
385
+ actual: Record<string, string>,
386
+ ): string[] {
387
+ // `verifyDeviceMode` stays the single judge of what counts as a mismatch — only the
388
+ // key is read back out of its message, and the values come from the maps it returns.
389
+ const { expected, mismatches } = verifyDeviceMode(options, actual);
390
+ const changed = new Set(mismatches.map((mismatch) => mismatch.split(":")[0]));
391
+ return Object.entries(expected)
392
+ .filter(([key]) => changed.has(key))
393
+ .map(([key, want]) => `${key}: ${actual[key] ?? "(unset)"} -> ${want}`);
394
+ }
395
+
396
+ /** Fold an applied device-mode selection into what `machine.json` already records.
397
+ *
398
+ * `setDeviceMode()` used to overwrite `state.deviceMode` with the request, so
399
+ * `set <name> --device-mode-enable container` on a machine provisioned with
400
+ * `ipsec=yes` left the record claiming ipsec had never been asked for. The guest
401
+ * still had it: device-mode updates are cumulative, and only the settings named in
402
+ * a request move. The record has to be cumulative too, or it describes a machine
403
+ * that does not exist — and `classifyProvisioningRequest()` reads it. */
404
+ export function mergeDeviceModeOptions(
405
+ current: DeviceModeOptions | undefined,
406
+ applied: ResolvedDeviceModeOptions,
407
+ ): DeviceModeOptions {
408
+ if (applied.skip) return current ?? {};
409
+ const enable = new Set(normalizeFeatureList(current?.enable));
410
+ const disable = new Set(normalizeFeatureList(current?.disable));
411
+ for (const [feature, value] of Object.entries(applied.features)) {
412
+ // A feature only ever sits on one side: the newest instruction for it wins.
413
+ enable.delete(feature);
414
+ disable.delete(feature);
415
+ (value === "yes" ? enable : disable).add(feature);
416
+ }
417
+ return {
418
+ mode: applied.mode ?? current?.mode,
419
+ enable: enable.size > 0 ? [...enable].sort() : undefined,
420
+ disable: disable.size > 0 ? [...disable].sort() : undefined,
421
+ };
422
+ }
@@ -54,18 +54,22 @@ export type BootProbeOutcome =
54
54
  | "reset"
55
55
  /** Connect or response exceeded the per-probe budget — socket hung. */
56
56
  | "probe-timeout"
57
- /** 401/403 — REST answered; auth layer is up. */
57
+ /** 401/403 from REST, or a login `!trap` from the native API — the service
58
+ * answered and refused the credentials; its auth layer is up. */
58
59
  | "unauthorized"
59
60
  /** 2xx but not the expected `board-name` singleton — REST still initializing. */
60
61
  | "wrong-body"
61
62
  /** Any other HTTP status. */
62
63
  | "http-error"
63
- /** 2xx with the expected body. */
64
+ /** 2xx with the expected body, or a native-API login `!done`. */
64
65
  | "ready"
65
66
  /** Anything unclassified — `detail` carries the raw text. */
66
67
  | "other";
67
68
 
68
69
  export interface BootProbeStats {
70
+ /** Which readiness probe ran — REST by default, the native-API login for a guest
71
+ * without REST (#182). Absent means REST. */
72
+ kind?: "rest" | "api";
69
73
  /** Total probe attempts made. */
70
74
  attempts: number;
71
75
  /** Attempt count per outcome. */
@@ -90,9 +94,11 @@ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; d
90
94
  const message = err instanceof Error ? err.message : String(err);
91
95
  if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
92
96
  if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
93
- if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
97
+ if (code === "ETIMEDOUT" || message.includes("restGet timeout") || message.includes("apiLogin timeout")) {
94
98
  return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
95
99
  }
100
+ // apiLogin(): slirp accepted, then the guest side closed before any reply.
101
+ if (message === "closed before reply") return { outcome: "reset", detail: message };
96
102
  return { outcome: "other", detail: code ? `${code}: ${message}` : message };
97
103
  }
98
104
 
@@ -123,9 +129,10 @@ export function summarizeBootProbe(stats: BootProbeStats): string {
123
129
  .sort((a, b) => b[1] - a[1])
124
130
  .map(([k, v]) => `${k}=${v}`)
125
131
  .join(" ");
132
+ const answer = stats.kind === "api" ? "API reply" : "HTTP response";
126
133
  const reachedHttp = stats.firstHttpAtMs === null
127
- ? "forwarded port never produced an HTTP response"
128
- : `first HTTP response at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
134
+ ? `forwarded port never produced an ${answer}`
135
+ : `first ${answer} at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
129
136
  const last = stats.last
130
137
  ? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
131
138
  : "";
@@ -673,7 +680,7 @@ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootF
673
680
  lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
674
681
  }
675
682
  }
676
- if (ctx.probe) lines.push(`REST probe: ${summarizeBootProbe(ctx.probe)}`);
683
+ if (ctx.probe) lines.push(`${ctx.probe.kind === "api" ? "API login" : "REST"} probe: ${summarizeBootProbe(ctx.probe)}`);
677
684
  lines.push(`QEMU process: ${record.host.qemuProcess}`);
678
685
  if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
679
686
  if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);
package/src/lib/images.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Image download, ZIP extraction, and cache management.
3
3
  */
4
4
 
5
- import { existsSync, copyFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
5
+ import { existsSync, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -20,6 +20,37 @@ function finalizeExtractedImage(zipPath: string, imgPath: string): string {
20
20
  return imgPath;
21
21
  }
22
22
 
23
+ /**
24
+ * Check that a cached raw image has a complete DOS partition table.
25
+ *
26
+ * RouterOS CHR images for both supported architectures use a DOS partition
27
+ * table. Checking its signature and partition extents catches empty and
28
+ * truncated files while avoiding a version-specific image-size assumption.
29
+ */
30
+ export async function isUsableCachedImage(imgPath: string): Promise<boolean> {
31
+ const file = Bun.file(imgPath);
32
+ if (!(await file.exists()) || file.size < 512) return false;
33
+
34
+ try {
35
+ const sector = new Uint8Array(await file.slice(0, 512).arrayBuffer());
36
+ if (sector[510] !== 0x55 || sector[511] !== 0xaa) return false;
37
+
38
+ let hasPartition = false;
39
+ for (let offset = 446; offset < 510; offset += 16) {
40
+ const start = new DataView(sector.buffer, sector.byteOffset + offset + 8, 8);
41
+ const firstSector = start.getUint32(0, true);
42
+ const sectorCount = start.getUint32(4, true);
43
+ if (sectorCount === 0) continue;
44
+ hasPartition = true;
45
+ const end = (firstSector + sectorCount) * 512;
46
+ if (!Number.isSafeInteger(end) || end > file.size) return false;
47
+ }
48
+ return hasPartition;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
23
54
  /** Download a CHR image ZIP if not already cached. Returns path to the ZIP. */
24
55
  export async function downloadImage(
25
56
  version: string,
@@ -58,39 +89,38 @@ export async function extractImage(
58
89
  const imgName = basename(zipPath, ".zip");
59
90
  const imgPath = join(cache, imgName);
60
91
 
61
- if (existsSync(imgPath)) {
92
+ if (await isUsableCachedImage(imgPath)) {
62
93
  return finalizeExtractedImage(zipPath, imgPath);
63
94
  }
95
+ if (existsSync(imgPath)) unlinkSync(imgPath);
64
96
 
65
97
  const log = logger ?? createLogger();
66
98
  log.status(`Extracting: ${basename(zipPath)}`);
67
99
 
68
- extractZip(zipPath, cache);
100
+ const tempDir = mkdtempSync(join(cache, ".quickchr-image-"));
101
+ try {
102
+ extractZip(zipPath, tempDir);
69
103
 
70
- // MikroTik x86 ZIPs contain chr-X.Y.Z.img (no arch suffix).
71
- // Our ZIP is named chr-X.Y.Z.img.zip (without -x86 for x86). Check if we need to
72
- // find the extracted file.
73
- if (!existsSync(imgPath)) {
74
- const files = readdirSync(cache).filter(
75
- (f) => f.endsWith(".img") && f.startsWith("chr-"),
104
+ // MikroTik arm64 ZIPs may contain chr-X.Y.Z.img without the arch suffix.
105
+ const files = readdirSync(tempDir).filter((file) => file.endsWith(".img") && file.startsWith("chr-"));
106
+ const base = basename(zipPath, ".img.zip");
107
+ const expected = files.find(
108
+ (file) => file === `${base}.img` || file.replace("-arm64", "") === `${base.replace("-arm64", "")}.img`,
76
109
  );
77
- // Find the one that was just extracted (matching version)
78
- const expected = files.find((f) => {
79
- const base = basename(zipPath, ".img.zip");
80
- // chr-7.22.1.img matches chr-7.22.1.img.zip
81
- return f === base + ".img" || f.replace("-arm64", "") === base.replace("-arm64", "") + ".img";
82
- });
83
- if (expected) {
84
- const extractedPath = join(cache, expected);
85
- if (extractedPath !== imgPath) {
86
- renameSync(extractedPath, imgPath);
87
- }
88
- } else {
110
+ if (!expected) {
89
111
  throw new QuickCHRError(
90
112
  "PROCESS_FAILED",
91
113
  `Expected ${imgPath} after unzip, but not found. Files: ${files.join(", ")}`,
92
114
  );
93
115
  }
116
+
117
+ const extractedPath = join(tempDir, expected);
118
+ if (!(await isUsableCachedImage(extractedPath))) {
119
+ throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
120
+ }
121
+ renameSync(extractedPath, imgPath);
122
+ } finally {
123
+ rmSync(tempDir, { recursive: true, force: true });
94
124
  }
95
125
 
96
126
  return finalizeExtractedImage(zipPath, imgPath);
@@ -105,11 +135,12 @@ export async function ensureCachedImage(
105
135
  ): Promise<string> {
106
136
  const cache = cacheDir ?? getCacheDir();
107
137
  const imgPath = join(cache, `${chrImageBasename(version, arch)}.img`);
108
- if (existsSync(imgPath)) {
138
+ if (await isUsableCachedImage(imgPath)) {
109
139
  const log = logger ?? createLogger();
110
140
  log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
111
141
  return imgPath;
112
142
  }
143
+ if (existsSync(imgPath)) unlinkSync(imgPath);
113
144
  assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
114
145
  const zipPath = await downloadImage(version, arch, cacheDir, logger);
115
146
  const extractedImgPath = await extractImage(zipPath, cacheDir, logger);