Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion Sources/tart/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down
271 changes: 271 additions & 0 deletions Sources/tart/Network/NetworkVmnet.swift
Original file line number Diff line number Diff line change
@@ -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: &etherAddress) { 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[..<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)
}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hashing vmnet subnets into only 253 buckets

When a host runs two VMs whose different MAC addresses hash to the same third octet, both --net-vmnet-expose runs try to create the same 192.168.<octet>.0/24 vmnet network and reserve the same guest address, so the second vmnet_network_create fails even though Tart's MAC-collision check passed. With only 253 possible subnets this becomes likely in CI fleets or hosts that run many VMs; derive the subnet from more MAC bits or allocate/check active subnets instead of reducing to one octet.

Useful? React with 👍 / 👎.

}

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<AnyObject>.fromOpaque(UnsafeRawPointer(pointer)).release()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove manual releases of vmnet references

When --net-vmnet creates a vmnet configuration/network, these refs are Swift strong references to vmnet Objective-C objects; ARC releases them when the local/property references go away. Calling Unmanaged.release() here means configuration is released once by the defer and again by ARC at the end of init, and network is released in deinit and again as the stored property is destroyed, so vmnet runs can over-release and crash during network creation or teardown. Let ARC manage these references instead of manually releasing them.

Useful? React with 👍 / 👎.

}
}

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