diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 9788db11..8922401a 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -243,6 +243,29 @@ struct Run: AsyncParsableCommand { @Flag(help: ArgumentHelp("Restrict network access to the host-only network")) var netHost: Bool = false + #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 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 26 (or newer). + """)) + var netVmnet: Bool = false + + @Option(help: ArgumentHelp("Comma-separated list of ports to forward into the vmnet guest (e.g. --net-vmnet-expose 2222:22,8080:80/tcp,5353:53/udp)", + discussion: """ + Each rule has the form EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] where PROTOCOL + is tcp (default) or udp. EXTERNAL_PORT is bound on the host's egress interface + and forwarded to INTERNAL_PORT on the guest. + + Implies --net-vmnet. + """, + valueName: "comma-separated port specifications")) + var netVmnetExpose: String? + #endif + @Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")", discussion: """ Options are comma-separated and are as follows: @@ -322,11 +345,30 @@ struct Run: AsyncParsableCommand { if netBridged.count > 0 { netFlags += 1 } if netSoftnet { netFlags += 1 } if netHost { netFlags += 1 } + #if compiler(>=6.4) + // Automatically enable --net-vmnet when --net-vmnet-expose is specified + if netVmnetExpose != nil { + netVmnet = true + } + if netVmnet { netFlags += 1 } + #endif if netFlags > 1 { - throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive") + #if compiler(>=6.4) + throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-vmnet are mutually exclusive") + #else + throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive") + #endif } + #if compiler(>=6.4) + if netVmnet { + if #unavailable(macOS 26) { + throw ValidationError("--net-vmnet requires the host to be running macOS 26 (or newer)") + } + } + #endif + if graphics && noGraphics { throw ValidationError("--graphics and --no-graphics are mutually exclusive") } @@ -690,6 +732,14 @@ struct Run: AsyncParsableCommand { return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments) } + #if compiler(>=6.4) + 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(vmMACAddress: config.macAddress.string, portForwardings: portForwardings) + } + #endif + if netBridged.count > 0 { func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface { let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in diff --git a/Sources/tart/Network/NetworkVmnet.swift b/Sources/tart/Network/NetworkVmnet.swift new file mode 100644 index 00000000..48136882 --- /dev/null +++ b/Sources/tart/Network/NetworkVmnet.swift @@ -0,0 +1,271 @@ +// 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 +// configuration is created in-process and handed to +// VZVmnetNetworkDeviceAttachment, so the VM keeps using the standard +// VZVirtioNetworkDeviceConfiguration path. +// +// The whole file is gated behind `#if compiler(>=6.4)` to match how Tart +// already gates VZMacGuestProvisioningOptions in VM.swift (the same situation: +// new SDK symbols referenced by a tree that still builds under Xcode 26). + +import Darwin +import Foundation +import Semaphore +import Virtualization + +#if compiler(>=6.4) + import vmnet + + @available(macOS 26, *) + class NetworkVmnet: Network { + enum NetworkProtocol: String, CaseIterable { + case tcp + case udp + } + + struct PortForwarding: Equatable { + let proto: NetworkProtocol + let externalPort: UInt16 + let internalPort: UInt16 + } + + private let network: vmnet_network_ref + + 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 { Self.release(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 { + throw NetworkVmnetError.NetworkCreationFailed(status: networkStatus) + } + self.network = network + } + + deinit { + Self.release(network) + } + + func attachments() -> [VZNetworkDeviceAttachment] { + [VZVmnetNetworkDeviceAttachment(network: network)] + } + + func run(_ sema: AsyncSemaphore) throws { + // vmnet networks run in-process. There is no sidecar to monitor. + } + + func stop() async throws { + // The network handle is released in deinit; the VM tears down the + // attachment as part of its normal shutdown. + } + + private static func applyPortForwarding( + rules: [PortForwarding], + internalAddress: in_addr, + to configuration: vmnet_network_configuration_ref + ) throws { + 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] { + try spec.split(separator: ",").map { try parseSingle(String($0)) } + } + + private static func parseSingle(_ raw: String) throws -> PortForwarding { + let (portPart, protoPart): (String, String) = { + if let slashIdx = raw.firstIndex(of: "/") { + return (String(raw[.. 0, internalPort > 0 + else { + throw NetworkVmnetError.InvalidPortForwardingSpec( + spec: raw, + why: "expected EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] with non-zero ports" + ) + } + + guard let proto = NetworkProtocol(rawValue: protoPart.lowercased()) else { + throw NetworkVmnetError.InvalidPortForwardingSpec( + spec: raw, + why: "unknown protocol \"\(protoPart)\", expected tcp or udp" + ) + } + + 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 InvalidIPv4Address(String) + case InvalidMACAddress(String) + + var description: String { + switch self { + case .ConfigurationCreationFailed(let status): + return "vmnet_network_configuration_create() failed with status \(status)" + case .NetworkCreationFailed(let status): + return "vmnet_network_create() failed with status \(status)" + case .InvalidPortForwardingSpec(let spec, let why): + return "invalid port forwarding spec \"\(spec)\": \(why)" + 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)\"" + } + } + } + +#endif diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 77ef4576..c5cc89f6 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -66,6 +66,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { self.network = network configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, vmConfig: config, + label: name, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, serialPorts: serialPorts, @@ -197,7 +198,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, - vmConfig: config, network: network, + vmConfig: config, label: name, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, serialPorts: serialPorts @@ -315,6 +316,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { diskURL: URL, nvramURL: URL, vmConfig: VMConfig, + label: String? = nil, network: Network = NetworkShared(), additionalStorageDevices: [VZStorageDeviceConfiguration], directorySharingDevices: [VZDirectorySharingDeviceConfiguration], @@ -444,11 +446,26 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Socket device configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()] + #if compiler(>=6.4) + if #available(macOS 27, *), let label, let virtualMachineLabel = Self.virtualMachineLabel(for: label) { + configuration.label = virtualMachineLabel + } + #endif + try configuration.validate() return configuration } + static func virtualMachineLabel(for name: String) -> String? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedName.contains(where: { !$0.isWhitespace }) else { + return nil + } + + return String(trimmedName.prefix(64)) + } + func guestDidStop(_ virtualMachine: VZVirtualMachine) { print("guest has stopped the virtual machine") sema.signal() diff --git a/Tests/TartTests/NetworkVmnetTests.swift b/Tests/TartTests/NetworkVmnetTests.swift new file mode 100644 index 00000000..c52fcd7f --- /dev/null +++ b/Tests/TartTests/NetworkVmnetTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import tart + +#if compiler(>=6.4) + @available(macOS 26, *) + final class NetworkVmnetTests: XCTestCase { + func testParsesSingleTCPRule() throws { + let rules = try NetworkVmnet.parsePortForwardings("2222:22") + XCTAssertEqual(rules, [ + NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 2222, internalPort: 22), + ]) + } + + func testParsesExplicitProtocols() throws { + let rules = try NetworkVmnet.parsePortForwardings("8080:80/tcp,5353:53/udp") + XCTAssertEqual(rules, [ + NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 8080, internalPort: 80), + NetworkVmnet.PortForwarding(proto: .udp, externalPort: 5353, internalPort: 53), + ]) + } + + func testProtocolIsCaseInsensitive() throws { + let rules = try NetworkVmnet.parsePortForwardings("9000:9000/UDP") + XCTAssertEqual(rules.first?.proto, .udp) + } + + func testRejectsMissingInternalPort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222")) + } + + func testRejectsZeroPort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("0:22")) + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:0")) + } + + func testRejectsUnknownProtocol() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:22/sctp")) + } + + func testRejectsOutOfRangePort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("99999:22")) + } + } +#endif diff --git a/Tests/TartTests/VMTests.swift b/Tests/TartTests/VMTests.swift new file mode 100644 index 00000000..82407d2a --- /dev/null +++ b/Tests/TartTests/VMTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import tart + +final class VMTests: XCTestCase { + func testVirtualMachineLabelUsesVMName() { + XCTAssertEqual(VM.virtualMachineLabel(for: "macos-runner"), "macos-runner") + } + + func testVirtualMachineLabelTrimsWhitespace() { + XCTAssertEqual(VM.virtualMachineLabel(for: " macos-runner "), "macos-runner") + } + + func testVirtualMachineLabelRejectsWhitespaceOnlyName() { + XCTAssertNil(VM.virtualMachineLabel(for: " ")) + } + + func testVirtualMachineLabelCapsAtSixtyFourCharacters() { + XCTAssertEqual( + VM.virtualMachineLabel(for: String(repeating: "a", count: 65)), + String(repeating: "a", count: 64) + ) + } +}