@tikoci/quickchr 0.2.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.
- package/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
package/src/lib/qemu.ts
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QEMU argument builder and process lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Builds QEMU command-line arguments for x86_64 and aarch64 CHR,
|
|
5
|
+
* manages process spawn/stop, and handles PID tracking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSync, closeSync, unlinkSync, truncateSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
|
|
11
|
+
import { QuickCHRError } from "./types.ts";
|
|
12
|
+
import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
|
|
13
|
+
import { buildHostfwdString } from "./network.ts";
|
|
14
|
+
import { ensureDir } from "./state.ts";
|
|
15
|
+
import { restGet } from "./rest.ts";
|
|
16
|
+
|
|
17
|
+
export interface QemuLaunchConfig {
|
|
18
|
+
arch: Arch;
|
|
19
|
+
machineDir: string;
|
|
20
|
+
bootDisk: { path: string; format: BootDiskFormat };
|
|
21
|
+
extraDisks?: { path: string; format: "qcow2" }[];
|
|
22
|
+
mem: number;
|
|
23
|
+
cpu: number;
|
|
24
|
+
ports: Record<string, PortMapping>;
|
|
25
|
+
/** @deprecated Use `networks` instead. */
|
|
26
|
+
network?: NetworkMode;
|
|
27
|
+
/** Network interfaces with resolved QEMU args. */
|
|
28
|
+
networks: NetworkConfig[];
|
|
29
|
+
background: boolean;
|
|
30
|
+
/** Port block base for this machine (portBase+6=monitor, +7=serial, +8=qga on Windows TCP). */
|
|
31
|
+
portBase: number;
|
|
32
|
+
/**
|
|
33
|
+
* Pre-detected QEMU accelerator (kvm/hvf/tcg).
|
|
34
|
+
* If provided, skips the internal detectAccel() call so the same value
|
|
35
|
+
* can be used for both QEMU args and boot timeout calculation.
|
|
36
|
+
*/
|
|
37
|
+
accel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build the full QEMU command-line arguments array. */
|
|
41
|
+
export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]> {
|
|
42
|
+
const { arch, machineDir, bootDisk, extraDisks, mem, cpu, ports, networks, background, portBase } = config;
|
|
43
|
+
|
|
44
|
+
const qemuBin = requireQemu(arch);
|
|
45
|
+
const accel = config.accel ?? await detectAccel(arch);
|
|
46
|
+
const args: string[] = [qemuBin];
|
|
47
|
+
|
|
48
|
+
// Machine type
|
|
49
|
+
if (arch === "x86") {
|
|
50
|
+
args.push("-M", "pc");
|
|
51
|
+
} else {
|
|
52
|
+
args.push("-M", "virt");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// CPU and memory
|
|
56
|
+
args.push("-m", String(mem), "-smp", String(cpu));
|
|
57
|
+
|
|
58
|
+
// Acceleration
|
|
59
|
+
if (accel === "tcg") {
|
|
60
|
+
args.push("-accel", "tcg,tb-size=256");
|
|
61
|
+
} else {
|
|
62
|
+
args.push("-accel", accel);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Use -cpu host for hardware-backed acceleration (KVM / HVF) to expose
|
|
66
|
+
// the real host CPU features. This eliminates CPUID mismatch warnings
|
|
67
|
+
// (e.g. SVM on nested-KVM CI runners) and improves guest performance.
|
|
68
|
+
if (arch === "x86") {
|
|
69
|
+
if (accel === "kvm" || accel === "hvf") {
|
|
70
|
+
args.push("-cpu", "host");
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
if (accel === "hvf") {
|
|
74
|
+
args.push("-cpu", "host");
|
|
75
|
+
} else {
|
|
76
|
+
args.push("-cpu", "cortex-a710");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// UEFI firmware (arm64 only)
|
|
81
|
+
if (arch === "arm64") {
|
|
82
|
+
const fw = requireFirmware();
|
|
83
|
+
const varsPath = join(machineDir, "efi-vars.fd");
|
|
84
|
+
|
|
85
|
+
// Copy and size-match the vars file if not already done
|
|
86
|
+
if (!existsSync(varsPath)) {
|
|
87
|
+
prepareEfiVars(fw.code, fw.vars, varsPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
args.push(
|
|
91
|
+
"-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
|
|
92
|
+
"-drive", `if=pflash,format=raw,unit=1,file=${varsPath}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Boot disk drive
|
|
97
|
+
if (arch === "arm64") {
|
|
98
|
+
// ARM64: MUST use explicit virtio-blk-pci (not if=virtio which maps to MMIO)
|
|
99
|
+
args.push(
|
|
100
|
+
"-drive", `file=${bootDisk.path},format=${bootDisk.format},if=none,id=drive0`,
|
|
101
|
+
"-device", "virtio-blk-pci,drive=drive0",
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
// x86: if=virtio is fine (maps to PCI on q35/pc)
|
|
105
|
+
args.push("-drive", `file=${bootDisk.path},format=${bootDisk.format},if=virtio`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extra disks
|
|
109
|
+
if (extraDisks && extraDisks.length > 0) {
|
|
110
|
+
for (let i = 0; i < extraDisks.length; i++) {
|
|
111
|
+
const disk = extraDisks[i] as { path: string; format: "qcow2" };
|
|
112
|
+
const driveId = `drive${i + 1}`;
|
|
113
|
+
if (arch === "arm64") {
|
|
114
|
+
args.push(
|
|
115
|
+
"-drive", `file=${disk.path},format=${disk.format},if=none,id=${driveId}`,
|
|
116
|
+
"-device", `virtio-blk-pci,drive=${driveId}`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
args.push("-drive", `file=${disk.path},format=${disk.format},if=virtio`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Networking
|
|
125
|
+
buildNetworkArgs(args, ports, networks);
|
|
126
|
+
|
|
127
|
+
// Display
|
|
128
|
+
args.push("-display", "none");
|
|
129
|
+
|
|
130
|
+
// Serial, monitor, QGA channels
|
|
131
|
+
buildChannelArgs(args, arch, machineDir, background, portBase);
|
|
132
|
+
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build networking arguments from NetworkConfig array. */
|
|
137
|
+
function buildNetworkArgs(
|
|
138
|
+
args: string[],
|
|
139
|
+
ports: Record<string, PortMapping>,
|
|
140
|
+
networks: NetworkConfig[],
|
|
141
|
+
): void {
|
|
142
|
+
for (const net of networks) {
|
|
143
|
+
// If resolution produced QEMU args, use those directly
|
|
144
|
+
if (net.resolved?.qemuNetdevArgs) {
|
|
145
|
+
args.push(...net.resolved.qemuNetdevArgs);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback: build args from specifier (legacy path and simple cases)
|
|
150
|
+
const spec = net.specifier;
|
|
151
|
+
const id = net.id;
|
|
152
|
+
|
|
153
|
+
if (spec === "vmnet-shared") {
|
|
154
|
+
args.push(
|
|
155
|
+
"-netdev", `vmnet-shared,id=${id}`,
|
|
156
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
157
|
+
);
|
|
158
|
+
} else if (typeof spec === "object" && spec.type === "vmnet-bridged") {
|
|
159
|
+
args.push(
|
|
160
|
+
"-netdev", `vmnet-bridged,id=${id},ifname=${spec.iface}`,
|
|
161
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
162
|
+
);
|
|
163
|
+
} else if (typeof spec === "object" && spec.type === "socket-listen") {
|
|
164
|
+
args.push(
|
|
165
|
+
"-netdev", `socket,id=${id},listen=:${spec.port}`,
|
|
166
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
167
|
+
);
|
|
168
|
+
} else if (typeof spec === "object" && spec.type === "socket-connect") {
|
|
169
|
+
args.push(
|
|
170
|
+
"-netdev", `socket,id=${id},connect=127.0.0.1:${spec.port}`,
|
|
171
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
172
|
+
);
|
|
173
|
+
} else if (typeof spec === "object" && spec.type === "socket-mcast") {
|
|
174
|
+
args.push(
|
|
175
|
+
"-netdev", `socket,id=${id},mcast=${spec.group}:${spec.port}`,
|
|
176
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
177
|
+
);
|
|
178
|
+
} else if (typeof spec === "object" && spec.type === "tap") {
|
|
179
|
+
args.push(
|
|
180
|
+
"-netdev", `tap,id=${id},ifname=${spec.ifname},script=no,downscript=no`,
|
|
181
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
// Default: user-mode networking with port forwarding (only on the first user NIC)
|
|
185
|
+
const hostfwd = buildHostfwdString(ports);
|
|
186
|
+
args.push(
|
|
187
|
+
"-netdev", `user,id=${id}${hostfwd ? "," + hostfwd : ""}`,
|
|
188
|
+
"-device", `virtio-net-pci,netdev=${id}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Build serial/monitor/QGA channel arguments. */
|
|
195
|
+
function buildChannelArgs(
|
|
196
|
+
args: string[],
|
|
197
|
+
arch: Arch,
|
|
198
|
+
machineDir: string,
|
|
199
|
+
background: boolean,
|
|
200
|
+
portBase: number,
|
|
201
|
+
): void {
|
|
202
|
+
const isWin = process.platform === "win32";
|
|
203
|
+
|
|
204
|
+
let monitorSock: string;
|
|
205
|
+
let serialSock: string;
|
|
206
|
+
let qgaSock: string;
|
|
207
|
+
|
|
208
|
+
if (isWin) {
|
|
209
|
+
// QEMU on Windows: chardev socket bind() cannot handle \\.\pipe\ paths
|
|
210
|
+
// on all builds. Use TCP localhost instead; the port block reserves +6/+7/+8
|
|
211
|
+
// for monitor/serial/qga IPC.
|
|
212
|
+
monitorSock = `host=127.0.0.1,port=${portBase + 6}`;
|
|
213
|
+
serialSock = `host=127.0.0.1,port=${portBase + 7}`;
|
|
214
|
+
qgaSock = `host=127.0.0.1,port=${portBase + 8}`;
|
|
215
|
+
} else {
|
|
216
|
+
monitorSock = `path=${join(machineDir, "monitor.sock")}`;
|
|
217
|
+
serialSock = `path=${join(machineDir, "serial.sock")}`;
|
|
218
|
+
qgaSock = `path=${join(machineDir, "qga.sock")}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (background) {
|
|
222
|
+
// Background: all channels on sockets/pipes
|
|
223
|
+
args.push(
|
|
224
|
+
"-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
|
|
225
|
+
"-mon", "chardev=monitor0,mode=readline",
|
|
226
|
+
"-chardev", `socket,id=serial0,${serialSock},server=on,wait=off`,
|
|
227
|
+
"-serial", "chardev:serial0",
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
// Foreground: serial on stdio with mux (Ctrl-A C for monitor).
|
|
231
|
+
// Also bind a socket monitor so monitorCommand() works while in fg mode.
|
|
232
|
+
args.push(
|
|
233
|
+
"-chardev", "stdio,id=serial0,mux=on,signal=off",
|
|
234
|
+
"-mon", "chardev=serial0,mode=readline",
|
|
235
|
+
"-serial", "chardev:serial0",
|
|
236
|
+
"-chardev", `socket,id=monitor0,${monitorSock},server=on,wait=off`,
|
|
237
|
+
"-mon", "chardev=monitor0,mode=readline",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// QGA channel — x86 only. RouterOS QGA requires KVM; arm64 CHR does not implement QGA.
|
|
242
|
+
if (arch === "x86") {
|
|
243
|
+
args.push(
|
|
244
|
+
"-device", "virtio-serial-pci,id=virtio-serial-qga",
|
|
245
|
+
"-chardev", `socket,id=qga0,${qgaSock},server=on,wait=off`,
|
|
246
|
+
"-device", "virtserialport,chardev=qga0,name=org.qemu.guest_agent.0,id=qga-port0",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Copy and size-match EFI vars file to match code ROM size. */
|
|
252
|
+
function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string): void {
|
|
253
|
+
ensureDir(join(destPath, ".."));
|
|
254
|
+
copyFileSync(varsTemplatePath, destPath);
|
|
255
|
+
|
|
256
|
+
// pflash units must be identical size — pad/truncate vars to match code
|
|
257
|
+
const codeSize = statSync(codePath).size;
|
|
258
|
+
const varsSize = statSync(destPath).size;
|
|
259
|
+
|
|
260
|
+
if (varsSize !== codeSize) {
|
|
261
|
+
// Pad or truncate vars to match code ROM size — truncateSync extends with
|
|
262
|
+
// zero bytes when target is larger, and truncates when smaller.
|
|
263
|
+
try {
|
|
264
|
+
truncateSync(destPath, codeSize);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
throw new QuickCHRError(
|
|
267
|
+
"PROCESS_FAILED",
|
|
268
|
+
`Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Extract socket_vmnet wrapper from resolved networks (at most one allowed). */
|
|
275
|
+
export function extractWrapper(
|
|
276
|
+
networks: NetworkConfig[],
|
|
277
|
+
): { command: string; args: string[] } | undefined {
|
|
278
|
+
const wrappers: string[][] = [];
|
|
279
|
+
for (const n of networks) {
|
|
280
|
+
if (n.resolved?.wrapper) {
|
|
281
|
+
wrappers.push(n.resolved.wrapper);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (wrappers.length > 1) {
|
|
285
|
+
throw new QuickCHRError(
|
|
286
|
+
"INVALID_NETWORK",
|
|
287
|
+
"Multiple networks require socket_vmnet wrapper — only one is supported per VM",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const w = wrappers[0];
|
|
291
|
+
if (!w || w.length === 0) return undefined;
|
|
292
|
+
const [command, ...wrapperArgs] = w;
|
|
293
|
+
if (!command) return undefined;
|
|
294
|
+
return { command, args: wrapperArgs };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Parse QEMU log output and produce a human-friendly error message. */
|
|
298
|
+
export function buildQemuErrorMessage(log: string): string {
|
|
299
|
+
if (log.includes("Permission denied")) {
|
|
300
|
+
return `QEMU exited immediately (permission denied — check image file permissions):\n${log}`;
|
|
301
|
+
}
|
|
302
|
+
if (log.includes("pflash") || (log.includes("EFI") && log.includes("size"))) {
|
|
303
|
+
return `QEMU exited immediately (EFI firmware size mismatch — try 'quickchr clean <name>' to reset):\n${log}`;
|
|
304
|
+
}
|
|
305
|
+
if (log.includes("Address already in use")) {
|
|
306
|
+
return `QEMU exited immediately (port already in use — another process may be using the same ports):\n${log}`;
|
|
307
|
+
}
|
|
308
|
+
return `QEMU exited immediately:\n${log}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Spawn QEMU process. Returns the process or PID. */
|
|
312
|
+
export async function spawnQemu(
|
|
313
|
+
qemuArgs: string[],
|
|
314
|
+
machineDir: string,
|
|
315
|
+
background: boolean,
|
|
316
|
+
wrapper?: { command: string; args: string[] },
|
|
317
|
+
): Promise<{ pid: number; process?: ReturnType<typeof Bun.spawn> }> {
|
|
318
|
+
ensureDir(machineDir);
|
|
319
|
+
|
|
320
|
+
const logPath = join(machineDir, "qemu.log");
|
|
321
|
+
const pidPath = join(machineDir, "qemu.pid");
|
|
322
|
+
|
|
323
|
+
const [bin, ...args] = qemuArgs;
|
|
324
|
+
|
|
325
|
+
if (!bin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args");
|
|
326
|
+
|
|
327
|
+
let spawnCmd: string[];
|
|
328
|
+
if (wrapper) {
|
|
329
|
+
// socket_vmnet wrapper path logged at debug level — full paths belong in `doctor`
|
|
330
|
+
if (process.env.QUICKCHR_DEBUG === "1") {
|
|
331
|
+
process.stderr.write(`[quickchr] Using socket_vmnet wrapper: ${wrapper.command} ${wrapper.args.join(" ")}\n`);
|
|
332
|
+
}
|
|
333
|
+
spawnCmd = [wrapper.command, ...wrapper.args, bin, ...args];
|
|
334
|
+
} else {
|
|
335
|
+
spawnCmd = [bin, ...args];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (background) {
|
|
339
|
+
// Open log file as an fd — more reliable than BunFile for subprocess stdio.
|
|
340
|
+
// After spawn, close parent's copy; child process inherits its own copy.
|
|
341
|
+
const logFd = openSync(logPath, "a");
|
|
342
|
+
|
|
343
|
+
let spawnedPid: number;
|
|
344
|
+
|
|
345
|
+
if (process.platform === "win32") {
|
|
346
|
+
// On Windows, Bun.spawn().unref() does NOT escape the Windows Job Object.
|
|
347
|
+
// When the parent CLI process exits, Windows terminates all processes in
|
|
348
|
+
// the same job, killing QEMU. Use node:child_process with detached: true
|
|
349
|
+
// to create QEMU in a new process group outside the parent job object.
|
|
350
|
+
const { spawn: cpSpawn } = await import("node:child_process");
|
|
351
|
+
const [spawnBin, ...spawnArgs] = spawnCmd;
|
|
352
|
+
if (!spawnBin) throw new QuickCHRError("SPAWN_FAILED", "Empty QEMU args");
|
|
353
|
+
const child = cpSpawn(spawnBin, spawnArgs, {
|
|
354
|
+
detached: true,
|
|
355
|
+
stdio: ["ignore", logFd, logFd],
|
|
356
|
+
windowsHide: true,
|
|
357
|
+
});
|
|
358
|
+
closeSync(logFd);
|
|
359
|
+
if (child.pid === undefined) {
|
|
360
|
+
throw new QuickCHRError("SPAWN_FAILED", "Failed to spawn QEMU process on Windows");
|
|
361
|
+
}
|
|
362
|
+
child.unref();
|
|
363
|
+
spawnedPid = child.pid;
|
|
364
|
+
} else {
|
|
365
|
+
const proc = Bun.spawn(spawnCmd, {
|
|
366
|
+
stdout: logFd,
|
|
367
|
+
stderr: logFd,
|
|
368
|
+
stdin: "ignore",
|
|
369
|
+
});
|
|
370
|
+
closeSync(logFd);
|
|
371
|
+
// unref() lets the parent Bun process exit without waiting for QEMU.
|
|
372
|
+
// QEMU becomes an orphan adopted by init/launchd and keeps running.
|
|
373
|
+
proc.unref();
|
|
374
|
+
spawnedPid = proc.pid;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Give QEMU 1.5 s to fully initialise — if it exits immediately the
|
|
378
|
+
// arguments were bad (e.g. port already in use). Detect this early so
|
|
379
|
+
// the caller gets a clear error instead of a 120-second waitForBoot.
|
|
380
|
+
await Bun.sleep(1500);
|
|
381
|
+
try {
|
|
382
|
+
process.kill(spawnedPid, 0);
|
|
383
|
+
} catch {
|
|
384
|
+
let logTail = "(no log)";
|
|
385
|
+
try {
|
|
386
|
+
if (existsSync(logPath)) {
|
|
387
|
+
logTail = readFileSync(logPath, "utf-8").slice(-800);
|
|
388
|
+
}
|
|
389
|
+
} catch { /* ignore */ }
|
|
390
|
+
throw new QuickCHRError("SPAWN_FAILED", buildQemuErrorMessage(logTail));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
writeFileSync(pidPath, String(spawnedPid));
|
|
394
|
+
return { pid: spawnedPid };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Foreground — stdin/stdout connected to terminal. Blocks until QEMU exits.
|
|
398
|
+
const proc = Bun.spawn(spawnCmd, {
|
|
399
|
+
stdout: "inherit",
|
|
400
|
+
stderr: "inherit",
|
|
401
|
+
stdin: "inherit",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const pid = proc.pid;
|
|
405
|
+
writeFileSync(pidPath, String(pid));
|
|
406
|
+
await proc.exited;
|
|
407
|
+
try { unlinkSync(pidPath); } catch { /* ignore */ }
|
|
408
|
+
return { pid };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Stop a QEMU process by PID. Tries SIGTERM first, then SIGKILL. */
|
|
412
|
+
export async function stopQemu(pid: number): Promise<boolean> {
|
|
413
|
+
try {
|
|
414
|
+
process.kill(pid, 0); // Check if alive
|
|
415
|
+
} catch {
|
|
416
|
+
return false; // Already dead
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Try graceful shutdown
|
|
420
|
+
process.kill(pid, "SIGTERM");
|
|
421
|
+
|
|
422
|
+
// Wait up to 5s for graceful exit
|
|
423
|
+
for (let i = 0; i < 50; i++) {
|
|
424
|
+
await Bun.sleep(100);
|
|
425
|
+
try {
|
|
426
|
+
process.kill(pid, 0);
|
|
427
|
+
} catch {
|
|
428
|
+
return true; // Exited
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Force kill
|
|
433
|
+
try {
|
|
434
|
+
process.kill(pid, "SIGKILL");
|
|
435
|
+
} catch { /* already dead */ }
|
|
436
|
+
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
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;
|
|
447
|
+
for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
|
|
448
|
+
const path = join(machineDir, sock);
|
|
449
|
+
try {
|
|
450
|
+
if (existsSync(path)) {
|
|
451
|
+
const { unlinkSync } = await import("node:fs");
|
|
452
|
+
unlinkSync(path);
|
|
453
|
+
}
|
|
454
|
+
} catch { /* ignore */ }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return stopped;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Wait for the RouterOS REST layer to be fully stable.
|
|
462
|
+
*
|
|
463
|
+
* RouterOS startup is staged from our perspective:
|
|
464
|
+
* 1. Connection refused — QEMU still booting
|
|
465
|
+
* 2. ECONNRESET — HTTP server started but REST subsystem not yet accepting
|
|
466
|
+
* 3. 401 from /rest — auth middleware is up; REST handler may still be initializing
|
|
467
|
+
* 4. 200 but wrong body — startup race: REST initializes before routing tables settle; /system/resource may return an array (e.g. /user list) for a brief window
|
|
468
|
+
* 5. 200 with correct body — REST is fully operational
|
|
469
|
+
*
|
|
470
|
+
* With proper credentials this function reaches stage 5 before returning. With
|
|
471
|
+
* unauthenticated or admin-disabled instances (where we get 401) it accepts
|
|
472
|
+
* stage 3 — auth rejection proves the REST layer responded, which is sufficient
|
|
473
|
+
* for callers that do not need body validation.
|
|
474
|
+
*
|
|
475
|
+
* Requires 2 consecutive successful probes to prevent false-positives during
|
|
476
|
+
* the brief window where RouterOS intermittently resets new connections
|
|
477
|
+
* immediately after coming up.
|
|
478
|
+
*
|
|
479
|
+
* @param httpPort HTTP port of the CHR instance.
|
|
480
|
+
* @param timeoutMs Overall deadline (default 120 s).
|
|
481
|
+
* @param auth HTTP Basic Authorization header to use for probes.
|
|
482
|
+
* Defaults to `admin:` (empty password) which works on fresh
|
|
483
|
+
* CHR images; pass instance credentials for post-reboot probes
|
|
484
|
+
* on provisioned machines so body validation can be performed.
|
|
485
|
+
*/
|
|
486
|
+
export async function waitForBoot(
|
|
487
|
+
httpPort: number,
|
|
488
|
+
timeoutMs: number = 120_000,
|
|
489
|
+
auth: string = `Basic ${btoa("admin:")}`,
|
|
490
|
+
): Promise<boolean> {
|
|
491
|
+
const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
|
|
492
|
+
const deadline = Date.now() + timeoutMs;
|
|
493
|
+
let consecutiveReady = 0;
|
|
494
|
+
|
|
495
|
+
while (Date.now() < deadline) {
|
|
496
|
+
try {
|
|
497
|
+
const { status, body } = await restGet(url, auth, 3000);
|
|
498
|
+
|
|
499
|
+
if (status === 401 || status === 403) {
|
|
500
|
+
// Auth rejected — the REST layer is up and responded, even though
|
|
501
|
+
// we cannot validate the body. Count as ready.
|
|
502
|
+
consecutiveReady++;
|
|
503
|
+
} else if (status >= 200 && status < 300) {
|
|
504
|
+
// RouterOS REST may return wrong data (array body) briefly after boot —
|
|
505
|
+
// a startup race. Only accept once the body is the expected singleton object.
|
|
506
|
+
let parsed: unknown = null;
|
|
507
|
+
try { parsed = JSON.parse(body); } catch { /* ignore */ }
|
|
508
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "board-name" in (parsed as object)) {
|
|
509
|
+
consecutiveReady++;
|
|
510
|
+
} else {
|
|
511
|
+
consecutiveReady = 0; // wrong body — REST not fully initialized yet
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
consecutiveReady = 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (consecutiveReady >= 2) return true;
|
|
518
|
+
} catch {
|
|
519
|
+
// ECONNRESET, connection refused, timeout — not ready yet
|
|
520
|
+
consecutiveReady = 0;
|
|
521
|
+
}
|
|
522
|
+
await Bun.sleep(2000);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return false;
|
|
526
|
+
}
|