From 9649eda98ad136d588082f87615164002191732f 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 --- Package.resolved | 8 +-- .../BridgedNetworkInterface.swift | 59 +++++++++++++++++++ Sources/Containerization/Interface.swift | 4 +- Sources/Containerization/LinuxContainer.swift | 4 +- Sources/Containerization/LinuxPod.swift | 4 +- Sources/Containerization/NATInterface.swift | 2 +- .../NATNetworkInterface.swift | 2 +- .../VirtualMachineAgent+Interface.swift | 23 +++++--- Sources/Containerization/VmnetNetwork.swift | 2 +- Sources/Integration/ContainerTests.swift | 4 +- Sources/cctl/RunCommand.swift | 5 +- 11 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 Sources/Containerization/BridgedNetworkInterface.swift diff --git a/Package.resolved b/Package.resolved index 8319ea5d..06d732a8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" + "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", + "version" : "2.87.0" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", - "version" : "1.43.0" + "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", + "version" : "1.38.0" } }, { 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..31b28d14 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -19,8 +19,8 @@ import ContainerizationExtras /// A network interface. 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 } + /// Example: `192.168.64.3/24`. nil when the address is assigned dynamically (e.g. DHCP). + 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 58ffa4a3..b19c93f1 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -629,9 +629,9 @@ extension LinuxContainer { } // For every interface asked for: - // 1. Add the address requested + // 1. Add the address requested (skipped for bridge/DHCP interfaces) // 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 3e5d58f0..3f14cf7c 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -653,9 +653,9 @@ extension LinuxPod { } // For every interface asked for: - // 1. Add the address requested + // 1. Add the address requested (skipped for bridge/DHCP interfaces) // 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..4930f753 100644 --- a/Sources/Containerization/VirtualMachineAgent+Interface.swift +++ b/Sources/Containerization/VirtualMachineAgent+Interface.swift @@ -26,20 +26,25 @@ 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) + ) + } else { + logger?.debug("up interface \(name) (DHCP + SLAAC/PD)") + } + 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 62c37918..e66b3607 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4919,7 +4919,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] )) } From 16813a7ff41cc048498b7dcc9f98abfbb377e90d 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. --- 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 6bbd7ac8..176f8020 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -1332,13 +1332,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)", ]) @@ -1347,8 +1346,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 ca0d56c6eee1ff4693efaa74cedc12fd15473ca9 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