From fbe480d9c7e88501aa830ff90efc24254e79d735 Mon Sep 17 00:00:00 2001 From: Hamza <12420351+0xMH@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:14:42 +0200 Subject: [PATCH 1/2] Fix vmnet VPN external interface refresh --- .../Server/DefaultNetworkService.swift | 27 ++ .../Server/ReservedVmnetNetwork.swift | 263 +++++++++++++++++- 2 files changed, 282 insertions(+), 8 deletions(-) diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 29f3fbe82..06e5239ea 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -24,6 +24,7 @@ public actor DefaultNetworkService: NetworkService { private let network: any Network private let log: Logger private var allocator: AttachmentAllocator + private var allocatorSubnet: CIDRv4 private var macAddresses: [UInt32: MACAddress] private var allocationsBySession: [XPCServerSession: [(hostname: String, index: UInt32)]] @@ -41,12 +42,14 @@ public actor DefaultNetworkService: NetworkService { self.network = network self.log = log self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.allocatorSubnet = subnet self.macAddresses = [:] self.allocationsBySession = [:] } @Sendable public func status() async throws -> NetworkStatus { + try refreshNetworkStateIfNeeded() guard let status = await network.status else { throw ContainerizationError(.invalidState, message: "network \(network.id) is not running") } @@ -62,9 +65,11 @@ public actor DefaultNetworkService: NetworkService { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + try refreshNetworkStateIfNeeded() guard let status = await network.status else { throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") } + try resetAllocatorIfNeeded(status: status) let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) let index = try await allocator.allocate(hostname: hostname) @@ -122,9 +127,11 @@ public actor DefaultNetworkService: NetworkService { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + try refreshNetworkStateIfNeeded() guard let status = await network.status else { throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") } + try resetAllocatorIfNeeded(status: status) // Invariant: hostname -> index if and only if index -> MAC address let index = try await allocator.lookup(hostname: hostname) @@ -157,4 +164,24 @@ public actor DefaultNetworkService: NetworkService { return attachment } + + private func refreshNetworkStateIfNeeded() throws { + try network.withAdditionalData { _ in } + } + + private func resetAllocatorIfNeeded(status: NetworkStatus) throws { + let subnet = status.ipv4Subnet + guard allocatorSubnet.description != subnet.description else { + return + } + + guard allocationsBySession.isEmpty else { + throw ContainerizationError(.invalidState, message: "network \(network.id) changed subnet while allocations are active") + } + + let size = Int(subnet.upper.value - subnet.lower.value - 3) + allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + allocatorSubnet = subnet + macAddresses = [:] + } } diff --git a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift index c11942427..e67181e0c 100644 --- a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift +++ b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift @@ -19,18 +19,53 @@ import ContainerResource import ContainerXPC import ContainerizationError import ContainerizationExtras +import Darwin import Foundation import Logging import Synchronization +import SystemConfiguration import XPC import vmnet +struct VmnetInterfaceAddress: Equatable { + let name: String + let flags: UInt32 + let family: Int32 + let ip: String +} + +enum VmnetExternalInterfaceSelector { + static func activePointToPointIPv4Interface(primaryInterface: String?, in addresses: [VmnetInterfaceAddress]) -> String? { + guard let primaryInterface else { + return nil + } + guard let address = addresses.first(where: { $0.name == primaryInterface }) else { + return nil + } + guard address.family == AF_INET, + address.flags & UInt32(IFF_UP) != 0, + address.flags & UInt32(IFF_RUNNING) != 0, + address.flags & UInt32(IFF_POINTOPOINT) != 0, + address.ip != "127.0.0.1", + isTunnelInterfaceName(address.name) + else { + return nil + } + return address.name + } + + private static func isTunnelInterfaceName(_ name: String) -> Bool { + name.hasPrefix("utun") || name.hasPrefix("tun") || name.hasPrefix("tap") || name.hasPrefix("ppp") + } +} + /// Creates a vmnet network with reservation APIs. @available(macOS 26, *) public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { private struct State { var status: NetworkStatus? var network: vmnet_network_ref? + var networkStateSignature: String? } private struct NetworkInfo { @@ -38,11 +73,13 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { let ipv4Subnet: CIDRv4 let ipv4Gateway: IPv4Address let ipv6Subnet: CIDRv6 + let externalInterface: String? } private let configuration: NetworkConfiguration private let stateMutex: Mutex private let log: Logger + private let networkStateSignatureProvider: @Sendable () -> String? /// Configure a bridge network that allows external system access using /// network address translation. @@ -57,6 +94,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { log.info("creating vmnet network") self.configuration = configuration self.log = log + self.networkStateSignatureProvider = ReservedVmnetNetwork.networkStateSignature stateMutex = Mutex(State()) log.info("created vmnet network") } @@ -69,6 +107,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { try stateMutex.withLock { state in + try refreshNetworkIfNeeded(state: &state) try handler(state.network.map { try Self.serialize_network_ref(ref: $0) }) } } @@ -81,13 +120,200 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { let networkInfo = try startNetwork(configuration: configuration, log: log) - state.status = NetworkStatus( - ipv4Subnet: networkInfo.ipv4Subnet, - ipv4Gateway: networkInfo.ipv4Gateway, - ipv6Subnet: networkInfo.ipv6Subnet - ) + state.status = Self.status(for: networkInfo) state.network = networkInfo.network + state.networkStateSignature = networkStateSignatureProvider() + } + } + + private func refreshNetworkIfNeeded(state: inout State) throws { + guard configuration.mode == .nat else { + return + } + + guard let currentSignature = networkStateSignatureProvider() else { + return + } + + guard state.networkStateSignature != nil, state.networkStateSignature != currentSignature else { + return + } + + log.info("network state changed, recreating vmnet network", metadata: ["id": "\(configuration.id)"]) + let previousStatus = state.status + state.network = nil + let networkInfo: NetworkInfo + do { + networkInfo = try startNetwork( + configuration: configuration, + log: log, + preferredIPv4Subnet: previousStatus?.ipv4Subnet, + preferredIPv6Subnet: previousStatus?.ipv6Subnet + ) + } catch { + guard configuration.ipv4Subnet == nil, configuration.ipv6Subnet == nil, previousStatus != nil else { + throw error + } + log.info( + "failed to recreate vmnet network with previous subnets, retrying automatic subnet selection", + metadata: [ + "id": "\(configuration.id)", + "error": "\(error)", + ] + ) + networkInfo = try startNetwork(configuration: configuration, log: log) + } + state.status = Self.status(for: networkInfo) + state.network = networkInfo.network + state.networkStateSignature = currentSignature + } + + private static func status(for networkInfo: NetworkInfo) -> NetworkStatus { + NetworkStatus( + ipv4Subnet: networkInfo.ipv4Subnet, + ipv4Gateway: networkInfo.ipv4Gateway, + ipv6Subnet: networkInfo.ipv6Subnet + ) + } + + private static func networkStateSignature() -> String? { + guard let store = SCDynamicStoreCreate(nil, "com.apple.container.network-vmnet" as CFString, nil, nil) else { + return nil + } + + let patterns = + [ + "State:/Network/Global/IPv4", + "State:/Network/Global/IPv6", + "State:/Network/Interface/.*/IPv4", + "State:/Network/Interface/.*/IPv6", + ] as CFArray + + guard let values = SCDynamicStoreCopyMultiple(store, nil, patterns) as? [String: Any] else { + return nil + } + + var parts = [String]() + for key in values.keys.sorted() { + guard let value = values[key], + let data = try? PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0) + else { + continue + } + parts.append("\(key)=\(data.base64EncodedString())") + } + + parts += interfaceAddressSignature() + + return parts.joined(separator: "|") + } + + private static func interfaceAddressSignature() -> [String] { + interfaceAddresses().map { address in + switch address.family { + case AF_INET: + return "ifaddr4:\(address.name)=\(address.ip)" + case AF_INET6: + return "ifaddr6:\(address.name)=\(address.ip)" + default: + return nil + } + } + .compactMap { $0 } + .sorted() + } + + private static func interfaceAddresses() -> [VmnetInterfaceAddress] { + var addresses: UnsafeMutablePointer? + guard getifaddrs(&addresses) == 0, let firstAddress = addresses else { + return [] + } + defer { freeifaddrs(firstAddress) } + + var parts = [VmnetInterfaceAddress]() + var current: UnsafeMutablePointer? = firstAddress + while let address = current { + defer { current = address.pointee.ifa_next } + guard let socketAddress = address.pointee.ifa_addr else { + continue + } + + let name = stringFromNullTerminatedCString(address.pointee.ifa_name) + let family = Int32(socketAddress.pointee.sa_family) + switch family { + case AF_INET: + guard let ip = ipv4AddressString(socketAddress) else { + continue + } + parts.append(VmnetInterfaceAddress(name: name, flags: address.pointee.ifa_flags, family: family, ip: ip)) + case AF_INET6: + guard let ip = ipv6AddressString(socketAddress) else { + continue + } + parts.append(VmnetInterfaceAddress(name: name, flags: address.pointee.ifa_flags, family: family, ip: ip)) + default: + continue + } + } + + return parts + } + + private static func activePointToPointIPv4Interface() -> String? { + VmnetExternalInterfaceSelector.activePointToPointIPv4Interface( + primaryInterface: primaryIPv4Interface(), + in: interfaceAddresses() + ) + } + + private static func primaryIPv4Interface() -> String? { + guard let store = SCDynamicStoreCreate(nil, "com.apple.container.network-vmnet" as CFString, nil, nil) else { + return nil + } + guard let globalIPv4 = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString) as? [String: Any] else { + return nil + } + return globalIPv4["PrimaryInterface"] as? String + } + + private static func ipv4AddressString(_ socketAddress: UnsafeMutablePointer) -> String? { + var address = socketAddress.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { pointer in + pointer.pointee.sin_addr + } + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &address, &buffer, socklen_t(buffer.count)) != nil else { + return nil + } + return stringFromNullTerminatedBuffer(buffer) + } + + private static func ipv6AddressString(_ socketAddress: UnsafeMutablePointer) -> String? { + var address = socketAddress.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { pointer in + pointer.pointee.sin6_addr + } + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(buffer.count)) != nil else { + return nil + } + return stringFromNullTerminatedBuffer(buffer) + } + + private static func stringFromNullTerminatedBuffer(_ buffer: [CChar]) -> String? { + guard let endIndex = buffer.firstIndex(of: 0) else { + return nil + } + let bytes = buffer[..) -> String { + var bytes = [UInt8]() + var offset = 0 + while pointer[offset] != 0 { + bytes.append(UInt8(bitPattern: pointer[offset])) + offset += 1 } + return String(decoding: bytes, as: UTF8.self) } private static func serialize_network_ref(ref: vmnet_network_ref) throws -> XPCMessage { @@ -98,7 +324,12 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { return XPCMessage(object: refObject) } - private func startNetwork(configuration: NetworkConfiguration, log: Logger) throws -> NetworkInfo { + private func startNetwork( + configuration: NetworkConfiguration, + log: Logger, + preferredIPv4Subnet: CIDRv4? = nil, + preferredIPv6Subnet: CIDRv6? = nil + ) throws -> NetworkInfo { log.info( "starting vmnet network", metadata: [ @@ -116,8 +347,22 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { vmnet_network_configuration_disable_dhcp(vmnetConfiguration) - let ipv4Subnet = configuration.ipv4Subnet - let ipv6Subnet = configuration.ipv6Subnet + let externalInterface = configuration.mode == .nat ? Self.activePointToPointIPv4Interface() : nil + if let externalInterface { + let status = externalInterface.withCString { + vmnet_network_configuration_set_external_interface(vmnetConfiguration, $0) + } + guard status == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set external interface \(externalInterface) for network \(configuration.id)") + } + log.info( + "configuring vmnet external interface", + metadata: ["interface": "\(externalInterface)"] + ) + } + + let ipv4Subnet = configuration.ipv4Subnet ?? preferredIPv4Subnet + let ipv6Subnet = configuration.ipv6Subnet ?? preferredIPv6Subnet // set the IPv4 subnet if let ipv4Subnet { @@ -187,6 +432,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { "mode": "\(configuration.mode)", "cidr": "\(runningSubnet)", "cidrv6": "\(runningV6Subnet)", + "externalInterface": "\(externalInterface ?? "")", ] ) @@ -195,6 +441,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { ipv4Subnet: runningSubnet, ipv4Gateway: runningGateway, ipv6Subnet: runningV6Subnet, + externalInterface: externalInterface ) } } From 91993c301bdb8d49ece04860d69ffb54e3bb809c Mon Sep 17 00:00:00 2001 From: Hamza <12420351+0xMH@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:26:08 +0200 Subject: [PATCH 2/2] Add vmnet VPN interface selection tests --- Package.swift | 6 + .../ReservedVmnetNetworkTest.swift | 106 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 Tests/ContainerNetworkVmnetServerTests/ReservedVmnetNetworkTest.swift diff --git a/Package.swift b/Package.swift index e2371d5ef..f039dbefc 100644 --- a/Package.swift +++ b/Package.swift @@ -348,6 +348,12 @@ let package = Package( ], path: "Sources/Services/NetworkVmnet/Server" ), + .testTarget( + name: "ContainerNetworkVmnetServerTests", + dependencies: [ + "ContainerNetworkVmnetServer" + ] + ), .target( name: "ContainerRuntimeLinuxClient", dependencies: [], diff --git a/Tests/ContainerNetworkVmnetServerTests/ReservedVmnetNetworkTest.swift b/Tests/ContainerNetworkVmnetServerTests/ReservedVmnetNetworkTest.swift new file mode 100644 index 000000000..909f8128d --- /dev/null +++ b/Tests/ContainerNetworkVmnetServerTests/ReservedVmnetNetworkTest.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Darwin +import Testing + +@testable import ContainerNetworkVmnetServer + +struct ReservedVmnetNetworkTest { + @Test + func selectsPrimaryActivePointToPointIPv4TunnelInterface() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "en0", flags: activeFlags, family: AF_INET, ip: "192.0.2.10"), + .init(name: "utun8", flags: activePointToPointFlags, family: AF_INET, ip: "10.153.0.2"), + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun8", in: addresses) == "utun8") + } + + @Test + func ignoresNonPrimaryTunnelInterfaces() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "en0", flags: activeFlags, family: AF_INET, ip: "192.0.2.10"), + .init(name: "utun8", flags: activePointToPointFlags, family: AF_INET, ip: "10.153.0.2"), + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "en0", in: addresses) == nil) + } + + @Test + func ignoresInactiveNonRunningAndNonTunnelInterfaces() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "utun1", flags: UInt32(IFF_POINTOPOINT), family: AF_INET, ip: "10.0.0.2"), + .init(name: "utun2", flags: UInt32(IFF_UP) | UInt32(IFF_POINTOPOINT), family: AF_INET, ip: "10.0.1.2"), + .init(name: "en0", flags: activePointToPointFlags, family: AF_INET, ip: "192.0.2.10"), + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun1", in: addresses) == nil) + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun2", in: addresses) == nil) + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "en0", in: addresses) == nil) + } + + @Test + func ignoresIPv6OnlyAndLoopbackEntries() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "utun1", flags: activePointToPointFlags, family: AF_INET6, ip: "fd00::2"), + .init(name: "utun2", flags: activePointToPointFlags, family: AF_INET, ip: "127.0.0.1"), + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun1", in: addresses) == nil) + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun2", in: addresses) == nil) + } + + @Test + func acceptsCommonPointToPointTunnelPrefixes() { + for name in ["utun8", "tun0", "tap0", "ppp0"] { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: name, flags: activePointToPointFlags, family: AF_INET, ip: "10.153.0.2") + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: name, in: addresses) == name) + } + } + + @Test + func usesPrimaryInterfaceInsteadOfSortingMultipleTunnels() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "utun8", flags: activePointToPointFlags, family: AF_INET, ip: "10.153.0.2"), + .init(name: "utun3", flags: activePointToPointFlags, family: AF_INET, ip: "10.154.0.2"), + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun8", in: addresses) == "utun8") + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun3", in: addresses) == "utun3") + } + + @Test + func ignoresMissingPrimaryInterface() { + let addresses: [VmnetInterfaceAddress] = [ + .init(name: "utun8", flags: activePointToPointFlags, family: AF_INET, ip: "10.153.0.2") + ] + + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: nil, in: addresses) == nil) + #expect(VmnetExternalInterfaceSelector.activePointToPointIPv4Interface(primaryInterface: "utun3", in: addresses) == nil) + } + + private var activeFlags: UInt32 { + UInt32(IFF_UP) | UInt32(IFF_RUNNING) + } + + private var activePointToPointFlags: UInt32 { + activeFlags | UInt32(IFF_POINTOPOINT) + } +}