@braedonsaunders/appkit-desk 0.7.0 → 0.8.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.
Files changed (46) hide show
  1. package/README.md +55 -0
  2. package/backend.d.ts +28 -7
  3. package/backend.d.ts.map +1 -1
  4. package/backend.js +18 -521
  5. package/backend.js.map +1 -1
  6. package/host.d.ts +4 -1
  7. package/host.d.ts.map +1 -1
  8. package/host.js +31 -16
  9. package/host.js.map +1 -1
  10. package/index.d.ts +2 -0
  11. package/index.d.ts.map +1 -1
  12. package/index.js +2 -0
  13. package/index.js.map +1 -1
  14. package/machine.d.ts +145 -0
  15. package/machine.d.ts.map +1 -0
  16. package/machine.js +518 -0
  17. package/machine.js.map +1 -0
  18. package/package.json +2 -2
  19. package/plan.d.ts +129 -31
  20. package/plan.d.ts.map +1 -1
  21. package/plan.js +165 -9
  22. package/plan.js.map +1 -1
  23. package/port-relay.d.ts +13 -0
  24. package/port-relay.d.ts.map +1 -0
  25. package/port-relay.js +56 -0
  26. package/port-relay.js.map +1 -0
  27. package/virtualization.d.ts +16 -0
  28. package/virtualization.d.ts.map +1 -0
  29. package/virtualization.js +148 -0
  30. package/virtualization.js.map +1 -0
  31. package/vz/Package.swift +23 -0
  32. package/vz/Sources/DeskNet/Addresses.swift +106 -0
  33. package/vz/Sources/DeskNet/DHCP.swift +111 -0
  34. package/vz/Sources/DeskNet/Fence.swift +57 -0
  35. package/vz/Sources/DeskNet/NetworkStack.swift +547 -0
  36. package/vz/Sources/DeskNet/Packets.swift +355 -0
  37. package/vz/Sources/DeskNet/Plumbing.swift +312 -0
  38. package/vz/Sources/DeskNet/Sockets.swift +221 -0
  39. package/vz/Sources/DeskNet/TCPFlow.swift +448 -0
  40. package/vz/Sources/appkit-desk-vz/Control.swift +152 -0
  41. package/vz/Sources/appkit-desk-vz/Helper.swift +505 -0
  42. package/vz/Sources/appkit-desk-vz/main.swift +38 -0
  43. package/vz/Tests/DeskNetTests/DeskNetTests.swift +426 -0
  44. package/vz/appkit-desk-vz.entitlements +8 -0
  45. package/vz/build.sh +23 -0
  46. package/vz/check/extract-boot.py +221 -0
