diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 9788db1..21901cc 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 27 (WWDC26). + 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). + """)) + 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 27) { + throw ValidationError("--net-vmnet requires the host to be running macOS 27 (or newer)") + } + } + #endif + if graphics && noGraphics { throw ValidationError("--graphics and --no-graphics are mutually exclusive") } @@ -690,6 +732,13 @@ struct Run: AsyncParsableCommand { return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments) } + #if compiler(>=6.4) + if netVmnet, #available(macOS 27, *) { + let portForwardings = try netVmnetExpose.map { try NetworkVmnet.parsePortForwardings($0) } ?? [] + return try NetworkVmnet(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 0000000..b560e5e --- /dev/null +++ b/Sources/tart/Network/NetworkVmnet.swift @@ -0,0 +1,156 @@ +// Native vmnet-backed networking, adopting the macOS 27 (WWDC26) Virtualization +// API surface introduced in session 224 ("Expand the capabilities of your +// Virtualization app"). +// +// 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. +// +// 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). + +import Foundation +import Semaphore +import Virtualization + +#if compiler(>=6.4) + import vmnet + + @available(macOS 27, *) + 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_t + private let portForwardings: [PortForwarding] + + init(portForwardings: [PortForwarding] = []) throws { + self.portForwardings = portForwardings + + 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) } + + try Self.applyPortForwarding(rules: portForwardings, 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 { + vmnet_network_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. + } + + // 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 + ) throws { + guard !rules.isEmpty else { return } + throw NetworkVmnetError.PortForwardingPendingSDKFinalization + } + + 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) + } + } + + enum NetworkVmnetError: Error, CustomStringConvertible { + case ConfigurationCreationFailed(status: vmnet_return_t) + case NetworkCreationFailed(status: vmnet_return_t) + case InvalidPortForwardingSpec(spec: String, why: String) + case PortForwardingPendingSDKFinalization + + 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 .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" + } + } + } + +#endif diff --git a/Tests/TartTests/NetworkVmnetTests.swift b/Tests/TartTests/NetworkVmnetTests.swift new file mode 100644 index 0000000..90e2d9d --- /dev/null +++ b/Tests/TartTests/NetworkVmnetTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import tart + +#if compiler(>=6.4) + @available(macOS 27, *) + 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