@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.
Files changed (46) hide show
  1. package/README.md +126 -1
  2. package/backend.d.ts +32 -7
  3. package/backend.d.ts.map +1 -1
  4. package/backend.js +107 -524
  5. package/backend.js.map +1 -1
  6. package/host.d.ts +27 -1
  7. package/host.d.ts.map +1 -1
  8. package/host.js +47 -19
  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 +154 -0
  15. package/machine.d.ts.map +1 -0
  16. package/machine.js +573 -0
  17. package/machine.js.map +1 -0
  18. package/package.json +2 -2
  19. package/plan.d.ts +192 -31
  20. package/plan.d.ts.map +1 -1
  21. package/plan.js +298 -12
  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 +156 -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 +550 -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
@@ -0,0 +1,152 @@
1
+ import Foundation
2
+
3
+ /// The control channel: newline-delimited JSON on stdin and stdout.
4
+ ///
5
+ /// Every request carries an `id` and an `op`, and is answered by exactly one
6
+ /// response with the same `id` — `{"id","ok":true,"result":{…}}` or
7
+ /// `{"id","ok":false,"error":"…"}`. Unsolicited events carry `event` and no
8
+ /// `id`. Diagnostics go to stderr, never stdout, so stdout is always parseable.
9
+ @MainActor
10
+ final class ControlChannel {
11
+ private var buffer = Data()
12
+ private let input: DispatchSourceRead
13
+ var onRequest: ((_ id: String, _ op: String, _ body: [String: Any]) -> Void)?
14
+ var onClose: (() -> Void)?
15
+
16
+ static let lineLimit = 1 << 20
17
+
18
+ init() {
19
+ let flags = fcntl(STDIN_FILENO, F_GETFL)
20
+ _ = fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK)
21
+ input = DispatchSource.makeReadSource(fileDescriptor: STDIN_FILENO, queue: .main)
22
+ input.setEventHandler { @Sendable [weak self] in Task { @MainActor in self?.readable() } }
23
+ }
24
+
25
+ func start() { input.resume() }
26
+
27
+ private func readable() {
28
+ var chunk = [UInt8](repeating: 0, count: 64 * 1024)
29
+ while true {
30
+ let count = chunk.withUnsafeMutableBytes { read(STDIN_FILENO, $0.baseAddress, $0.count) }
31
+ if count > 0 {
32
+ buffer.append(contentsOf: chunk[0..<count])
33
+ drainLines()
34
+ continue
35
+ }
36
+ if count == 0 {
37
+ // The host is gone. A helper that outlived it would leave a VM nobody
38
+ // can reach running on the Mac, so end of input is a stop.
39
+ input.cancel()
40
+ onClose?()
41
+ return
42
+ }
43
+ if errno == EAGAIN || errno == EINTR { return }
44
+ input.cancel()
45
+ onClose?()
46
+ return
47
+ }
48
+ }
49
+
50
+ private func drainLines() {
51
+ while let newline = buffer.firstIndex(of: 0x0a) {
52
+ let line = buffer[buffer.startIndex..<newline]
53
+ buffer.removeSubrange(buffer.startIndex...newline)
54
+ handle(Data(line))
55
+ }
56
+ if buffer.count > Self.lineLimit {
57
+ log("control line exceeded \(Self.lineLimit) bytes; discarding it")
58
+ buffer.removeAll()
59
+ }
60
+ }
61
+
62
+ private func handle(_ line: Data) {
63
+ if line.allSatisfy({ $0 == 0x20 || $0 == 0x0d || $0 == 0x09 }) { return }
64
+ guard let object = try? JSONSerialization.jsonObject(with: line), let body = object as? [String: Any] else {
65
+ log("ignoring a control line that is not a JSON object")
66
+ return
67
+ }
68
+ guard let id = body["id"] as? String, !id.isEmpty else {
69
+ log("ignoring a control request without an id")
70
+ return
71
+ }
72
+ guard let op = body["op"] as? String else {
73
+ respond(id, error: "Request has no op.")
74
+ return
75
+ }
76
+ onRequest?(id, op, body)
77
+ }
78
+
79
+ func respond(_ id: String, result: [String: Any]) {
80
+ write(["id": id, "ok": true, "result": result])
81
+ }
82
+
83
+ func respond(_ id: String, error: String) {
84
+ write(["id": id, "ok": false, "error": error])
85
+ }
86
+
87
+ func emit(_ event: String, _ fields: [String: Any] = [:]) {
88
+ var message = fields
89
+ message["event"] = event
90
+ write(message)
91
+ }
92
+
93
+ private func write(_ message: [String: Any]) {
94
+ guard var data = try? JSONSerialization.data(withJSONObject: message, options: [.sortedKeys]) else { return }
95
+ data.append(0x0a)
96
+ FileHandle.standardOutput.write(data)
97
+ }
98
+ }
99
+
100
+ func log(_ message: String) {
101
+ FileHandle.standardError.write(Data("appkit-desk-vz: \(message)\n".utf8))
102
+ }
103
+
104
+ /// A malformed or impossible request, reported back as the response's error.
105
+ struct RequestError: Error, CustomStringConvertible {
106
+ let description: String
107
+ init(_ description: String) { self.description = description }
108
+ }
109
+
110
+ extension Dictionary where Key == String, Value == Any {
111
+ func string(_ key: String) throws -> String {
112
+ guard let value = self[key] as? String, !value.isEmpty else {
113
+ throw RequestError("\(key) must be a non-empty string.")
114
+ }
115
+ return value
116
+ }
117
+
118
+ func optionalString(_ key: String) throws -> String? {
119
+ guard let value = self[key], !(value is NSNull) else { return nil }
120
+ guard let text = value as? String, !text.isEmpty else {
121
+ throw RequestError("\(key) must be a non-empty string when present.")
122
+ }
123
+ return text
124
+ }
125
+
126
+ func path(_ key: String) throws -> String {
127
+ let value = try string(key)
128
+ guard value.hasPrefix("/") else { throw RequestError("\(key) must be an absolute path.") }
129
+ return value
130
+ }
131
+
132
+ func optionalPath(_ key: String) throws -> String? {
133
+ guard let value = try optionalString(key) else { return nil }
134
+ guard value.hasPrefix("/") else { throw RequestError("\(key) must be an absolute path.") }
135
+ return value
136
+ }
137
+
138
+ func positiveInteger(_ key: String) throws -> Int {
139
+ guard let number = self[key] as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
140
+ number.doubleValue == number.doubleValue.rounded(), number.intValue > 0
141
+ else { throw RequestError("\(key) must be a positive integer.") }
142
+ return number.intValue
143
+ }
144
+
145
+ func bool(_ key: String, default fallback: Bool) throws -> Bool {
146
+ guard let value = self[key], !(value is NSNull) else { return fallback }
147
+ guard let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else {
148
+ throw RequestError("\(key) must be a boolean.")
149
+ }
150
+ return number.boolValue
151
+ }
152
+ }
@@ -0,0 +1,550 @@
1
+ import DeskNet
2
+ import Foundation
3
+ import Virtualization
4
+
5
+ /// A host directory shared into the guest over virtio-fs.
6
+ struct ShareRequest {
7
+ let tag: String
8
+ let directory: URL
9
+ let readOnly: Bool
10
+ }
11
+
12
+ /// Everything `boot` needs, validated before anything is created.
13
+ struct BootRequest {
14
+ let kernel: URL
15
+ let initrd: URL?
16
+ let cmdline: String
17
+ let disk: URL
18
+ let memoryMb: Int
19
+ let vcpus: Int
20
+ let vsockSocket: String
21
+ let portSocket: String?
22
+ let restoreFrom: URL?
23
+ let machineIdentifier: Data?
24
+ let consoleLog: URL?
25
+ let network: NetworkConfiguration
26
+ let shares: [ShareRequest]
27
+
28
+ init(_ body: [String: Any]) throws {
29
+ kernel = URL(fileURLWithPath: try body.path("kernel"))
30
+ initrd = try body.optionalPath("initrd").map { URL(fileURLWithPath: $0) }
31
+ cmdline = (body["cmdline"] as? String) ?? ""
32
+ disk = URL(fileURLWithPath: try body.path("disk"))
33
+ memoryMb = try body.positiveInteger("memoryMb")
34
+ vcpus = try body.positiveInteger("vcpus")
35
+ vsockSocket = try body.path("vsockSocket")
36
+ portSocket = try body.optionalPath("portSocket")
37
+ restoreFrom = try body.optionalPath("restoreFrom").map { URL(fileURLWithPath: $0) }
38
+ if let encoded = try body.optionalString("machineIdentifier") {
39
+ guard let data = Data(base64Encoded: encoded) else {
40
+ throw RequestError("machineIdentifier must be base64.")
41
+ }
42
+ machineIdentifier = data
43
+ } else {
44
+ machineIdentifier = nil
45
+ }
46
+ consoleLog = try body.optionalPath("consoleLog").map { URL(fileURLWithPath: $0) }
47
+ if restoreFrom != nil && machineIdentifier == nil {
48
+ throw RequestError("restoreFrom requires the machineIdentifier the machine was saved with.")
49
+ }
50
+ shares = try Self.shares(body["shares"])
51
+
52
+ let network = (body["network"] as? [String: Any]) ?? [:]
53
+ var configuration = NetworkConfiguration(allowLan: try network.bool("allowLan", default: false))
54
+ if let text = try network.optionalString("macAddress") {
55
+ guard let mac = MAC(text), !mac.isMulticast else { throw RequestError("network.macAddress is not a unicast MAC address.") }
56
+ configuration.guestMAC = mac
57
+ }
58
+ if let text = try network.optionalString("guestAddress") {
59
+ guard let address = IPv4(text) else { throw RequestError("network.guestAddress is not an IPv4 address.") }
60
+ configuration.guestAddress = address
61
+ }
62
+ if let text = try network.optionalString("gatewayAddress") {
63
+ guard let address = IPv4(text) else { throw RequestError("network.gatewayAddress is not an IPv4 address.") }
64
+ configuration.gatewayAddress = address
65
+ }
66
+ if network["prefixLength"] != nil {
67
+ let prefix = try network.positiveInteger("prefixLength")
68
+ guard (8...30).contains(prefix) else { throw RequestError("network.prefixLength must be between 8 and 30.") }
69
+ configuration.prefixLength = prefix
70
+ }
71
+ if let list = network["dns"] {
72
+ guard let entries = list as? [String], !entries.isEmpty, entries.count <= 3 else {
73
+ throw RequestError("network.dns must list one to three IPv4 resolvers.")
74
+ }
75
+ configuration.dns = try entries.map { entry in
76
+ guard let address = IPv4(entry) else { throw RequestError("network.dns entry \(entry) is not an IPv4 address.") }
77
+ return address
78
+ }
79
+ }
80
+ let subnet = CIDR(configuration.gatewayAddress, configuration.prefixLength)
81
+ guard subnet.contains(configuration.guestAddress), configuration.guestAddress != configuration.gatewayAddress else {
82
+ throw RequestError("network.guestAddress must be a different address in the gateway's subnet.")
83
+ }
84
+ self.network = configuration
85
+ }
86
+
87
+ /// Tags are checked against Virtualization.framework's own rules when the
88
+ /// devices are made; here, only that each is present and unique.
89
+ private static func shares(_ value: Any?) throws -> [ShareRequest] {
90
+ guard let value, !(value is NSNull) else { return [] }
91
+ guard let entries = value as? [[String: Any]] else { throw RequestError("shares must be an array of objects.") }
92
+ var tags = Set<String>()
93
+ return try entries.enumerated().map { index, entry in
94
+ let tag = try entry.string("tag")
95
+ guard tags.insert(tag).inserted else { throw RequestError("shares[\(index)].tag \(tag) is used by another share.") }
96
+ let path = try entry.path("path")
97
+ var isDirectory: ObjCBool = false
98
+ guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue else {
99
+ throw RequestError("shares[\(index)].path is not an existing directory: \(path)")
100
+ }
101
+ guard entry["readOnly"] is NSNumber else { throw RequestError("shares[\(index)].readOnly must be a boolean.") }
102
+ return ShareRequest(
103
+ tag: tag, directory: URL(fileURLWithPath: path, isDirectory: true),
104
+ readOnly: try entry.bool("readOnly", default: false))
105
+ }
106
+ }
107
+ }
108
+
109
+ /// One helper process runs one VM. It owns the VM, the guest's network, and
110
+ /// the two Unix sockets the host reaches the guest through.
111
+ @MainActor
112
+ final class Helper: NSObject, VZVirtualMachineDelegate {
113
+ private let control: ControlChannel
114
+ private var machine: VZVirtualMachine?
115
+ private var stack: NetworkStack?
116
+ private var link: DatagramLink?
117
+ private var vsockListener: HandshakeListener?
118
+ private var portListener: HandshakeListener?
119
+ private var relays: [ObjectIdentifier: Relay] = [:]
120
+ private var stateObservation: NSKeyValueObservation?
121
+ private var guestAddress: String?
122
+ private var machineIdentifier: Data?
123
+ private var booting = false
124
+ private var exiting = false
125
+
126
+ init(control: ControlChannel) {
127
+ self.control = control
128
+ super.init()
129
+ }
130
+
131
+ func handle(id: String, op: String, body: [String: Any]) {
132
+ switch op {
133
+ case "boot": boot(id, body)
134
+ case "pause": pause(id)
135
+ case "resume": resume(id)
136
+ case "save": save(id, body)
137
+ case "stop": stop(id)
138
+ case "status": control.respond(id, result: status())
139
+ default: control.respond(id, error: "Unknown op \(op).")
140
+ }
141
+ }
142
+
143
+ // MARK: Requests
144
+
145
+ private func boot(_ id: String, _ body: [String: Any]) {
146
+ guard machine == nil, !booting else {
147
+ return control.respond(id, error: "This helper has already booted a machine; one helper runs one VM.")
148
+ }
149
+ let request: BootRequest
150
+ let configuration: VZVirtualMachineConfiguration
151
+ do {
152
+ request = try BootRequest(body)
153
+ configuration = try makeConfiguration(request)
154
+ try configuration.validate()
155
+ if request.restoreFrom != nil { try configuration.validateSaveRestoreSupport() }
156
+ } catch {
157
+ return control.respond(id, error: describe(error))
158
+ }
159
+ booting = true
160
+ let vm = VZVirtualMachine(configuration: configuration, queue: .main)
161
+ vm.delegate = self
162
+ machine = vm
163
+ stateObservation = vm.observe(\.state, options: [.new]) { [weak self] _, _ in
164
+ Task { @MainActor [weak self] in
165
+ guard let self, let state = self.machine?.state else { return }
166
+ self.control.emit("state", ["state": Self.name(state)])
167
+ }
168
+ }
169
+ do {
170
+ try startListeners(request, vm: vm)
171
+ } catch {
172
+ teardown()
173
+ machine = nil
174
+ booting = false
175
+ return control.respond(id, error: describe(error))
176
+ }
177
+ let finish: @MainActor (Error?) -> Void = { [weak self] error in
178
+ guard let self else { return }
179
+ self.booting = false
180
+ if let error {
181
+ self.teardown()
182
+ self.machine = nil
183
+ self.control.respond(id, error: self.describe(error))
184
+ return
185
+ }
186
+ var result = self.status()
187
+ result["saveRestore"] = (try? configuration.validateSaveRestoreSupport()) != nil
188
+ self.control.respond(id, result: result)
189
+ }
190
+ if let restoreFrom = request.restoreFrom {
191
+ // Restored PAUSED: the host destroys the snapshot it came from, then
192
+ // sends `resume`. Until then the guest cannot write to its disk.
193
+ vm.restoreMachineStateFrom(url: restoreFrom) { error in
194
+ Task { @MainActor in finish(error) }
195
+ }
196
+ } else {
197
+ vm.start { result in
198
+ Task { @MainActor in
199
+ switch result {
200
+ case .success: finish(nil)
201
+ case .failure(let error): finish(error)
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ private func pause(_ id: String) {
209
+ guard let vm = machine, vm.canPause else { return control.respond(id, error: notNow("paused")) }
210
+ vm.pause { [weak self] result in
211
+ Task { @MainActor in self?.answer(id, result) }
212
+ }
213
+ }
214
+
215
+ private func resume(_ id: String) {
216
+ guard let vm = machine, vm.canResume else { return control.respond(id, error: notNow("resumed")) }
217
+ vm.resume { [weak self] result in
218
+ Task { @MainActor in self?.answer(id, result) }
219
+ }
220
+ }
221
+
222
+ /// Pause if running, then write the machine's memory and device state to
223
+ /// `path`. The VM is left paused; the host stops it next.
224
+ private func save(_ id: String, _ body: [String: Any]) {
225
+ let path: String
226
+ do {
227
+ path = try body.path("path")
228
+ } catch {
229
+ return control.respond(id, error: describe(error))
230
+ }
231
+ guard let vm = machine else { return control.respond(id, error: notNow("saved")) }
232
+ let url = URL(fileURLWithPath: path)
233
+ let write: @MainActor () -> Void = { [weak self] in
234
+ vm.saveMachineStateTo(url: url) { error in
235
+ Task { @MainActor in
236
+ guard let self else { return }
237
+ if let error { return self.control.respond(id, error: self.describe(error)) }
238
+ var result = self.status()
239
+ result["path"] = path
240
+ self.control.respond(id, result: result)
241
+ }
242
+ }
243
+ }
244
+ switch vm.state {
245
+ case .paused:
246
+ write()
247
+ case .running:
248
+ vm.pause { [weak self] result in
249
+ Task { @MainActor in
250
+ guard let self else { return }
251
+ if case .failure(let error) = result { return self.control.respond(id, error: self.describe(error)) }
252
+ write()
253
+ }
254
+ }
255
+ default:
256
+ control.respond(id, error: notNow("saved"))
257
+ }
258
+ }
259
+
260
+ /// Hard stop, like pulling the power: the host has synced the guest first.
261
+ /// The helper exits once the response is written.
262
+ func stop(_ id: String?) {
263
+ guard let vm = machine, vm.state != .stopped, vm.state != .error else {
264
+ if let id { control.respond(id, result: ["state": "stopped"]) }
265
+ return exit(code: 0)
266
+ }
267
+ let done: @MainActor (Error?) -> Void = { [weak self] error in
268
+ guard let self else { return }
269
+ if let id {
270
+ if let error {
271
+ self.control.respond(id, error: self.describe(error))
272
+ } else {
273
+ self.control.respond(id, result: ["state": "stopped"])
274
+ }
275
+ }
276
+ self.exit(code: error == nil ? 0 : 1)
277
+ }
278
+ if vm.canStop {
279
+ vm.stop { error in Task { @MainActor in done(error) } }
280
+ } else {
281
+ // Mid-transition (starting, saving, restoring): nothing to stop cleanly.
282
+ // Exiting the process ends the VM with it.
283
+ done(nil)
284
+ }
285
+ }
286
+
287
+ private func status() -> [String: Any] {
288
+ var result: [String: Any] = ["state": machine.map { Self.name($0.state) } ?? "stopped"]
289
+ if let stack {
290
+ result["guestAddress"] = stack.configuration.guestAddress.description
291
+ result["gatewayAddress"] = stack.configuration.gatewayAddress.description
292
+ }
293
+ result["guestAddressSeen"] = guestAddress != nil
294
+ if let machineIdentifier { result["machineIdentifier"] = machineIdentifier.base64EncodedString() }
295
+ return result
296
+ }
297
+
298
+ private func answer(_ id: String, _ result: Result<Void, Error>) {
299
+ switch result {
300
+ case .success: control.respond(id, result: status())
301
+ case .failure(let error): control.respond(id, error: describe(error))
302
+ }
303
+ }
304
+
305
+ private func notNow(_ verb: String) -> String {
306
+ let state = machine.map { Self.name($0.state) } ?? "not booted"
307
+ return "The machine cannot be \(verb) while it is \(state)."
308
+ }
309
+
310
+ // MARK: Configuration
311
+
312
+ private func makeConfiguration(_ request: BootRequest) throws -> VZVirtualMachineConfiguration {
313
+ let configuration = VZVirtualMachineConfiguration()
314
+ let loader = VZLinuxBootLoader(kernelURL: request.kernel)
315
+ loader.initialRamdiskURL = request.initrd
316
+ loader.commandLine = request.cmdline
317
+ configuration.bootLoader = loader
318
+
319
+ // A restored machine must be the same machine: the identifier is part of
320
+ // what the saved state is checked against.
321
+ let platform = VZGenericPlatformConfiguration()
322
+ if let data = request.machineIdentifier {
323
+ guard let identifier = VZGenericMachineIdentifier(dataRepresentation: data) else {
324
+ throw RequestError("machineIdentifier is not a valid machine identifier.")
325
+ }
326
+ platform.machineIdentifier = identifier
327
+ }
328
+ machineIdentifier = platform.machineIdentifier.dataRepresentation
329
+ configuration.platform = platform
330
+
331
+ let minimumCPUs = VZVirtualMachineConfiguration.minimumAllowedCPUCount
332
+ let maximumCPUs = VZVirtualMachineConfiguration.maximumAllowedCPUCount
333
+ guard (minimumCPUs...maximumCPUs).contains(request.vcpus) else {
334
+ throw RequestError("vcpus must be between \(minimumCPUs) and \(maximumCPUs) on this Mac.")
335
+ }
336
+ configuration.cpuCount = request.vcpus
337
+ let memory = UInt64(request.memoryMb) * 1024 * 1024
338
+ let minimumMemory = VZVirtualMachineConfiguration.minimumAllowedMemorySize
339
+ let maximumMemory = VZVirtualMachineConfiguration.maximumAllowedMemorySize
340
+ guard memory >= minimumMemory, memory <= maximumMemory else {
341
+ throw RequestError(
342
+ "memoryMb must be between \(minimumMemory / 1_048_576) and \(maximumMemory / 1_048_576) on this Mac.")
343
+ }
344
+ configuration.memorySize = memory
345
+
346
+ let disk = try VZDiskImageStorageDeviceAttachment(
347
+ url: request.disk, readOnly: false, cachingMode: .automatic, synchronizationMode: .full)
348
+ configuration.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: disk)]
349
+
350
+ let (helperEnd, vmEnd) = try DatagramLink.makePair()
351
+ let nic = VZVirtioNetworkDeviceConfiguration()
352
+ nic.attachment = VZFileHandleNetworkDeviceAttachment(
353
+ fileHandle: FileHandle(fileDescriptor: vmEnd, closeOnDealloc: true))
354
+ if let mac = request.network.guestMAC {
355
+ guard let address = VZMACAddress(string: mac.description) else {
356
+ throw RequestError("network.macAddress is not a valid MAC address.")
357
+ }
358
+ nic.macAddress = address
359
+ }
360
+ configuration.networkDevices = [nic]
361
+ var network = request.network
362
+ network.guestMAC = MAC(nic.macAddress.string) ?? network.guestMAC
363
+ attachNetwork(network, fd: helperEnd)
364
+
365
+ // Built from the request on every boot, so a restore has exactly the
366
+ // devices the machine was saved with as long as the host asks for the same
367
+ // shares — which it checks before restoring.
368
+ configuration.directorySharingDevices = try request.shares.map { share in
369
+ do {
370
+ try VZVirtioFileSystemDeviceConfiguration.validateTag(share.tag)
371
+ } catch {
372
+ throw RequestError("Share tag \(share.tag) is not a valid virtio-fs tag: \(describe(error))")
373
+ }
374
+ let device = VZVirtioFileSystemDeviceConfiguration(tag: share.tag)
375
+ device.share = VZSingleDirectoryShare(
376
+ directory: VZSharedDirectory(url: share.directory, readOnly: share.readOnly))
377
+ return device
378
+ }
379
+
380
+ configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
381
+ configuration.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
382
+ configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
383
+
384
+ // Linux guests commonly use hvc0 as their primary console. Keep the
385
+ // device present even when its output is discarded.
386
+ let console = VZVirtioConsoleDeviceSerialPortConfiguration()
387
+ if let consoleLog = request.consoleLog {
388
+ console.attachment = try VZFileSerialPortAttachment(url: consoleLog, append: true)
389
+ } else {
390
+ console.attachment = VZFileHandleSerialPortAttachment(
391
+ fileHandleForReading: nil, fileHandleForWriting: try FileHandle(forWritingTo: URL(fileURLWithPath: "/dev/null")))
392
+ }
393
+ configuration.serialPorts = [console]
394
+ return configuration
395
+ }
396
+
397
+ private func attachNetwork(_ configuration: NetworkConfiguration, fd: Int32) {
398
+ let link = DatagramLink(fd: fd)
399
+ let stack = NetworkStack(configuration: configuration) { [weak link] frame in link?.send(frame) }
400
+ link.onFrame = { [weak stack] frame in stack?.receiveFrame(frame) }
401
+ stack.onGuestAddress = { [weak self] address in
402
+ guard let self, self.guestAddress == nil else { return }
403
+ self.guestAddress = address.description
404
+ self.control.emit("guest-ip", ["address": address.description])
405
+ }
406
+ self.link = link
407
+ self.stack = stack
408
+ }
409
+
410
+ // MARK: Sockets the host reaches the guest through
411
+
412
+ private func startListeners(_ request: BootRequest, vm: VZVirtualMachine) throws {
413
+ vsockListener = try HandshakeListener(path: request.vsockSocket) { [weak self] client, port, early in
414
+ self?.bridgeVsock(client: client, port: port, early: early)
415
+ }
416
+ if let portSocket = request.portSocket {
417
+ portListener = try HandshakeListener(path: portSocket) { [weak self] client, port, early in
418
+ guard let self, let stack = self.stack, port >= 1, port <= 65_535 else {
419
+ Darwin.close(client)
420
+ return
421
+ }
422
+ stack.connectToGuest(port: UInt16(port), clientFD: client, early: early)
423
+ }
424
+ }
425
+ }
426
+
427
+ /// Connect to a guest vsock port and join it to the client. A refused port
428
+ /// closes the client with nothing written, which is how Cloud Hypervisor's
429
+ /// hybrid vsock presents one; the host retries on that.
430
+ private func bridgeVsock(client: Int32, port: UInt32, early: [UInt8]) {
431
+ guard let vm = machine, vm.state == .running,
432
+ let device = vm.socketDevices.first as? VZVirtioSocketDevice
433
+ else {
434
+ Darwin.close(client)
435
+ return
436
+ }
437
+ device.connect(toPort: port) { [weak self] result in
438
+ Task { @MainActor in
439
+ guard let self else {
440
+ Darwin.close(client)
441
+ return
442
+ }
443
+ switch result {
444
+ case .failure(let error):
445
+ log("vsock connect to port \(port) failed: \(error.localizedDescription)")
446
+ Darwin.close(client)
447
+ case .success(let connection):
448
+ // Own a duplicate of the connection's descriptor so the relay's
449
+ // lifetime, not the connection object's, decides when it closes.
450
+ let guestFD = dup(connection.fileDescriptor)
451
+ guard guestFD >= 0 else {
452
+ connection.close()
453
+ Darwin.close(client)
454
+ return
455
+ }
456
+ let banner = Array("OK \(connection.sourcePort)\n".utf8)
457
+ let written = banner.withUnsafeBytes { Darwin.write(client, $0.baseAddress, banner.count) }
458
+ guard written == banner.count else {
459
+ connection.close()
460
+ Darwin.close(client)
461
+ Darwin.close(guestFD)
462
+ return
463
+ }
464
+ var key: ObjectIdentifier?
465
+ let relay = Relay(left: client, right: guestFD, early: early) { [weak self] in
466
+ connection.close()
467
+ if let key { self?.relays.removeValue(forKey: key) }
468
+ }
469
+ key = ObjectIdentifier(relay)
470
+ self.relays[ObjectIdentifier(relay)] = relay
471
+ }
472
+ }
473
+ }
474
+ }
475
+
476
+ // MARK: Delegate
477
+
478
+ nonisolated func guestDidStop(_ virtualMachine: VZVirtualMachine) {
479
+ Task { @MainActor in
480
+ control.emit("state", ["state": "stopped", "reason": "guest"])
481
+ exit(code: 0)
482
+ }
483
+ }
484
+
485
+ nonisolated func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
486
+ Task { @MainActor in
487
+ control.emit("state", ["state": "error", "error": describe(error)])
488
+ exit(code: 1)
489
+ }
490
+ }
491
+
492
+ nonisolated func virtualMachine(
493
+ _ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice,
494
+ attachmentWasDisconnectedWithError error: Error
495
+ ) {
496
+ Task { @MainActor in
497
+ control.emit("network-disconnected", ["error": describe(error)])
498
+ }
499
+ }
500
+
501
+ // MARK: Exit
502
+
503
+ private func teardown() {
504
+ for relay in Array(relays.values) { relay.close() }
505
+ relays.removeAll()
506
+ vsockListener?.close()
507
+ vsockListener = nil
508
+ portListener?.close()
509
+ portListener = nil
510
+ stack?.shutdown()
511
+ link?.close()
512
+ stateObservation?.invalidate()
513
+ stateObservation = nil
514
+ }
515
+
516
+ func exit(code: Int32) {
517
+ guard !exiting else { return }
518
+ exiting = true
519
+ teardown()
520
+ Foundation.exit(code)
521
+ }
522
+
523
+ func describe(_ error: Error) -> String {
524
+ if let error = error as? RequestError { return error.description }
525
+ if let error = error as? POSIXFailure { return error.description }
526
+ let ns = error as NSError
527
+ var text = ns.localizedDescription
528
+ if let reason = ns.localizedFailureReason, !text.contains(reason) { text += " \(reason)" }
529
+ if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError {
530
+ text += " (\(underlying.domain) \(underlying.code): \(underlying.localizedDescription))"
531
+ }
532
+ return "\(text) [\(ns.domain) \(ns.code)]"
533
+ }
534
+
535
+ static func name(_ state: VZVirtualMachine.State) -> String {
536
+ switch state {
537
+ case .stopped: "stopped"
538
+ case .running: "running"
539
+ case .paused: "paused"
540
+ case .error: "error"
541
+ case .starting: "starting"
542
+ case .pausing: "pausing"
543
+ case .resuming: "resuming"
544
+ case .stopping: "stopping"
545
+ case .saving: "saving"
546
+ case .restoring: "restoring"
547
+ @unknown default: "unknown"
548
+ }
549
+ }
550
+ }