@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
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port allocation, network specifier utilities, conflict detection, and
|
|
3
|
+
* platform-aware network resolution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createConnection } from "node:net";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PORT_BASE,
|
|
9
|
+
PORTS_PER_BLOCK,
|
|
10
|
+
type ChrPorts,
|
|
11
|
+
type NetworkConfig,
|
|
12
|
+
type NetworkMode,
|
|
13
|
+
type NetworkSpecifier,
|
|
14
|
+
type PlatformInfo,
|
|
15
|
+
type PortMapping,
|
|
16
|
+
type ResolvedNetwork,
|
|
17
|
+
type ServiceName,
|
|
18
|
+
type SocketVmnetInfo,
|
|
19
|
+
SERVICE_PORTS,
|
|
20
|
+
QuickCHRError,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import { getNamedSocket } from "./socket-registry.ts";
|
|
23
|
+
import { resolveInterfaceAlias, isSocketVmnetDaemonRunning } from "./platform.ts";
|
|
24
|
+
|
|
25
|
+
/** Allocate a port block for a new instance, avoiding conflicts with existing machines. */
|
|
26
|
+
export function allocatePortBlock(
|
|
27
|
+
usedBases: number[],
|
|
28
|
+
requestedBase?: number,
|
|
29
|
+
): number {
|
|
30
|
+
if (requestedBase !== undefined) {
|
|
31
|
+
return requestedBase;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let base = DEFAULT_PORT_BASE;
|
|
35
|
+
const sorted = [...usedBases].sort((a, b) => a - b);
|
|
36
|
+
|
|
37
|
+
for (const used of sorted) {
|
|
38
|
+
if (base >= used && base < used + PORTS_PER_BLOCK) {
|
|
39
|
+
base = used + PORTS_PER_BLOCK;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return base;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build port mappings for an instance given a base port. */
|
|
47
|
+
export function buildPortMappings(
|
|
48
|
+
portBase: number,
|
|
49
|
+
excludePorts: ServiceName[] = [],
|
|
50
|
+
extraPorts: PortMapping[] = [],
|
|
51
|
+
): Record<string, PortMapping> {
|
|
52
|
+
const mappings: Record<string, PortMapping> = {};
|
|
53
|
+
|
|
54
|
+
for (const [name, spec] of Object.entries(SERVICE_PORTS)) {
|
|
55
|
+
if (excludePorts.includes(name as ServiceName)) continue;
|
|
56
|
+
mappings[name] = {
|
|
57
|
+
name,
|
|
58
|
+
host: portBase + spec.offset,
|
|
59
|
+
guest: spec.guest,
|
|
60
|
+
proto: spec.proto,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Add custom port mappings at offset 6+
|
|
65
|
+
for (let i = 0; i < extraPorts.length; i++) {
|
|
66
|
+
const extra = extraPorts[i];
|
|
67
|
+
if (!extra) continue;
|
|
68
|
+
const name = extra.name || `custom-${i}`;
|
|
69
|
+
mappings[name] = {
|
|
70
|
+
name,
|
|
71
|
+
host: extra.host || portBase + 6 + i,
|
|
72
|
+
guest: extra.guest,
|
|
73
|
+
proto: extra.proto,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return mappings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build QEMU hostfwd string from port mappings. */
|
|
81
|
+
export function buildHostfwdString(ports: Record<string, PortMapping>): string {
|
|
82
|
+
return Object.values(ports)
|
|
83
|
+
.map((p) => `hostfwd=${p.proto}::${p.host}-:${p.guest}`)
|
|
84
|
+
.join(",");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Check if a TCP port is available on localhost.
|
|
88
|
+
* Uses a connect probe: if anything is actively listening, the connect
|
|
89
|
+
* succeeds and we return false. ECONNREFUSED means nothing is listening.
|
|
90
|
+
* This approach is immune to SO_REUSEADDR/SO_REUSEPORT semantics that
|
|
91
|
+
* can cause bind-based checks to give false positives on macOS when a
|
|
92
|
+
* wildcard (0.0.0.0) listener is present. */
|
|
93
|
+
export async function isPortAvailable(port: number): Promise<boolean> {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
96
|
+
socket.setTimeout(300);
|
|
97
|
+
socket.on("connect", () => {
|
|
98
|
+
socket.destroy();
|
|
99
|
+
resolve(false); // Something is listening — port occupied
|
|
100
|
+
});
|
|
101
|
+
socket.on("error", (err: Error & { code?: string }) => {
|
|
102
|
+
resolve(err.code === "ECONNREFUSED");
|
|
103
|
+
});
|
|
104
|
+
socket.on("timeout", () => {
|
|
105
|
+
socket.destroy();
|
|
106
|
+
resolve(true); // Nothing answered in time — treat as available
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Check all ports in a mapping for availability. Returns conflicting ports. */
|
|
112
|
+
export async function checkPortConflicts(
|
|
113
|
+
ports: Record<string, PortMapping>,
|
|
114
|
+
): Promise<PortMapping[]> {
|
|
115
|
+
const conflicts: PortMapping[] = [];
|
|
116
|
+
|
|
117
|
+
for (const mapping of Object.values(ports)) {
|
|
118
|
+
const available = await isPortAvailable(mapping.host);
|
|
119
|
+
if (!available) {
|
|
120
|
+
conflicts.push(mapping);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return conflicts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Find the next available port block that has no conflicts. */
|
|
128
|
+
export async function findAvailablePortBlock(
|
|
129
|
+
usedBases: number[],
|
|
130
|
+
excludePorts: ServiceName[] = [],
|
|
131
|
+
extraPorts: PortMapping[] = [],
|
|
132
|
+
startBase?: number,
|
|
133
|
+
): Promise<number> {
|
|
134
|
+
let base = startBase ?? allocatePortBlock(usedBases);
|
|
135
|
+
const maxAttempts = 20;
|
|
136
|
+
|
|
137
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
138
|
+
const mappings = buildPortMappings(base, excludePorts, extraPorts);
|
|
139
|
+
const conflicts = await checkPortConflicts(mappings);
|
|
140
|
+
if (conflicts.length === 0) {
|
|
141
|
+
return base;
|
|
142
|
+
}
|
|
143
|
+
base += PORTS_PER_BLOCK;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new QuickCHRError(
|
|
147
|
+
"PORT_CONFLICT",
|
|
148
|
+
`Could not find available port block after ${maxAttempts} attempts (tried ${startBase ?? DEFAULT_PORT_BASE}–${base})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Extract ChrPorts from port mappings. */
|
|
153
|
+
export function toChrPorts(ports: Record<string, PortMapping>): ChrPorts {
|
|
154
|
+
const result: Record<string, number> = {};
|
|
155
|
+
for (const [name, mapping] of Object.entries(ports)) {
|
|
156
|
+
// Convert kebab-case to camelCase for API
|
|
157
|
+
const key = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
158
|
+
result[key] = mapping.host;
|
|
159
|
+
}
|
|
160
|
+
return result as ChrPorts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Network config helpers ──────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Convert a legacy `NetworkMode` to the new `NetworkConfig[]` format. */
|
|
166
|
+
export function networkModeToConfigs(mode: NetworkMode): NetworkConfig[] {
|
|
167
|
+
if (mode === "vmnet-shared") {
|
|
168
|
+
return [{ specifier: "vmnet-shared", id: "net0" }];
|
|
169
|
+
}
|
|
170
|
+
if (typeof mode === "object" && mode.type === "vmnet-bridge") {
|
|
171
|
+
return [
|
|
172
|
+
{ specifier: { type: "vmnet-bridged", iface: mode.iface }, id: "net0" },
|
|
173
|
+
// Old behavior added a management vmnet-shared NIC too
|
|
174
|
+
{ specifier: "vmnet-shared", id: "net1" },
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
return [{ specifier: "user", id: "net0" }];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Parse a user-provided network specifier string into a `NetworkSpecifier`. */
|
|
181
|
+
export function parseNetworkSpecifier(input: string): NetworkSpecifier {
|
|
182
|
+
const trimmed = input.trim();
|
|
183
|
+
if (!trimmed) {
|
|
184
|
+
throw new QuickCHRError("INVALID_NETWORK", "Empty network specifier");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Exact literal matches
|
|
188
|
+
switch (trimmed) {
|
|
189
|
+
case "user":
|
|
190
|
+
return "user";
|
|
191
|
+
case "shared":
|
|
192
|
+
case "auto":
|
|
193
|
+
return "shared";
|
|
194
|
+
case "vmnet-shared":
|
|
195
|
+
return "vmnet-shared";
|
|
196
|
+
case "wifi":
|
|
197
|
+
return { type: "bridged", iface: "wifi" };
|
|
198
|
+
case "ethernet":
|
|
199
|
+
return { type: "bridged", iface: "ethernet" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// bridged:<iface>
|
|
203
|
+
if (trimmed.startsWith("bridged:")) {
|
|
204
|
+
const iface = trimmed.slice("bridged:".length);
|
|
205
|
+
if (!iface) {
|
|
206
|
+
throw new QuickCHRError(
|
|
207
|
+
"INVALID_NETWORK",
|
|
208
|
+
"bridged: requires an interface name (e.g. bridged:en0)",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { type: "bridged", iface };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// vmnet-bridged:<iface>
|
|
215
|
+
if (trimmed.startsWith("vmnet-bridged:")) {
|
|
216
|
+
const iface = trimmed.slice("vmnet-bridged:".length);
|
|
217
|
+
if (!iface) {
|
|
218
|
+
throw new QuickCHRError(
|
|
219
|
+
"INVALID_NETWORK",
|
|
220
|
+
"vmnet-bridged: requires an interface name (e.g. vmnet-bridged:en0)",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return { type: "vmnet-bridged", iface };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// tap:<ifname>
|
|
227
|
+
if (trimmed.startsWith("tap:")) {
|
|
228
|
+
const ifname = trimmed.slice("tap:".length);
|
|
229
|
+
if (!ifname) {
|
|
230
|
+
throw new QuickCHRError(
|
|
231
|
+
"INVALID_NETWORK",
|
|
232
|
+
"tap: requires an interface name (e.g. tap:tap-chr0)",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return { type: "tap", ifname };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// socket:<subtype>:<arg>
|
|
239
|
+
if (trimmed.startsWith("socket:")) {
|
|
240
|
+
return parseSocketSpecifier(trimmed);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new QuickCHRError(
|
|
244
|
+
"INVALID_NETWORK",
|
|
245
|
+
`Unknown network specifier: "${trimmed}"`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseSocketSpecifier(input: string): NetworkSpecifier {
|
|
250
|
+
// socket::name → named socket
|
|
251
|
+
if (input.startsWith("socket::")) {
|
|
252
|
+
const name = input.slice("socket::".length);
|
|
253
|
+
if (!name) {
|
|
254
|
+
throw new QuickCHRError(
|
|
255
|
+
"INVALID_NETWORK",
|
|
256
|
+
"socket:: requires a link name (e.g. socket::mylink)",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return { type: "socket", name };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const parts = input.split(":");
|
|
263
|
+
const subtype = parts[1];
|
|
264
|
+
|
|
265
|
+
if (subtype === "listen" || subtype === "connect") {
|
|
266
|
+
const port = Number(parts[2]);
|
|
267
|
+
if (!parts[2] || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
268
|
+
throw new QuickCHRError(
|
|
269
|
+
"INVALID_NETWORK",
|
|
270
|
+
`socket:${subtype}: requires a valid port number (1–65535)`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { type: `socket-${subtype}`, port };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (subtype === "mcast") {
|
|
277
|
+
// socket:mcast:<group>:<port>
|
|
278
|
+
const group = parts[2];
|
|
279
|
+
const port = Number(parts[3]);
|
|
280
|
+
if (
|
|
281
|
+
!group ||
|
|
282
|
+
!parts[3] ||
|
|
283
|
+
!Number.isInteger(port) ||
|
|
284
|
+
port < 1 ||
|
|
285
|
+
port > 65535
|
|
286
|
+
) {
|
|
287
|
+
throw new QuickCHRError(
|
|
288
|
+
"INVALID_NETWORK",
|
|
289
|
+
"socket:mcast requires group and port (e.g. socket:mcast:230.0.0.1:4001)",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return { type: "socket-mcast", group, port };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new QuickCHRError(
|
|
296
|
+
"INVALID_NETWORK",
|
|
297
|
+
`Unknown socket subtype: "${subtype}" in "${input}"`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Build default `NetworkConfig[]` from `StartOptions.networks` or legacy `network`. */
|
|
302
|
+
export function resolveStartNetworks(
|
|
303
|
+
networks?: NetworkSpecifier[],
|
|
304
|
+
legacyNetwork?: NetworkMode,
|
|
305
|
+
): NetworkConfig[] {
|
|
306
|
+
if (networks && networks.length > 0) {
|
|
307
|
+
return networks.map((specifier, i) => ({
|
|
308
|
+
specifier,
|
|
309
|
+
id: `net${i}`,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
if (legacyNetwork) {
|
|
313
|
+
return networkModeToConfigs(legacyNetwork);
|
|
314
|
+
}
|
|
315
|
+
return [{ specifier: "user", id: "net0" }];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Returns true when at least one network uses QEMU user-mode (hostfwd) networking.
|
|
319
|
+
* Only user-mode networks forward localhost ports — shared/bridged/tap/socket networks
|
|
320
|
+
* assign a DHCP address that is not reachable from host localhost. */
|
|
321
|
+
export function hasUserModeNetwork(networks: NetworkConfig[]): boolean {
|
|
322
|
+
return networks.some((n) => n.specifier === "user");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Platform-aware network resolution ───────────────────────────────
|
|
326
|
+
|
|
327
|
+
export interface ResolutionContext {
|
|
328
|
+
platform: PlatformInfo;
|
|
329
|
+
socketVmnet?: SocketVmnetInfo;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function deviceArgs(id: string): string[] {
|
|
333
|
+
return ["-device", `virtio-net-pci,netdev=${id}`];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function resolveUser(
|
|
337
|
+
config: NetworkConfig,
|
|
338
|
+
hostfwd: string,
|
|
339
|
+
): ResolvedNetwork {
|
|
340
|
+
const netdevValue = hostfwd
|
|
341
|
+
? `user,id=${config.id},${hostfwd}`
|
|
342
|
+
: `user,id=${config.id}`;
|
|
343
|
+
return {
|
|
344
|
+
qemuNetdevArgs: [
|
|
345
|
+
"-netdev", netdevValue,
|
|
346
|
+
...deviceArgs(config.id),
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function resolveShared(
|
|
352
|
+
config: NetworkConfig,
|
|
353
|
+
ctx: ResolutionContext,
|
|
354
|
+
): ResolvedNetwork {
|
|
355
|
+
if (ctx.platform.os === "darwin") {
|
|
356
|
+
const svn = ctx.socketVmnet ?? ctx.platform.socketVmnet;
|
|
357
|
+
if (svn?.sharedSocket) {
|
|
358
|
+
if (!isSocketVmnetDaemonRunning(svn.sharedSocket)) {
|
|
359
|
+
throw new QuickCHRError(
|
|
360
|
+
"NETWORK_UNAVAILABLE",
|
|
361
|
+
`socket_vmnet daemon is not running (socket not found: ${svn.sharedSocket})`,
|
|
362
|
+
"Start it with: sudo brew services start socket_vmnet",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
qemuNetdevArgs: [
|
|
367
|
+
"-netdev", `socket,id=${config.id},fd=3`,
|
|
368
|
+
...deviceArgs(config.id),
|
|
369
|
+
],
|
|
370
|
+
wrapper: [svn.client, svn.sharedSocket],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
qemuNetdevArgs: [
|
|
375
|
+
"-netdev", `vmnet-shared,id=${config.id}`,
|
|
376
|
+
...deviceArgs(config.id),
|
|
377
|
+
],
|
|
378
|
+
downgraded: {
|
|
379
|
+
from: "shared (socket_vmnet)",
|
|
380
|
+
reason: "Requires root — socket_vmnet not available",
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
throw new QuickCHRError(
|
|
385
|
+
"NETWORK_UNAVAILABLE",
|
|
386
|
+
`"shared" network is not yet supported on ${ctx.platform.os} (TAP support planned)`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function resolveVmnetShared(
|
|
391
|
+
config: NetworkConfig,
|
|
392
|
+
ctx: ResolutionContext,
|
|
393
|
+
): ResolvedNetwork {
|
|
394
|
+
if (ctx.platform.os !== "darwin") {
|
|
395
|
+
throw new QuickCHRError(
|
|
396
|
+
"NETWORK_UNAVAILABLE",
|
|
397
|
+
"vmnet-shared is macOS only",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
qemuNetdevArgs: [
|
|
402
|
+
"-netdev", `vmnet-shared,id=${config.id}`,
|
|
403
|
+
...deviceArgs(config.id),
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resolveBridged(
|
|
409
|
+
config: NetworkConfig,
|
|
410
|
+
iface: string,
|
|
411
|
+
ctx: ResolutionContext,
|
|
412
|
+
): ResolvedNetwork {
|
|
413
|
+
if (ctx.platform.os === "darwin") {
|
|
414
|
+
const resolved = resolveInterfaceAlias(iface);
|
|
415
|
+
const svn = ctx.socketVmnet ?? ctx.platform.socketVmnet;
|
|
416
|
+
const bridgeSocket = svn?.bridgedSockets[resolved];
|
|
417
|
+
if (svn && bridgeSocket) {
|
|
418
|
+
if (!isSocketVmnetDaemonRunning(bridgeSocket)) {
|
|
419
|
+
throw new QuickCHRError(
|
|
420
|
+
"NETWORK_UNAVAILABLE",
|
|
421
|
+
`socket_vmnet bridged daemon is not running (socket not found: ${bridgeSocket})`,
|
|
422
|
+
"Start it with: sudo brew services start socket_vmnet",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
qemuNetdevArgs: [
|
|
427
|
+
"-netdev", `socket,id=${config.id},fd=3`,
|
|
428
|
+
...deviceArgs(config.id),
|
|
429
|
+
],
|
|
430
|
+
wrapper: [svn.client, bridgeSocket],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
qemuNetdevArgs: [
|
|
435
|
+
"-netdev", `vmnet-bridged,id=${config.id},ifname=${resolved}`,
|
|
436
|
+
...deviceArgs(config.id),
|
|
437
|
+
],
|
|
438
|
+
downgraded: {
|
|
439
|
+
from: `bridged:${iface} (socket_vmnet)`,
|
|
440
|
+
reason: "Requires root — socket_vmnet not available",
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
throw new QuickCHRError(
|
|
445
|
+
"NETWORK_UNAVAILABLE",
|
|
446
|
+
`"bridged" network is not yet supported on ${ctx.platform.os} (TAP bridge support planned)`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function resolveVmnetBridged(
|
|
451
|
+
config: NetworkConfig,
|
|
452
|
+
iface: string,
|
|
453
|
+
ctx: ResolutionContext,
|
|
454
|
+
): ResolvedNetwork {
|
|
455
|
+
if (ctx.platform.os !== "darwin") {
|
|
456
|
+
throw new QuickCHRError(
|
|
457
|
+
"NETWORK_UNAVAILABLE",
|
|
458
|
+
"vmnet-bridged is macOS only",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
qemuNetdevArgs: [
|
|
463
|
+
"-netdev", `vmnet-bridged,id=${config.id},ifname=${iface}`,
|
|
464
|
+
...deviceArgs(config.id),
|
|
465
|
+
],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function resolveSocketNamed(
|
|
470
|
+
config: NetworkConfig,
|
|
471
|
+
name: string,
|
|
472
|
+
): ResolvedNetwork {
|
|
473
|
+
const entry = getNamedSocket(name);
|
|
474
|
+
if (!entry) {
|
|
475
|
+
throw new QuickCHRError(
|
|
476
|
+
"NETWORK_UNAVAILABLE",
|
|
477
|
+
`Named socket "${name}" not found — create it first with 'quickchr networks sockets create ${name}'`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (entry.mode === "mcast") {
|
|
481
|
+
return {
|
|
482
|
+
qemuNetdevArgs: [
|
|
483
|
+
"-netdev", `socket,id=${config.id},mcast=${entry.mcastGroup}:${entry.port}`,
|
|
484
|
+
...deviceArgs(config.id),
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const isFirst = entry.members.length === 0;
|
|
489
|
+
const netdevArg = isFirst
|
|
490
|
+
? `socket,id=${config.id},listen=:${entry.port}`
|
|
491
|
+
: `socket,id=${config.id},connect=127.0.0.1:${entry.port}`;
|
|
492
|
+
return {
|
|
493
|
+
qemuNetdevArgs: [
|
|
494
|
+
"-netdev", netdevArg,
|
|
495
|
+
...deviceArgs(config.id),
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function resolveSocketListen(
|
|
501
|
+
config: NetworkConfig,
|
|
502
|
+
port: number,
|
|
503
|
+
): ResolvedNetwork {
|
|
504
|
+
return {
|
|
505
|
+
qemuNetdevArgs: [
|
|
506
|
+
"-netdev", `socket,id=${config.id},listen=:${port}`,
|
|
507
|
+
...deviceArgs(config.id),
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function resolveSocketConnect(
|
|
513
|
+
config: NetworkConfig,
|
|
514
|
+
port: number,
|
|
515
|
+
): ResolvedNetwork {
|
|
516
|
+
return {
|
|
517
|
+
qemuNetdevArgs: [
|
|
518
|
+
"-netdev", `socket,id=${config.id},connect=127.0.0.1:${port}`,
|
|
519
|
+
...deviceArgs(config.id),
|
|
520
|
+
],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolveSocketMcast(
|
|
525
|
+
config: NetworkConfig,
|
|
526
|
+
group: string,
|
|
527
|
+
port: number,
|
|
528
|
+
): ResolvedNetwork {
|
|
529
|
+
return {
|
|
530
|
+
qemuNetdevArgs: [
|
|
531
|
+
"-netdev", `socket,id=${config.id},mcast=${group}:${port}`,
|
|
532
|
+
...deviceArgs(config.id),
|
|
533
|
+
],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function resolveTap(
|
|
538
|
+
config: NetworkConfig,
|
|
539
|
+
ifname: string,
|
|
540
|
+
ctx: ResolutionContext,
|
|
541
|
+
): ResolvedNetwork {
|
|
542
|
+
if (ctx.platform.os !== "linux") {
|
|
543
|
+
throw new QuickCHRError(
|
|
544
|
+
"NETWORK_UNAVAILABLE",
|
|
545
|
+
"TAP networking is Linux only",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
qemuNetdevArgs: [
|
|
550
|
+
"-netdev", `tap,id=${config.id},ifname=${ifname},script=no,downscript=no`,
|
|
551
|
+
...deviceArgs(config.id),
|
|
552
|
+
],
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Resolve a single NetworkConfig's specifier into QEMU arguments for the current platform. */
|
|
557
|
+
export function resolveNetworkConfig(
|
|
558
|
+
config: NetworkConfig,
|
|
559
|
+
ctx: ResolutionContext,
|
|
560
|
+
hostfwd = "",
|
|
561
|
+
): NetworkConfig {
|
|
562
|
+
const spec = config.specifier;
|
|
563
|
+
let resolved: ResolvedNetwork;
|
|
564
|
+
|
|
565
|
+
if (spec === "user") {
|
|
566
|
+
resolved = resolveUser(config, hostfwd);
|
|
567
|
+
} else if (spec === "shared") {
|
|
568
|
+
resolved = resolveShared(config, ctx);
|
|
569
|
+
} else if (spec === "vmnet-shared") {
|
|
570
|
+
resolved = resolveVmnetShared(config, ctx);
|
|
571
|
+
} else if (typeof spec === "object") {
|
|
572
|
+
switch (spec.type) {
|
|
573
|
+
case "bridged":
|
|
574
|
+
resolved = resolveBridged(config, spec.iface, ctx);
|
|
575
|
+
break;
|
|
576
|
+
case "vmnet-bridged":
|
|
577
|
+
resolved = resolveVmnetBridged(config, spec.iface, ctx);
|
|
578
|
+
break;
|
|
579
|
+
case "socket":
|
|
580
|
+
resolved = resolveSocketNamed(config, spec.name);
|
|
581
|
+
break;
|
|
582
|
+
case "socket-listen":
|
|
583
|
+
resolved = resolveSocketListen(config, spec.port);
|
|
584
|
+
break;
|
|
585
|
+
case "socket-connect":
|
|
586
|
+
resolved = resolveSocketConnect(config, spec.port);
|
|
587
|
+
break;
|
|
588
|
+
case "socket-mcast":
|
|
589
|
+
resolved = resolveSocketMcast(config, spec.group, spec.port);
|
|
590
|
+
break;
|
|
591
|
+
case "tap":
|
|
592
|
+
resolved = resolveTap(config, spec.ifname, ctx);
|
|
593
|
+
break;
|
|
594
|
+
default:
|
|
595
|
+
throw new QuickCHRError(
|
|
596
|
+
"INVALID_NETWORK",
|
|
597
|
+
`Unknown network specifier type: ${(spec as { type: string }).type}`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
throw new QuickCHRError(
|
|
602
|
+
"INVALID_NETWORK",
|
|
603
|
+
`Unknown network specifier: ${String(spec)}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return { ...config, resolved };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Resolve all network configs and check for conflicts. */
|
|
611
|
+
export function resolveAllNetworks(
|
|
612
|
+
configs: NetworkConfig[],
|
|
613
|
+
ctx: ResolutionContext,
|
|
614
|
+
hostfwd = "",
|
|
615
|
+
): NetworkConfig[] {
|
|
616
|
+
const resolved = configs.map((c) => resolveNetworkConfig(c, ctx, hostfwd));
|
|
617
|
+
|
|
618
|
+
const userCount = configs.filter((c) => c.specifier === "user").length;
|
|
619
|
+
if (userCount > 1) {
|
|
620
|
+
throw new QuickCHRError(
|
|
621
|
+
"INVALID_NETWORK",
|
|
622
|
+
`Multiple user-mode NICs (${userCount}) — QEMU only supports one user-mode netdev reliably`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const wrappers = resolved.filter((c) => c.resolved?.wrapper);
|
|
627
|
+
if (wrappers.length > 1) {
|
|
628
|
+
throw new QuickCHRError(
|
|
629
|
+
"INVALID_NETWORK",
|
|
630
|
+
"Multiple networks require socket_vmnet wrappers — only one wrapper is supported per QEMU process",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return resolved;
|
|
635
|
+
}
|