diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 48682b38..2e2edd7b 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -302,15 +302,17 @@ public struct ContainerManager: Sendable { if let imageConfig { config.process = .init(from: imageConfig) } - if networking, let interface = try self.network?.createInterface(id) { - config.interfaces = [interface] - guard let gateway = interface.ipv4Gateway else { - throw ContainerizationError( - .invalidState, - message: "missing ipv4 gateway for container \(id)" - ) + if networking { + if let interface = try self.network?.createInterface(id) { + config.interfaces = [interface] + guard let gateway = interface.ipv4Gateway else { + throw ContainerizationError( + .invalidState, + message: "missing ipv4 gateway for container \(id)" + ) + } + config.dns = .init(nameservers: [gateway.description]) } - config.dns = .init(nameservers: [gateway.description]) } config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log")) try configuration(&config) @@ -378,4 +380,11 @@ extension CIDRv4 { } } +extension CIDRv6 { + /// The gateway address of the network. + public var gateway: IPv6Address { + IPv6Address(self.lower.value + 1) + } +} + #endif diff --git a/Sources/Containerization/Interface.swift b/Sources/Containerization/Interface.swift index 80eac49b..a95c58f9 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -22,9 +22,16 @@ public protocol Interface: Sendable { /// Example: `192.168.64.3/24` var ipv4Address: CIDRv4 { get } - /// The IP address for the default route, or nil for no default route. + /// The IPv4 gateway address for the default route, or nil for no IPv4 default route. var ipv4Gateway: IPv4Address? { get } + /// The interface IPv6 address and subnet prefix length, as a CIDRv6 address, or nil for no IPv6 address. + /// Example: `fd00::1/64` + var ipv6Address: CIDRv6? { get } + + /// The IPv6 gateway address for the default route, or nil for no IPv6 default route. + var ipv6Gateway: IPv6Address? { get } + /// The interface MAC address, or nil to auto-configure the address. var macAddress: MACAddress? { get } @@ -34,4 +41,6 @@ public protocol Interface: Sendable { extension Interface { public var mtu: UInt32 { 1500 } + public var ipv6Address: CIDRv6? { nil } + public var ipv6Gateway: IPv6Address? { nil } } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 7b1395b0..58ffa4a3 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -635,22 +635,12 @@ extension LinuxContainer { var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - self.logger?.debug("setting up interface \(name) with address \(i.ipv4Address)") - try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) - try await agent.up(name: name, mtu: i.mtu) - if defaultRouteSet { - continue - } - if let ipv4Gateway = i.ipv4Gateway { - if !i.ipv4Address.contains(ipv4Gateway) { - self.logger?.debug("gateway \(ipv4Gateway) is outside subnet \(i.ipv4Address), adding a route first") - try await agent.routeAddLink(name: name, dstIPv4Addr: ipv4Gateway, srcIPv4Addr: i.ipv4Address.address) - } - try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) - } else { - self.logger?.debug("no gateway for \(name)") - try await agent.routeAddDefault(name: name, ipv4Gateway: nil) - } + try await agent.setupInterface( + i, + name: name, + setDefaultRoute: !defaultRouteSet, + logger: self.logger + ) defaultRouteSet = true } diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 1ace38b6..3e5d58f0 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -659,22 +659,12 @@ extension LinuxPod { var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - self.logger?.debug("setting up interface \(name) with address \(i.ipv4Address)") - try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) - try await agent.up(name: name, mtu: i.mtu) - if defaultRouteSet { - continue - } - if let ipv4Gateway = i.ipv4Gateway { - if !i.ipv4Address.contains(ipv4Gateway) { - self.logger?.debug("gateway \(ipv4Gateway) is outside subnet \(i.ipv4Address), adding a route first") - try await agent.routeAddLink(name: name, dstIPv4Addr: ipv4Gateway, srcIPv4Addr: nil) - } - try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) - } else { - self.logger?.debug("no gateway for \(name)") - try await agent.routeAddDefault(name: name, ipv4Gateway: nil) - } + try await agent.setupInterface( + i, + name: name, + setDefaultRoute: !defaultRouteSet, + logger: self.logger + ) defaultRouteSet = true } diff --git a/Sources/Containerization/NATInterface.swift b/Sources/Containerization/NATInterface.swift index 22383627..c37fbc06 100644 --- a/Sources/Containerization/NATInterface.swift +++ b/Sources/Containerization/NATInterface.swift @@ -19,12 +19,23 @@ import ContainerizationExtras public struct NATInterface: Interface { public var ipv4Address: CIDRv4 public var ipv4Gateway: IPv4Address? + public var ipv6Address: CIDRv6? + public var ipv6Gateway: IPv6Address? public var macAddress: MACAddress? public var mtu: UInt32 - public init(ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, macAddress: MACAddress? = nil, mtu: UInt32 = 1500) { + public init( + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address?, + ipv6Address: CIDRv6? = nil, + ipv6Gateway: IPv6Address? = nil, + macAddress: MACAddress? = nil, + mtu: UInt32 = 1500 + ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address + self.ipv6Gateway = ipv6Gateway self.macAddress = macAddress self.mtu = mtu } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 8157b5ae..e15abcbb 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1098,9 +1098,20 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: Sendable { public var ipv4Address: String = String() + public var ipv6Address: String { + get {_ipv6Address ?? String()} + set {_ipv6Address = newValue} + } + /// Returns true if `ipv6Address` has been explicitly set. + public var hasIpv6Address: Bool {self._ipv6Address != nil} + /// Clears the value of `ipv6Address`. Subsequent reads from it will return its default value. + public mutating func clearIpv6Address() {self._ipv6Address = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _ipv6Address: String? = nil } public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: Sendable { @@ -1124,9 +1135,30 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: Senda public var srcIpv4Addr: String = String() + public var dstIpv6Addr: String { + get {_dstIpv6Addr ?? String()} + set {_dstIpv6Addr = newValue} + } + /// Returns true if `dstIpv6Addr` has been explicitly set. + public var hasDstIpv6Addr: Bool {self._dstIpv6Addr != nil} + /// Clears the value of `dstIpv6Addr`. Subsequent reads from it will return its default value. + public mutating func clearDstIpv6Addr() {self._dstIpv6Addr = nil} + + public var srcIpv6Addr: String { + get {_srcIpv6Addr ?? String()} + set {_srcIpv6Addr = newValue} + } + /// Returns true if `srcIpv6Addr` has been explicitly set. + public var hasSrcIpv6Addr: Bool {self._srcIpv6Addr != nil} + /// Clears the value of `srcIpv6Addr`. Subsequent reads from it will return its default value. + public mutating func clearSrcIpv6Addr() {self._srcIpv6Addr = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _dstIpv6Addr: String? = nil + fileprivate var _srcIpv6Addr: String? = nil } public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: Sendable { @@ -1148,9 +1180,20 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: Se public var ipv4Gateway: String = String() + public var ipv6Gateway: String { + get {_ipv6Gateway ?? String()} + set {_ipv6Gateway = newValue} + } + /// Returns true if `ipv6Gateway` has been explicitly set. + public var hasIpv6Gateway: Bool {self._ipv6Gateway != nil} + /// Clears the value of `ipv6Gateway`. Subsequent reads from it will return its default value. + public mutating func clearIpv6Gateway() {self._ipv6Gateway = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _ipv6Gateway: String? = nil } public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: Sendable { @@ -3203,7 +3246,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: SwiftProtobuf extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpAddrAddRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Address\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Address\0\u{1}ipv6Address\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3213,24 +3256,33 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf. switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Address) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._ipv6Address) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } if !self.ipv4Address.isEmpty { try visitor.visitSingularStringField(value: self.ipv4Address, fieldNumber: 2) } + try { if let v = self._ipv6Address { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest) -> Bool { if lhs.interface != rhs.interface {return false} if lhs.ipv4Address != rhs.ipv4Address {return false} + if lhs._ipv6Address != rhs._ipv6Address {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3257,7 +3309,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: SwiftProtobuf extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddLinkRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}dstIpv4Addr\0\u{1}srcIpv4Addr\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}dstIpv4Addr\0\u{1}srcIpv4Addr\0\u{1}dstIpv6Addr\0\u{1}srcIpv6Addr\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3268,12 +3320,18 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.dstIpv4Addr) }() case 3: try { try decoder.decodeSingularStringField(value: &self.srcIpv4Addr) }() + case 4: try { try decoder.decodeSingularStringField(value: &self._dstIpv6Addr) }() + case 5: try { try decoder.decodeSingularStringField(value: &self._srcIpv6Addr) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } @@ -3283,6 +3341,12 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt if !self.srcIpv4Addr.isEmpty { try visitor.visitSingularStringField(value: self.srcIpv4Addr, fieldNumber: 3) } + try { if let v = self._dstIpv6Addr { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } }() + try { if let v = self._srcIpv6Addr { + try visitor.visitSingularStringField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -3290,6 +3354,8 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt if lhs.interface != rhs.interface {return false} if lhs.dstIpv4Addr != rhs.dstIpv4Addr {return false} if lhs.srcIpv4Addr != rhs.srcIpv4Addr {return false} + if lhs._dstIpv6Addr != rhs._dstIpv6Addr {return false} + if lhs._srcIpv6Addr != rhs._srcIpv6Addr {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3316,7 +3382,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: SwiftPro extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddDefaultRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Gateway\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Gateway\0\u{1}ipv6Gateway\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3326,24 +3392,33 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftP switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Gateway) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._ipv6Gateway) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } if !self.ipv4Gateway.isEmpty { try visitor.visitSingularStringField(value: self.ipv4Gateway, fieldNumber: 2) } + try { if let v = self._ipv6Gateway { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest) -> Bool { if lhs.interface != rhs.interface {return false} if lhs.ipv4Gateway != rhs.ipv4Gateway {return false} + if lhs._ipv6Gateway != rhs._ipv6Gateway {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index dfe8cd66..efce1ee5 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -303,6 +303,7 @@ message IpLinkSetResponse {} message IpAddrAddRequest { string interface = 1; string ipv4Address = 2; + optional string ipv6Address = 3; } message IpAddrAddResponse {} @@ -311,6 +312,8 @@ message IpRouteAddLinkRequest { string interface = 1; string dstIpv4Addr = 2; string srcIpv4Addr = 3; + optional string dstIpv6Addr = 4; + optional string srcIpv6Addr = 5; } message IpRouteAddLinkResponse {} @@ -318,6 +321,7 @@ message IpRouteAddLinkResponse {} message IpRouteAddDefaultRequest { string interface = 1; string ipv4Gateway = 2; + optional string ipv6Gateway = 3; } message IpRouteAddDefaultResponse {} diff --git a/Sources/Containerization/VirtualMachineAgent+Interface.swift b/Sources/Containerization/VirtualMachineAgent+Interface.swift new file mode 100644 index 00000000..e2fe7227 --- /dev/null +++ b/Sources/Containerization/VirtualMachineAgent+Interface.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Logging + +extension VirtualMachineAgent { + /// Configure a single network interface inside the sandbox: assign addresses, + /// bring the link up, and (when requested) install the link/default routes. + func setupInterface( + _ interface: any Interface, + name: String, + 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 + + let needsIPv4LinkRoute: Bool + if let ipv4Gateway { + needsIPv4LinkRoute = !ipv4Address.contains(ipv4Gateway) + } else { + needsIPv4LinkRoute = false + } + + let needsIPv6LinkRoute: Bool + if let ipv6Gateway, let ipv6Address { + needsIPv6LinkRoute = !ipv6Address.contains(ipv6Gateway) + } else { + needsIPv6LinkRoute = false + } + + if needsIPv4LinkRoute, let ipv4Gateway { + logger?.debug("v4 gateway \(ipv4Gateway) is outside subnet \(ipv4Address), adding a route first") + } + if needsIPv6LinkRoute, let ipv6Gateway, let ipv6Address { + logger?.debug("v6 gateway \(ipv6Gateway) is outside subnet \(ipv6Address), adding a route first") + } + + if needsIPv4LinkRoute || needsIPv6LinkRoute { + try await routeAddLink( + name: name, + route: .init( + ipv4Destination: needsIPv4LinkRoute ? ipv4Gateway : nil, + ipv4Source: needsIPv4LinkRoute ? ipv4Address.address : nil, + ipv6Destination: needsIPv6LinkRoute ? ipv6Gateway : nil, + ipv6Source: needsIPv6LinkRoute ? ipv6Address?.address : nil + ) + ) + } + + if ipv4Gateway == nil && ipv6Gateway == nil { + logger?.debug("no gateway for \(name)") + } + try await routeAddDefault( + name: name, + route: .init(ipv4Gateway: ipv4Gateway, ipv6Gateway: ipv6Gateway) + ) + } +} diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index 9ded5d7f..88c641ae 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -67,9 +67,9 @@ public protocol VirtualMachineAgent: Sendable { // Networking func up(name: String, mtu: UInt32?) async throws func down(name: String) async throws - func addressAdd(name: String, ipv4Address: CIDRv4) async throws - func routeAddLink(name: String, dstIPv4Addr: IPv4Address, srcIPv4Addr: IPv4Address?) async throws - func routeAddDefault(name: String, ipv4Gateway: IPv4Address?) async throws + func addressAdd(name: String, address: InterfaceAddress) async throws + func routeAddLink(name: String, route: LinkRoute) async throws + func routeAddDefault(name: String, route: DefaultRoute) async throws func configureDNS(config: DNS, location: String) async throws func configureHosts(config: Hosts, location: String) async throws diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 92e6ee8d..fd49f095 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -397,33 +397,50 @@ extension Vminitd { } /// Add an IP address to the sandbox's network interfaces. - public func addressAdd(name: String, ipv4Address: CIDRv4) async throws { + public func addressAdd(name: String, address: InterfaceAddress) async throws { _ = try await client.ipAddrAdd( .with { $0.interface = name - $0.ipv4Address = ipv4Address.description + $0.ipv4Address = address.ipv4Address.description + if let ipv6Address = address.ipv6Address { + $0.ipv6Address = ipv6Address.description + } }) } - /// Add a route in the sandbox's environment. - public func routeAddLink(name: String, dstIPv4Addr: IPv4Address, srcIPv4Addr: IPv4Address? = nil) async throws { - let dstCIDR = "\(dstIPv4Addr.description)/32" + /// Add a link-scoped route in the sandbox's environment, used to install an + /// on-link host route (a /32 for v4, /128 for v6) to a gateway that lives + /// outside the interface's subnet so the kernel will accept the default route. + /// `route.ipv4Destination`/`route.ipv6Destination` carry the + /// gateway address; the wire format is a CIDR string with the per-family host prefix appended. + public func routeAddLink(name: String, route: LinkRoute) async throws { _ = try await client.ipRouteAddLink( .with { $0.interface = name - $0.dstIpv4Addr = dstCIDR - if let srcIPv4Addr { - $0.srcIpv4Addr = srcIPv4Addr.description + if let ipv4Destination = route.ipv4Destination { + $0.dstIpv4Addr = "\(ipv4Destination.description)/32" + } + if let ipv4Source = route.ipv4Source { + $0.srcIpv4Addr = ipv4Source.description + } + if let ipv6Destination = route.ipv6Destination { + $0.dstIpv6Addr = "\(ipv6Destination.description)/128" + } + if let ipv6Source = route.ipv6Source { + $0.srcIpv6Addr = ipv6Source.description } }) } /// Set the default route in the sandbox's environment. - public func routeAddDefault(name: String, ipv4Gateway: IPv4Address?) async throws { + public func routeAddDefault(name: String, route: DefaultRoute) async throws { _ = try await client.ipRouteAddDefault( .with { $0.interface = name - $0.ipv4Gateway = ipv4Gateway?.description ?? "" + $0.ipv4Gateway = route.ipv4Gateway?.description ?? "" + if let ipv6Gateway = route.ipv6Gateway { + $0.ipv6Gateway = ipv6Gateway.description + } }) } diff --git a/Sources/Containerization/VmnetNetwork.swift b/Sources/Containerization/VmnetNetwork.swift index 28ea32a8..86ec92cd 100644 --- a/Sources/Containerization/VmnetNetwork.swift +++ b/Sources/Containerization/VmnetNetwork.swift @@ -31,39 +31,82 @@ public struct VmnetNetwork: Network { /// The IPv4 subnet of this network. public let subnet: CIDRv4 + /// The IPv6 prefix of this network. + public let prefixV6: CIDRv6? + /// The IPv4 gateway address of this network. public var ipv4Gateway: IPv4Address { subnet.gateway } - struct Allocator: Sendable { - private let addressAllocator: any AddressAllocator - private let cidr: CIDRv4 - private var allocations: [String: UInt32] + /// The IPv6 gateway address of this network, if a prefix exists. + public var ipv6Gateway: IPv6Address? { + prefixV6?.gateway + } - init(cidr: CIDRv4) throws { - self.cidr = cidr + struct Allocator: Sendable { + private let indexAllocatorV4: any AddressAllocator + private let indexAllocatorV6: (any AddressAllocator)? + private let cidrV4: CIDRv4 + private let cidrV6: CIDRv6? + private var allocations: [String: (v4: UInt32, v6: UInt32?)] + + init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws { + self.cidrV4 = cidrV4 + self.cidrV6 = cidrV6 self.allocations = .init() - let size = Int(cidr.upper.value - cidr.lower.value - 3) - self.addressAllocator = try UInt32.rotatingAllocator( - lower: cidr.lower.value + 2, - size: UInt32(size) + let v4Size = Int(cidrV4.upper.value - cidrV4.lower.value - 3) + self.indexAllocatorV4 = try UInt32.rotatingAllocator( + lower: cidrV4.lower.value + 2, + size: UInt32(v4Size) ) + if cidrV6 != nil { + // Independent v6 allocator. The host portion is sourced from a + // UInt32 index regardless of prefix length, and we never need + // more v6 entries than v4 can serve. + self.indexAllocatorV6 = try UInt32.rotatingAllocator( + lower: 2, + size: UInt32(v4Size) + ) + } else { + self.indexAllocatorV6 = nil + } } - mutating func allocate(_ id: String) throws -> CIDRv4 { + mutating func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) { if allocations[id] != nil { throw ContainerizationError(.exists, message: "allocation with id \(id) already exists") } - let index = try addressAllocator.allocate() - allocations[id] = index - let ip = IPv4Address(index) - return try CIDRv4(ip, prefix: cidr.prefix) + let v4Index = try indexAllocatorV4.allocate() + let v4 = try CIDRv4(IPv4Address(v4Index), prefix: cidrV4.prefix) + + var v6Index: UInt32? = nil + let v6: CIDRv6? + if let indexAllocatorV6, let cidrV6 { + do { + let idx = try indexAllocatorV6.allocate() + v6Index = idx + let v6Value = (cidrV6.address.value & cidrV6.prefix.prefixMask128) | UInt128(idx) + v6 = try CIDRv6(IPv6Address(v6Value), prefix: cidrV6.prefix) + } catch { + // Roll back v4 so the pair stays atomic. + try? indexAllocatorV4.release(v4Index) + throw error + } + } else { + v6 = nil + } + + allocations[id] = (v4: v4Index, v6: v6Index) + return (v4, v6) } mutating func release(_ id: String) throws { - if let index = self.allocations[id] { - try addressAllocator.release(index) + if let entry = self.allocations[id] { + try indexAllocatorV4.release(entry.v4) + if let v6Index = entry.v6 { + try indexAllocatorV6?.release(v6Index) + } allocations.removeValue(forKey: id) } } @@ -73,6 +116,8 @@ public struct VmnetNetwork: Network { public struct Interface: Containerization.Interface, VZInterface, Sendable { public let ipv4Address: CIDRv4 public let ipv4Gateway: IPv4Address? + public let ipv6Address: CIDRv6? + public let ipv6Gateway: IPv6Address? public let macAddress: MACAddress? public let mtu: UInt32 @@ -83,11 +128,15 @@ public struct VmnetNetwork: Network { reference: vmnet_network_ref, ipv4Address: CIDRv4, ipv4Gateway: IPv4Address? = nil, + ipv6Address: CIDRv6? = nil, + ipv6Gateway: IPv6Address? = nil, macAddress: MACAddress? = nil, mtu: UInt32 = 1500 ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address + self.ipv6Gateway = ipv6Gateway self.macAddress = macAddress self.mtu = mtu self.reference = reference @@ -110,8 +159,13 @@ public struct VmnetNetwork: Network { /// Creates a new network. /// - Parameters: /// - mode: The vmnet operating mode. Defaults to `.VMNET_SHARED_MODE`. - /// - subnet: The subnet to use for this network. - public init(mode: vmnet.operating_modes_t = .VMNET_SHARED_MODE, subnet: CIDRv4? = nil) throws { + /// - subnetV4: The IPv4 subnet to use for this network. + /// - prefixV6: The IPv6 prefix to use for this network. + public init( + mode: vmnet.operating_modes_t = .VMNET_SHARED_MODE, + subnet: CIDRv4? = nil, + prefixV6: CIDRv6? = nil + ) throws { var status: vmnet_return_t = .VMNET_FAILURE guard let config = vmnet_network_configuration_create(mode, &status) else { throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") @@ -120,39 +174,38 @@ public struct VmnetNetwork: Network { vmnet_network_configuration_disable_dhcp(config) if let subnet { - try Self.configureSubnet(config, subnet: subnet) + try Self.configureSubnetV4(config, subnetV4: subnet) + } + if let prefixV6 { + try Self.configurePrefixV6(config, prefixV6: prefixV6) } guard let ref = vmnet_network_create(config, &status), status == .VMNET_SUCCESS else { throw ContainerizationError(.unsupported, message: "failed to create vmnet network with status \(status)") } - let cidr = try Self.getSubnet(ref) + let cidrV4 = try Self.getSubnetV4(ref) + let cidrV6 = Self.getPrefixV6(ref) - self.allocator = try .init(cidr: cidr) - self.subnet = cidr + self.allocator = try .init(cidrV4: cidrV4, cidrV6: cidrV6) + self.subnet = cidrV4 + self.prefixV6 = cidrV6 self.reference = ref } - /// Returns a new interface for use with a container. + /// Returns a new interface for use with a container. Allocates an IPv4 + /// address from the network's subnet, and — when the network has an IPv6 + /// prefix — an IPv6 address from that prefix. The two allocations are + /// independent. /// - Parameter id: The container ID. public mutating func createInterface(_ id: String) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) + let (v4, v6) = try allocator.allocate(id) return Self.Interface( reference: self.reference, - ipv4Address: ipv4Address, + ipv4Address: v4, ipv4Gateway: self.ipv4Gateway, - ) - } - - /// Returns a new interface without a default gateway route. - /// Use this for secondary interfaces where another interface already provides the default route. - /// - Parameter id: The container ID. - public mutating func createInterfaceWithoutGateway(_ id: String) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) - return Self.Interface( - reference: self.reference, - ipv4Address: ipv4Address, + ipv6Address: v6, + ipv6Gateway: self.ipv6Gateway ) } @@ -161,22 +214,37 @@ public struct VmnetNetwork: Network { /// - id: The container ID. /// - mtu: The MTU for the interface. public mutating func createInterface(_ id: String, mtu: UInt32) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) + let (v4, v6) = try allocator.allocate(id) return Self.Interface( reference: self.reference, - ipv4Address: ipv4Address, + ipv4Address: v4, ipv4Gateway: self.ipv4Gateway, + ipv6Address: v6, + ipv6Gateway: self.ipv6Gateway, mtu: mtu ) } + /// Returns a new interface without a default gateway route. Useful for + /// secondary interfaces where another interface already provides the + /// default route. + /// - Parameter id: The container ID. + public mutating func createInterfaceWithoutGateway(_ id: String) throws -> Containerization.Interface? { + let (v4, v6) = try allocator.allocate(id) + return Self.Interface( + reference: self.reference, + ipv4Address: v4, + ipv6Address: v6 + ) + } + /// Performs cleanup of an interface. /// - Parameter id: The container ID. public mutating func releaseInterface(_ id: String) throws { try allocator.release(id) } - private static func getSubnet(_ ref: vmnet_network_ref) throws -> CIDRv4 { + private static func getSubnetV4(_ ref: vmnet_network_ref) throws -> CIDRv4 { var subnet = in_addr() var mask = in_addr() vmnet_network_get_ipv4_subnet(ref, &subnet, &mask) @@ -190,18 +258,43 @@ public struct VmnetNetwork: Network { return try CIDRv4(lower: lower, upper: upper) } - private static func configureSubnet(_ config: vmnet_network_configuration_ref, subnet: CIDRv4) throws { - let gateway = subnet.gateway + private static func configureSubnetV4(_ config: vmnet_network_configuration_ref, subnetV4: CIDRv4) throws { + let gateway = subnetV4.gateway var ga = in_addr() inet_pton(AF_INET, gateway.description, &ga) - let mask = IPv4Address(subnet.prefix.prefixMask32) + let mask = IPv4Address(subnetV4.prefix.prefixMask32) var ma = in_addr() inet_pton(AF_INET, mask.description, &ma) guard vmnet_network_configuration_set_ipv4_subnet(config, &ga, &ma) == .VMNET_SUCCESS else { - throw ContainerizationError(.internalError, message: "failed to set subnet \(subnet) for network") + throw ContainerizationError(.internalError, message: "failed to set IPv4 subnet \(subnetV4) for network") + } + } + + private static func getPrefixV6(_ ref: vmnet_network_ref) -> CIDRv6? { + var p = in6_addr() + var len: UInt8 = 0 + vmnet_network_get_ipv6_prefix(ref, &p, &len) + + guard len > 0, let prefix = Prefix.ipv6(len) else { + return nil + } + + let bytes: [UInt8] = withUnsafeBytes(of: p) { Array($0) } + guard let address = try? IPv6Address(bytes) else { + return nil + } + return try? CIDRv6(address, prefix: prefix) + } + + private static func configurePrefixV6(_ config: vmnet_network_configuration_ref, prefixV6: CIDRv6) throws { + var p = in6_addr() + inet_pton(AF_INET6, prefixV6.lower.description, &p) + + guard vmnet_network_configuration_set_ipv6_prefix(config, &p, prefixV6.prefix.length) == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set IPv6 prefix \(prefixV6) for network") } } } diff --git a/Sources/ContainerizationExtras/NetworkConfiguration.swift b/Sources/ContainerizationExtras/NetworkConfiguration.swift new file mode 100644 index 00000000..a72a5f45 --- /dev/null +++ b/Sources/ContainerizationExtras/NetworkConfiguration.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// A network interface's addresses. +public struct InterfaceAddress: Sendable, Hashable { + public var ipv4Address: CIDRv4 + public var ipv6Address: CIDRv6? + + public init(ipv4Address: CIDRv4, ipv6Address: CIDRv6? = nil) { + self.ipv4Address = ipv4Address + self.ipv6Address = ipv6Address + } +} + +/// A link-scoped route — a destination directly reachable on an interface. +public struct LinkRoute: Sendable, Hashable { + public var ipv4Destination: IPv4Address? + public var ipv4Source: IPv4Address? + public var ipv6Destination: IPv6Address? + public var ipv6Source: IPv6Address? + + public init( + ipv4Destination: IPv4Address? = nil, + ipv4Source: IPv4Address? = nil, + ipv6Destination: IPv6Address? = nil, + ipv6Source: IPv6Address? = nil + ) { + self.ipv4Destination = ipv4Destination + self.ipv4Source = ipv4Source + self.ipv6Destination = ipv6Destination + self.ipv6Source = ipv6Source + } +} + +/// The default-route gateway for a network interface. +public struct DefaultRoute: Sendable, Hashable { + public var ipv4Gateway: IPv4Address? + public var ipv6Gateway: IPv6Address? + + public init(ipv4Gateway: IPv4Address? = nil, ipv6Gateway: IPv6Address? = nil) { + self.ipv4Gateway = ipv4Gateway + self.ipv6Gateway = ipv6Gateway + } +} diff --git a/Sources/ContainerizationNetlink/NetlinkSession.swift b/Sources/ContainerizationNetlink/NetlinkSession.swift index 19478f0b..a586fc74 100644 --- a/Sources/ContainerizationNetlink/NetlinkSession.swift +++ b/Sources/ContainerizationNetlink/NetlinkSession.swift @@ -259,6 +259,53 @@ public struct NetlinkSession { } } + /// Adds an IPv6 address to an interface. + /// - Parameters: + /// - interface: The name of the interface. + /// - ipv6Address: The CIDRv6 address describing the interface IP and subnet prefix length. + public func addressAdd(interface: String, ipv6Address: CIDRv6) throws { + let interfaceIndex = try getInterfaceIndex(interface) + + let ipAddressBytes = ipv6Address.address.bytes + let addressAttrSize = RTAttribute.size + MemoryLayout.size * ipAddressBytes.count + let requestSize = NetlinkMessageHeader.size + AddressInfo.size + addressAttrSize + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWADDR, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + seq: 0, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = AddressInfo( + family: UInt8(AddressFamily.AF_INET6), + prefixLength: ipv6Address.prefix.length, + flags: AddressFlags.IFA_F_PERMANENT | AddressFlags.IFA_F_NODAD, + scope: NetlinkScope.RT_SCOPE_UNIVERSE, + index: UInt32(interfaceIndex)) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let ipAddressAttr = RTAttribute(len: UInt16(addressAttrSize), type: AddressAttributeType.IFA_ADDRESS) + requestOffset = try ipAddressAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let requestOffset = requestBuffer.copyIn(buffer: ipAddressBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFA_ADDRESS") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWADDR) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + /// Adds an IPv4 route to an interface. /// - Parameters: /// - interface: The name of the interface. @@ -421,6 +468,160 @@ public struct NetlinkSession { } } + /// Adds an IPv6 route to an interface. Used to install an on-link host + /// route (typically a /128) to a gateway that lives outside the interface's + /// subnet, so the kernel will accept the v6 default route. The chosen + /// `proto STATIC, scope LINK` matches what `iproute2` emits for explicit + /// `ip -6 route add /128 dev `. + /// - Parameters: + /// - interface: The name of the interface. + /// - dstIpv6Addr: The CIDRv6 address describing the destination network and prefix length. + /// - srcIpv6Addr: The source IPv6 address to route from. + public func routeAdd( + interface: String, + dstIpv6Addr: CIDRv6, + srcIpv6Addr: IPv6Address? + ) throws { + let interfaceIndex = try getInterfaceIndex(interface) + + let dstAddrBytes = dstIpv6Addr.address.bytes + let dstAddrAttrSize = RTAttribute.size + dstAddrBytes.count + let srcAddrAttrSize: Int + if let srcIpv6Addr { + let srcAddrBytes = srcIpv6Addr.bytes + srcAddrAttrSize = RTAttribute.size + srcAddrBytes.count + } else { + srcAddrAttrSize = 0 + } + let interfaceAttrSize = RTAttribute.size + MemoryLayout.size + let requestSize = + NetlinkMessageHeader.size + RouteInfo.size + dstAddrAttrSize + srcAddrAttrSize + interfaceAttrSize + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWROUTE, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = RouteInfo( + family: UInt8(AddressFamily.AF_INET6), + dstLen: dstIpv6Addr.prefix.length, + srcLen: 0, + tos: 0, + table: RouteTable.MAIN, + proto: RouteProtocol.STATIC, + scope: RouteScope.LINK, + type: RouteType.UNICAST, + flags: 0) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let dstAddrAttr = RTAttribute(len: UInt16(dstAddrAttrSize), type: RouteAttributeType.DST) + requestOffset = try dstAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard var requestOffset = requestBuffer.copyIn(buffer: dstAddrBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_DST") + } + + if let srcIpv6Addr { + let srcAddrBytes = srcIpv6Addr.bytes + let srcAddrAttr = RTAttribute(len: UInt16(srcAddrAttrSize), type: RouteAttributeType.PREFSRC) + requestOffset = try srcAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let newOffset = requestBuffer.copyIn(buffer: srcAddrBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_PREFSRC") + } + requestOffset = newOffset + } + + let interfaceAttr = RTAttribute(len: UInt16(interfaceAttrSize), type: RouteAttributeType.OIF) + requestOffset = try interfaceAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard + let requestOffset = requestBuffer.copyIn( + as: UInt32.self, + value: UInt32(interfaceIndex), + offset: requestOffset) + else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_OIF") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Adds a default IPv6 route to an interface. + /// - Parameters: + /// - interface: The name of the interface. + /// - ipv6Gateway: The gateway address. + public func routeAddDefault( + interface: String, + ipv6Gateway: IPv6Address + ) throws { + let gatewayBytes = ipv6Gateway.bytes + let gatewaySize = RTAttribute.size + gatewayBytes.count + + let interfaceAttrSize = RTAttribute.size + MemoryLayout.size + let interfaceIndex = try getInterfaceIndex(interface) + let requestSize = NetlinkMessageHeader.size + RouteInfo.size + gatewaySize + interfaceAttrSize + + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWROUTE, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = RouteInfo( + family: UInt8(AddressFamily.AF_INET6), + dstLen: 0, + srcLen: 0, + tos: 0, + table: RouteTable.MAIN, + proto: RouteProtocol.BOOT, + scope: RouteScope.UNIVERSE, + type: RouteType.UNICAST, + flags: 0) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let dstAddrAttr = RTAttribute(len: UInt16(gatewaySize), type: RouteAttributeType.GATEWAY) + requestOffset = try dstAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard var requestOffset = requestBuffer.copyIn(buffer: gatewayBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_GATEWAY") + } + let interfaceAttr = RTAttribute(len: UInt16(interfaceAttrSize), type: RouteAttributeType.OIF) + requestOffset = try interfaceAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard + let requestOffset = requestBuffer.copyIn( + as: UInt32.self, + value: UInt32(interfaceIndex), + offset: requestOffset) + else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_OIF") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + private func getInterfaceName(_ interface: String) throws -> [UInt8] { guard let interfaceNameData = interface.data(using: .utf8) else { throw BindError.sendMarshalFailure(type: "String", field: "interface") diff --git a/Sources/ContainerizationNetlink/Types.swift b/Sources/ContainerizationNetlink/Types.swift index 2691fd1c..5c0a0e8c 100644 --- a/Sources/ContainerizationNetlink/Types.swift +++ b/Sources/ContainerizationNetlink/Types.swift @@ -101,6 +101,11 @@ struct AddressAttributeType { static let IFA_LOCAL: UInt16 = 2 } +struct AddressFlags { + static let IFA_F_NODAD: UInt8 = 0x02 + static let IFA_F_PERMANENT: UInt8 = 0x80 +} + struct RouteTable { static let MAIN: UInt8 = 254 } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index eda29b75..62c37918 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4549,6 +4549,447 @@ extension IntegrationSuite { } } + @available(macOS 26.0, *) + func testNetworkingEnabledIPv6() async throws { + let id = "test-networking-enabled-ipv6" + let bs = try await bootstrap(id) + + let network = try VmnetNetwork() + var manager = try ContainerManager(vmm: bs.vmm, network: network) + defer { + try? manager.delete(id) + } + + let buffer = BufferWriter() + let container = try await manager.create( + id, + image: bs.image, + rootfs: bs.rootfs + ) { config in + config.process.arguments = ["ip", "-6", "addr", "show", "eth0", "scope", "global"] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("inet6 fd") else { + throw IntegrationError.assert( + msg: "expected a global-scope IPv6 address on eth0, got: \(output)") + } + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6AddressAdd() async throws { + let id = "test-ipv6-address" + let bs = try await bootstrap(id) + + // Pin the v6 prefix so the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Check that the IPv6 address was assigned to eth0. + let exec = try await container.exec("check-ipv6") { config in + config.arguments = ["ip", "-6", "addr", "show", "eth0"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected fd00::2 in output, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6DefaultRoute() async throws { + let id = "test-ipv6-default-route" + let bs = try await bootstrap(id) + + // Pin the network's v6 prefix so the gateway is deterministically fd00::1 + // and the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Inspect IPv6 routes inside the container. + let exec = try await container.exec("check-v6-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // The default v6 route must point at the gateway we configured, on eth0. + guard output.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6GatewayOutsideSubnet() async throws { + let id = "test-ipv6-gateway-outside-subnet" + let bs = try await bootstrap(id) + + // Address in fd00::/120, gateway in fd01::/120 — subnets don't overlap, so the + // LinuxContainer wiring must add a /128 link route to the gateway before the + // default route. The two prefixes are independent so we drive this directly + // via NATInterface rather than the VmnetNetwork allocator (which always + // derives the gateway from the network's own prefix). + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: try IPv4Address("192.0.2.1"), + ipv6Address: try CIDRv6("fd00::2/120"), + ipv6Gateway: try IPv6Address("fd01::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-routes") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // Both the link-scoped route to the gateway AND the default via that gateway + // must be present. Without the link route, the kernel would refuse the default. + // Match the link route on a line that starts with the gateway address (no "via") + // so it can't be satisfied by a substring of the default-via line. + let lines = output.split(separator: "\n").map(String.init) + let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") } + guard hasLinkRoute else { + throw IntegrationError.assert( + msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)") + } + guard output.contains("default via fd01::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6OnlyDefaultRoute() async throws { + let id = "test-ipv6-only-default-route" + let bs = try await bootstrap(id) + + // Construct a NATInterface with a nil IPv4 gateway and a v6 gateway, so + // LinuxContainer takes the no-v4-gateway branch in setupInterface. The v4 + // address comes from TEST-NET-1; nothing in the test traffics over v4. + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: nil, + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes when ipv4Gateway is nil, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6OnlyGatewayOutsideSubnet() async throws { + let id = "test-ipv6-only-gateway-outside-subnet" + let bs = try await bootstrap(id) + + // No v4 gateway AND v6 gateway is outside the v6 subnet. Exercises + // setupInterface's "no v4 gateway, but v6 link route required before + // v6 default route" branch — the exact bug the helper extraction fixed. + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: nil, + ipv6Address: try CIDRv6("fd00::2/120"), + ipv6Gateway: try IPv6Address("fd01::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-routes") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // Both the on-link route to the gateway AND the default via it must be present. + // Without the link route the kernel rejects the default — that was the bug. + let lines = output.split(separator: "\n").map(String.init) + let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") } + guard hasLinkRoute else { + throw IntegrationError.assert( + msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)") + } + guard output.contains("default via fd01::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6DualStack() async throws { + let id = "test-ipv6-dual-stack" + let bs = try await bootstrap(id) + + // Pin the network's v6 prefix so the gateway is deterministically fd00::1 + // and the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + // Capture the v4 address vmnet allocated so we can assert it ends up on eth0. + let expectedV4 = interface.ipv4Address.address.description + + let addrBuffer = BufferWriter() + let routeBuffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // `ip addr show` (no family flag) lists both v4 and v6. + let addrExec = try await container.exec("check-dual-stack-addr") { config in + config.arguments = ["ip", "addr", "show", "eth0"] + config.stdout = addrBuffer + } + try await addrExec.start() + let addrStatus = try await addrExec.wait() + try await addrExec.delete() + + guard addrStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip addr show failed with status \(addrStatus)") + } + + guard let addrOutput = String(data: addrBuffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert addr output to UTF8") + } + + guard addrOutput.contains(expectedV4) else { + throw IntegrationError.assert( + msg: "expected v4 address \(expectedV4) on eth0, got: \(addrOutput)") + } + guard addrOutput.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected v6 address fd00::2 on eth0, got: \(addrOutput)") + } + + // The dual-stack default routes must both be installed. + let routeExec = try await container.exec("check-dual-stack-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = routeBuffer + } + try await routeExec.start() + let routeStatus = try await routeExec.wait() + try await routeExec.delete() + + guard routeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(routeStatus)") + } + + guard let routeOutput = String(data: routeBuffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert route output to UTF8") + } + + guard routeOutput.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(routeOutput)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + func testSysctl() async throws { let id = "test-container-sysctl" diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 42b43df6..74342013 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -17,6 +17,7 @@ import ArgumentParser import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation @@ -2087,4 +2088,60 @@ extension IntegrationSuite { } } } + + @available(macOS 26.0, *) + func testPodIPv6AddressAdd() async throws { + let id = "test-pod-ipv6-address" + let bs = try await bootstrap(id) + + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + config.interfaces = [interface] + } + + try await pod.addContainer("container1", rootfs: bs.rootfs) { config in + config.process.arguments = ["/bin/sleep", "100"] + } + + try await pod.create() + try await pod.startContainer("container1") + + let buffer = BufferWriter() + let exec = try await pod.execInContainer("container1", processID: "check-v6") { config in + config.arguments = ["ip", "-6", "addr", "show", "eth0"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + try await pod.killContainer("container1", signal: .kill) + try await pod.waitContainer("container1") + try await pod.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected fd00::2 on eth0 inside pod container, got: \(output)") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 276fce0a..786763c9 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -264,6 +264,14 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container interface custom MTU", testInterfaceMTU), Test("container networking disabled", testNetworkingDisabled), Test("container networking enabled", testNetworkingEnabled), + Test("container networking enabled ipv6", testNetworkingEnabledIPv6), + Test("container IPv6 address", testIPv6AddressAdd), + Test("container IPv6 default route", testIPv6DefaultRoute), + Test("container IPv6 gateway outside subnet", testIPv6GatewayOutsideSubnet), + Test("container IPv6 only default route", testIPv6OnlyDefaultRoute), + Test("container IPv6 only gateway outside subnet", testIPv6OnlyGatewayOutsideSubnet), + Test("container IPv6 dual stack", testIPv6DualStack), + Test("pod IPv6 address", testPodIPv6AddressAdd), ] } return [] diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index 3a3d579e..66cf2081 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -106,7 +106,8 @@ extension Application { id, reference: imageReference, rootfsSizeInBytes: fsSizeInMB.mib(), - readOnly: readOnly + readOnly: readOnly, + networking: true ) { config in config.cpus = cpus config.memoryInBytes = memory.mib() diff --git a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift index d0f3a956..6c31a0ca 100644 --- a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift +++ b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift @@ -290,6 +290,46 @@ struct NetlinkSessionTest { #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) } + @Test func testNetworkAddressAddIPv6() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name, truncated response with no attributes (not needed at present). + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 address to interface. + let expectedAddRequest = + "2c00000014000506000000000cc00cc0" // Netlink header (16 B): len=44 + + "0a40820002000000" // ifaddrmsg (8 B): AF_INET6, /64, flags=PERMANENT|NODAD, ifindex 2 + + "14000100fd000000000000000000000000000001" // RT attr: IFA_ADDRESS fd00::1 + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "0000000040000000140005060000000000000000" // nlmsg_err payload (20 B) + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.addressAdd(interface: "eth0", ipv6Address: try CIDRv6("fd00::1/64")) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + @Test func testNetworkRouteAddIpLink() throws { let mockSocket = try MockNetlinkSocket() mockSocket.pid = 0xc00c_c00c @@ -389,6 +429,149 @@ struct NetlinkSessionTest { #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) } + @Test func testNetworkRouteAddIpv6Link() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 link route with source. + let expectedAddRequest = + "4c00000018000506000000000cc00cc0" // Netlink header (16 B): len=76 + + "0a400000fe04fd0100000000" // struct rtmsg (12 B): AF_INET6, dst/64, + // table=MAIN(0xfe), proto=STATIC(0x04), scope=LINK(0xfd), type=UNICAST(0x01) + + "14000100fd000000000000000000000000000000" // RTA_DST fd00:: + + "14000700fd000000000000000000000000000001" // RTA_PREFSRC fd00::1 + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAdd( + interface: "eth0", + dstIpv6Addr: try CIDRv6("fd00::/64"), + srcIpv6Addr: try IPv6Address("fd00::1") + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + + @Test func testNetworkRouteAddIpv6LinkWithoutSrc() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 link route without source. + let expectedAddRequest = + "3800000018000506000000000cc00cc0" // Netlink header (16 B): len=56 + + "0a400000fe04fd0100000000" // struct rtmsg (12 B): AF_INET6, dst/64 + + "14000100fd000000000000000000000000000000" // RTA_DST fd00:: + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAdd( + interface: "eth0", + dstIpv6Addr: try CIDRv6("fd00::/64"), + srcIpv6Addr: nil + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + + @Test func testNetworkRouteAddDefaultIpv6() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add default IPv6 route via gateway. + let expectedAddRequest = + "3800000018000506000000000cc00cc0" // Netlink header (16 B): len=56 + + "0a000000fe03000100000000" // struct rtmsg (12 B): AF_INET6, dst/0, + // table=MAIN(0xfe), proto=BOOT(0x03), scope=UNIVERSE(0x00), type=UNICAST(0x01) + + "14000500fd000000000000000000000000000001" // RTA_GATEWAY fd00::1 + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAddDefault( + interface: "eth0", + ipv6Gateway: try IPv6Address("fd00::1") + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + @Test func testNetworkLinkGetMultipleMessagesInSingleBuffer() throws { let mockSocket = try MockNetlinkSocket() mockSocket.pid = 0x8765_4321 diff --git a/Tests/ContainerizationTests/AllocatorTests.swift b/Tests/ContainerizationTests/AllocatorTests.swift new file mode 100644 index 00000000..8bb80d2a --- /dev/null +++ b/Tests/ContainerizationTests/AllocatorTests.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import Containerization + +struct AllocatorTests { + + @Test func allocateDualStackReturnsDistinctPairs() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + let (a4, a6) = try alloc.allocate("a") + let (b4, b6) = try alloc.allocate("b") + + #expect(a4 != b4) + #expect(a6 != nil && b6 != nil) + #expect(a6 != b6) + + // The v4 allocator starts at lower + 2 (skipping network base + gateway), + // so the first two allocations are .2 and .3. + #expect(a4 == (try CIDRv4("192.168.64.2/24"))) + #expect(b4 == (try CIDRv4("192.168.64.3/24"))) + } + + @Test func allocateWithNoV6PrefixReturnsNilV6() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: nil) + + let (_, a6) = try alloc.allocate("a") + #expect(a6 == nil) + } + + @Test func duplicateIdThrows() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + _ = try alloc.allocate("a") + #expect(throws: ContainerizationError.self) { + _ = try alloc.allocate("a") + } + } + + @Test func releaseAllowsIdReuse() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + _ = try alloc.allocate("a") + // Re-allocating 'a' would throw .exists if release didn't clear it. + try alloc.release("a") + _ = try alloc.allocate("a") + } + + @Test func releaseUnknownIdIsNoOp() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + try alloc.release("never-allocated") + } + + @Test func v6HostPortionUsesOrdinalIndex() throws { + guard #available(macOS 26, *) else { + return + } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + let (_, a6) = try alloc.allocate("a") + let (_, b6) = try alloc.allocate("b") + + let aHost = a6!.address.value & a6!.prefix.suffixMask128 + let bHost = b6!.address.value & b6!.prefix.suffixMask128 + #expect(aHost == 2) + #expect(bHost == 3) + } + + @Test func cidrV6Gateway() throws { + // The network gateway is the lowest address + 1. + #expect((try CIDRv6("fd00::/64")).gateway == (try IPv6Address("fd00::1"))) + #expect((try CIDRv6("fd00:abcd:1234::/48")).gateway == (try IPv6Address("fd00:abcd:1234::1"))) + } + + @available(macOS 26, *) + private actor SerialAllocator { + private var inner: VmnetNetwork.Allocator + init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws { + self.inner = try VmnetNetwork.Allocator(cidrV4: cidrV4, cidrV6: cidrV6) + } + func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) { + try inner.allocate(id) + } + func release(_ id: String) throws { + try inner.release(id) + } + } + + @Test func returnsUniqueAddressesUnderConcurrentLoad() async throws { + guard #available(macOS 26, *) else { return } + // /16 host space is much larger than `count`, so we won't hit the + // pool ceiling — we're testing for collisions/state corruption. + let alloc = try SerialAllocator( + cidrV4: try CIDRv4("10.0.0.0/16"), + cidrV6: try CIDRv6("fd00::/64")) + + let count = 1000 + let pairs = try await withThrowingTaskGroup(of: (CIDRv4, CIDRv6?).self) { group in + for i in 0..")", ]) do { @@ -1199,6 +1200,30 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ let session = NetlinkSession(socket: socket, log: log) let ipv4Address = try CIDRv4(request.ipv4Address) try session.addressAdd(interface: request.interface, ipv4Address: ipv4Address) + if request.hasIpv6Address { + // Suppress SLAAC on this interface before adding the static + // address: the host would provide a static IPv6 config, this + // auto-derived IPv6 config would compete with the static one. + let confPath = URL(fileURLWithPath: "/proc/sys/net/ipv6/conf/\(request.interface)") + for key in ["accept_ra", "autoconf"] { + let setting = confPath.appendingPathComponent(key) + do { + let fh = try FileHandle(forWritingTo: setting) + defer { try? fh.close() } + try fh.write(contentsOf: Data("0".utf8)) + } catch { + log.warning( + "ipAddrAdd: failed to disable IPv6 auto-configuration", + metadata: [ + "path": "\(setting.path)", + "error": "\(error)", + ]) + } + } + + let ipv6Address = try CIDRv6(request.ipv6Address) + try session.addressAdd(interface: request.interface, ipv6Address: ipv6Address) + } } catch { log.error( "ipAddrAdd", @@ -1220,18 +1245,38 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ "interface": "\(request.interface)", "dstIpv4Addr": "\(request.dstIpv4Addr)", "srcIpv4Addr": "\(request.srcIpv4Addr)", + "dstIpv6Addr": "\(request.hasDstIpv6Addr ? request.dstIpv6Addr : "")", + "srcIpv6Addr": "\(request.hasSrcIpv6Addr ? request.srcIpv6Addr : "")", ]) + guard !request.dstIpv4Addr.isEmpty || request.hasDstIpv6Addr else { + throw RPCError( + code: .invalidArgument, + message: "ipRouteAddLink requires at least one of dstIpv4Addr or dstIpv6Addr" + ) + } + do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - let dstIpv4Addr = try CIDRv4(request.dstIpv4Addr) - let srcIpv4Addr = request.srcIpv4Addr.isEmpty ? nil : try IPv4Address(request.srcIpv4Addr) - try session.routeAdd( - interface: request.interface, - dstIpv4Addr: dstIpv4Addr, - srcIpv4Addr: srcIpv4Addr - ) + if !request.dstIpv4Addr.isEmpty { + let dstIpv4Addr = try CIDRv4(request.dstIpv4Addr) + let srcIpv4Addr = request.srcIpv4Addr.isEmpty ? nil : try IPv4Address(request.srcIpv4Addr) + try session.routeAdd( + interface: request.interface, + dstIpv4Addr: dstIpv4Addr, + srcIpv4Addr: srcIpv4Addr + ) + } + if request.hasDstIpv6Addr { + let dstIpv6Addr = try CIDRv6(request.dstIpv6Addr) + let srcIpv6Addr = request.hasSrcIpv6Addr ? try IPv6Address(request.srcIpv6Addr) : nil + try session.routeAdd( + interface: request.interface, + dstIpv6Addr: dstIpv6Addr, + srcIpv6Addr: srcIpv6Addr + ) + } } catch { log.error( "ipRouteAddLink", @@ -1253,13 +1298,24 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ metadata: [ "interface": "\(request.interface)", "ipv4Gateway": "\(request.ipv4Gateway)", + "ipv6Gateway": "\(request.hasIpv6Gateway ? request.ipv6Gateway : "")", ]) do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - let ipv4Gateway = !request.ipv4Gateway.isEmpty ? try IPv4Address(request.ipv4Gateway) : nil - try session.routeAddDefault(interface: request.interface, ipv4Gateway: ipv4Gateway) + if !request.ipv4Gateway.isEmpty { + let ipv4Gateway = try IPv4Address(request.ipv4Gateway) + try session.routeAddDefault(interface: request.interface, ipv4Gateway: ipv4Gateway) + } else if !request.hasIpv6Gateway { + // No v4 gateway and no v6 either: install a v4 default route + // with no gateway (preserves pre-IPv6 behavior). + try session.routeAddDefault(interface: request.interface, ipv4Gateway: nil) + } + if request.hasIpv6Gateway { + let ipv6Gateway = try IPv6Address(request.ipv6Gateway) + try session.routeAddDefault(interface: request.interface, ipv6Gateway: ipv6Gateway) + } } catch { log.error( "ipRouteAddDefault",