From 96af3fd35c4c5abcfa168455095eaadbfc42cd9c Mon Sep 17 00:00:00 2001 From: michael_crosby Date: Mon, 18 May 2026 13:57:56 -0400 Subject: [PATCH] add IPv6 support Extends the network plumbing to support per-interface IPv6 address configuration. The `Interface` protocol supports `ipv6Address` and `ipv6Gateway`. The agent's networking RPCs carry per-family fields via new `InterfaceAddress`, `LinkRoute`, and `DefaultRoute` types in `ContainerizationExtras`. `NetlinkSession` adds IPv6 methods for address and route operations. Co-authored-by: Michael Crosby --- .../Containerization/ContainerManager.swift | 18 +- Sources/Containerization/Interface.swift | 11 +- Sources/Containerization/LinuxContainer.swift | 22 +- Sources/Containerization/LinuxPod.swift | 22 +- Sources/Containerization/NATInterface.swift | 13 +- .../SandboxContext/SandboxContext.pb.swift | 81 +++- .../SandboxContext/SandboxContext.proto | 4 + .../VirtualMachineAgent+Routes.swift | 84 ++++ .../VirtualMachineAgent.swift | 6 +- Sources/Containerization/Vminitd.swift | 37 +- Sources/Containerization/VmnetNetwork.swift | 24 +- .../NetworkConfiguration.swift | 57 +++ .../NetlinkSession.swift | 201 +++++++++ Sources/ContainerizationNetlink/Types.swift | 5 + Sources/Integration/ContainerTests.swift | 405 ++++++++++++++++++ Sources/Integration/PodTests.swift | 62 +++ Sources/Integration/Suite.swift | 7 + Sources/cctl/RunCommand.swift | 49 ++- .../NetlinkSessionTest.swift | 183 ++++++++ .../InterfaceTests.swift | 59 +++ vminitd/Sources/VminitdCore/Server+GRPC.swift | 54 ++- 21 files changed, 1335 insertions(+), 69 deletions(-) create mode 100644 Sources/Containerization/VirtualMachineAgent+Routes.swift create mode 100644 Sources/ContainerizationExtras/NetworkConfiguration.swift create mode 100644 Tests/ContainerizationTests/InterfaceTests.swift diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 48682b38..b1f3bf56 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) 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 e68f70b9..db84bbe4 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 { @@ -3193,7 +3236,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() { @@ -3203,24 +3246,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 } @@ -3247,7 +3299,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() { @@ -3258,12 +3310,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) } @@ -3273,6 +3331,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) } @@ -3280,6 +3344,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 } @@ -3306,7 +3372,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() { @@ -3316,24 +3382,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 e30edbe9..84184fd9 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+Routes.swift b/Sources/Containerization/VirtualMachineAgent+Routes.swift new file mode 100644 index 00000000..e2fe7227 --- /dev/null +++ b/Sources/Containerization/VirtualMachineAgent+Routes.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 d23eff53..fcc7eeab 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -392,33 +392,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..57efa4a3 100644 --- a/Sources/Containerization/VmnetNetwork.swift +++ b/Sources/Containerization/VmnetNetwork.swift @@ -73,6 +73,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 +85,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 @@ -137,11 +143,27 @@ public struct VmnetNetwork: Network { /// Returns a new interface for use with a container. /// - Parameter id: The container ID. public mutating func createInterface(_ id: String) throws -> Containerization.Interface? { + let ipv4Address = try allocator.allocate(id) + return Self.Interface( + reference: self.reference, + ipv4Address: ipv4Address, + ipv4Gateway: self.ipv4Gateway + ) + } + + /// Returns a new interface for use with a container, with an optional IPv6 address. + /// - Parameters: + /// - id: The container ID. + /// - ipv6Address: The IPv6 CIDR address to assign to the interface. + /// - ipv6Gateway: The IPv6 gateway address for the default route. + public mutating func createInterface(_ id: String, ipv6Address: CIDRv6, ipv6Gateway: IPv6Address? = nil) throws -> Containerization.Interface? { let ipv4Address = try allocator.allocate(id) return Self.Interface( reference: self.reference, ipv4Address: ipv4Address, ipv4Gateway: self.ipv4Gateway, + ipv6Address: ipv6Address, + ipv6Gateway: ipv6Gateway ) } @@ -152,7 +174,7 @@ public struct VmnetNetwork: Network { let ipv4Address = try allocator.allocate(id) return Self.Interface( reference: self.reference, - ipv4Address: ipv4Address, + ipv4Address: ipv4Address ) } 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..414d4f1d 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4549,6 +4549,411 @@ extension IntegrationSuite { } } + @available(macOS 26.0, *) + func testIPv6AddressAdd() async throws { + let id = "test-ipv6-address" + let bs = try await bootstrap(id) + + var network = try VmnetNetwork() + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id, ipv6Address: try CIDRv6("fd00::1/64")) 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::1") else { + throw IntegrationError.assert( + msg: "expected fd00::1 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) + + var network = try VmnetNetwork() + defer { + try? network.releaseInterface(id) + } + + guard + let interface = try network.createInterface( + id, + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + 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) + + var network = try VmnetNetwork() + defer { + try? network.releaseInterface(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. + guard + let interface = try network.createInterface( + id, + ipv6Address: try CIDRv6("fd00::2/120"), + ipv6Gateway: try IPv6Address("fd01::1")) + 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() + + 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) + + var network = try VmnetNetwork() + defer { + try? network.releaseInterface(id) + } + + guard + let interface = try network.createInterface( + id, + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + 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..1b657a03 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,65 @@ 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() + defer { + try? network.releaseInterface(id) + } + + guard + let interface = try network.createInterface( + id, + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + 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..49879b79 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -264,6 +264,13 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container interface custom MTU", testInterfaceMTU), Test("container networking disabled", testNetworkingDisabled), Test("container networking enabled", testNetworkingEnabled), + 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..de86c4fa 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -54,6 +54,12 @@ extension Application { @Option(name: .customLong("ns"), help: "Nameserver addresses") var nameservers: [String] = [] + @Option(name: .customLong("ipv6-address"), help: "IPv6 CIDR address for the container (e.g. fd00::2/64)") + var ipv6Address: String? + + @Option(name: .customLong("ipv6-gateway"), help: "IPv6 gateway address (e.g. fd00::1)") + var ipv6Gateway: String? + @Option(name: .long, help: "Path to OCI runtime to use for spawning the container") var ociRuntimePath: String? @@ -102,12 +108,53 @@ extension Application { try current.setraw() defer { current.tryReset() } + let parsedIPv6Address = try ipv6Address.map { try CIDRv6($0) } + let parsedIPv6Gateway = try ipv6Gateway.map { try IPv6Address($0) } + if parsedIPv6Gateway != nil && parsedIPv6Address == nil { + throw ContainerizationError( + .invalidArgument, + message: "--ipv6-gateway requires --ipv6-address; pass both or neither" + ) + } + + // When IPv6 is requested, build the interface ourselves so we can + // pass it via `config.interfaces`. + let preBuiltInterface: (any Interface)? + if let parsedIPv6Address { + guard #available(macOS 26, *) else { + throw ContainerizationError( + .unsupported, + message: "IPv6 requires macOS 26 or later" + ) + } + guard var vmnetNetwork = network as? VmnetNetwork else { + throw ContainerizationError( + .invalidState, + message: "IPv6 requires a VmnetNetwork-backed network" + ) + } + preBuiltInterface = try vmnetNetwork.createInterface( + id, + ipv6Address: parsedIPv6Address, + ipv6Gateway: parsedIPv6Gateway + ) + } else { + preBuiltInterface = nil + } + let container = try await manager.create( id, reference: imageReference, rootfsSizeInBytes: fsSizeInMB.mib(), - readOnly: readOnly + readOnly: readOnly, + networking: preBuiltInterface == nil ) { config in + if let preBuiltInterface { + config.interfaces = [preBuiltInterface] + if let gateway = preBuiltInterface.ipv4Gateway { + config.dns = .init(nameservers: [gateway.description]) + } + } config.cpus = cpus config.memoryInBytes = memory.mib() config.process.setTerminalIO(terminal: current) 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/InterfaceTests.swift b/Tests/ContainerizationTests/InterfaceTests.swift new file mode 100644 index 00000000..c00ecca1 --- /dev/null +++ b/Tests/ContainerizationTests/InterfaceTests.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. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Testing + +@testable import Containerization + +struct InterfaceTests { + + /// A minimal `Interface` conformer that only sets the IPv4 surface, relying on the + /// protocol's default extensions to fill in `ipv6Address`, `ipv6Gateway`, and `mtu`. + private struct V4OnlyInterface: Interface { + let ipv4Address: CIDRv4 + let ipv4Gateway: IPv4Address? + let macAddress: MACAddress? + } + + @Test func interfaceProtocolV6Defaults() throws { + let i = V4OnlyInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1"), + macAddress: nil) + #expect(i.ipv6Address == nil) + #expect(i.ipv6Gateway == nil) + #expect(i.mtu == 1500) + } + + @Test func natInterfaceRoundTripsV6Fields() throws { + let nat = NATInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1"), + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + #expect(nat.ipv6Address == (try CIDRv6("fd00::2/64"))) + #expect(nat.ipv6Gateway == (try IPv6Address("fd00::1"))) + } + + @Test func natInterfaceV6FieldsDefaultToNil() throws { + let nat = NATInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1")) + #expect(nat.ipv6Address == nil) + #expect(nat.ipv6Gateway == nil) + } +} diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 15e8aa56..bef59387 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -1192,6 +1192,7 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ metadata: [ "interface": "\(request.interface)", "ipv4Address": "\(request.ipv4Address)", + "ipv6Address": "\(request.hasIpv6Address ? request.ipv6Address : "")", ]) do { @@ -1199,6 +1200,10 @@ 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 { + let ipv6Address = try CIDRv6(request.ipv6Address) + try session.addressAdd(interface: request.interface, ipv6Address: ipv6Address) + } } catch { log.error( "ipAddrAdd", @@ -1220,18 +1225,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 +1278,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",