From cf304103958274ba32fab509740991a9ac6395d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Tue, 16 Jun 2026 16:26:57 +0200 Subject: [PATCH] fix(tart): wire native vmnet port forwarding --- Sources/tart/Commands/Run.swift | 13 +- Sources/tart/Network/NetworkVmnet.swift | 191 +++++++++++++++++++----- Tests/TartTests/NetworkVmnetTests.swift | 2 +- 3 files changed, 161 insertions(+), 45 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 21901cc..8922401 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -246,11 +246,11 @@ struct Run: AsyncParsableCommand { #if compiler(>=6.4) @Flag(help: ArgumentHelp("Use native vmnet-backed networking instead of Softnet for port forwarding", discussion: """ - Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 27 (WWDC26). + Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 26. vmnet networks run in-process with no sidecar, so this can replace --net-softnet for the common "CI VM with a few forwarded ports" case. - Requires the host to be running macOS 27 (or newer). + Requires the host to be running macOS 26 (or newer). """)) var netVmnet: Bool = false @@ -363,8 +363,8 @@ struct Run: AsyncParsableCommand { #if compiler(>=6.4) if netVmnet { - if #unavailable(macOS 27) { - throw ValidationError("--net-vmnet requires the host to be running macOS 27 (or newer)") + if #unavailable(macOS 26) { + throw ValidationError("--net-vmnet requires the host to be running macOS 26 (or newer)") } } #endif @@ -733,9 +733,10 @@ struct Run: AsyncParsableCommand { } #if compiler(>=6.4) - if netVmnet, #available(macOS 27, *) { + if netVmnet, #available(macOS 26, *) { + let config = try VMConfig.init(fromURL: vmDir.configURL) let portForwardings = try netVmnetExpose.map { try NetworkVmnet.parsePortForwardings($0) } ?? [] - return try NetworkVmnet(portForwardings: portForwardings) + return try NetworkVmnet(vmMACAddress: config.macAddress.string, portForwardings: portForwardings) } #endif diff --git a/Sources/tart/Network/NetworkVmnet.swift b/Sources/tart/Network/NetworkVmnet.swift index b560e5e..4813688 100644 --- a/Sources/tart/Network/NetworkVmnet.swift +++ b/Sources/tart/Network/NetworkVmnet.swift @@ -1,6 +1,5 @@ -// Native vmnet-backed networking, adopting the macOS 27 (WWDC26) Virtualization -// API surface introduced in session 224 ("Expand the capabilities of your -// Virtualization app"). +// Native vmnet-backed networking, adopting the vmnet logical network API +// surface introduced alongside VZVmnetNetworkDeviceAttachment. // // Goal: provide a sidecar-free alternative to the external Softnet process for // CI-style port forwarding from host TCP/UDP ports to guest ports. The vmnet @@ -8,18 +7,11 @@ // VZVmnetNetworkDeviceAttachment, so the VM keeps using the standard // VZVirtioNetworkDeviceConfiguration path. // -// Compile-verification status: this file targets Swift 6.4 (Xcode 27 beta) and -// the macOS 27 SDK headers. The host Swift available when this branch was -// written was 6.3.2, so it has not been compiled. The C entry points used here -// (vmnet_network_configuration_create, vmnet_network_create) are the names -// shown verbatim in WWDC26 session 224. The port-forwarding configuration -// symbol is not stated in the session and is left as a clearly marked FIXME so -// it can be wired up once the final Xcode 27 headers are available. -// // The whole file is gated behind `#if compiler(>=6.4)` to match how Tart // already gates VZMacGuestProvisioningOptions in VM.swift (the same situation: -// macOS 27 SDK symbols referenced by a tree that still builds under Xcode 26). +// new SDK symbols referenced by a tree that still builds under Xcode 26). +import Darwin import Foundation import Semaphore import Virtualization @@ -27,7 +19,7 @@ import Virtualization #if compiler(>=6.4) import vmnet - @available(macOS 27, *) + @available(macOS 26, *) class NetworkVmnet: Network { enum NetworkProtocol: String, CaseIterable { case tcp @@ -40,19 +32,22 @@ import Virtualization let internalPort: UInt16 } - private let network: vmnet_network_t - private let portForwardings: [PortForwarding] + private let network: vmnet_network_ref - init(portForwardings: [PortForwarding] = []) throws { - self.portForwardings = portForwardings + init(vmMACAddress: String, portForwardings: [PortForwarding] = []) throws { + let macAddress = try Self.parseMACAddress(vmMACAddress) + let addressing = try Self.addressing(for: macAddress) var configStatus: vmnet_return_t = .VMNET_FAILURE guard let configuration = vmnet_network_configuration_create(.VMNET_SHARED_MODE, &configStatus) else { throw NetworkVmnetError.ConfigurationCreationFailed(status: configStatus) } - defer { vmnet_network_configuration_release(configuration) } + defer { Self.release(configuration) } - try Self.applyPortForwarding(rules: portForwardings, to: configuration) + if !portForwardings.isEmpty { + try Self.configureIPv4Addressing(addressing, for: macAddress, to: configuration) + try Self.applyPortForwarding(rules: portForwardings, internalAddress: addressing.guestAddress, to: configuration) + } var networkStatus: vmnet_return_t = .VMNET_FAILURE guard let network = vmnet_network_create(configuration, &networkStatus) else { @@ -62,7 +57,7 @@ import Virtualization } deinit { - vmnet_network_release(network) + Self.release(network) } func attachments() -> [VZNetworkDeviceAttachment] { @@ -78,23 +73,65 @@ import Virtualization // attachment as part of its normal shutdown. } - // FIXME(macOS 27 SDK): wire up the actual vmnet port-forwarding setter. - // - // WWDC26 session 224 advertises "forward host TCP/UDP ports to specific - // VMs" as part of the new vmnet_network_configuration_t surface but does - // not show the exact C symbol. Once the final Xcode 27 SDK ships, replace - // the body below with the real calls (likely shaped like - // `vmnet_network_configuration_add_port_forwarding_rule(configuration, - // protocol, externalPort, internalPort, &status)`). - // - // For now, refuse to start a VM with port-forwarding rules so the failure - // mode is loud rather than silently dropped traffic. private static func applyPortForwarding( rules: [PortForwarding], - to configuration: vmnet_network_configuration_t + internalAddress: in_addr, + to configuration: vmnet_network_configuration_ref ) throws { - guard !rules.isEmpty else { return } - throw NetworkVmnetError.PortForwardingPendingSDKFinalization + for rule in rules { + var internalAddress = internalAddress + let status = withUnsafePointer(to: &internalAddress) { + vmnet_network_configuration_add_port_forwarding_rule( + configuration, + rule.vmnetProtocol, + sa_family_t(AF_INET), + rule.internalPort, + rule.externalPort, + UnsafeRawPointer($0) + ) + } + + guard status == .VMNET_SUCCESS else { + throw NetworkVmnetError.PortForwardingConfigurationFailed(rule: rule, status: status) + } + } + } + + private static func configureIPv4Addressing( + _ addressing: IPv4Addressing, + for macAddress: MACAddress, + to configuration: vmnet_network_configuration_ref + ) throws { + var subnet = addressing.subnet + var mask = addressing.mask + let subnetStatus = withUnsafePointer(to: &subnet) { subnetPointer in + withUnsafePointer(to: &mask) { maskPointer in + vmnet_network_configuration_set_ipv4_subnet(configuration, subnetPointer, maskPointer) + } + } + guard subnetStatus == .VMNET_SUCCESS else { + throw NetworkVmnetError.IPv4SubnetConfigurationFailed(status: subnetStatus) + } + + var guestAddress = addressing.guestAddress + var etherAddress = ether_addr_t( + octet: ( + macAddress.mac[0], + macAddress.mac[1], + macAddress.mac[2], + macAddress.mac[3], + macAddress.mac[4], + macAddress.mac[5] + ) + ) + let reservationStatus = withUnsafePointer(to: ðerAddress) { macPointer in + withUnsafePointer(to: &guestAddress) { guestAddressPointer in + vmnet_network_configuration_add_dhcp_reservation(configuration, macPointer, guestAddressPointer) + } + } + guard reservationStatus == .VMNET_SUCCESS else { + throw NetworkVmnetError.DHCPReservationConfigurationFailed(status: reservationStatus) + } } static func parsePortForwardings(_ spec: String) throws -> [PortForwarding] { @@ -130,13 +167,84 @@ import Virtualization return PortForwarding(proto: proto, externalPort: external, internalPort: internalPort) } + + private struct IPv4Addressing { + let subnet: in_addr + let mask: in_addr + let guestAddress: in_addr + } + + private static func addressing(for macAddress: MACAddress) throws -> IPv4Addressing { + let thirdOctet = subnetThirdOctet(for: macAddress) + + return try IPv4Addressing( + subnet: ipv4Address("192.168.\(thirdOctet).0"), + mask: ipv4Address("255.255.255.0"), + guestAddress: ipv4Address("192.168.\(thirdOctet).2") + ) + } + + private static func subnetThirdOctet(for macAddress: MACAddress) -> UInt8 { + var hash: UInt32 = 2_166_136_261 + for byte in macAddress.mac { + hash ^= UInt32(byte) + hash &*= 16_777_619 + } + + // Keep forwarded VMs on stable per-MAC subnets and avoid bridge100's common default. + var octet = UInt8((hash % 253) + 2) + if octet == 64 { + octet = 65 + } + return octet + } + + private static func ipv4Address(_ string: String) throws -> in_addr { + var address = in_addr() + guard inet_pton(AF_INET, string, &address) == 1 else { + throw NetworkVmnetError.InvalidIPv4Address(string) + } + return address + } + + private static func parseMACAddress(_ string: String) throws -> MACAddress { + guard let macAddress = MACAddress(fromString: string) else { + throw NetworkVmnetError.InvalidMACAddress(string) + } + return macAddress + } + + private static func release(_ pointer: OpaquePointer) { + Unmanaged.fromOpaque(UnsafeRawPointer(pointer)).release() + } } + @available(macOS 26, *) + extension NetworkVmnet.PortForwarding: CustomStringConvertible { + fileprivate var vmnetProtocol: UInt8 { + switch proto { + case .tcp: + return UInt8(IPPROTO_TCP) + case .udp: + return UInt8(IPPROTO_UDP) + } + } + + var description: String { + "\(externalPort):\(internalPort)/\(proto.rawValue)" + } + } + + @available(macOS 26, *) enum NetworkVmnetError: Error, CustomStringConvertible { case ConfigurationCreationFailed(status: vmnet_return_t) case NetworkCreationFailed(status: vmnet_return_t) + case IPv4SubnetConfigurationFailed(status: vmnet_return_t) + case DHCPReservationConfigurationFailed(status: vmnet_return_t) + case PortForwardingConfigurationFailed(rule: NetworkVmnet.PortForwarding, status: vmnet_return_t) case InvalidPortForwardingSpec(spec: String, why: String) - case PortForwardingPendingSDKFinalization + case InvalidIPv4Address(String) + case InvalidMACAddress(String) var description: String { switch self { @@ -146,9 +254,16 @@ import Virtualization return "vmnet_network_create() failed with status \(status)" case .InvalidPortForwardingSpec(let spec, let why): return "invalid port forwarding spec \"\(spec)\": \(why)" - case .PortForwardingPendingSDKFinalization: - return "--net-vmnet-expose is not yet wired through to the macOS 27 vmnet port-forwarding API; " - + "use --net-softnet-expose for now or remove the rule" + case .IPv4SubnetConfigurationFailed(let status): + return "vmnet_network_configuration_set_ipv4_subnet() failed with status \(status)" + case .DHCPReservationConfigurationFailed(let status): + return "vmnet_network_configuration_add_dhcp_reservation() failed with status \(status)" + case .PortForwardingConfigurationFailed(let rule, let status): + return "vmnet_network_configuration_add_port_forwarding_rule(\(rule)) failed with status \(status)" + case .InvalidIPv4Address(let address): + return "invalid IPv4 address \"\(address)\"" + case .InvalidMACAddress(let macAddress): + return "invalid MAC address \"\(macAddress)\"" } } } diff --git a/Tests/TartTests/NetworkVmnetTests.swift b/Tests/TartTests/NetworkVmnetTests.swift index 90e2d9d..c52fcd7 100644 --- a/Tests/TartTests/NetworkVmnetTests.swift +++ b/Tests/TartTests/NetworkVmnetTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import tart #if compiler(>=6.4) - @available(macOS 27, *) + @available(macOS 26, *) final class NetworkVmnetTests: XCTestCase { func testParsesSingleTCPRule() throws { let rules = try NetworkVmnet.parsePortForwardings("2222:22")