From 6bd7d113bc586bf6798e7f7fa9f7b5c8bc2c59e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Tue, 28 Apr 2026 02:12:11 +0200 Subject: [PATCH 1/3] Add BridgedNetworkInterface; make Interface.ipv4Address optional Adds BridgedNetworkInterface, which uses VZBridgedNetworkDeviceAttachment to place a container on the host's physical network. The IP address is assigned by the upstream DHCP server rather than our allocation pool, so ipv4Address is always nil for this type. Makes Interface.ipv4Address optional (CIDRv4?) to accommodate interfaces whose address is not known at configuration time. Updates all existing conformers (NATInterface, NATNetworkInterface, VmnetNetwork.Interface) and guards the static address/route setup in VirtualMachineAgent+Interface behind an ipv4Address nil-check. Fixes #457 Co-authored-by: Curd Becker --- .../BridgedNetworkInterface.swift | 59 +++++++++++++++++++ Sources/Containerization/Interface.swift | 2 +- Sources/Containerization/LinuxContainer.swift | 2 +- Sources/Containerization/LinuxPod.swift | 2 +- Sources/Containerization/NATInterface.swift | 2 +- .../NATNetworkInterface.swift | 2 +- .../VirtualMachineAgent+Interface.swift | 21 ++++--- Sources/Containerization/VmnetNetwork.swift | 2 +- Sources/Integration/ContainerTests.swift | 4 +- Sources/cctl/RunCommand.swift | 5 +- .../ContainerManagerTests.swift | 2 +- .../InterfaceTests.swift | 2 +- 12 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 Sources/Containerization/BridgedNetworkInterface.swift diff --git a/Sources/Containerization/BridgedNetworkInterface.swift b/Sources/Containerization/BridgedNetworkInterface.swift new file mode 100644 index 00000000..24a2da8e --- /dev/null +++ b/Sources/Containerization/BridgedNetworkInterface.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +import ContainerizationError +import ContainerizationExtras +import Virtualization + +/// A network interface that bridges the container onto a host physical interface. +/// The IP address is assigned by the upstream DHCP server; `ipv4Address` is always nil. +@available(macOS 26, *) +public final class BridgedNetworkInterface: Interface, Sendable { + public let hostInterfaceName: String + public let macAddress: MACAddress? + public let ipv4Address: CIDRv4? = nil + public let ipv4Gateway: IPv4Address? = nil + public let mtu: UInt32 = 1500 + + public init(hostInterfaceName: String, macAddress: MACAddress? = nil) { + self.hostInterfaceName = hostInterfaceName + self.macAddress = macAddress + } +} + +@available(macOS 26, *) +extension BridgedNetworkInterface: VZInterface { + public func device() throws -> VZVirtioNetworkDeviceConfiguration { + guard + let vzIface = VZBridgedNetworkInterface.networkInterfaces + .first(where: { $0.identifier == hostInterfaceName }) + else { + throw ContainerizationError( + .invalidArgument, + message: "no bridged interface named \(hostInterfaceName)") + } + let config = VZVirtioNetworkDeviceConfiguration() + config.attachment = VZBridgedNetworkDeviceAttachment(interface: vzIface) + if let mac = macAddress, let vzMac = VZMACAddress(string: mac.description) { + config.macAddress = vzMac + } + return config + } +} + +#endif diff --git a/Sources/Containerization/Interface.swift b/Sources/Containerization/Interface.swift index a95c58f9..7c381a9e 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -20,7 +20,7 @@ import ContainerizationExtras public protocol Interface: Sendable { /// The interface IPv4 address and subnet prefix length, as a CIDR address. /// Example: `192.168.64.3/24` - var ipv4Address: CIDRv4 { get } + var ipv4Address: CIDRv4? { get } /// The IPv4 gateway address for the default route, or nil for no IPv4 default route. var ipv4Gateway: IPv4Address? { get } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index cfdc5f64..e28f3624 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -631,7 +631,7 @@ extension LinuxContainer { // For every interface asked for: // 1. Add the address requested // 2. Online the adapter - // 3. For the first interface, add the default route + // 3. For the first interface with a static address, add the default route var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 92c8b181..8334d0d1 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -655,7 +655,7 @@ extension LinuxPod { // For every interface asked for: // 1. Add the address requested // 2. Online the adapter - // 3. For the first interface, add the default route + // 3. For the first interface with a static address, add the default route var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" diff --git a/Sources/Containerization/NATInterface.swift b/Sources/Containerization/NATInterface.swift index c37fbc06..6aeb2793 100644 --- a/Sources/Containerization/NATInterface.swift +++ b/Sources/Containerization/NATInterface.swift @@ -17,7 +17,7 @@ import ContainerizationExtras public struct NATInterface: Interface { - public var ipv4Address: CIDRv4 + public var ipv4Address: CIDRv4? public var ipv4Gateway: IPv4Address? public var ipv6Address: CIDRv6? public var ipv6Gateway: IPv6Address? diff --git a/Sources/Containerization/NATNetworkInterface.swift b/Sources/Containerization/NATNetworkInterface.swift index f4d63922..8737e637 100644 --- a/Sources/Containerization/NATNetworkInterface.swift +++ b/Sources/Containerization/NATNetworkInterface.swift @@ -26,7 +26,7 @@ import Synchronization /// container/virtual machine. @available(macOS 26, *) public final class NATNetworkInterface: Interface, Sendable { - public let ipv4Address: CIDRv4 + public let ipv4Address: CIDRv4? public let ipv4Gateway: IPv4Address? public let macAddress: MACAddress? public let mtu: UInt32 diff --git a/Sources/Containerization/VirtualMachineAgent+Interface.swift b/Sources/Containerization/VirtualMachineAgent+Interface.swift index e2fe7227..dc270159 100644 --- a/Sources/Containerization/VirtualMachineAgent+Interface.swift +++ b/Sources/Containerization/VirtualMachineAgent+Interface.swift @@ -26,20 +26,23 @@ extension VirtualMachineAgent { setDefaultRoute: Bool, logger: Logger? ) async throws { - logger?.debug("setting up interface \(name) with v4 \(interface.ipv4Address) v6 \(interface.ipv6Address?.description ?? "")") - try await addressAdd( - name: name, - address: .init(ipv4Address: interface.ipv4Address, ipv6Address: interface.ipv6Address) - ) - try await up(name: name, mtu: interface.mtu) - - guard setDefaultRoute else { return } - let ipv4Address = interface.ipv4Address let ipv4Gateway = interface.ipv4Gateway let ipv6Gateway = interface.ipv6Gateway let ipv6Address = interface.ipv6Address + if let ipv4Address { + logger?.debug("setting up interface \(name) with v4 \(ipv4Address) v6 \(interface.ipv6Address?.description ?? "")") + try await addressAdd( + name: name, + address: .init(ipv4Address: ipv4Address, ipv6Address: interface.ipv6Address) + ) + } + try await up(name: name, mtu: interface.mtu) + + guard setDefaultRoute else { return } + guard let ipv4Address else { return } + let needsIPv4LinkRoute: Bool if let ipv4Gateway { needsIPv4LinkRoute = !ipv4Address.contains(ipv4Gateway) diff --git a/Sources/Containerization/VmnetNetwork.swift b/Sources/Containerization/VmnetNetwork.swift index 86ec92cd..538cd4c7 100644 --- a/Sources/Containerization/VmnetNetwork.swift +++ b/Sources/Containerization/VmnetNetwork.swift @@ -114,7 +114,7 @@ public struct VmnetNetwork: Network { /// A network interface supporting the vmnet_network_ref. public struct Interface: Containerization.Interface, VZInterface, Sendable { - public let ipv4Address: CIDRv4 + public let ipv4Address: CIDRv4? public let ipv4Gateway: IPv4Address? public let ipv6Address: CIDRv6? public let ipv6Gateway: IPv6Address? diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 498aa3f8..0f2d7361 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -5144,7 +5144,9 @@ extension IntegrationSuite { } // Capture the v4 address vmnet allocated so we can assert it ends up on eth0. - let expectedV4 = interface.ipv4Address.address.description + guard let expectedV4 = interface.ipv4Address?.address.description else { + throw IntegrationError.assert(msg: "network interface needs IPv4 address") + } let addrBuffer = BufferWriter() let routeBuffer = BufferWriter() diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index 66cf2081..725d8385 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -143,11 +143,10 @@ extension Application { } // Add host entry for the container using just the IP (not CIDR) - if #available(macOS 26, *), !config.interfaces.isEmpty { - let interface = config.interfaces[0] + if #available(macOS 26, *), let addr = config.interfaces.first?.ipv4Address { hosts.entries.append( Hosts.Entry( - ipAddress: interface.ipv4Address.address.description, + ipAddress: addr.address.description, hostnames: [id] )) } diff --git a/Tests/ContainerizationTests/ContainerManagerTests.swift b/Tests/ContainerizationTests/ContainerManagerTests.swift index 8b7f945d..09abd472 100644 --- a/Tests/ContainerizationTests/ContainerManagerTests.swift +++ b/Tests/ContainerizationTests/ContainerManagerTests.swift @@ -26,7 +26,7 @@ import Testing @testable import Containerization private struct NilGatewayInterface: Interface { - let ipv4Address: CIDRv4 + let ipv4Address: CIDRv4? let ipv4Gateway: IPv4Address? = nil let macAddress: MACAddress? = nil diff --git a/Tests/ContainerizationTests/InterfaceTests.swift b/Tests/ContainerizationTests/InterfaceTests.swift index c00ecca1..9f900e73 100644 --- a/Tests/ContainerizationTests/InterfaceTests.swift +++ b/Tests/ContainerizationTests/InterfaceTests.swift @@ -24,7 +24,7 @@ struct InterfaceTests { /// A minimal `Interface` conformer that only sets the IPv4 surface, relying on the /// protocol's default extensions to fill in `ipv6Address`, `ipv6Gateway`, and `mtu`. private struct V4OnlyInterface: Interface { - let ipv4Address: CIDRv4 + let ipv4Address: CIDRv4? let ipv4Gateway: IPv4Address? let macAddress: MACAddress? } From 17b9885e4552c9aa8a39acd87976a4b222213aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Tue, 28 Apr 2026 16:47:47 +0200 Subject: [PATCH 2/3] vminitd: Fall back to /proc/net/pnp for unset nameservers or domain When configureDns is called with an empty nameservers list or no domain, read /proc/net/pnp (written by the kernel IP_PNP DHCP client) and use any nameserver and domain lines found there. The two are filled in independently, so an explicit nameserver does not prevent the domain from being read from pnp. This provides automatic DNS configuration for bridge-mode containers without a new RPC or proto change. Co-authored-by: Curd Becker --- vminitd/Sources/VminitdCore/Server+GRPC.swift | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 6659199c..f9cd812d 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -1427,13 +1427,12 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ request: Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest, context: GRPCCore.ServerContext ) async throws -> Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse { - let domain = request.hasDomain ? request.domain : nil log.debug( "configureDns", metadata: [ "location": "\(request.location)", "nameservers": "\(request.nameservers)", - "domain": "\(domain ?? "")", + "domain": "\(request.hasDomain ? request.domain : "")", "searchDomains": "\(request.searchDomains)", "options": "\(request.options)", ]) @@ -1442,8 +1441,27 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ let etc = URL(fileURLWithPath: request.location).appendingPathComponent("etc") try FileManager.default.createDirectory(atPath: etc.path, withIntermediateDirectories: true) let resolvConf = etc.appendingPathComponent("resolv.conf") + var nameservers = request.nameservers + var domain = request.hasDomain ? request.domain : nil + if nameservers.isEmpty || domain == nil, + let pnp = try? String(contentsOfFile: "/proc/net/pnp", encoding: .utf8) + { + let lines = pnp.split(separator: "\n") + if nameservers.isEmpty { + nameservers = + lines + .filter { $0.hasPrefix("nameserver") } + .compactMap { $0.split(separator: " ").dropFirst().first.map(String.init) } + } + if domain == nil { + domain = + lines + .first { $0.hasPrefix("domain") } + .flatMap { $0.split(separator: " ").dropFirst().first.map(String.init) } + } + } let config = DNS( - nameservers: request.nameservers, + nameservers: nameservers, domain: domain, searchDomains: request.searchDomains, options: request.options From d0ce69c6f0e6545215f357b3a437fcf791b0755f Mon Sep 17 00:00:00 2001 From: Curd Becker Date: Sat, 30 May 2026 20:25:13 +0200 Subject: [PATCH 3/3] Add FileHandleNetworkInterface FileHandleNetworkInterface uses VZFileHandleNetworkDeviceAttachment to attach the container to a file handle where another service can provide an arbitrary network. This might be an entirely simulated network, a virtual network like a VPN, e.g. using Wireguard, but it could be also a bridged physical network from the host where the service will then take over the responsibility of bridging the traffic between the container and host. We do not make any assumption about IP addresses for now, but instead also assume that the IP address can get assigned via DHCP. --- .../BridgedNetworkInterface.swift | 1 - .../FileHandleNetworkInterface.swift | 51 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Sources/Containerization/FileHandleNetworkInterface.swift diff --git a/Sources/Containerization/BridgedNetworkInterface.swift b/Sources/Containerization/BridgedNetworkInterface.swift index 24a2da8e..44f2456a 100644 --- a/Sources/Containerization/BridgedNetworkInterface.swift +++ b/Sources/Containerization/BridgedNetworkInterface.swift @@ -28,7 +28,6 @@ public final class BridgedNetworkInterface: Interface, Sendable { public let macAddress: MACAddress? public let ipv4Address: CIDRv4? = nil public let ipv4Gateway: IPv4Address? = nil - public let mtu: UInt32 = 1500 public init(hostInterfaceName: String, macAddress: MACAddress? = nil) { self.hostInterfaceName = hostInterfaceName diff --git a/Sources/Containerization/FileHandleNetworkInterface.swift b/Sources/Containerization/FileHandleNetworkInterface.swift new file mode 100644 index 00000000..ba070410 --- /dev/null +++ b/Sources/Containerization/FileHandleNetworkInterface.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +import ContainerizationError +import ContainerizationExtras +import Virtualization + +/// A network interface that connects the container to an arbitrary FileHandle-backed +/// network service. The IP address might be assigned by the upstream DHCP server or +/// configured inside the container; `ipv4Address` is always nil. +@available(macOS 26, *) +public final class FileHandleNetworkInterface: Interface, Sendable { + public let macAddress: MACAddress? + public let ipv4Address: CIDRv4? = nil + public let ipv4Gateway: IPv4Address? = nil + public let fileHandle: FileHandle + + public init(fileHandle: FileHandle, macAddress: MACAddress? = nil) { + self.macAddress = macAddress + self.fileHandle = fileHandle + } +} + +@available(macOS 26, *) +extension FileHandleNetworkInterface: VZInterface { + public func device() throws -> VZVirtioNetworkDeviceConfiguration { + let config = VZVirtioNetworkDeviceConfiguration() + config.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fileHandle) + if let mac = macAddress, let vzMac = VZMACAddress(string: mac.description) { + config.macAddress = vzMac + } + return config + } +} + +#endif