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
8 changes: 4 additions & 4 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions Sources/Containerization/BridgedNetworkInterface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//===----------------------------------------------------------------------===//
// 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 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
51 changes: 51 additions & 0 deletions Sources/Containerization/FileHandleNetworkInterface.swift
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions Sources/Containerization/Interface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
4 changes: 2 additions & 2 deletions Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion Sources/Containerization/NATInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion Sources/Containerization/NATNetworkInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions Sources/Containerization/VirtualMachineAgent+Interface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "<none>")")
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 ?? "<none>")")
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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Containerization/VmnetNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 3 additions & 1 deletion Sources/Integration/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 2 additions & 3 deletions Sources/cctl/RunCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
))
}
Expand Down
24 changes: 21 additions & 3 deletions vminitd/Sources/VminitdCore/Server+GRPC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
])
Expand All @@ -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
Expand Down