@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
@@ -0,0 +1,426 @@
1
+ import Darwin
2
+ import Testing
3
+
4
+ @testable import DeskNet
5
+
6
+ // MARK: - Pure codecs and policy
7
+
8
+ @Test func parsesAddressesStrictly() {
9
+ #expect(IPv4("10.0.2.15") == IPv4(10, 0, 2, 15))
10
+ #expect(IPv4("256.0.0.1") == nil)
11
+ #expect(IPv4("1.2.3") == nil)
12
+ #expect(IPv4("1.2.3.4 ") == nil)
13
+ #expect(IPv4("01.2.3.4.5") == nil)
14
+ #expect(MAC("06:00:00:00:00:64")?.description == "06:00:00:00:00:64")
15
+ #expect(MAC("06:00:00:00:00") == nil)
16
+ #expect(CIDR("100.64.0.0/10")?.contains(IPv4(100, 127, 255, 255)) == true)
17
+ #expect(CIDR("100.64.0.0/10")?.contains(IPv4(100, 128, 0, 0)) == false)
18
+ }
19
+
20
+ @Test func fencesLocalSpaceAndTheMacUnlessTheLanIsAllowed() {
21
+ let fence = EgressFence(allowLan: false)
22
+ let mac: Set<IPv4> = [IPv4(151, 101, 1, 69)]
23
+ for fenced in ["10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1", "169.254.169.254", "100.64.0.1", "100.100.100.100", "151.101.1.69"] {
24
+ #expect(fence.verdict(for: IPv4(fenced)!, hostAddresses: mac) == .fenced, "\(fenced)")
25
+ }
26
+ for open in ["1.1.1.1", "9.9.9.9", "172.32.0.1", "100.128.0.1"] {
27
+ #expect(fence.verdict(for: IPv4(open)!, hostAddresses: mac) == .allowed, "\(open)")
28
+ }
29
+ for invalid in ["127.0.0.1", "0.1.2.3", "224.0.0.251", "255.255.255.255", "240.0.0.1"] {
30
+ #expect(fence.verdict(for: IPv4(invalid)!, hostAddresses: mac) == .invalid, "\(invalid)")
31
+ }
32
+ let lan = EgressFence(allowLan: true)
33
+ #expect(lan.verdict(for: IPv4(192, 168, 1, 1), hostAddresses: mac) == .allowed)
34
+ #expect(lan.verdict(for: IPv4(151, 101, 1, 69), hostAddresses: mac) == .allowed)
35
+ #expect(lan.verdict(for: IPv4(127, 0, 0, 1), hostAddresses: mac) == .invalid)
36
+ }
37
+
38
+ @Test func documentationNetworksRemainBlockedWhenLanIsAllowed() {
39
+ for allowLan in [false, true] {
40
+ let fence = EgressFence(allowLan: allowLan)
41
+ for address in ["192.0.2.0", "192.0.2.1", "192.0.2.255", "198.51.100.0", "198.51.100.255", "203.0.113.0", "203.0.113.255"] {
42
+ #expect(fence.verdict(for: IPv4(address)!, hostAddresses: []) == .invalid, "\(address)")
43
+ }
44
+ for address in ["192.0.1.255", "192.0.3.0", "198.51.99.255", "198.51.101.0", "203.0.112.255", "203.0.114.0"] {
45
+ #expect(fence.verdict(for: IPv4(address)!, hostAddresses: []) == .allowed, "\(address)")
46
+ }
47
+ }
48
+ }
49
+
50
+ @Test func computesTheInternetChecksum() {
51
+ // The worked example from RFC 1071-style references: a real IPv4 header.
52
+ let header: [UInt8] = [
53
+ 0x45, 0x00, 0x00, 0x73, 0x00, 0x00, 0x40, 0x00, 0x40, 0x11, 0x00, 0x00, 0xc0, 0xa8, 0x00, 0x01,
54
+ 0xc0, 0xa8, 0x00, 0xc7,
55
+ ]
56
+ #expect(internetChecksum(header) == 0xb861)
57
+ }
58
+
59
+ @Test func roundTripsTCPWithAValidChecksum() throws {
60
+ let source = IPv4(10, 0, 2, 15)
61
+ let destination = IPv4(1, 1, 1, 1)
62
+ let segment = TCPSegment(
63
+ sourcePort: 40_000, destinationPort: 443, sequence: 0xffff_fff0, acknowledgement: 7,
64
+ flags: [.syn], window: 64_240, mss: 1460, payload: [])
65
+ let bytes = segment.encoded(source: source, destination: destination)
66
+ let sum = pseudoHeaderSum(source: source, destination: destination, protocol: IPProtocol.tcp, length: bytes.count)
67
+ #expect(internetChecksum(bytes, initial: sum) == 0)
68
+ let parsed = try #require(TCPSegment(parsing: bytes))
69
+ #expect(parsed.mss == 1460)
70
+ #expect(parsed.sequence == 0xffff_fff0)
71
+ #expect(parsed.flags == [.syn])
72
+ #expect(parsed.sequenceLength == 1)
73
+ #expect(seqLT(0xffff_fff0, 5))
74
+ }
75
+
76
+ @Test func parsesConnectLines() {
77
+ #expect(PendingHandshake.parse("CONNECT 5252") == 5252)
78
+ #expect(PendingHandshake.parse("CONNECT 14500\r") == 14500)
79
+ #expect(PendingHandshake.parse("CONNECT") == nil)
80
+ #expect(PendingHandshake.parse("CONNECT 1") == nil)
81
+ #expect(PendingHandshake.parse("connect 1") == nil)
82
+ #expect(PendingHandshake.parse("CONNECT -1") == nil)
83
+ }
84
+
85
+ // MARK: - A guest on the other end of the stack
86
+
87
+ let guestIP = IPv4(10, 0, 2, 15)
88
+ let gatewayIP = IPv4(10, 0, 2, 2)
89
+ let guestMAC = MAC([0x06, 0, 0, 0, 0, 0x64])
90
+
91
+ /// Dials socket pairs instead of the internet and keeps the far ends.
92
+ final class PairDialer: Dialer, @unchecked Sendable {
93
+ var tcpPeers: [Int32] = []
94
+ var udpPeers: [Int32] = []
95
+ var dialed: [(IPv4, UInt16)] = []
96
+ var refuse = false
97
+
98
+ func tcp(to ip: IPv4, port: UInt16) throws -> Int32 {
99
+ dialed.append((ip, port))
100
+ if refuse { throw POSIXFailure("connect", ECONNREFUSED) }
101
+ let (mine, theirs) = try Self.pair(SOCK_STREAM)
102
+ tcpPeers.append(theirs)
103
+ return mine
104
+ }
105
+
106
+ func udp(to ip: IPv4, port: UInt16) throws -> Int32 {
107
+ dialed.append((ip, port))
108
+ let (mine, theirs) = try Self.pair(SOCK_DGRAM)
109
+ udpPeers.append(theirs)
110
+ return mine
111
+ }
112
+
113
+ func icmp() throws -> Int32 { throw POSIXFailure("socket", EPERM) }
114
+
115
+ static func pair(_ type: Int32) throws -> (Int32, Int32) {
116
+ var fds: [Int32] = [0, 0]
117
+ guard socketpair(AF_UNIX, type, 0, &fds) == 0 else { throw POSIXFailure("socketpair") }
118
+ Socket.setNonBlocking(fds[0])
119
+ Socket.setNonBlocking(fds[1])
120
+ Socket.setNoSigPipe(fds[0])
121
+ Socket.setNoSigPipe(fds[1])
122
+ return (fds[0], fds[1])
123
+ }
124
+ }
125
+
126
+ @MainActor
127
+ final class Harness {
128
+ var frames: [[UInt8]] = []
129
+ let dialer = PairDialer()
130
+ var stack: NetworkStack!
131
+ var announced: [IPv4] = []
132
+
133
+ init(allowLan: Bool = false, hostAddresses: Set<IPv4> = [], dns: [IPv4] = [IPv4(1, 1, 1, 1), IPv4(9, 9, 9, 9)]) {
134
+ stack = NetworkStack(
135
+ configuration: NetworkConfiguration(dns: dns, guestMAC: guestMAC, allowLan: allowLan), dialer: dialer,
136
+ hostAddresses: { hostAddresses }, sendFrame: { [unowned self] in self.frames.append($0) })
137
+ stack.onGuestAddress = { [unowned self] in self.announced.append($0) }
138
+ }
139
+
140
+ func send(ip payload: [UInt8], proto: UInt8, to destination: IPv4, from source: IPv4 = guestIP) {
141
+ let packet = IPv4Packet.encode(source: source, destination: destination, proto: proto, payload: payload)
142
+ stack.receiveFrame(EthernetFrame(destination: .gateway, source: guestMAC, type: EtherType.ipv4, payload: packet).encoded)
143
+ }
144
+
145
+ func send(tcp segment: TCPSegment, to destination: IPv4) {
146
+ send(ip: segment.encoded(source: guestIP, destination: destination), proto: IPProtocol.tcp, to: destination)
147
+ }
148
+
149
+ /// Every IPv4 packet sent to the guest since the last call.
150
+ func takePackets() -> [IPv4Packet] {
151
+ defer { frames.removeAll() }
152
+ return frames.compactMap { EthernetFrame(parsing: $0) }.filter { $0.type == EtherType.ipv4 }
153
+ .compactMap { IPv4Packet(parsing: $0.payload) }
154
+ }
155
+
156
+ func takeSegments() -> [TCPSegment] {
157
+ takePackets().filter { $0.proto == IPProtocol.tcp }.compactMap { TCPSegment(parsing: $0.payload) }
158
+ }
159
+
160
+ func waitFor(_ condition: () -> Bool) async {
161
+ for _ in 0..<200 where !condition() {
162
+ try? await Task.sleep(for: .milliseconds(10))
163
+ }
164
+ }
165
+ }
166
+
167
+ func readAll(_ fd: Int32) -> [UInt8]? {
168
+ var buffer = [UInt8](repeating: 0, count: 65_536)
169
+ let count = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
170
+ if count < 0 { return nil }
171
+ return Array(buffer[0..<count])
172
+ }
173
+
174
+ func writeAll(_ fd: Int32, _ bytes: [UInt8]) {
175
+ _ = bytes.withUnsafeBytes { write(fd, $0.baseAddress, bytes.count) }
176
+ }
177
+
178
+ @MainActor @Test func answersARPForEveryAddressButTheGuests() throws {
179
+ let harness = Harness()
180
+ let request = ARPPacket(
181
+ operation: ARPPacket.request, senderMAC: guestMAC, senderIP: guestIP, targetMAC: MAC([0, 0, 0, 0, 0, 0]),
182
+ targetIP: gatewayIP)
183
+ harness.stack.receiveFrame(EthernetFrame(destination: .broadcast, source: guestMAC, type: EtherType.arp, payload: request.encoded).encoded)
184
+ let first = try #require(harness.frames.first)
185
+ let frame = try #require(EthernetFrame(parsing: first))
186
+ let reply = try #require(ARPPacket(parsing: frame.payload))
187
+ #expect(reply.operation == ARPPacket.reply)
188
+ #expect(reply.senderMAC == .gateway)
189
+ #expect(reply.senderIP == gatewayIP)
190
+ harness.frames.removeAll()
191
+ // A duplicate-address probe for the guest's own address goes unanswered.
192
+ let probe = ARPPacket(
193
+ operation: ARPPacket.request, senderMAC: guestMAC, senderIP: .zero, targetMAC: MAC([0, 0, 0, 0, 0, 0]),
194
+ targetIP: guestIP)
195
+ harness.stack.receiveFrame(EthernetFrame(destination: .broadcast, source: guestMAC, type: EtherType.arp, payload: probe.encoded).encoded)
196
+ #expect(harness.frames.isEmpty)
197
+ }
198
+
199
+ @MainActor @Test func leasesTheGuestItsAddressOverDHCP() throws {
200
+ let harness = Harness()
201
+ func dhcp(_ type: UInt8) -> [UInt8] {
202
+ var bootp = [UInt8](repeating: 0, count: 240)
203
+ bootp[0] = 1
204
+ bootp[1] = 1
205
+ bootp[2] = 6
206
+ bootp[4...7] = [1, 2, 3, 4]
207
+ bootp[28..<34] = guestMAC.bytes[0..<6]
208
+ bootp[236..<240] = [99, 130, 83, 99]
209
+ return bootp + [53, 1, type, 255]
210
+ }
211
+ for (type, expected) in [(UInt8(1), UInt8(2)), (UInt8(3), UInt8(5))] {
212
+ let udp = UDPDatagram(sourcePort: 68, destinationPort: 67, payload: dhcp(type))
213
+ harness.send(ip: udp.encoded(source: .zero, destination: .broadcast), proto: IPProtocol.udp, to: .broadcast, from: .zero)
214
+ let packet = try #require(harness.takePackets().first)
215
+ let reply = try #require(UDPDatagram(parsing: packet.payload))
216
+ #expect(reply.destinationPort == 68)
217
+ #expect(IPv4(bytes: reply.payload, at: 16) == guestIP)
218
+ let options = DHCPServer.options(reply.payload)
219
+ #expect(options[53] == [expected])
220
+ #expect(options[3] == gatewayIP.bytes)
221
+ #expect(options[6] == [1, 1, 1, 1, 9, 9, 9, 9])
222
+ }
223
+ #expect(harness.announced == [guestIP])
224
+ }
225
+
226
+ @MainActor @Test func refusesFencedDestinationsWithAdministrativelyProhibited() throws {
227
+ let harness = Harness(hostAddresses: [IPv4(151, 101, 1, 69)])
228
+ for target in ["192.168.1.1", "10.0.0.1", "169.254.169.254", "100.64.0.1", "151.101.1.69"] {
229
+ let syn = TCPSegment(sourcePort: 40_000, destinationPort: 80, sequence: 1, acknowledgement: 0, flags: [.syn], window: 64_240)
230
+ harness.send(tcp: syn, to: IPv4(target)!)
231
+ let packet = try #require(harness.takePackets().first, "\(target)")
232
+ let icmp = try #require(ICMPMessage(parsing: packet.payload))
233
+ #expect(icmp.type == ICMPMessage.destinationUnreachable)
234
+ #expect(icmp.code == ICMPMessage.administrativelyProhibited)
235
+ // The quote carries the original header, so the guest maps it to its socket.
236
+ #expect(IPv4(bytes: icmp.payload, at: 16) == IPv4(target)!)
237
+ }
238
+ #expect(harness.dialer.dialed.isEmpty)
239
+ }
240
+
241
+ @MainActor @Test func lanAccessLiftsTheFence() async throws {
242
+ let harness = Harness(allowLan: true)
243
+ let syn = TCPSegment(sourcePort: 40_000, destinationPort: 80, sequence: 1, acknowledgement: 0, flags: [.syn], window: 64_240)
244
+ harness.send(tcp: syn, to: IPv4(192, 168, 1, 1))
245
+ #expect(harness.dialer.dialed.count == 1)
246
+ await harness.waitFor { !harness.frames.isEmpty }
247
+ #expect(harness.takeSegments().first?.flags == [.syn, .ack])
248
+ harness.stack.shutdown()
249
+ }
250
+
251
+ @MainActor @Test func resetsAGuestWhoseUpstreamRefuses() throws {
252
+ let harness = Harness()
253
+ harness.dialer.refuse = true
254
+ let syn = TCPSegment(sourcePort: 40_000, destinationPort: 81, sequence: 99, acknowledgement: 0, flags: [.syn], window: 64_240)
255
+ harness.send(tcp: syn, to: IPv4(1, 1, 1, 1))
256
+ let reset = try #require(harness.takeSegments().first)
257
+ #expect(reset.flags == [.rst, .ack])
258
+ #expect(reset.acknowledgement == 100)
259
+ }
260
+
261
+ @MainActor @Test func relaysAGuestTCPConnectionBothWaysWithHalfClose() async throws {
262
+ let harness = Harness()
263
+ let remote = IPv4(1, 1, 1, 1)
264
+ var seq: UInt32 = 1_000
265
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: 0, flags: [.syn], window: 64_240, mss: 1460), to: remote)
266
+ await harness.waitFor { !harness.frames.isEmpty }
267
+ let synAck = try #require(harness.takeSegments().first)
268
+ #expect(synAck.flags == [.syn, .ack])
269
+ #expect(synAck.acknowledgement == seq + 1)
270
+ #expect(synAck.mss == 1460)
271
+ seq += 1
272
+ var theirs = synAck.sequence &+ 1
273
+ let peer = try #require(harness.dialer.tcpPeers.first)
274
+
275
+ // Guest -> upstream.
276
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: theirs, flags: [.ack, .psh], window: 64_240, payload: Array("hello".utf8)), to: remote)
277
+ seq += 5
278
+ #expect(harness.takeSegments().last?.acknowledgement == seq)
279
+ #expect(readAll(peer) == Array("hello".utf8))
280
+
281
+ // Upstream -> guest, segmented to the guest's MSS.
282
+ let big = [UInt8](repeating: 0x61, count: 4_000)
283
+ writeAll(peer, big)
284
+ var received: [UInt8] = []
285
+ await harness.waitFor {
286
+ for segment in harness.takeSegments() where !segment.payload.isEmpty {
287
+ #expect(segment.sequence == theirs)
288
+ #expect(segment.payload.count <= 1460)
289
+ received += segment.payload
290
+ theirs &+= UInt32(segment.payload.count)
291
+ }
292
+ return received.count == big.count
293
+ }
294
+ #expect(received == big)
295
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: theirs, flags: [.ack], window: 64_240), to: remote)
296
+
297
+ // Guest half-closes: the upstream reads end of stream but can still write.
298
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: theirs, flags: [.ack, .fin], window: 64_240), to: remote)
299
+ seq += 1
300
+ #expect(harness.takeSegments().last?.acknowledgement == seq)
301
+ #expect(readAll(peer) == [])
302
+ writeAll(peer, Array("bye".utf8))
303
+ await harness.waitFor { harness.frames.count >= 1 }
304
+ let tail = try #require(harness.takeSegments().first)
305
+ #expect(tail.payload == Array("bye".utf8))
306
+ theirs &+= 3
307
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: theirs, flags: [.ack], window: 64_240), to: remote)
308
+
309
+ // Upstream closes: the guest gets a FIN, acknowledges it, and the flow ends.
310
+ close(peer)
311
+ await harness.waitFor { !harness.frames.isEmpty }
312
+ let fin = try #require(harness.takeSegments().first)
313
+ #expect(fin.flags.contains(.fin))
314
+ #expect(fin.sequence == theirs)
315
+ harness.send(tcp: TCPSegment(sourcePort: 40_000, destinationPort: 443, sequence: seq, acknowledgement: theirs &+ 1, flags: [.ack], window: 64_240), to: remote)
316
+ #expect(harness.stack.flowCount == 0)
317
+ }
318
+
319
+ @MainActor @Test func retransmitsWhatTheGuestDidNotAcknowledge() async throws {
320
+ var clock: UInt64 = 0
321
+ var frames: [[UInt8]] = []
322
+ let dialer = PairDialer()
323
+ let stack = NetworkStack(
324
+ configuration: NetworkConfiguration(guestMAC: guestMAC), dialer: dialer, now: { clock },
325
+ hostAddresses: { [] }, sendFrame: { frames.append($0) })
326
+ let remote = IPv4(1, 1, 1, 1)
327
+ let syn = TCPSegment(sourcePort: 40_001, destinationPort: 80, sequence: 1, acknowledgement: 0, flags: [.syn], window: 64_240)
328
+ func deliver(_ segment: TCPSegment) {
329
+ let packet = IPv4Packet.encode(source: guestIP, destination: remote, proto: IPProtocol.tcp, payload: segment.encoded(source: guestIP, destination: remote))
330
+ stack.receiveFrame(EthernetFrame(destination: .gateway, source: guestMAC, type: EtherType.ipv4, payload: packet).encoded)
331
+ }
332
+ func segments() -> [TCPSegment] {
333
+ defer { frames.removeAll() }
334
+ return frames.compactMap { EthernetFrame(parsing: $0) }.compactMap { IPv4Packet(parsing: $0.payload) }
335
+ .compactMap { TCPSegment(parsing: $0.payload) }
336
+ }
337
+ deliver(syn)
338
+ for _ in 0..<200 where frames.isEmpty { try? await Task.sleep(for: .milliseconds(10)) }
339
+ let synAck = try #require(segments().first)
340
+ deliver(TCPSegment(sourcePort: 40_001, destinationPort: 80, sequence: 2, acknowledgement: synAck.sequence &+ 1, flags: [.ack], window: 64_240))
341
+ writeAll(try #require(dialer.tcpPeers.first), Array("payload".utf8))
342
+ for _ in 0..<200 where frames.isEmpty { try? await Task.sleep(for: .milliseconds(10)) }
343
+ let first = try #require(segments().first)
344
+ #expect(first.payload == Array("payload".utf8))
345
+ // No acknowledgement arrives: the timer resends the same bytes.
346
+ clock += 300
347
+ stack.tick()
348
+ let again = try #require(segments().first)
349
+ #expect(again.sequence == first.sequence)
350
+ #expect(again.payload == first.payload)
351
+ stack.shutdown()
352
+ }
353
+
354
+ @MainActor @Test func connectsTheMacToAGuestPort() async throws {
355
+ let harness = Harness()
356
+ let (client, mine) = try PairDialer.pair(SOCK_STREAM)
357
+ writeAll(client, [])
358
+ harness.stack.connectToGuest(port: 14_500, clientFD: mine, early: Array("GET".utf8))
359
+ let syn = try #require(harness.takeSegments().first)
360
+ #expect(syn.flags == [.syn])
361
+ #expect(syn.destinationPort == 14_500)
362
+ // Nothing reaches the client before the guest accepts.
363
+ #expect(readAll(client) == nil)
364
+ harness.send(tcp: TCPSegment(sourcePort: 14_500, destinationPort: syn.sourcePort, sequence: 5_000, acknowledgement: syn.sequence &+ 1, flags: [.syn, .ack], window: 64_240, mss: 1460), to: gatewayIP)
365
+ await harness.waitFor { harness.frames.count >= 2 }
366
+ let segments = harness.takeSegments()
367
+ #expect(segments.first?.flags == [.ack])
368
+ #expect(segments.contains { $0.payload == Array("GET".utf8) })
369
+ try? await Task.sleep(for: .milliseconds(50))
370
+ let banner = try #require(readAll(client))
371
+ #expect(String(decoding: banner, as: UTF8.self) == "OK \(syn.sourcePort)\n")
372
+ harness.send(tcp: TCPSegment(sourcePort: 14_500, destinationPort: syn.sourcePort, sequence: 5_001, acknowledgement: syn.sequence &+ 4, flags: [.ack, .psh], window: 64_240, payload: Array("200".utf8)), to: gatewayIP)
373
+ try? await Task.sleep(for: .milliseconds(50))
374
+ #expect(readAll(client) == Array("200".utf8))
375
+ close(client)
376
+ harness.stack.shutdown()
377
+ }
378
+
379
+ @MainActor @Test func closesTheClientWhenTheGuestRefusesThePort() async throws {
380
+ let harness = Harness()
381
+ let (client, mine) = try PairDialer.pair(SOCK_STREAM)
382
+ harness.stack.connectToGuest(port: 9, clientFD: mine)
383
+ let syn = try #require(harness.takeSegments().first)
384
+ harness.send(tcp: TCPSegment(sourcePort: 9, destinationPort: syn.sourcePort, sequence: 0, acknowledgement: syn.sequence &+ 1, flags: [.rst, .ack], window: 0), to: gatewayIP)
385
+ await harness.waitFor { readAll(client) != nil }
386
+ // End of stream with no reply line: a refusal, as a refused vsock port reads.
387
+ #expect(readAll(client) == [])
388
+ #expect(harness.stack.flowCount == 0)
389
+ close(client)
390
+ }
391
+
392
+ @MainActor @Test func natsUDPAndForwardsGatewayDNS() async throws {
393
+ let harness = Harness()
394
+ let query = UDPDatagram(sourcePort: 5_353, destinationPort: 53, payload: [1, 2, 3])
395
+ harness.send(ip: query.encoded(source: guestIP, destination: gatewayIP), proto: IPProtocol.udp, to: gatewayIP)
396
+ #expect(harness.dialer.dialed.first?.0 == IPv4(1, 1, 1, 1))
397
+ let peer = try #require(harness.dialer.udpPeers.first)
398
+ #expect(readAll(peer) == [1, 2, 3])
399
+ writeAll(peer, [4, 5])
400
+ await harness.waitFor { !harness.frames.isEmpty }
401
+ let packet = try #require(harness.takePackets().first)
402
+ #expect(packet.source == gatewayIP)
403
+ let answer = try #require(UDPDatagram(parsing: packet.payload))
404
+ #expect(answer.sourcePort == 53)
405
+ #expect(answer.destinationPort == 5_353)
406
+ #expect(answer.payload == [4, 5])
407
+ harness.stack.shutdown()
408
+ }
409
+
410
+ @MainActor @Test func dropsTrafficFromAnyAddressButTheGuests() {
411
+ let harness = Harness()
412
+ let syn = TCPSegment(sourcePort: 1, destinationPort: 80, sequence: 1, acknowledgement: 0, flags: [.syn], window: 1)
413
+ harness.send(ip: syn.encoded(source: IPv4(10, 0, 2, 99), destination: IPv4(1, 1, 1, 1)), proto: IPProtocol.tcp, to: IPv4(1, 1, 1, 1), from: IPv4(10, 0, 2, 99))
414
+ #expect(harness.frames.isEmpty)
415
+ #expect(harness.dialer.dialed.isEmpty)
416
+ }
417
+
418
+ @MainActor @Test func gatewayDNSCannotBypassTheLanFence() {
419
+ let harness = Harness(dns: [IPv4(192, 168, 1, 1)])
420
+ let query = UDPDatagram(sourcePort: 5353, destinationPort: 53, payload: [1, 2, 3])
421
+ harness.send(ip: query.encoded(source: guestIP, destination: gatewayIP), proto: IPProtocol.udp, to: gatewayIP)
422
+ #expect(harness.dialer.dialed.isEmpty)
423
+ let packets = harness.takePackets()
424
+ #expect(packets.contains { $0.proto == IPProtocol.icmp })
425
+ harness.stack.shutdown()
426
+ }
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.virtualization</key>
6
+ <true/>
7
+ </dict>
8
+ </plist>
package/vz/build.sh ADDED
@@ -0,0 +1,23 @@
1
+ #!/bin/sh
2
+ # Build appkit-desk-vz for release and sign it with the virtualization
3
+ # entitlement, without which Virtualization.framework refuses to start a VM.
4
+ #
5
+ # packages/desk/vz/build.sh ad hoc signature (local use)
6
+ # APPKIT_CODESIGN_IDENTITY="Developer ID Application: …" packages/desk/vz/build.sh
7
+ #
8
+ # Prints the path of the signed binary on stdout.
9
+ set -eu
10
+ here=$(cd "$(dirname "$0")" && pwd)
11
+ cd "$here"
12
+ swift build -c release -j "${APPKIT_SWIFT_JOBS:-2}" --product appkit-desk-vz >&2
13
+ binary="$(swift build -c release --show-bin-path)/appkit-desk-vz"
14
+ identity=${APPKIT_CODESIGN_IDENTITY:--}
15
+ if [ "$identity" = "-" ]; then
16
+ codesign --force --sign - --entitlements appkit-desk-vz.entitlements "$binary" >&2
17
+ else
18
+ # A Developer ID build is hardened and timestamped so it can be notarized.
19
+ codesign --force --sign "$identity" --options runtime --timestamp \
20
+ --entitlements appkit-desk-vz.entitlements "$binary" >&2
21
+ fi
22
+ codesign --verify --strict "$binary" >&2
23
+ echo "$binary"
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """Copy a Debian disk image's kernel and initrd out of its ext4 root, read-only.
3
+
4
+ extract-boot.py <disk.raw> <out-dir>
5
+
6
+ Writes <out-dir>/Image (an uncompressed arm64 Image, which is what
7
+ VZLinuxBootLoader boots), <out-dir>/initrd, and <out-dir>/release (the kernel
8
+ version). The kernel and initrd come from the image itself because that is the
9
+ only place Debian publishes the initrd that matches the image's modules.
10
+
11
+ macOS cannot mount ext4, so this reads the filesystem directly: GPT, the
12
+ superblock, group descriptors, inodes, extent trees and directories. It only
13
+ reads, and it supports exactly what a stock Debian cloud image uses.
14
+ """
15
+ import gzip
16
+ import os
17
+ import shutil
18
+ import struct
19
+ import subprocess
20
+ import sys
21
+ import zlib
22
+
23
+ SECTOR = 512
24
+ EXT4_MAGIC = 0xEF53
25
+ EXTENTS_FLAG = 0x80000
26
+ INLINE_DATA_FLAG = 0x10000000
27
+ INCOMPAT_64BIT = 0x80
28
+ S_IFMT = 0o170000
29
+ S_IFDIR = 0o040000
30
+ S_IFLNK = 0o120000
31
+
32
+
33
+ def partitions(image):
34
+ image.seek(SECTOR)
35
+ header = image.read(92)
36
+ if header[:8] != b"EFI PART":
37
+ return [0]
38
+ entries_lba, count, size = struct.unpack_from("<QII", header, 72)
39
+ offsets = []
40
+ for index in range(count):
41
+ image.seek(entries_lba * SECTOR + index * size)
42
+ entry = image.read(size)
43
+ if entry[:16] == b"\0" * 16:
44
+ continue
45
+ first = struct.unpack_from("<Q", entry, 32)[0]
46
+ offsets.append(first * SECTOR)
47
+ return offsets
48
+
49
+
50
+ class Ext4:
51
+ def __init__(self, image, offset):
52
+ self.image = image
53
+ self.offset = offset
54
+ superblock = self.read(1024, 1024)
55
+ if struct.unpack_from("<H", superblock, 0x38)[0] != EXT4_MAGIC:
56
+ raise ValueError("not ext4")
57
+ self.block_size = 1024 << struct.unpack_from("<I", superblock, 0x18)[0]
58
+ self.first_data_block = struct.unpack_from("<I", superblock, 0x14)[0]
59
+ self.inodes_per_group = struct.unpack_from("<I", superblock, 0x28)[0]
60
+ self.inode_size = struct.unpack_from("<H", superblock, 0x58)[0]
61
+ incompat = struct.unpack_from("<I", superblock, 0x60)[0]
62
+ self.is64 = bool(incompat & INCOMPAT_64BIT)
63
+ self.desc_size = struct.unpack_from("<H", superblock, 0xFE)[0] if self.is64 else 32
64
+ self.gdt = (self.first_data_block + 1) * self.block_size
65
+
66
+ def read(self, position, length):
67
+ self.image.seek(self.offset + position)
68
+ data = self.image.read(length)
69
+ if len(data) != length:
70
+ raise ValueError("read past the end of the image")
71
+ return data
72
+
73
+ def inode(self, number):
74
+ group, index = divmod(number - 1, self.inodes_per_group)
75
+ desc = self.read(self.gdt + group * self.desc_size, self.desc_size)
76
+ table = struct.unpack_from("<I", desc, 0x8)[0]
77
+ if self.is64 and self.desc_size >= 64:
78
+ table |= struct.unpack_from("<I", desc, 0x28)[0] << 32
79
+ return self.read(table * self.block_size + index * self.inode_size, self.inode_size)
80
+
81
+ @staticmethod
82
+ def mode(inode):
83
+ return struct.unpack_from("<H", inode, 0)[0]
84
+
85
+ @staticmethod
86
+ def size(inode):
87
+ low, high = struct.unpack_from("<I", inode, 0x4)[0], struct.unpack_from("<I", inode, 0x6C)[0]
88
+ return high << 32 | low
89
+
90
+ def extents(self, node):
91
+ magic, entries, _, depth = struct.unpack_from("<HHHH", node, 0)
92
+ if magic != 0xF30A:
93
+ raise ValueError("bad extent header")
94
+ for index in range(entries):
95
+ entry = node[12 + index * 12: 24 + index * 12]
96
+ if depth == 0:
97
+ logical, length, start_high, start_low = struct.unpack("<IHHI", entry)
98
+ uninitialized = length > 32768
99
+ if uninitialized:
100
+ length -= 32768
101
+ yield logical, length, start_high << 32 | start_low, uninitialized
102
+ else:
103
+ _, leaf_low, leaf_high = struct.unpack_from("<IIH", entry, 0)
104
+ child = self.read((leaf_high << 32 | leaf_low) * self.block_size, self.block_size)
105
+ yield from self.extents(child)
106
+
107
+ def contents(self, inode):
108
+ flags = struct.unpack_from("<I", inode, 0x20)[0]
109
+ size = self.size(inode)
110
+ if flags & INLINE_DATA_FLAG:
111
+ raise ValueError("inline data is not supported")
112
+ if not flags & EXTENTS_FLAG:
113
+ raise ValueError("block-mapped files are not supported")
114
+ data = bytearray(size)
115
+ for logical, length, physical, uninitialized in self.extents(inode[0x28:0x28 + 60]):
116
+ begin = logical * self.block_size
117
+ if begin >= size or uninitialized:
118
+ continue
119
+ count = min(length * self.block_size, size - begin)
120
+ data[begin:begin + count] = self.read(physical * self.block_size, count)
121
+ return bytes(data)
122
+
123
+ def entries(self, inode):
124
+ data = self.contents(inode)
125
+ position = 0
126
+ while position + 8 <= len(data):
127
+ number, record, name_length = struct.unpack_from("<IHB", data, position)
128
+ if record < 8:
129
+ break
130
+ if number:
131
+ yield data[position + 8: position + 8 + name_length].decode("utf-8", "replace"), number
132
+ position += record
133
+
134
+ def lookup(self, path, follow=True, depth=0):
135
+ if depth > 16:
136
+ raise ValueError("too many symbolic links")
137
+ number = 2
138
+ parts = [part for part in path.split("/") if part]
139
+ for index, part in enumerate(parts):
140
+ directory = self.inode(number)
141
+ if self.mode(directory) & S_IFMT != S_IFDIR:
142
+ raise FileNotFoundError(path)
143
+ found = dict(self.entries(directory)).get(part)
144
+ if found is None:
145
+ raise FileNotFoundError(path)
146
+ number = found
147
+ node = self.inode(number)
148
+ last = index == len(parts) - 1
149
+ if self.mode(node) & S_IFMT == S_IFLNK and (follow or not last):
150
+ target = self.symlink(node)
151
+ base = "/" + "/".join(parts[:index])
152
+ rest = "/".join(parts[index + 1:])
153
+ joined = target if target.startswith("/") else os.path.join(base, target)
154
+ return self.lookup(os.path.normpath(joined + ("/" + rest if rest else "")), follow, depth + 1)
155
+ return self.inode(number)
156
+
157
+ def symlink(self, inode):
158
+ size = self.size(inode)
159
+ flags = struct.unpack_from("<I", inode, 0x20)[0]
160
+ if size < 60 and not flags & EXTENTS_FLAG:
161
+ return inode[0x28:0x28 + size].decode()
162
+ return self.contents(inode).decode()
163
+
164
+
165
+ def uncompressed_image(kernel):
166
+ """Debian's arm64 vmlinuz is a gzip'd Image or an EFI zboot image."""
167
+ if kernel[:2] == b"\x1f\x8b":
168
+ return gzip.decompress(kernel)
169
+ if kernel[:2] == b"MZ" and kernel[4:8] == b"zimg":
170
+ start, length = struct.unpack_from("<II", kernel, 8)
171
+ kind = kernel[24:32].rstrip(b"\0").decode()
172
+ payload = kernel[start:start + length]
173
+ if kind == "gzip":
174
+ return zlib.decompress(payload, 16 + zlib.MAX_WBITS)
175
+ if kind == "zstd":
176
+ zstd = shutil.which("zstd")
177
+ if not zstd:
178
+ raise SystemExit("this kernel is zstd-compressed EFI zboot; install zstd to unpack it")
179
+ return subprocess.run([zstd, "-dc"], input=payload, check=True, capture_output=True).stdout
180
+ raise SystemExit(f"unsupported EFI zboot compression: {kind}")
181
+ if kernel[0x38:0x3C] == b"ARM\x64":
182
+ return kernel
183
+ raise SystemExit("the kernel is neither an arm64 Image, a gzip'd Image, nor EFI zboot")
184
+
185
+
186
+ def main():
187
+ if len(sys.argv) != 3:
188
+ raise SystemExit(__doc__)
189
+ disk, out = sys.argv[1], sys.argv[2]
190
+ with open(disk, "rb") as image:
191
+ for offset in partitions(image):
192
+ try:
193
+ fs = Ext4(image, offset)
194
+ boot = dict(fs.entries(fs.lookup("/boot")))
195
+ except (ValueError, FileNotFoundError):
196
+ continue
197
+ kernels = sorted(name for name in boot if name.startswith("vmlinuz-"))
198
+ if not kernels:
199
+ continue
200
+ release = kernels[-1][len("vmlinuz-"):]
201
+ if f"initrd.img-{release}" not in boot:
202
+ raise SystemExit(f"/boot has vmlinuz-{release} but no matching initrd")
203
+ kernel = fs.contents(fs.lookup(f"/boot/vmlinuz-{release}"))
204
+ initrd = fs.contents(fs.lookup(f"/boot/initrd.img-{release}"))
205
+ image_bytes = uncompressed_image(kernel)
206
+ if image_bytes[0x38:0x3C] != b"ARM\x64":
207
+ raise SystemExit("the unpacked kernel is not an arm64 Image")
208
+ os.makedirs(out, exist_ok=True)
209
+ with open(os.path.join(out, "Image"), "wb") as handle:
210
+ handle.write(image_bytes)
211
+ with open(os.path.join(out, "initrd"), "wb") as handle:
212
+ handle.write(initrd)
213
+ with open(os.path.join(out, "release"), "w") as handle:
214
+ handle.write(release + "\n")
215
+ print(release)
216
+ return
217
+ raise SystemExit("no ext4 partition with a kernel in /boot was found")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()