feat(tart): native vmnet networking via VZVmnetNetworkDeviceAttachment

This commit is contained in:
Pedro Piñera Buendía 2026-06-16 16:15:12 +02:00
parent 32d084e9ed
commit 1d0ae4566a
3 changed files with 250 additions and 1 deletions

View File

@ -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

View File

@ -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[..<slashIdx]), String(raw[raw.index(after: slashIdx)...]))
}
return (raw, "tcp")
}()
let ports = portPart.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard ports.count == 2,
let external = UInt16(ports[0]),
let internalPort = UInt16(ports[1]),
external > 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

View File

@ -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