@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/qga.ts
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QEMU Guest Agent (QGA) protocol implementation.
|
|
3
|
+
*
|
|
4
|
+
* QGA communicates over a virtio-serial channel using newline-delimited
|
|
5
|
+
* JSON messages. RouterOS ships a custom QGA implementation (not stock
|
|
6
|
+
* qemu-ga) that supports guest-exec with `input-data` (base64-encoded
|
|
7
|
+
* RouterOS script), file operations, and system queries.
|
|
8
|
+
*
|
|
9
|
+
* KVM required — RouterOS CHR only starts the QGA daemon under KVM hypervisors.
|
|
10
|
+
* On macOS (HVF) or Windows, QEMU presents the virtio-serial port correctly
|
|
11
|
+
* but the guest never opens it (confirmed by investigation 2026-04-12).
|
|
12
|
+
* ARM64 CHR does not implement QGA at all (MikroTik feature, pending).
|
|
13
|
+
*
|
|
14
|
+
* Protocol notes (from mikropkl Lab testing):
|
|
15
|
+
* - First message must be `guest-sync-delimited` to flush stale data
|
|
16
|
+
* - 0xFF bytes may appear as sync markers — strip before JSON parsing
|
|
17
|
+
* - `guest-exec` is async: returns PID, poll `guest-exec-status` for output
|
|
18
|
+
* - All data fields (input-data, out-data, err-data) are base64-encoded
|
|
19
|
+
* - `guest-file-close` may return empty response (quirk) — handle gracefully
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { connect, type Socket } from "node:net";
|
|
23
|
+
import type {
|
|
24
|
+
ChannelTcpEndpoint,
|
|
25
|
+
QgaCommand,
|
|
26
|
+
QgaFsFreezeStatus,
|
|
27
|
+
QgaNetworkInterface,
|
|
28
|
+
QgaOsInfo,
|
|
29
|
+
QgaTimezone,
|
|
30
|
+
} from "./types.ts";
|
|
31
|
+
import { QuickCHRError } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
/** Result of a QGA guest-exec command. */
|
|
34
|
+
export interface QgaExecResult {
|
|
35
|
+
exitcode: number;
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Info about a single QGA-supported command. */
|
|
41
|
+
export interface QgaCommandInfo {
|
|
42
|
+
name: string;
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Default timeout for QGA operations. */
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
48
|
+
|
|
49
|
+
/** Polling interval for guest-exec-status checks. */
|
|
50
|
+
const EXEC_POLL_INTERVAL_MS = 500;
|
|
51
|
+
|
|
52
|
+
/** Maximum number of guest-exec-status polls before giving up. */
|
|
53
|
+
const EXEC_MAX_POLLS = 40; // 20s at 500ms intervals
|
|
54
|
+
|
|
55
|
+
/** Best-effort close for a QGA socket so QEMU can accept a fresh client. */
|
|
56
|
+
async function closeSocket(socket: Socket, waitMs: number = 250): Promise<void> {
|
|
57
|
+
if (socket.destroyed) return;
|
|
58
|
+
|
|
59
|
+
await new Promise<void>((resolve) => {
|
|
60
|
+
let settled = false;
|
|
61
|
+
const finish = () => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(fallback);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fallback = setTimeout(() => {
|
|
69
|
+
try {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
} catch {
|
|
72
|
+
// Ignore forced-close failures during cleanup.
|
|
73
|
+
}
|
|
74
|
+
finish();
|
|
75
|
+
}, waitMs);
|
|
76
|
+
socket.once("close", finish);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
socket.end();
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
socket.destroy();
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore forced-close failures during cleanup.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Strip 0xFF sync marker bytes from a buffer.
|
|
92
|
+
* RouterOS QGA may emit these as delimiters between messages.
|
|
93
|
+
*/
|
|
94
|
+
export function stripSyncMarkers(data: string): string {
|
|
95
|
+
return data.replace(/\xff/g, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse newline-delimited JSON messages from a raw buffer.
|
|
100
|
+
* Returns parsed messages and any remaining incomplete data.
|
|
101
|
+
*/
|
|
102
|
+
export function parseQgaMessages(buffer: string): { messages: unknown[]; remainder: string } {
|
|
103
|
+
const cleaned = stripSyncMarkers(buffer);
|
|
104
|
+
const lines = cleaned.split("\n");
|
|
105
|
+
const remainder = lines.pop() || "";
|
|
106
|
+
const messages: unknown[] = [];
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed) continue;
|
|
111
|
+
try {
|
|
112
|
+
messages.push(JSON.parse(trimmed));
|
|
113
|
+
} catch {
|
|
114
|
+
// Incomplete or malformed JSON — skip
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { messages, remainder };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Connect to a QGA socket or TCP endpoint with timeout.
|
|
123
|
+
* Returns the connected socket or throws.
|
|
124
|
+
* Accepts either a Unix socket path (string) or a TCP endpoint ({host, port}).
|
|
125
|
+
*/
|
|
126
|
+
function connectSocket(endpoint: string | ChannelTcpEndpoint, timeoutMs: number): Promise<Socket> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const socket = typeof endpoint === "string"
|
|
129
|
+
? connect({ path: endpoint })
|
|
130
|
+
: connect(endpoint.port, endpoint.host);
|
|
131
|
+
let settled = false;
|
|
132
|
+
|
|
133
|
+
const finish = (err?: QuickCHRError) => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
socket.removeListener("connect", onConnect);
|
|
138
|
+
socket.removeListener("error", onError);
|
|
139
|
+
if (err) reject(err);
|
|
140
|
+
else resolve(socket);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const timeout = setTimeout(() => {
|
|
144
|
+
try {
|
|
145
|
+
socket.destroy();
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore timeout cleanup failures.
|
|
148
|
+
}
|
|
149
|
+
finish(new QuickCHRError("QGA_TIMEOUT", `QGA socket connect timed out after ${timeoutMs}ms — guest agent may not be running (requires KVM on Linux)`));
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
|
|
152
|
+
const onConnect = () => {
|
|
153
|
+
finish();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
157
|
+
// ENOENT on connect = named pipe / socket does not exist = machine not running
|
|
158
|
+
const code = err.code === "ENOENT" ? "MACHINE_STOPPED" : "PROCESS_FAILED";
|
|
159
|
+
finish(new QuickCHRError(code, `QGA connection failed: ${err.message}`));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
socket.on("connect", onConnect);
|
|
163
|
+
socket.on("error", onError);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Send a JSON-RPC command and wait for a single response.
|
|
169
|
+
* Handles 0xFF marker stripping and newline-delimited parsing.
|
|
170
|
+
*/
|
|
171
|
+
function sendAndReceive(
|
|
172
|
+
socket: Socket,
|
|
173
|
+
payload: Record<string, unknown>,
|
|
174
|
+
timeoutMs: number,
|
|
175
|
+
): Promise<unknown> {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
let settled = false;
|
|
178
|
+
let buffer = "";
|
|
179
|
+
|
|
180
|
+
const finish = (result?: unknown, err?: QuickCHRError) => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
socket.removeListener("data", onData);
|
|
185
|
+
socket.removeListener("error", onError);
|
|
186
|
+
socket.removeListener("close", onClose);
|
|
187
|
+
if (err) reject(err);
|
|
188
|
+
else resolve(result);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
finish(undefined, new QuickCHRError("QGA_TIMEOUT", "QGA command timed out — guest agent may not be running (requires KVM on Linux)"));
|
|
193
|
+
}, timeoutMs);
|
|
194
|
+
|
|
195
|
+
const onData = (data: Buffer) => {
|
|
196
|
+
buffer += data.toString();
|
|
197
|
+
const { messages, remainder } = parseQgaMessages(buffer);
|
|
198
|
+
buffer = remainder;
|
|
199
|
+
|
|
200
|
+
for (const msg of messages) {
|
|
201
|
+
const response = msg as Record<string, unknown>;
|
|
202
|
+
if (response.error) {
|
|
203
|
+
const err = response.error as Record<string, unknown>;
|
|
204
|
+
finish(undefined, new QuickCHRError(
|
|
205
|
+
"PROCESS_FAILED",
|
|
206
|
+
`QGA error: ${err.desc || JSON.stringify(err)}`,
|
|
207
|
+
));
|
|
208
|
+
} else {
|
|
209
|
+
finish(response.return);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onError = (err: Error) => {
|
|
216
|
+
finish(undefined, new QuickCHRError("PROCESS_FAILED", `QGA connection failed: ${err.message}`));
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const onClose = () => {
|
|
220
|
+
finish(undefined, new QuickCHRError("PROCESS_FAILED", "QGA socket closed before a response was received"));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
socket.on("data", onData);
|
|
224
|
+
socket.on("error", onError);
|
|
225
|
+
socket.on("close", onClose);
|
|
226
|
+
socket.write(JSON.stringify(payload) + "\n", (err?: Error | null) => {
|
|
227
|
+
if (err) {
|
|
228
|
+
finish(undefined, new QuickCHRError("PROCESS_FAILED", `QGA write failed: ${err.message}`));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Perform guest-sync-delimited handshake.
|
|
236
|
+
*
|
|
237
|
+
* This must be the first operation on each QGA connection to flush any
|
|
238
|
+
* stale data in the virtio-serial buffer. Retries once on failure
|
|
239
|
+
* (stale data from a previous session may cause the first sync to fail).
|
|
240
|
+
*/
|
|
241
|
+
export async function qgaSync(
|
|
242
|
+
endpoint: string | ChannelTcpEndpoint,
|
|
243
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
244
|
+
): Promise<{ socket: Socket; syncId: number }> {
|
|
245
|
+
const socket = await connectSocket(endpoint, timeoutMs);
|
|
246
|
+
const syncId = Date.now() % 1_000_000_000; // Keep it within safe integer range
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const result = await sendAndReceive(
|
|
250
|
+
socket,
|
|
251
|
+
{ execute: "guest-sync-delimited", arguments: { id: syncId } },
|
|
252
|
+
timeoutMs,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (result !== syncId) {
|
|
256
|
+
// Stale data — retry once
|
|
257
|
+
const retrySyncId = (syncId + 1) % 1_000_000_000;
|
|
258
|
+
const retryResult = await sendAndReceive(
|
|
259
|
+
socket,
|
|
260
|
+
{ execute: "guest-sync-delimited", arguments: { id: retrySyncId } },
|
|
261
|
+
timeoutMs,
|
|
262
|
+
);
|
|
263
|
+
if (retryResult !== retrySyncId) {
|
|
264
|
+
socket.destroy();
|
|
265
|
+
throw new QuickCHRError("PROCESS_FAILED", "QGA sync failed after retry — unexpected sync ID");
|
|
266
|
+
}
|
|
267
|
+
return { socket, syncId: retrySyncId };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { socket, syncId };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
await closeSocket(socket);
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Execute a RouterOS script via QGA guest-exec.
|
|
279
|
+
*
|
|
280
|
+
* The script is base64-encoded and sent as `input-data` (RouterOS QGA
|
|
281
|
+
* does not support the `path` field — it only accepts scripts via input-data).
|
|
282
|
+
* Execution is async: guest-exec returns a PID, then we poll
|
|
283
|
+
* guest-exec-status until the process exits.
|
|
284
|
+
*
|
|
285
|
+
* @param socketPath Path to the QGA Unix socket
|
|
286
|
+
* @param script RouterOS CLI script to execute
|
|
287
|
+
* @param timeoutMs Overall timeout for the operation
|
|
288
|
+
*/
|
|
289
|
+
export async function qgaExec(
|
|
290
|
+
endpoint: string | ChannelTcpEndpoint,
|
|
291
|
+
script: string,
|
|
292
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
293
|
+
): Promise<QgaExecResult> {
|
|
294
|
+
const { socket } = await qgaSync(endpoint, Math.min(timeoutMs, 5000));
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
// Send guest-exec with base64-encoded script
|
|
298
|
+
const inputData = btoa(script);
|
|
299
|
+
const execResult = await sendAndReceive(
|
|
300
|
+
socket,
|
|
301
|
+
{
|
|
302
|
+
execute: "guest-exec",
|
|
303
|
+
arguments: {
|
|
304
|
+
"input-data": inputData,
|
|
305
|
+
"capture-output": true,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
timeoutMs,
|
|
309
|
+
) as Record<string, unknown>;
|
|
310
|
+
|
|
311
|
+
const pid = execResult.pid as number;
|
|
312
|
+
if (typeof pid !== "number") {
|
|
313
|
+
throw new QuickCHRError("PROCESS_FAILED", `QGA guest-exec did not return a PID: ${JSON.stringify(execResult)}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Poll guest-exec-status until exited
|
|
317
|
+
const pollTimeout = timeoutMs;
|
|
318
|
+
const deadline = Date.now() + pollTimeout;
|
|
319
|
+
for (let i = 0; i < EXEC_MAX_POLLS && Date.now() < deadline; i++) {
|
|
320
|
+
const status = await sendAndReceive(
|
|
321
|
+
socket,
|
|
322
|
+
{ execute: "guest-exec-status", arguments: { pid } },
|
|
323
|
+
Math.min(5000, deadline - Date.now()),
|
|
324
|
+
) as Record<string, unknown>;
|
|
325
|
+
|
|
326
|
+
if (status.exited) {
|
|
327
|
+
const exitcode = (status.exitcode as number) ?? 0;
|
|
328
|
+
const stdout = status["out-data"]
|
|
329
|
+
? atob(status["out-data"] as string)
|
|
330
|
+
: "";
|
|
331
|
+
const stderr = status["err-data"]
|
|
332
|
+
? atob(status["err-data"] as string)
|
|
333
|
+
: "";
|
|
334
|
+
return { exitcode, stdout, stderr };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await Bun.sleep(EXEC_POLL_INTERVAL_MS);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
throw new QuickCHRError("BOOT_TIMEOUT", `QGA guest-exec PID ${pid} did not exit within ${timeoutMs}ms`);
|
|
341
|
+
} finally {
|
|
342
|
+
await closeSocket(socket);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Probe whether QGA is responding on the given socket.
|
|
348
|
+
* Returns true if guest-ping succeeds after sync, false otherwise.
|
|
349
|
+
*/
|
|
350
|
+
export async function qgaProbe(
|
|
351
|
+
endpoint: string | ChannelTcpEndpoint,
|
|
352
|
+
timeoutMs: number = 5000,
|
|
353
|
+
): Promise<boolean> {
|
|
354
|
+
try {
|
|
355
|
+
const { socket } = await qgaSync(endpoint, timeoutMs);
|
|
356
|
+
try {
|
|
357
|
+
await sendAndReceive(
|
|
358
|
+
socket,
|
|
359
|
+
{ execute: "guest-ping" },
|
|
360
|
+
timeoutMs,
|
|
361
|
+
);
|
|
362
|
+
return true;
|
|
363
|
+
} finally {
|
|
364
|
+
await closeSocket(socket);
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Query QGA for supported commands via guest-info.
|
|
373
|
+
* Returns the list of supported command names and their enabled status.
|
|
374
|
+
*/
|
|
375
|
+
export async function qgaInfo(
|
|
376
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
377
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
378
|
+
): Promise<QgaCommandInfo[]> {
|
|
379
|
+
const { socket } = await qgaSync(socketPath, timeoutMs);
|
|
380
|
+
try {
|
|
381
|
+
const result = await sendAndReceive(
|
|
382
|
+
socket,
|
|
383
|
+
{ execute: "guest-info" },
|
|
384
|
+
timeoutMs,
|
|
385
|
+
) as Record<string, unknown>;
|
|
386
|
+
|
|
387
|
+
const commands = result.supported_commands as Array<Record<string, unknown>> | undefined;
|
|
388
|
+
if (!Array.isArray(commands)) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return commands.map((cmd) => ({
|
|
393
|
+
name: String(cmd.name),
|
|
394
|
+
enabled: Boolean(cmd.enabled),
|
|
395
|
+
}));
|
|
396
|
+
} finally {
|
|
397
|
+
await closeSocket(socket);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Execute a raw QGA command and return its untyped `return` payload. */
|
|
402
|
+
export async function qgaRawCommand(
|
|
403
|
+
endpoint: string | ChannelTcpEndpoint,
|
|
404
|
+
command: QgaCommand,
|
|
405
|
+
args?: Record<string, unknown>,
|
|
406
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
407
|
+
): Promise<unknown> {
|
|
408
|
+
return qgaRun<unknown>(endpoint, command, args, timeoutMs);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Typed high-level helpers
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Internal helper — sync the QGA socket, send one command, return the
|
|
417
|
+
* raw `return` value, then destroy the socket.
|
|
418
|
+
*/
|
|
419
|
+
async function qgaRun<T>(
|
|
420
|
+
endpoint: string | ChannelTcpEndpoint,
|
|
421
|
+
command: QgaCommand,
|
|
422
|
+
args?: Record<string, unknown>,
|
|
423
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
424
|
+
): Promise<T> {
|
|
425
|
+
const { socket } = await qgaSync(endpoint, Math.min(timeoutMs, 5000));
|
|
426
|
+
try {
|
|
427
|
+
const payload: Record<string, unknown> = { execute: command };
|
|
428
|
+
if (args) payload.arguments = args;
|
|
429
|
+
return await sendAndReceive(socket, payload, timeoutMs) as T;
|
|
430
|
+
} finally {
|
|
431
|
+
await closeSocket(socket);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Liveness probe — succeeds if QGA responds to guest-ping.
|
|
437
|
+
* Throws on failure (use {@link qgaProbe} for a boolean version).
|
|
438
|
+
*/
|
|
439
|
+
export async function qgaPing(
|
|
440
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
441
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
442
|
+
): Promise<void> {
|
|
443
|
+
await qgaRun<unknown>(socketPath, "guest-ping", undefined, timeoutMs);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* OS information including RouterOS version and kernel build.
|
|
448
|
+
*
|
|
449
|
+
* Typical response:
|
|
450
|
+
* ```json
|
|
451
|
+
* { "id": "routeros", "name": "RouterOS", "pretty-name": "RouterOS 7.22",
|
|
452
|
+
* "kernel-release": "5.6.3-64", "machine": "x86_64" }
|
|
453
|
+
* ```
|
|
454
|
+
*/
|
|
455
|
+
export async function qgaGetOsInfo(
|
|
456
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
457
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
458
|
+
): Promise<QgaOsInfo> {
|
|
459
|
+
const raw = await qgaRun<Record<string, unknown>>(socketPath, "guest-get-osinfo", undefined, timeoutMs);
|
|
460
|
+
return {
|
|
461
|
+
id: String(raw.id),
|
|
462
|
+
name: String(raw.name),
|
|
463
|
+
prettyName: String(raw["pretty-name"]),
|
|
464
|
+
kernelRelease: String(raw["kernel-release"]),
|
|
465
|
+
machine: String(raw.machine),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* RouterOS identity name (equivalent to `/system identity get name`).
|
|
471
|
+
*/
|
|
472
|
+
export async function qgaGetHostName(
|
|
473
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
474
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
475
|
+
): Promise<string> {
|
|
476
|
+
const raw = await qgaRun<Record<string, unknown>>(socketPath, "guest-get-host-name", undefined, timeoutMs);
|
|
477
|
+
return String(raw["host-name"]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* System time as nanoseconds since the Unix epoch.
|
|
482
|
+
*/
|
|
483
|
+
export async function qgaGetTime(
|
|
484
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
485
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
486
|
+
): Promise<number> {
|
|
487
|
+
return qgaRun<number>(socketPath, "guest-get-time", undefined, timeoutMs);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Timezone configuration. RouterOS CHR defaults to UTC (offset = 0).
|
|
492
|
+
*/
|
|
493
|
+
export async function qgaGetTimezone(
|
|
494
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
495
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
496
|
+
): Promise<QgaTimezone> {
|
|
497
|
+
const raw = await qgaRun<Record<string, unknown>>(socketPath, "guest-get-timezone", undefined, timeoutMs);
|
|
498
|
+
return {
|
|
499
|
+
offset: Number(raw.offset),
|
|
500
|
+
zone: raw.zone ? String(raw.zone) : undefined,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Network interfaces as seen by RouterOS, including MAC addresses and IP
|
|
506
|
+
* assignments. Equivalent to `/ip address print` combined with
|
|
507
|
+
* `/interface print`.
|
|
508
|
+
*/
|
|
509
|
+
export async function qgaGetNetworkInterfaces(
|
|
510
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
511
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
512
|
+
): Promise<QgaNetworkInterface[]> {
|
|
513
|
+
const raw = await qgaRun<Array<Record<string, unknown>>>(
|
|
514
|
+
socketPath,
|
|
515
|
+
"guest-network-get-interfaces",
|
|
516
|
+
undefined,
|
|
517
|
+
timeoutMs,
|
|
518
|
+
);
|
|
519
|
+
return raw.map((iface) => ({
|
|
520
|
+
name: String(iface.name),
|
|
521
|
+
mac: iface["hardware-address"] ? String(iface["hardware-address"]) : undefined,
|
|
522
|
+
ipAddresses: (iface["ip-addresses"] as Array<Record<string, unknown>> ?? []).map((ip) => ({
|
|
523
|
+
type: String(ip["ip-address-type"]) as "ipv4" | "ipv6",
|
|
524
|
+
address: String(ip["ip-address"]),
|
|
525
|
+
prefix: Number(ip.prefix),
|
|
526
|
+
})),
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Filesystem freeze state. Returns "thawed" (normal) or "frozen"
|
|
532
|
+
* (held for snapshot consistency).
|
|
533
|
+
*/
|
|
534
|
+
export async function qgaFsFreezeStatus(
|
|
535
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
536
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
537
|
+
): Promise<QgaFsFreezeStatus> {
|
|
538
|
+
const status = await qgaRun<string>(socketPath, "guest-fsfreeze-status", undefined, timeoutMs);
|
|
539
|
+
return status as QgaFsFreezeStatus;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Freeze the RouterOS filesystem for consistent snapshot capture.
|
|
544
|
+
* Returns the number of frozen filesystems.
|
|
545
|
+
* Call {@link qgaFsFreezeThaw} immediately after capturing the snapshot.
|
|
546
|
+
*/
|
|
547
|
+
export async function qgaFsFreezeFreeze(
|
|
548
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
549
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
550
|
+
): Promise<number> {
|
|
551
|
+
return qgaRun<number>(socketPath, "guest-fsfreeze-freeze", undefined, timeoutMs);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Thaw a previously frozen filesystem.
|
|
556
|
+
* Returns the number of thawed filesystems.
|
|
557
|
+
*/
|
|
558
|
+
export async function qgaFsFreezeThaw(
|
|
559
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
560
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
561
|
+
): Promise<number> {
|
|
562
|
+
return qgaRun<number>(socketPath, "guest-fsfreeze-thaw", undefined, timeoutMs);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Initiate a graceful shutdown of the RouterOS VM.
|
|
567
|
+
*
|
|
568
|
+
* The QEMU process will terminate within a few seconds. The QGA socket
|
|
569
|
+
* may close before a response is received — this is expected and handled
|
|
570
|
+
* gracefully.
|
|
571
|
+
*/
|
|
572
|
+
export async function qgaShutdown(
|
|
573
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
574
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const { socket } = await qgaSync(socketPath, Math.min(timeoutMs, 5000));
|
|
577
|
+
try {
|
|
578
|
+
// guest-shutdown may close the socket before sending a response. Ignore errors.
|
|
579
|
+
await sendAndReceive(socket, { execute: "guest-shutdown" }, timeoutMs).catch(() => {});
|
|
580
|
+
} finally {
|
|
581
|
+
await closeSocket(socket);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Write text content to a flat RouterOS file via the guest agent.
|
|
587
|
+
*
|
|
588
|
+
* **Path constraints (RouterOS QGA quirk):** Only flat filenames are accepted —
|
|
589
|
+
* no directory separators, no disk prefixes (`flash/`, `disk1/`), no Unix
|
|
590
|
+
* absolute paths. Example valid names: `"backup.rsc"`, `"config.txt"`.
|
|
591
|
+
*
|
|
592
|
+
* Files written here are visible in RouterOS `/file print` and can be
|
|
593
|
+
* executed with `/import`.
|
|
594
|
+
*
|
|
595
|
+
* `guest-file-close` may return an empty response (protocol quirk) —
|
|
596
|
+
* this is handled gracefully; the write still succeeds.
|
|
597
|
+
*/
|
|
598
|
+
export async function qgaFileWrite(
|
|
599
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
600
|
+
filename: string,
|
|
601
|
+
content: string,
|
|
602
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
603
|
+
): Promise<void> {
|
|
604
|
+
const { socket } = await qgaSync(socketPath, Math.min(timeoutMs, 5000));
|
|
605
|
+
try {
|
|
606
|
+
const handle = await sendAndReceive(
|
|
607
|
+
socket,
|
|
608
|
+
{ execute: "guest-file-open", arguments: { path: filename, mode: "w" } },
|
|
609
|
+
timeoutMs,
|
|
610
|
+
) as number;
|
|
611
|
+
|
|
612
|
+
await sendAndReceive(
|
|
613
|
+
socket,
|
|
614
|
+
{ execute: "guest-file-write", arguments: { handle, "buf-b64": btoa(content) } },
|
|
615
|
+
timeoutMs,
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// guest-file-close often returns empty response — ignore timeout
|
|
619
|
+
await sendAndReceive(
|
|
620
|
+
socket,
|
|
621
|
+
{ execute: "guest-file-close", arguments: { handle } },
|
|
622
|
+
2000,
|
|
623
|
+
).catch(() => {});
|
|
624
|
+
} finally {
|
|
625
|
+
await closeSocket(socket);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Read text content from a flat RouterOS file via the guest agent.
|
|
631
|
+
*
|
|
632
|
+
* See {@link qgaFileWrite} for path format constraints.
|
|
633
|
+
* Maximum file size: 1 MiB.
|
|
634
|
+
*/
|
|
635
|
+
export async function qgaFileRead(
|
|
636
|
+
socketPath: string | ChannelTcpEndpoint,
|
|
637
|
+
filename: string,
|
|
638
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
639
|
+
): Promise<string> {
|
|
640
|
+
const { socket } = await qgaSync(socketPath, Math.min(timeoutMs, 5000));
|
|
641
|
+
try {
|
|
642
|
+
const handle = await sendAndReceive(
|
|
643
|
+
socket,
|
|
644
|
+
{ execute: "guest-file-open", arguments: { path: filename, mode: "r" } },
|
|
645
|
+
timeoutMs,
|
|
646
|
+
) as number;
|
|
647
|
+
|
|
648
|
+
const readResult = await sendAndReceive(
|
|
649
|
+
socket,
|
|
650
|
+
{ execute: "guest-file-read", arguments: { handle, count: 1_048_576 } },
|
|
651
|
+
timeoutMs,
|
|
652
|
+
) as Record<string, unknown>;
|
|
653
|
+
|
|
654
|
+
const content = readResult["buf-b64"] ? atob(readResult["buf-b64"] as string) : "";
|
|
655
|
+
|
|
656
|
+
// guest-file-close often returns empty response — ignore timeout
|
|
657
|
+
await sendAndReceive(
|
|
658
|
+
socket,
|
|
659
|
+
{ execute: "guest-file-close", arguments: { handle } },
|
|
660
|
+
2000,
|
|
661
|
+
).catch(() => {});
|
|
662
|
+
|
|
663
|
+
return content;
|
|
664
|
+
} finally {
|
|
665
|
+
await closeSocket(socket);
|
|
666
|
+
}
|
|
667
|
+
}
|