@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/index.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @tikoci/quickchr
|
|
3
|
+
*
|
|
4
|
+
* CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU.
|
|
5
|
+
*
|
|
6
|
+
* ## Quick start
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { QuickCHR } from "@tikoci/quickchr";
|
|
9
|
+
*
|
|
10
|
+
* const chr = await QuickCHR.start({ channel: "stable" });
|
|
11
|
+
* const info = await chr.rest("/system/resource");
|
|
12
|
+
* console.log(info);
|
|
13
|
+
* await chr.remove();
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## Key exports
|
|
17
|
+
* - {@link QuickCHR} — Main class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`
|
|
18
|
+
* - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `snapshot`, etc.
|
|
19
|
+
* - {@link StartOptions} — All options for creating/starting a CHR instance
|
|
20
|
+
* - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `TIMEOUT`, `EXEC_FAILED`
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Main class
|
|
26
|
+
export { QuickCHR } from "./lib/quickchr.ts";
|
|
27
|
+
|
|
28
|
+
// Types
|
|
29
|
+
export type {
|
|
30
|
+
Arch,
|
|
31
|
+
BootDiskFormat,
|
|
32
|
+
Channel,
|
|
33
|
+
ChrInstance,
|
|
34
|
+
ChrPorts,
|
|
35
|
+
DeviceModeOptions,
|
|
36
|
+
DoctorCheck,
|
|
37
|
+
DoctorResult,
|
|
38
|
+
EfiFirmwarePaths,
|
|
39
|
+
ExecOptions,
|
|
40
|
+
ExecResult,
|
|
41
|
+
ExecTransport,
|
|
42
|
+
ChrLoadSample,
|
|
43
|
+
LicenseInput,
|
|
44
|
+
LicenseLevel,
|
|
45
|
+
LicenseOptions,
|
|
46
|
+
MachineConfig,
|
|
47
|
+
MachineState,
|
|
48
|
+
NetworkConfig,
|
|
49
|
+
NetworkMode,
|
|
50
|
+
NetworkSpecifier,
|
|
51
|
+
PackageManager,
|
|
52
|
+
PlatformInfo,
|
|
53
|
+
PortMapping,
|
|
54
|
+
QgaCommand,
|
|
55
|
+
QgaExecResult,
|
|
56
|
+
QgaFsFreezeStatus,
|
|
57
|
+
QgaNetworkInterface,
|
|
58
|
+
QgaNetworkIpAddress,
|
|
59
|
+
QgaOsInfo,
|
|
60
|
+
QgaTimezone,
|
|
61
|
+
ServiceName,
|
|
62
|
+
SnapshotInfo,
|
|
63
|
+
StartOptions,
|
|
64
|
+
} from "./lib/types.ts";
|
|
65
|
+
|
|
66
|
+
export { QuickCHRError } from "./lib/types.ts";
|
|
67
|
+
export type { ErrorCode, HostInterface } from "./lib/types.ts";
|
|
68
|
+
|
|
69
|
+
// Interface detection
|
|
70
|
+
export { detectPhysicalInterfaces, resolveInterfaceAlias } from "./lib/platform.ts";
|
|
71
|
+
|
|
72
|
+
// Disk utilities
|
|
73
|
+
export { getDiskInfo, createQcow2Disk, listSnapshots, parseSnapshotList, formatSnapshotTable, formatDiskSize } from "./lib/disk.ts";
|
|
74
|
+
export type { DiskInfo } from "./lib/disk.ts";
|
|
75
|
+
|
|
76
|
+
// Auth utilities
|
|
77
|
+
export { resolveAuth } from "./lib/auth.ts";
|
|
78
|
+
export type { ResolvedAuth } from "./lib/auth.ts";
|
|
79
|
+
|
|
80
|
+
// Exec utilities
|
|
81
|
+
export { restExecute } from "./lib/exec.ts";
|
|
82
|
+
|
|
83
|
+
// QGA (QEMU Guest Agent) utilities
|
|
84
|
+
export {
|
|
85
|
+
qgaExec,
|
|
86
|
+
qgaProbe,
|
|
87
|
+
qgaSync,
|
|
88
|
+
qgaInfo,
|
|
89
|
+
qgaPing,
|
|
90
|
+
qgaGetOsInfo,
|
|
91
|
+
qgaGetHostName,
|
|
92
|
+
qgaGetTime,
|
|
93
|
+
qgaGetTimezone,
|
|
94
|
+
qgaGetNetworkInterfaces,
|
|
95
|
+
qgaFsFreezeStatus,
|
|
96
|
+
qgaFsFreezeFreeze,
|
|
97
|
+
qgaFsFreezeThaw,
|
|
98
|
+
qgaShutdown,
|
|
99
|
+
qgaFileWrite,
|
|
100
|
+
qgaFileRead,
|
|
101
|
+
} from "./lib/qga.ts";
|
|
102
|
+
export type { QgaCommandInfo } from "./lib/qga.ts";
|
|
103
|
+
|
|
104
|
+
// Console exec utilities
|
|
105
|
+
export { consoleExec, isConsoleReady, stripAnsi } from "./lib/console.ts";
|
|
106
|
+
|
|
107
|
+
// Channel utilities
|
|
108
|
+
export { isQgaReady } from "./lib/channels.ts";
|
|
109
|
+
|
|
110
|
+
// License utilities
|
|
111
|
+
export { renewLicense, getLicenseInfo } from "./lib/license.ts";
|
|
112
|
+
export type { LicenseInfo } from "./lib/license.ts";
|
|
113
|
+
|
|
114
|
+
// Credential utilities
|
|
115
|
+
export { getStoredCredentials, saveCredentials, deleteCredentials, credentialStorageLabel } from "./lib/credentials.ts";
|
|
116
|
+
export { getInstanceCredentials, saveInstanceCredentials, deleteInstanceCredentials } from "./lib/credentials.ts";
|
|
117
|
+
export type { MikrotikCredentials, InstanceCredentials } from "./lib/credentials.ts";
|
|
118
|
+
|
|
119
|
+
// Package utilities
|
|
120
|
+
export { downloadPackages, listAvailablePackages, downloadAndListPackages, installPackages, installAllPackages } from "./lib/packages.ts";
|
|
121
|
+
|
|
122
|
+
// Logging
|
|
123
|
+
export { createLogger } from "./lib/log.ts";
|
|
124
|
+
export type { ProgressLogger } from "./lib/log.ts";
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution for CHR instances.
|
|
3
|
+
*
|
|
4
|
+
* Centralises the logic for choosing which username:password to use when
|
|
5
|
+
* talking to a running RouterOS instance. Today this is a thin helper;
|
|
6
|
+
* it becomes the extension point for credential profiles, SSH keys, and
|
|
7
|
+
* user-preference overrides.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MachineState } from "./types.ts";
|
|
11
|
+
import { getInstanceCredentials } from "./credentials.ts";
|
|
12
|
+
|
|
13
|
+
export interface ResolvedAuth {
|
|
14
|
+
/** HTTP Basic Authorization header value. */
|
|
15
|
+
header: string;
|
|
16
|
+
/** Username that was resolved (for display / logging). */
|
|
17
|
+
user: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the best credentials for a machine.
|
|
22
|
+
*
|
|
23
|
+
* Priority:
|
|
24
|
+
* 1. Explicit overrides (`user` + `password` args) — caller knows best.
|
|
25
|
+
* 2. Provisioned user stored in machine.json (`state.user`).
|
|
26
|
+
* 3. Per-instance credentials stored in config file (written during provisioning).
|
|
27
|
+
* 4. CHR default: `admin` with empty password.
|
|
28
|
+
*
|
|
29
|
+
* When `state.disableAdmin` is true and no provisioned user exists the
|
|
30
|
+
* default admin credentials are unlikely to work. We still return them
|
|
31
|
+
* (the caller will get a 401) rather than throwing — the caller may have
|
|
32
|
+
* re-enabled admin or created a user out-of-band.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveAuth(
|
|
35
|
+
state: Pick<MachineState, "name" | "user" | "disableAdmin">,
|
|
36
|
+
user?: string,
|
|
37
|
+
password?: string,
|
|
38
|
+
): ResolvedAuth {
|
|
39
|
+
if (user !== undefined) {
|
|
40
|
+
return {
|
|
41
|
+
header: `Basic ${btoa(`${user}:${password ?? ""}`)}`,
|
|
42
|
+
user,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// state.user is the canonical provisioned user written to machine.json.
|
|
47
|
+
// Check this first — no I/O, no keychain, no blocking.
|
|
48
|
+
if (state.user) {
|
|
49
|
+
return {
|
|
50
|
+
header: `Basic ${btoa(`${state.user.name}:${state.user.password}`)}`,
|
|
51
|
+
user: state.user.name,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check per-instance credentials stored in config file (sync, no keychain).
|
|
56
|
+
const stored = getInstanceCredentials(state.name);
|
|
57
|
+
if (stored) {
|
|
58
|
+
return {
|
|
59
|
+
header: `Basic ${btoa(`${stored.user}:${stored.password}`)}`,
|
|
60
|
+
user: stored.user,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
header: `Basic ${btoa("admin:")}`,
|
|
66
|
+
user: "admin",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Communication channels — QEMU monitor, serial console, QGA (QEMU Guest Agent).
|
|
3
|
+
*
|
|
4
|
+
* Uses Unix domain sockets stored in the machine directory.
|
|
5
|
+
* QGA protocol implementation lives in qga.ts — this module provides
|
|
6
|
+
* the high-level qgaCommand() wrapper with arch guards and path resolution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, basename } from "node:path";
|
|
11
|
+
import { connect } from "node:net";
|
|
12
|
+
import type { Arch, ChannelTcpEndpoint, QgaCommand } from "./types.ts";
|
|
13
|
+
import { QuickCHRError } from "./types.ts";
|
|
14
|
+
import { qgaProbe, qgaRawCommand } from "./qga.ts";
|
|
15
|
+
import { qgaKvmWarning } from "./platform.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the IPC path for a QEMU channel (monitor, serial, qga).
|
|
19
|
+
* On Unix: a .sock file in machineDir.
|
|
20
|
+
* On Windows: a named pipe — both QEMU and Node.js net.connect() recognize \\.\pipe\ paths.
|
|
21
|
+
*/
|
|
22
|
+
export function channelPath(machineDir: string, channel: "monitor" | "serial" | "qga"): string {
|
|
23
|
+
if (process.platform === "win32") {
|
|
24
|
+
return `\\\\.\\pipe\\quickchr-${basename(machineDir)}-${channel}`;
|
|
25
|
+
}
|
|
26
|
+
return join(machineDir, `${channel}.sock`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the IPC connection options for a QEMU channel.
|
|
31
|
+
* On Windows with a portBase, returns a TCP endpoint (QEMU's Winsock bind() can't handle named pipes).
|
|
32
|
+
* Otherwise returns the socket path string (Unix socket or Windows named pipe fallback).
|
|
33
|
+
*/
|
|
34
|
+
export function channelEndpoint(
|
|
35
|
+
machineDir: string,
|
|
36
|
+
channel: "monitor" | "serial" | "qga",
|
|
37
|
+
portBase?: number,
|
|
38
|
+
): string | ChannelTcpEndpoint {
|
|
39
|
+
if (process.platform === "win32" && portBase !== undefined) {
|
|
40
|
+
const OFFSETS: Record<string, number> = { monitor: 6, serial: 7, qga: 8 };
|
|
41
|
+
return { host: "127.0.0.1", port: portBase + (OFFSETS[channel] ?? 6) };
|
|
42
|
+
}
|
|
43
|
+
return channelPath(machineDir, channel);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Return true if the channel IPC endpoint can be checked via existsSync (Unix only). */
|
|
47
|
+
export function channelFileExists(path: string): boolean {
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
// Named pipes don't appear as regular filesystem entries — let connect() fail instead.
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return existsSync(path);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send a command to the QEMU monitor and return the response. */
|
|
56
|
+
export async function monitorCommand(
|
|
57
|
+
machineDir: string,
|
|
58
|
+
command: string,
|
|
59
|
+
timeoutMs: number = 5000,
|
|
60
|
+
portBase?: number,
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
const socketPath = channelPath(machineDir, "monitor");
|
|
63
|
+
if (!channelFileExists(socketPath)) {
|
|
64
|
+
throw new QuickCHRError(
|
|
65
|
+
"MACHINE_STOPPED",
|
|
66
|
+
"Monitor socket not found — is the machine running in background mode?",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const endpoint = channelEndpoint(machineDir, "monitor", portBase);
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const timeout = setTimeout(() => {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
reject(new QuickCHRError("BOOT_TIMEOUT", "Monitor command timed out"));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
|
|
78
|
+
let buffer = "";
|
|
79
|
+
let sentCommand = false;
|
|
80
|
+
|
|
81
|
+
const socket = typeof endpoint === "string"
|
|
82
|
+
? connect({ path: endpoint })
|
|
83
|
+
: connect(endpoint.port, endpoint.host);
|
|
84
|
+
|
|
85
|
+
socket.on("data", (data) => {
|
|
86
|
+
buffer += data.toString();
|
|
87
|
+
// Wait for the (qemu) prompt before sending command
|
|
88
|
+
if (!sentCommand && buffer.includes("(qemu)")) {
|
|
89
|
+
sentCommand = true;
|
|
90
|
+
buffer = "";
|
|
91
|
+
socket.write(command + "\n");
|
|
92
|
+
} else if (sentCommand && buffer.includes("(qemu)")) {
|
|
93
|
+
// Got response — strip the prompt
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
const response = buffer.replace(/\(qemu\)\s*/g, "").trim();
|
|
96
|
+
socket.destroy();
|
|
97
|
+
resolve(response);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// When the socket closes after we've already sent the command, QEMU exited.
|
|
102
|
+
// This is the normal outcome for `quit` — no further (qemu) prompt is sent.
|
|
103
|
+
socket.on("close", () => {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
if (sentCommand) {
|
|
106
|
+
resolve(buffer.replace(/\(qemu\)\s*/g, "").trim());
|
|
107
|
+
} else {
|
|
108
|
+
reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
socket.on("error", (err) => {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
115
|
+
// ENOENT = named pipe not found (Windows); ECONNREFUSED = server not listening
|
|
116
|
+
if (code === "ENOENT" || code === "ECONNREFUSED") {
|
|
117
|
+
reject(new QuickCHRError("MACHINE_STOPPED", "Monitor not available — is the machine running in background mode?"));
|
|
118
|
+
} else {
|
|
119
|
+
reject(new QuickCHRError("PROCESS_FAILED", `Monitor connection failed: ${err.message}`));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Get a readable/writable stream pair for the serial console. */
|
|
126
|
+
export function serialStreams(machineDir: string, portBase?: number): {
|
|
127
|
+
readable: ReadableStream<Uint8Array>;
|
|
128
|
+
writable: WritableStream<Uint8Array>;
|
|
129
|
+
} {
|
|
130
|
+
const socketPath = channelPath(machineDir, "serial");
|
|
131
|
+
if (!channelFileExists(socketPath)) {
|
|
132
|
+
throw new QuickCHRError(
|
|
133
|
+
"MACHINE_STOPPED",
|
|
134
|
+
"Serial socket not found — is the machine running in background mode?",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const endpoint = channelEndpoint(machineDir, "serial", portBase);
|
|
139
|
+
const socket = typeof endpoint === "string"
|
|
140
|
+
? connect({ path: endpoint })
|
|
141
|
+
: connect(endpoint.port, endpoint.host);
|
|
142
|
+
|
|
143
|
+
const readable = new ReadableStream<Uint8Array>({
|
|
144
|
+
start(controller) {
|
|
145
|
+
socket.on("data", (chunk: Buffer) => {
|
|
146
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
147
|
+
});
|
|
148
|
+
socket.on("end", () => controller.close());
|
|
149
|
+
socket.on("error", (err) => controller.error(err));
|
|
150
|
+
},
|
|
151
|
+
cancel() {
|
|
152
|
+
socket.destroy();
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const writable = new WritableStream<Uint8Array>({
|
|
157
|
+
write(chunk) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
socket.write(chunk, (err) => {
|
|
160
|
+
if (err) reject(err);
|
|
161
|
+
else resolve();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
close() {
|
|
166
|
+
socket.destroy();
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { readable, writable };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Send a QGA (QEMU Guest Agent) command via Unix socket. x86 only.
|
|
174
|
+
* Delegates to qga.ts for protocol handling — this wrapper adds arch guard,
|
|
175
|
+
* KVM availability warning, and socket path resolution. */
|
|
176
|
+
export async function qgaCommand(
|
|
177
|
+
machineDir: string,
|
|
178
|
+
arch: Arch,
|
|
179
|
+
command: QgaCommand,
|
|
180
|
+
args?: object,
|
|
181
|
+
timeoutMs: number = 10000,
|
|
182
|
+
portBase?: number,
|
|
183
|
+
): Promise<unknown> {
|
|
184
|
+
if (arch === "arm64") {
|
|
185
|
+
throw new QuickCHRError(
|
|
186
|
+
"QGA_UNSUPPORTED",
|
|
187
|
+
"QEMU Guest Agent is not yet functional on ARM64 CHR — MikroTik arm64 guest agent support is planned but not yet released",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const kvmWarning = qgaKvmWarning();
|
|
192
|
+
if (kvmWarning) {
|
|
193
|
+
console.warn(kvmWarning);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const socketPath = channelPath(machineDir, "qga");
|
|
197
|
+
if (!channelFileExists(socketPath)) {
|
|
198
|
+
throw new QuickCHRError(
|
|
199
|
+
"MACHINE_STOPPED",
|
|
200
|
+
"QGA socket not found — is the machine running in background mode?",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const endpoint = channelEndpoint(machineDir, "qga", portBase);
|
|
205
|
+
return qgaRawCommand(endpoint, command, args as Record<string, unknown> | undefined, timeoutMs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Check whether QGA is available and responding for a machine. */
|
|
209
|
+
export function isQgaReady(
|
|
210
|
+
machineDir: string,
|
|
211
|
+
arch: Arch,
|
|
212
|
+
portBase?: number,
|
|
213
|
+
): Promise<boolean> {
|
|
214
|
+
if (arch === "arm64") return Promise.resolve(false);
|
|
215
|
+
|
|
216
|
+
const socketPath = channelPath(machineDir, "qga");
|
|
217
|
+
// On Unix, skip the connect attempt entirely if the socket file doesn't exist.
|
|
218
|
+
if (process.platform !== "win32" && !existsSync(socketPath)) return Promise.resolve(false);
|
|
219
|
+
|
|
220
|
+
const endpoint = channelEndpoint(machineDir, "qga", portBase);
|
|
221
|
+
return qgaProbe(endpoint, 5000);
|
|
222
|
+
}
|