@braedonsaunders/appkit-desk 0.6.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.
- package/README.md +83 -1
- package/backend.d.ts +28 -7
- package/backend.d.ts.map +1 -1
- package/backend.js +18 -521
- package/backend.js.map +1 -1
- package/events.d.ts +3 -0
- package/events.d.ts.map +1 -1
- package/events.js.map +1 -1
- package/host.d.ts +32 -1
- package/host.d.ts.map +1 -1
- package/host.js +141 -23
- package/host.js.map +1 -1
- package/index.d.ts +3 -0
- package/index.d.ts.map +1 -1
- package/index.js +3 -0
- package/index.js.map +1 -1
- package/machine.d.ts +145 -0
- package/machine.d.ts.map +1 -0
- package/machine.js +518 -0
- package/machine.js.map +1 -0
- package/network.d.ts +37 -0
- package/network.d.ts.map +1 -0
- package/network.js +173 -0
- package/network.js.map +1 -0
- package/package.json +2 -2
- package/plan.d.ts +129 -31
- package/plan.d.ts.map +1 -1
- package/plan.js +165 -9
- 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 +148 -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 +505 -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,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
|
+
}
|
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()
|