@braedonsaunders/appkit-desk 0.7.0 → 0.9.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/README.md +126 -1
- package/backend.d.ts +32 -7
- package/backend.d.ts.map +1 -1
- package/backend.js +107 -524
- package/backend.js.map +1 -1
- package/host.d.ts +27 -1
- package/host.d.ts.map +1 -1
- package/host.js +47 -19
- package/host.js.map +1 -1
- package/index.d.ts +2 -0
- package/index.d.ts.map +1 -1
- package/index.js +2 -0
- package/index.js.map +1 -1
- package/machine.d.ts +154 -0
- package/machine.d.ts.map +1 -0
- package/machine.js +573 -0
- package/machine.js.map +1 -0
- package/package.json +2 -2
- package/plan.d.ts +192 -31
- package/plan.d.ts.map +1 -1
- package/plan.js +298 -12
- package/plan.js.map +1 -1
- package/port-relay.d.ts +13 -0
- package/port-relay.d.ts.map +1 -0
- package/port-relay.js +56 -0
- package/port-relay.js.map +1 -0
- package/virtualization.d.ts +16 -0
- package/virtualization.d.ts.map +1 -0
- package/virtualization.js +156 -0
- package/virtualization.js.map +1 -0
- package/vz/Package.swift +23 -0
- package/vz/Sources/DeskNet/Addresses.swift +106 -0
- package/vz/Sources/DeskNet/DHCP.swift +111 -0
- package/vz/Sources/DeskNet/Fence.swift +57 -0
- package/vz/Sources/DeskNet/NetworkStack.swift +547 -0
- package/vz/Sources/DeskNet/Packets.swift +355 -0
- package/vz/Sources/DeskNet/Plumbing.swift +312 -0
- package/vz/Sources/DeskNet/Sockets.swift +221 -0
- package/vz/Sources/DeskNet/TCPFlow.swift +448 -0
- package/vz/Sources/appkit-desk-vz/Control.swift +152 -0
- package/vz/Sources/appkit-desk-vz/Helper.swift +550 -0
- package/vz/Sources/appkit-desk-vz/main.swift +38 -0
- package/vz/Tests/DeskNetTests/DeskNetTests.swift +426 -0
- package/vz/appkit-desk-vz.entitlements +8 -0
- package/vz/build.sh +23 -0
- package/vz/check/extract-boot.py +221 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"port-relay.js","sourceRoot":"","sources":["../src/port-relay.ts"],"names":[],"mappings":"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","sourcesContent":["import { createServer, type Server, type Socket } from 'node:net'\nimport type { Duplex } from 'node:stream'\nimport { DeskError } from './plan'\n\n/** A local endpoint backed by a running desk's TCP connection. */\nexport interface DeskPortListener {\n host: '127.0.0.1'\n port: number\n /** Close the listener and every active connection. Safe to call repeatedly. */\n close(): Promise<void>\n}\n\n/** Expose one guest TCP port to local HTTP, WebSocket or other TCP clients. */\nexport async function listenDeskPort(\n desk: { connect(port: number): Promise<Duplex> },\n port: number,\n): Promise<DeskPortListener> {\n if (!Number.isInteger(port) || port < 1 || port > 65535) throw new DeskError('Guest TCP port must be between 1 and 65535.')\n const clients = new Set<Socket>()\n const guests = new Set<Duplex>()\n let closed = false\n let closing: Promise<void> | undefined\n const server: Server = createServer((client) => {\n if (closed) { client.destroy(); return }\n clients.add(client)\n client.once('close', () => clients.delete(client))\n client.on('error', () => client.destroy())\n void Promise.resolve().then(() => desk.connect(port)).then((guest) => {\n if (closed || client.destroyed) { guest.destroy(); return }\n guests.add(guest)\n guest.once('close', () => { guests.delete(guest); client.destroy() })\n guest.on('error', () => { guest.destroy(); client.destroy() })\n client.once('close', () => guest.destroy())\n client.pipe(guest).pipe(client)\n }, () => client.destroy())\n })\n await new Promise<void>((resolve, reject) => {\n const onError = (error: Error) => reject(error)\n server.once('error', onError)\n server.listen(0, '127.0.0.1', () => { server.off('error', onError); resolve() })\n })\n // Errors after listen must not become unhandled process errors.\n server.on('error', () => { void close() })\n const address = server.address()\n if (!address || typeof address === 'string') { server.close(); throw new DeskError('Could not determine desk relay address.') }\n function close(): Promise<void> {\n if (closing) return closing\n closed = true\n for (const client of clients) client.destroy()\n for (const guest of guests) guest.destroy()\n closing = new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))\n return closing\n }\n return { host: '127.0.0.1', port: address.port, close }\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Duplex } from 'node:stream';
|
|
2
|
+
import type { DeskBackend, DeskProcessLauncher } from './backend.js';
|
|
3
|
+
/** A signed helper is supplied by the host; it is not downloaded or executed at install time. */
|
|
4
|
+
export interface VirtualizationBackendOptions {
|
|
5
|
+
helperPath: string;
|
|
6
|
+
launcher?: DeskProcessLauncher;
|
|
7
|
+
connect?: (socketPath: string) => Duplex;
|
|
8
|
+
connectTimeoutMs?: number;
|
|
9
|
+
/** Existing file for the guest serial console; omit to disable it. */
|
|
10
|
+
consoleLogPath?: string;
|
|
11
|
+
requestTimeoutMs?: number;
|
|
12
|
+
guestAgentPort?: number;
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
14
|
+
}
|
|
15
|
+
export declare function createVirtualizationBackend(options: VirtualizationBackendOptions): DeskBackend;
|
|
16
|
+
//# sourceMappingURL=virtualization.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtualization.d.ts","sourceRoot":"","sources":["../src/virtualization.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAQjE,iGAAiG;AACjG,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,mBAAmB,CAAA;IAC9B,OAAO,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;IACxC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;CAC3B;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,4BAA4B,GAAG,WAAW,CA2G9F"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { DeskError } from './plan.js';
|
|
7
|
+
import { GUEST_AGENT_VSOCK_PORT } from './protocol.js';
|
|
8
|
+
import { assertShareManifest, attemptHandshake, connectWithRetry, createGuestMachine, defaultConnect, drainOutput, errorMessage, runToCompletion, terminate, watchExit, writeShareManifest, } from './machine.js';
|
|
9
|
+
export function createVirtualizationBackend(options) {
|
|
10
|
+
if (options.consoleLogPath && !isAbsolute(options.consoleLogPath))
|
|
11
|
+
throw new DeskError('consoleLogPath must be an absolute path.');
|
|
12
|
+
if (!isAbsolute(options.helperPath))
|
|
13
|
+
throw new DeskError('helperPath must be an absolute path.');
|
|
14
|
+
const launcher = options.launcher ?? ((command, args, { identity }) => spawn(command, [...args], {
|
|
15
|
+
stdio: ['pipe', 'pipe', 'pipe'], uid: identity?.uid, gid: identity?.gid,
|
|
16
|
+
}));
|
|
17
|
+
const timeout = options.connectTimeoutMs ?? 30_000;
|
|
18
|
+
return {
|
|
19
|
+
hypervisor: 'virtualization',
|
|
20
|
+
vmmPath: options.helperPath,
|
|
21
|
+
async boot(plan, hooks) {
|
|
22
|
+
if (plan.hypervisor !== 'virtualization')
|
|
23
|
+
throw new DeskError('Virtualization backend requires a virtualization plan.');
|
|
24
|
+
if ((options.platform ?? process.platform) !== 'darwin')
|
|
25
|
+
throw new DeskError('Virtualization requires macOS.');
|
|
26
|
+
if (plan.overlay.create)
|
|
27
|
+
await runToCompletion(launcher(plan.overlay.create.command, plan.overlay.create.args, { identity: plan.launcherIdentity }), `overlay creation for desk ${plan.deskId}`);
|
|
28
|
+
// A saved machine restores only into the device list it was saved with.
|
|
29
|
+
if (plan.snapshot.restoring) {
|
|
30
|
+
await assertShareManifest(plan.snapshot.dir, plan.shares).catch((error) => {
|
|
31
|
+
throw new DeskError(`Virtualization desk ${plan.deskId} cannot be restored: ${errorMessage(error)}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
await mkdir(dirname(plan.vsock.socketPath), { recursive: true, mode: 0o700 });
|
|
35
|
+
const child = launcher(options.helperPath, [], { identity: plan.launcherIdentity });
|
|
36
|
+
const exit = watchExit(child);
|
|
37
|
+
const log = drainOutput(child, 8192, ['stderr']);
|
|
38
|
+
if (!child.stdin || !child.stdout) {
|
|
39
|
+
await terminate(child, exit, 2000);
|
|
40
|
+
throw new DeskError('Virtualization helper requires piped stdin and stdout.');
|
|
41
|
+
}
|
|
42
|
+
const input = child.stdin;
|
|
43
|
+
// A helper exit may close stdin while a write is in flight.
|
|
44
|
+
input.on('error', () => undefined);
|
|
45
|
+
const pending = new Map();
|
|
46
|
+
const lines = createInterface({ input: child.stdout });
|
|
47
|
+
lines.on('line', (line) => {
|
|
48
|
+
let message;
|
|
49
|
+
try {
|
|
50
|
+
message = JSON.parse(line);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!message || typeof message !== 'object' || typeof message.id !== 'string')
|
|
56
|
+
return;
|
|
57
|
+
const entry = pending.get(message.id);
|
|
58
|
+
if (!entry)
|
|
59
|
+
return;
|
|
60
|
+
pending.delete(message.id);
|
|
61
|
+
clearTimeout(entry.timer);
|
|
62
|
+
if (message.ok === true && message.result && typeof message.result === 'object')
|
|
63
|
+
entry.resolve(message.result);
|
|
64
|
+
else
|
|
65
|
+
entry.reject(new DeskError(String(message.error ?? 'Helper request failed.')));
|
|
66
|
+
});
|
|
67
|
+
void exit.promise.then(() => {
|
|
68
|
+
for (const entry of pending.values()) {
|
|
69
|
+
clearTimeout(entry.timer);
|
|
70
|
+
entry.reject(new DeskError(`Virtualization helper exited: ${log.tail()}`));
|
|
71
|
+
}
|
|
72
|
+
pending.clear();
|
|
73
|
+
lines.close();
|
|
74
|
+
});
|
|
75
|
+
function call(op, fields = {}) {
|
|
76
|
+
if (exit.done)
|
|
77
|
+
return Promise.reject(new DeskError('Virtualization helper exited.'));
|
|
78
|
+
const id = randomUUID();
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new DeskError(`Virtualization helper ${op} timed out.`)); }, timeout);
|
|
81
|
+
pending.set(id, { resolve, reject, timer });
|
|
82
|
+
input.write(`${JSON.stringify({ ...fields, id, op })}\n`, (error) => {
|
|
83
|
+
if (error) {
|
|
84
|
+
const entry = pending.get(id);
|
|
85
|
+
if (entry) {
|
|
86
|
+
clearTimeout(entry.timer);
|
|
87
|
+
pending.delete(id);
|
|
88
|
+
reject(error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const stop = async () => {
|
|
95
|
+
try {
|
|
96
|
+
if (!exit.done)
|
|
97
|
+
await call('stop');
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await terminate(child, exit, 2000);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
const identifierPath = join(plan.snapshot.dir, 'machine-id');
|
|
105
|
+
const identifier = plan.snapshot.restoring ? await readFile(identifierPath, 'utf8') : undefined;
|
|
106
|
+
const boot = await call('boot', {
|
|
107
|
+
kernel: plan.kernelPath, initrd: plan.initramfsPath, cmdline: plan.kernelCmdline,
|
|
108
|
+
...(options.consoleLogPath ? { consoleLog: options.consoleLogPath } : {}),
|
|
109
|
+
disk: plan.overlay.path, memoryMb: plan.memoryMb, vcpus: plan.vcpus,
|
|
110
|
+
vsockSocket: plan.vsock.socketPath, portSocket: plan.ports.socketPath,
|
|
111
|
+
shares: plan.shares.map(({ tag, path, readOnly }) => ({ tag, path, readOnly })),
|
|
112
|
+
...(plan.snapshot.restoring ? { restoreFrom: join(plan.snapshot.dir, 'state'), machineIdentifier: identifier } : {}),
|
|
113
|
+
network: { ...plan.network, macAddress: plan.network.mac },
|
|
114
|
+
});
|
|
115
|
+
if (typeof boot.machineIdentifier !== 'string')
|
|
116
|
+
throw new DeskError('Helper did not return a machine identifier.');
|
|
117
|
+
const machineIdentifier = boot.machineIdentifier;
|
|
118
|
+
if (plan.snapshot.restoring) {
|
|
119
|
+
if (!hooks?.onRestored)
|
|
120
|
+
throw new DeskError('Restore requires snapshot invalidation before resume.');
|
|
121
|
+
await hooks.onRestored();
|
|
122
|
+
await call('resume');
|
|
123
|
+
}
|
|
124
|
+
const connectAgent = (abandoned) => connectWithRetry({
|
|
125
|
+
connect: options.connect ?? defaultConnect, now: Date.now, socketPath: plan.vsock.socketPath,
|
|
126
|
+
port: options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT, timeoutMs: timeout, retryDelayMs: 100,
|
|
127
|
+
attemptTimeoutMs: 5000, exited: exit, abandoned,
|
|
128
|
+
});
|
|
129
|
+
const stream = await connectAgent();
|
|
130
|
+
return createGuestMachine({
|
|
131
|
+
deskId: plan.deskId, exit, stream, reconnect: connectAgent, reconnectWindowMs: timeout,
|
|
132
|
+
now: Date.now, idFactory: randomUUID, requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
|
|
133
|
+
stop,
|
|
134
|
+
connect: (port) => attemptHandshake(options.connect ?? defaultConnect, plan.ports.socketPath, port, 5000),
|
|
135
|
+
...(boot.saveRestore === true ? {
|
|
136
|
+
park: async (dir) => {
|
|
137
|
+
try {
|
|
138
|
+
await call('save', { path: join(dir, 'state') });
|
|
139
|
+
await writeFile(join(dir, 'machine-id'), machineIdentifier, { mode: 0o600 });
|
|
140
|
+
await writeShareManifest(dir, plan.shares);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await stop();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
} : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
await terminate(child, exit, 2000);
|
|
151
|
+
throw new DeskError(`Virtualization desk ${plan.deskId} failed: ${errorMessage(error)}${log.tail() ? ` (${log.tail()})` : ''}`);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=virtualization.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtualization.js","sourceRoot":"","sources":["../src/virtualization.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process'\nimport { randomUUID } from 'node:crypto'\nimport { mkdir, readFile, writeFile } from 'node:fs/promises'\nimport { dirname, isAbsolute, join } from 'node:path'\nimport { createInterface } from 'node:readline'\nimport type { Duplex } from 'node:stream'\nimport type { DeskBackend, DeskProcessLauncher } from './backend'\nimport { DeskError } from './plan'\nimport { GUEST_AGENT_VSOCK_PORT } from './protocol'\nimport {\n assertShareManifest, attemptHandshake, connectWithRetry, createGuestMachine, defaultConnect, drainOutput,\n errorMessage, runToCompletion, terminate, watchExit, writeShareManifest,\n} from './machine'\n\n/** A signed helper is supplied by the host; it is not downloaded or executed at install time. */\nexport interface VirtualizationBackendOptions {\n helperPath: string\n launcher?: DeskProcessLauncher\n connect?: (socketPath: string) => Duplex\n connectTimeoutMs?: number\n /** Existing file for the guest serial console; omit to disable it. */\n consoleLogPath?: string\n requestTimeoutMs?: number\n guestAgentPort?: number\n platform?: NodeJS.Platform\n}\n\nexport function createVirtualizationBackend(options: VirtualizationBackendOptions): DeskBackend {\n if (options.consoleLogPath && !isAbsolute(options.consoleLogPath)) throw new DeskError('consoleLogPath must be an absolute path.')\n if (!isAbsolute(options.helperPath)) throw new DeskError('helperPath must be an absolute path.')\n const launcher: DeskProcessLauncher = options.launcher ?? ((command, args, { identity }) => spawn(command, [...args], {\n stdio: ['pipe', 'pipe', 'pipe'], uid: identity?.uid, gid: identity?.gid,\n }))\n const timeout = options.connectTimeoutMs ?? 30_000\n return {\n hypervisor: 'virtualization',\n vmmPath: options.helperPath,\n async boot(plan, hooks) {\n if (plan.hypervisor !== 'virtualization') throw new DeskError('Virtualization backend requires a virtualization plan.')\n if ((options.platform ?? process.platform) !== 'darwin') throw new DeskError('Virtualization requires macOS.')\n if (plan.overlay.create) await runToCompletion(launcher(plan.overlay.create.command, plan.overlay.create.args, { identity: plan.launcherIdentity }), `overlay creation for desk ${plan.deskId}`)\n // A saved machine restores only into the device list it was saved with.\n if (plan.snapshot.restoring) {\n await assertShareManifest(plan.snapshot.dir, plan.shares).catch((error: unknown) => {\n throw new DeskError(`Virtualization desk ${plan.deskId} cannot be restored: ${errorMessage(error)}`)\n })\n }\n await mkdir(dirname(plan.vsock.socketPath), { recursive: true, mode: 0o700 })\n const child = launcher(options.helperPath, [], { identity: plan.launcherIdentity })\n const exit = watchExit(child)\n const log = drainOutput(child, 8192, ['stderr'])\n if (!child.stdin || !child.stdout) {\n await terminate(child, exit, 2000)\n throw new DeskError('Virtualization helper requires piped stdin and stdout.')\n }\n const input = child.stdin\n // A helper exit may close stdin while a write is in flight.\n input.on('error', () => undefined)\n const pending = new Map<string, { resolve: (value: Record<string, unknown>) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>()\n const lines = createInterface({ input: child.stdout })\n lines.on('line', (line) => {\n let message: Record<string, unknown>\n try { message = JSON.parse(line) as Record<string, unknown> } catch { return }\n if (!message || typeof message !== 'object' || typeof message.id !== 'string') return\n const entry = pending.get(message.id)\n if (!entry) return\n pending.delete(message.id); clearTimeout(entry.timer)\n if (message.ok === true && message.result && typeof message.result === 'object') entry.resolve(message.result as Record<string, unknown>)\n else entry.reject(new DeskError(String(message.error ?? 'Helper request failed.')))\n })\n void exit.promise.then(() => {\n for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DeskError(`Virtualization helper exited: ${log.tail()}`)) }\n pending.clear(); lines.close()\n })\n function call(op: string, fields: Record<string, unknown> = {}): Promise<Record<string, unknown>> {\n if (exit.done) return Promise.reject(new DeskError('Virtualization helper exited.'))\n const id = randomUUID()\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => { pending.delete(id); reject(new DeskError(`Virtualization helper ${op} timed out.`)) }, timeout)\n pending.set(id, { resolve, reject, timer })\n input.write(`${JSON.stringify({ ...fields, id, op })}\\n`, (error) => {\n if (error) { const entry = pending.get(id); if (entry) { clearTimeout(entry.timer); pending.delete(id); reject(error) } }\n })\n })\n }\n const stop = async () => {\n try { if (!exit.done) await call('stop') } finally { await terminate(child, exit, 2000) }\n }\n try {\n const identifierPath = join(plan.snapshot.dir, 'machine-id')\n const identifier = plan.snapshot.restoring ? await readFile(identifierPath, 'utf8') : undefined\n const boot = await call('boot', {\n kernel: plan.kernelPath, initrd: plan.initramfsPath, cmdline: plan.kernelCmdline,\n ...(options.consoleLogPath ? { consoleLog: options.consoleLogPath } : {}),\n disk: plan.overlay.path, memoryMb: plan.memoryMb, vcpus: plan.vcpus,\n vsockSocket: plan.vsock.socketPath, portSocket: plan.ports.socketPath,\n shares: plan.shares.map(({ tag, path, readOnly }) => ({ tag, path, readOnly })),\n ...(plan.snapshot.restoring ? { restoreFrom: join(plan.snapshot.dir, 'state'), machineIdentifier: identifier } : {}),\n network: { ...plan.network, macAddress: plan.network.mac },\n })\n if (typeof boot.machineIdentifier !== 'string') throw new DeskError('Helper did not return a machine identifier.')\n const machineIdentifier = boot.machineIdentifier\n if (plan.snapshot.restoring) {\n if (!hooks?.onRestored) throw new DeskError('Restore requires snapshot invalidation before resume.')\n await hooks.onRestored()\n await call('resume')\n }\n const connectAgent = (abandoned?: () => boolean) => connectWithRetry({\n connect: options.connect ?? defaultConnect, now: Date.now, socketPath: plan.vsock.socketPath,\n port: options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT, timeoutMs: timeout, retryDelayMs: 100,\n attemptTimeoutMs: 5000, exited: exit, abandoned,\n })\n const stream = await connectAgent()\n return createGuestMachine({\n deskId: plan.deskId, exit, stream, reconnect: connectAgent, reconnectWindowMs: timeout,\n now: Date.now, idFactory: randomUUID, requestTimeoutMs: options.requestTimeoutMs ?? 30_000,\n stop,\n connect: (port) => attemptHandshake(options.connect ?? defaultConnect, plan.ports.socketPath, port, 5000),\n ...(boot.saveRestore === true ? {\n park: async (dir: string) => {\n try {\n await call('save', { path: join(dir, 'state') })\n await writeFile(join(dir, 'machine-id'), machineIdentifier, { mode: 0o600 })\n await writeShareManifest(dir, plan.shares)\n } finally { await stop() }\n },\n } : {}),\n })\n } catch (error) {\n await terminate(child, exit, 2000)\n throw new DeskError(`Virtualization desk ${plan.deskId} failed: ${errorMessage(error)}${log.tail() ? ` (${log.tail()})` : ''}`)\n }\n },\n }\n}\n"]}
|
package/vz/Package.swift
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// swift-tools-version: 6.0
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
let package = Package(
|
|
5
|
+
name: "appkit-desk-vz",
|
|
6
|
+
platforms: [.macOS(.v14)],
|
|
7
|
+
products: [
|
|
8
|
+
.executable(name: "appkit-desk-vz", targets: ["appkit-desk-vz"]),
|
|
9
|
+
],
|
|
10
|
+
targets: [
|
|
11
|
+
// The userspace network: Ethernet in, host sockets out, and the fence in
|
|
12
|
+
// between. No Virtualization.framework dependency, so it is unit-tested on
|
|
13
|
+
// its own against an in-memory guest.
|
|
14
|
+
.target(name: "DeskNet"),
|
|
15
|
+
.executableTarget(
|
|
16
|
+
name: "appkit-desk-vz",
|
|
17
|
+
dependencies: ["DeskNet"],
|
|
18
|
+
linkerSettings: [.linkedFramework("Virtualization")]
|
|
19
|
+
),
|
|
20
|
+
.testTarget(name: "DeskNetTests", dependencies: ["DeskNet"]),
|
|
21
|
+
],
|
|
22
|
+
swiftLanguageModes: [.v6]
|
|
23
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// IPv4 and MAC addresses, and the CIDR blocks the fence is written in.
|
|
2
|
+
public struct IPv4: Hashable, Comparable, Sendable, CustomStringConvertible {
|
|
3
|
+
public var value: UInt32
|
|
4
|
+
|
|
5
|
+
public init(_ value: UInt32) { self.value = value }
|
|
6
|
+
|
|
7
|
+
public init(_ a: UInt8, _ b: UInt8, _ c: UInt8, _ d: UInt8) {
|
|
8
|
+
value = UInt32(a) << 24 | UInt32(b) << 16 | UInt32(c) << 8 | UInt32(d)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// Strict dotted-quad parsing: four decimal octets, nothing else.
|
|
12
|
+
public init?(_ text: String) {
|
|
13
|
+
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
|
14
|
+
guard parts.count == 4 else { return nil }
|
|
15
|
+
var result: UInt32 = 0
|
|
16
|
+
for part in parts {
|
|
17
|
+
guard !part.isEmpty, part.count <= 3, part.allSatisfy({ $0.isASCII && $0.isNumber }),
|
|
18
|
+
let octet = UInt32(part), octet <= 255
|
|
19
|
+
else { return nil }
|
|
20
|
+
result = result << 8 | octet
|
|
21
|
+
}
|
|
22
|
+
value = result
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
init(bytes: [UInt8], at offset: Int) {
|
|
26
|
+
value = UInt32(bytes[offset]) << 24 | UInt32(bytes[offset + 1]) << 16
|
|
27
|
+
| UInt32(bytes[offset + 2]) << 8 | UInt32(bytes[offset + 3])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public var bytes: [UInt8] {
|
|
31
|
+
[UInt8(value >> 24 & 0xff), UInt8(value >> 16 & 0xff), UInt8(value >> 8 & 0xff), UInt8(value & 0xff)]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public var description: String { bytes.map(String.init).joined(separator: ".") }
|
|
35
|
+
|
|
36
|
+
public static let zero = IPv4(0)
|
|
37
|
+
public static let broadcast = IPv4(0xffff_ffff)
|
|
38
|
+
|
|
39
|
+
public static func < (lhs: IPv4, rhs: IPv4) -> Bool { lhs.value < rhs.value }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public struct CIDR: Hashable, Sendable, CustomStringConvertible {
|
|
43
|
+
public let base: IPv4
|
|
44
|
+
public let prefix: Int
|
|
45
|
+
|
|
46
|
+
public init(_ base: IPv4, _ prefix: Int) {
|
|
47
|
+
precondition((0...32).contains(prefix), "prefix out of range")
|
|
48
|
+
self.prefix = prefix
|
|
49
|
+
self.base = IPv4(base.value & CIDR.mask(prefix))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public init?(_ text: String) {
|
|
53
|
+
let parts = text.split(separator: "/")
|
|
54
|
+
guard parts.count == 2, let base = IPv4(String(parts[0])), let prefix = Int(parts[1]),
|
|
55
|
+
(0...32).contains(prefix)
|
|
56
|
+
else { return nil }
|
|
57
|
+
self.init(base, prefix)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public var mask: UInt32 { CIDR.mask(prefix) }
|
|
61
|
+
|
|
62
|
+
public func contains(_ address: IPv4) -> Bool {
|
|
63
|
+
address.value & mask == base.value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public var description: String { "\(base)/\(prefix)" }
|
|
67
|
+
|
|
68
|
+
static func mask(_ prefix: Int) -> UInt32 {
|
|
69
|
+
prefix == 0 ? 0 : UInt32.max << UInt32(32 - prefix)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public struct MAC: Hashable, Sendable, CustomStringConvertible {
|
|
74
|
+
public let bytes: [UInt8]
|
|
75
|
+
|
|
76
|
+
public init(_ bytes: [UInt8]) {
|
|
77
|
+
precondition(bytes.count == 6, "a MAC address is six bytes")
|
|
78
|
+
self.bytes = bytes
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public init?(_ text: String) {
|
|
82
|
+
let parts = text.split(separator: ":")
|
|
83
|
+
guard parts.count == 6 else { return nil }
|
|
84
|
+
var bytes: [UInt8] = []
|
|
85
|
+
for part in parts {
|
|
86
|
+
guard part.count == 2, let byte = UInt8(part, radix: 16) else { return nil }
|
|
87
|
+
bytes.append(byte)
|
|
88
|
+
}
|
|
89
|
+
self.bytes = bytes
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public var description: String {
|
|
93
|
+
bytes.map { byte in
|
|
94
|
+
let hex = String(byte, radix: 16)
|
|
95
|
+
return hex.count == 1 ? "0" + hex : hex
|
|
96
|
+
}.joined(separator: ":")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public static let broadcast = MAC([0xff, 0xff, 0xff, 0xff, 0xff, 0xff])
|
|
100
|
+
/// The gateway's address on the virtual link. Locally administered and
|
|
101
|
+
/// constant, because a restored guest keeps it in its ARP cache.
|
|
102
|
+
public static let gateway = MAC([0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xdd])
|
|
103
|
+
|
|
104
|
+
public var isBroadcast: Bool { self == .broadcast }
|
|
105
|
+
public var isMulticast: Bool { bytes[0] & 1 == 1 }
|
|
106
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/// The smallest DHCP server that hands one guest its one address.
|
|
2
|
+
///
|
|
3
|
+
/// The link has exactly one client, so there is no pool and no lease table:
|
|
4
|
+
/// every DISCOVER is offered the configured guest address and every REQUEST
|
|
5
|
+
/// for it is acknowledged. A guest configured statically never asks, and that
|
|
6
|
+
/// works too — the server is a convenience, not a gate.
|
|
7
|
+
public struct DHCPServer: Sendable {
|
|
8
|
+
public let guest: IPv4
|
|
9
|
+
public let gateway: IPv4
|
|
10
|
+
public let prefixLength: Int
|
|
11
|
+
public let dns: [IPv4]
|
|
12
|
+
public let leaseSeconds: UInt32
|
|
13
|
+
|
|
14
|
+
public init(guest: IPv4, gateway: IPv4, prefixLength: Int, dns: [IPv4], leaseSeconds: UInt32 = 86_400) {
|
|
15
|
+
self.guest = guest
|
|
16
|
+
self.gateway = gateway
|
|
17
|
+
self.prefixLength = prefixLength
|
|
18
|
+
self.dns = dns
|
|
19
|
+
self.leaseSeconds = leaseSeconds
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static let magicCookie: [UInt8] = [99, 130, 83, 99]
|
|
23
|
+
|
|
24
|
+
enum MessageType: UInt8 {
|
|
25
|
+
case discover = 1, offer, request, decline, ack, nak, release, inform
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public struct Reply: Sendable {
|
|
29
|
+
/// The BOOTP payload to send from the server port to the client port.
|
|
30
|
+
public let payload: [UInt8]
|
|
31
|
+
/// Where the reply goes on the link: the client's hardware address.
|
|
32
|
+
public let clientMAC: MAC
|
|
33
|
+
/// True when the guest was just given (or confirmed in) its address.
|
|
34
|
+
public let assigned: Bool
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Answer one client message, or nil when it needs no answer.
|
|
38
|
+
public func respond(to bytes: [UInt8]) -> Reply? {
|
|
39
|
+
guard bytes.count >= 240, bytes[0] == 1, bytes[1] == 1, bytes[2] == 6,
|
|
40
|
+
Array(bytes[236..<240]) == Self.magicCookie
|
|
41
|
+
else { return nil }
|
|
42
|
+
let options = Self.options(bytes)
|
|
43
|
+
guard let typeByte = options[53]?.first, let type = MessageType(rawValue: typeByte) else {
|
|
44
|
+
return nil
|
|
45
|
+
}
|
|
46
|
+
let clientMAC = MAC(Array(bytes[28..<34]))
|
|
47
|
+
let ciaddr = IPv4(bytes: bytes, at: 12)
|
|
48
|
+
switch type {
|
|
49
|
+
case .discover:
|
|
50
|
+
return Reply(payload: reply(to: bytes, type: .offer, yiaddr: guest), clientMAC: clientMAC, assigned: false)
|
|
51
|
+
case .request:
|
|
52
|
+
let requested = options[50].flatMap { $0.count == 4 ? IPv4(bytes: $0, at: 0) : nil } ?? ciaddr
|
|
53
|
+
if requested == guest || requested == .zero {
|
|
54
|
+
return Reply(payload: reply(to: bytes, type: .ack, yiaddr: guest), clientMAC: clientMAC, assigned: true)
|
|
55
|
+
}
|
|
56
|
+
return Reply(payload: reply(to: bytes, type: .nak, yiaddr: .zero), clientMAC: clientMAC, assigned: false)
|
|
57
|
+
case .inform:
|
|
58
|
+
return Reply(payload: reply(to: bytes, type: .ack, yiaddr: .zero, inform: true), clientMAC: clientMAC, assigned: false)
|
|
59
|
+
case .offer, .decline, .ack, .nak, .release:
|
|
60
|
+
return nil
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static func options(_ bytes: [UInt8]) -> [UInt8: [UInt8]] {
|
|
65
|
+
var result: [UInt8: [UInt8]] = [:]
|
|
66
|
+
var index = 240
|
|
67
|
+
while index < bytes.count {
|
|
68
|
+
let code = bytes[index]
|
|
69
|
+
if code == 255 { break }
|
|
70
|
+
if code == 0 {
|
|
71
|
+
index += 1
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
guard index + 1 < bytes.count else { break }
|
|
75
|
+
let length = Int(bytes[index + 1])
|
|
76
|
+
guard index + 2 + length <= bytes.count else { break }
|
|
77
|
+
result[code] = Array(bytes[(index + 2)..<(index + 2 + length)])
|
|
78
|
+
index += 2 + length
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private func reply(to request: [UInt8], type: MessageType, yiaddr: IPv4, inform: Bool = false) -> [UInt8] {
|
|
84
|
+
var bytes = [UInt8](repeating: 0, count: 240)
|
|
85
|
+
bytes[0] = 2
|
|
86
|
+
bytes[1] = 1
|
|
87
|
+
bytes[2] = 6
|
|
88
|
+
bytes[4..<8] = request[4..<8] // xid
|
|
89
|
+
bytes[10..<12] = request[10..<12] // flags
|
|
90
|
+
bytes[12..<16] = request[12..<16] // ciaddr
|
|
91
|
+
put32(&bytes, 16, yiaddr.value)
|
|
92
|
+
bytes[24..<28] = request[24..<28] // giaddr
|
|
93
|
+
bytes[28..<44] = request[28..<44] // chaddr
|
|
94
|
+
bytes[236..<240] = Self.magicCookie[0..<4]
|
|
95
|
+
var options: [UInt8] = [53, 1, type.rawValue, 54, 4] + gateway.bytes
|
|
96
|
+
if type != .nak {
|
|
97
|
+
options += [1, 4] + IPv4(CIDR.mask(prefixLength)).bytes
|
|
98
|
+
options += [3, 4] + gateway.bytes
|
|
99
|
+
if !dns.isEmpty {
|
|
100
|
+
options += [6, UInt8(dns.count * 4)] + dns.flatMap(\.bytes)
|
|
101
|
+
}
|
|
102
|
+
if !inform {
|
|
103
|
+
var lease = [UInt8](repeating: 0, count: 4)
|
|
104
|
+
put32(&lease, 0, leaseSeconds)
|
|
105
|
+
options += [51, 4] + lease
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
options.append(255)
|
|
109
|
+
return bytes + options
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/// Where a guest may send traffic.
|
|
2
|
+
///
|
|
3
|
+
/// The guest reaches the public internet and nothing else by default: not the
|
|
4
|
+
/// Mac's LAN, not link-local services (cloud metadata lives at 169.254.169.254),
|
|
5
|
+
/// not carrier-grade NAT space (where Tailscale and similar overlays put their
|
|
6
|
+
/// peers), and not the Mac itself on any of its addresses. `allowLan` lifts
|
|
7
|
+
/// that, for a computer the owner has explicitly let onto their network.
|
|
8
|
+
///
|
|
9
|
+
/// Some destinations are never meaningful through a NAT whatever the setting:
|
|
10
|
+
/// "this network", loopback (the guest's own loopback never leaves the guest,
|
|
11
|
+
/// so a packet for 127/8 arriving here is spoofed or broken), multicast, and
|
|
12
|
+
/// the reserved, documentation and broadcast space.
|
|
13
|
+
public struct EgressFence: Sendable {
|
|
14
|
+
public var allowLan: Bool
|
|
15
|
+
|
|
16
|
+
public init(allowLan: Bool) { self.allowLan = allowLan }
|
|
17
|
+
|
|
18
|
+
public static let alwaysDenied: [CIDR] = [
|
|
19
|
+
CIDR(IPv4(0, 0, 0, 0), 8),
|
|
20
|
+
CIDR(IPv4(127, 0, 0, 0), 8),
|
|
21
|
+
CIDR(IPv4(192, 0, 2, 0), 24),
|
|
22
|
+
CIDR(IPv4(198, 51, 100, 0), 24),
|
|
23
|
+
CIDR(IPv4(203, 0, 113, 0), 24),
|
|
24
|
+
CIDR(IPv4(224, 0, 0, 0), 4),
|
|
25
|
+
CIDR(IPv4(240, 0, 0, 0), 4),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/// Private, link-local, shared and special-purpose space a guest is fenced
|
|
29
|
+
/// out of unless the LAN is allowed.
|
|
30
|
+
public static let localRanges: [CIDR] = [
|
|
31
|
+
CIDR(IPv4(10, 0, 0, 0), 8),
|
|
32
|
+
CIDR(IPv4(172, 16, 0, 0), 12),
|
|
33
|
+
CIDR(IPv4(192, 168, 0, 0), 16),
|
|
34
|
+
CIDR(IPv4(169, 254, 0, 0), 16),
|
|
35
|
+
CIDR(IPv4(100, 64, 0, 0), 10),
|
|
36
|
+
CIDR(IPv4(192, 0, 0, 0), 24),
|
|
37
|
+
CIDR(IPv4(198, 18, 0, 0), 15),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
public enum Verdict: Equatable, Sendable {
|
|
41
|
+
case allowed
|
|
42
|
+
/// Never routable through the NAT.
|
|
43
|
+
case invalid
|
|
44
|
+
/// Fenced off: local network space or the Mac itself.
|
|
45
|
+
case fenced
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Judge a destination. `hostAddresses` are the Mac's own IPv4 addresses on
|
|
49
|
+
/// every interface, which are fenced even when public.
|
|
50
|
+
public func verdict(for destination: IPv4, hostAddresses: Set<IPv4>) -> Verdict {
|
|
51
|
+
if Self.alwaysDenied.contains(where: { $0.contains(destination) }) { return .invalid }
|
|
52
|
+
if allowLan { return .allowed }
|
|
53
|
+
if Self.localRanges.contains(where: { $0.contains(destination) }) { return .fenced }
|
|
54
|
+
if hostAddresses.contains(destination) { return .fenced }
|
|
55
|
+
return .allowed
|
|
56
|
+
}
|
|
57
|
+
}
|