@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,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
|
+
}
|