package/port-relay.js ADDED
@@ -0,0 +1,56 @@
1
+ import { createServer } from 'node:net';
2
+ import { DeskError } from './plan.js';
3
+ /** Expose one guest TCP port to local HTTP, WebSocket or other TCP clients. */
4
+ export async function listenDeskPort(desk, port) {
5
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
6
+ throw new DeskError('Guest TCP port must be between 1 and 65535.');
7
+ const clients = new Set();
8
+ const guests = new Set();
9
+ let closed = false;
10
+ let closing;
11
+ const server = createServer((client) => {
12
+ if (closed) {
13
+ client.destroy();
14
+ return;
15
+ }
16
+ clients.add(client);
17
+ client.once('close', () => clients.delete(client));
18
+ client.on('error', () => client.destroy());
19
+ void Promise.resolve().then(() => desk.connect(port)).then((guest) => {
20
+ if (closed || client.destroyed) {
21
+ guest.destroy();
22
+ return;
23
+ }
24
+ guests.add(guest);
25
+ guest.once('close', () => { guests.delete(guest); client.destroy(); });
26
+ guest.on('error', () => { guest.destroy(); client.destroy(); });
27
+ client.once('close', () => guest.destroy());
28
+ client.pipe(guest).pipe(client);
29
+ }, () => client.destroy());
30
+ });
31
+ await new Promise((resolve, reject) => {
32
+ const onError = (error) => reject(error);
33
+ server.once('error', onError);
34
+ server.listen(0, '127.0.0.1', () => { server.off('error', onError); resolve(); });
35
+ });
36
+ // Errors after listen must not become unhandled process errors.
37
+ server.on('error', () => { void close(); });
38
+ const address = server.address();
39
+ if (!address || typeof address === 'string') {
40
+ server.close();
41
+ throw new DeskError('Could not determine desk relay address.');
42
+ }
43
+ function close() {
44
+ if (closing)
45
+ return closing;
46
+ closed = true;
47
+ for (const client of clients)
48
+ client.destroy();
49
+ for (const guest of guests)
50
+ guest.destroy();
51
+ closing = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
52
+ return closing;
53
+ }
54
+ return { host: '127.0.0.1', port: address.port, close };
55
+ }
56
+ //# sourceMappingURL=port-relay.js.map
@@ -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,CAmG9F"}
@@ -0,0 +1,148 @@
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 { attemptHandshake, connectWithRetry, createGuestMachine, defaultConnect, drainOutput, errorMessage, runToCompletion, terminate, watchExit, } 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
+ await mkdir(dirname(plan.vsock.socketPath), { recursive: true, mode: 0o700 });
29
+ const child = launcher(options.helperPath, [], { identity: plan.launcherIdentity });
30
+ const exit = watchExit(child);
31
+ const log = drainOutput(child, 8192, ['stderr']);
32
+ if (!child.stdin || !child.stdout) {
33
+ await terminate(child, exit, 2000);
34
+ throw new DeskError('Virtualization helper requires piped stdin and stdout.');
35
+ }
36
+ const input = child.stdin;
37
+ // A helper exit may close stdin while a write is in flight.
38
+ input.on('error', () => undefined);
39
+ const pending = new Map();
40
+ const lines = createInterface({ input: child.stdout });
41
+ lines.on('line', (line) => {
42
+ let message;
43
+ try {
44
+ message = JSON.parse(line);
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ if (!message || typeof message !== 'object' || typeof message.id !== 'string')
50
+ return;
51
+ const entry = pending.get(message.id);
52
+ if (!entry)
53
+ return;
54
+ pending.delete(message.id);
55
+ clearTimeout(entry.timer);
56
+ if (message.ok === true && message.result && typeof message.result === 'object')
57
+ entry.resolve(message.result);
58
+ else
59
+ entry.reject(new DeskError(String(message.error ?? 'Helper request failed.')));
60
+ });
61
+ void exit.promise.then(() => {
62
+ for (const entry of pending.values()) {
63
+ clearTimeout(entry.timer);
64
+ entry.reject(new DeskError(`Virtualization helper exited: ${log.tail()}`));
65
+ }
66
+ pending.clear();
67
+ lines.close();
68
+ });
69
+ function call(op, fields = {}) {
70
+ if (exit.done)
71
+ return Promise.reject(new DeskError('Virtualization helper exited.'));
72
+ const id = randomUUID();
73
+ return new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => { pending.delete(id); reject(new DeskError(`Virtualization helper ${op} timed out.`)); }, timeout);
75
+ pending.set(id, { resolve, reject, timer });
76
+ input.write(`${JSON.stringify({ ...fields, id, op })}\n`, (error) => {
77
+ if (error) {
78
+ const entry = pending.get(id);
79
+ if (entry) {
80
+ clearTimeout(entry.timer);
81
+ pending.delete(id);
82
+ reject(error);
83
+ }
84
+ }
85
+ });
86
+ });
87
+ }
88
+ const stop = async () => {
89
+ try {
90
+ if (!exit.done)
91
+ await call('stop');
92
+ }
93
+ finally {
94
+ await terminate(child, exit, 2000);
95
+ }
96
+ };
97
+ try {
98
+ const identifierPath = join(plan.snapshot.dir, 'machine-id');
99
+ const identifier = plan.snapshot.restoring ? await readFile(identifierPath, 'utf8') : undefined;
100
+ const boot = await call('boot', {
101
+ kernel: plan.kernelPath, initrd: plan.initramfsPath, cmdline: plan.kernelCmdline,
102
+ ...(options.consoleLogPath ? { consoleLog: options.consoleLogPath } : {}),
103
+ disk: plan.overlay.path, memoryMb: plan.memoryMb, vcpus: plan.vcpus,
104
+ vsockSocket: plan.vsock.socketPath, portSocket: plan.ports.socketPath,
105
+ ...(plan.snapshot.restoring ? { restoreFrom: join(plan.snapshot.dir, 'state'), machineIdentifier: identifier } : {}),
106
+ network: { ...plan.network, macAddress: plan.network.mac },
107
+ });
108
+ if (typeof boot.machineIdentifier !== 'string')
109
+ throw new DeskError('Helper did not return a machine identifier.');
110
+ const machineIdentifier = boot.machineIdentifier;
111
+ if (plan.snapshot.restoring) {
112
+ if (!hooks?.onRestored)
113
+ throw new DeskError('Restore requires snapshot invalidation before resume.');
114
+ await hooks.onRestored();
115
+ await call('resume');
116
+ }
117
+ const connectAgent = (abandoned) => connectWithRetry({
118
+ connect: options.connect ?? defaultConnect, now: Date.now, socketPath: plan.vsock.socketPath,
119
+ port: options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT, timeoutMs: timeout, retryDelayMs: 100,
120
+ attemptTimeoutMs: 5000, exited: exit, abandoned,
121
+ });
122
+ const stream = await connectAgent();
123
+ return createGuestMachine({
124
+ deskId: plan.deskId, exit, stream, reconnect: connectAgent, reconnectWindowMs: timeout,
125
+ now: Date.now, idFactory: randomUUID, requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
126
+ stop,
127
+ connect: (port) => attemptHandshake(options.connect ?? defaultConnect, plan.ports.socketPath, port, 5000),
128
+ ...(boot.saveRestore === true ? {
129
+ park: async (dir) => {
130
+ try {
131
+ await call('save', { path: join(dir, 'state') });
132
+ await writeFile(join(dir, 'machine-id'), machineIdentifier, { mode: 0o600 });
133
+ }
134
+ finally {
135
+ await stop();
136
+ }
137
+ },
138
+ } : {}),
139
+ });
140
+ }
141
+ catch (error) {
142
+ await terminate(child, exit, 2000);
143
+ throw new DeskError(`Virtualization desk ${plan.deskId} failed: ${errorMessage(error)}${log.tail() ? ` (${log.tail()})` : ''}`);
144
+ }
145
+ },
146
+ };
147
+ }
148
+ //# 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 attemptHandshake, connectWithRetry, createGuestMachine, defaultConnect, drainOutput,\n errorMessage, runToCompletion, terminate, watchExit,\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 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 ...(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 } 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"]}
@@ -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
+ }