From 85d13dda9f6df1732cbd1b7e8cfcee726cd788fe Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 13 Mar 2026 17:29:48 -0700 Subject: [PATCH 1/3] Configure IPv6 DNS server using router advertisements. - Closes #466. - Adds a `DNSMonitor` that listens for router advertisements and updates the resolv.conf with the IPv6 DNS server. - The `configureDns` function now calls `DNSMonitor.update()` to write the initial configuration. - The monitor does not block container workload startup. IPv6-only containers will need to tolerate the initial lack of a DNS server. --- Package.resolved | 84 +-- Package.swift | 15 + .../ContainerizationExtras/IPAddress+OS.swift | 177 +++++++ Sources/ContainerizationICMP/Echo.swift | 107 ++++ Sources/ContainerizationICMP/ICMPError.swift | 42 ++ .../ContainerizationICMP/ICMPMessage.swift | 184 +++++++ .../ContainerizationICMP/ICMPSession.swift | 205 ++++++++ Sources/ContainerizationICMP/ICMPSocket.swift | 215 ++++++++ .../ICMPSocketError.swift | 50 ++ Sources/ContainerizationICMP/NDOption.swift | 492 ++++++++++++++++++ .../RouterAdvertisement.swift | 100 ++++ Sources/Integration/ContainerTests.swift | 64 +++ Sources/Integration/Suite.swift | 1 + .../ContainerizationICMPTests/EchoTests.swift | 216 ++++++++ .../ICMPMessageTests.swift | 265 ++++++++++ .../NDOptionTests.swift | 406 +++++++++++++++ .../RouterAdvertisementTests.swift | 153 ++++++ vminitd/Package.resolved | 62 +-- vminitd/Package.swift | 4 +- vminitd/Sources/vminitd/AgentCommand.swift | 3 +- vminitd/Sources/vminitd/DNSMonitor.swift | 176 +++++++ vminitd/Sources/vminitd/Server+GRPC.swift | 12 +- vminitd/Sources/vminitd/Server.swift | 9 +- 23 files changed, 2967 insertions(+), 75 deletions(-) create mode 100644 Sources/ContainerizationExtras/IPAddress+OS.swift create mode 100644 Sources/ContainerizationICMP/Echo.swift create mode 100644 Sources/ContainerizationICMP/ICMPError.swift create mode 100644 Sources/ContainerizationICMP/ICMPMessage.swift create mode 100644 Sources/ContainerizationICMP/ICMPSession.swift create mode 100644 Sources/ContainerizationICMP/ICMPSocket.swift create mode 100644 Sources/ContainerizationICMP/ICMPSocketError.swift create mode 100644 Sources/ContainerizationICMP/NDOption.swift create mode 100644 Sources/ContainerizationICMP/RouterAdvertisement.swift create mode 100644 Tests/ContainerizationICMPTests/EchoTests.swift create mode 100644 Tests/ContainerizationICMPTests/ICMPMessageTests.swift create mode 100644 Tests/ContainerizationICMPTests/NDOptionTests.swift create mode 100644 Tests/ContainerizationICMPTests/RouterAdvertisementTests.swift create mode 100644 vminitd/Sources/vminitd/DNSMonitor.swift diff --git a/Package.resolved b/Package.resolved index abcc214e..0722cbd3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f2af83112ef9c25538d60f115c1d21ccfa89e850a8685333af1b3492ff8cda36", + "originHash" : "7a99484d9c42c457a79fbc658977f6e2fb5b17436d81ae2e1256bb3cbba0c6f6", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "60235983163d040f343a489f7e2e77c1918a8bd9", - "version" : "1.26.1" + "revision" : "4b99975677236d13f0754339864e5360142ff5a1", + "version" : "1.30.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", - "version" : "1.3.2" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", - "version" : "1.15.0" + "revision" : "7d5f6124c91a2d06fb63a811695a3400d15a100e", + "version" : "1.17.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -105,8 +105,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", - "version" : "3.12.3" + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -114,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "d1691545d53581400b1de9b0472d45eb25c19fed", - "version" : "1.4.4" + "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", + "version" : "1.4.5" } }, { @@ -132,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "db6eea3692638a65e2124990155cd220c2915903", - "version" : "1.3.0" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } }, { @@ -141,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -159,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", - "version" : "2.87.0" + "revision" : "5e72fc102906ebe75a3487595a653e6f43725552", + "version" : "2.94.0" } }, { @@ -168,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "145db1962f4f33a4ea07a32e751d5217602eea29", - "version" : "1.28.0" + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" } }, { @@ -177,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" } }, { @@ -195,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" } }, { @@ -204,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { @@ -217,13 +226,22 @@ "version" : "1.36.0" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" } }, { diff --git a/Package.swift b/Package.swift index 63ed7e05..00626b23 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,7 @@ let package = Package( .library(name: "ContainerizationEXT4", targets: ["ContainerizationEXT4"]), .library(name: "ContainerizationOCI", targets: ["ContainerizationOCI"]), .library(name: "ContainerizationNetlink", targets: ["ContainerizationNetlink"]), + .library(name: "ContainerizationICMP", targets: ["ContainerizationICMP"]), .library(name: "ContainerizationIO", targets: ["ContainerizationIO"]), .library(name: "ContainerizationOS", targets: ["ContainerizationOS"]), .library(name: "ContainerizationExtras", targets: ["ContainerizationExtras"]), @@ -205,6 +206,20 @@ let package = Package( .product(name: "Crypto", package: "swift-crypto"), ] ), + .target( + name: "ContainerizationICMP", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + "ContainerizationExtras", + ] + ), + .testTarget( + name: "ContainerizationICMPTests", + dependencies: [ + "ContainerizationExtras", + "ContainerizationICMP", + ] + ), .target( name: "ContainerizationNetlink", dependencies: [ diff --git a/Sources/ContainerizationExtras/IPAddress+OS.swift b/Sources/ContainerizationExtras/IPAddress+OS.swift new file mode 100644 index 00000000..1a58d5ea --- /dev/null +++ b/Sources/ContainerizationExtras/IPAddress+OS.swift @@ -0,0 +1,177 @@ +//===----------------------------------------------------------------------===// +// 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 canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#else +#error("Platform not supported.") +#endif + +#if canImport(Darwin) +import Darwin +private let AF_LINK_TYPE = AF_LINK +#elseif canImport(Glibc) || canImport(Musl) +private let AF_LINK_TYPE = AF_PACKET +#endif + +extension sockaddr_in6 { + public init(address: IPv6Address) throws { + self.init() + self.sin6_family = sa_family_t(AF_INET6) + self.sin6_port = 0 + self.sin6_flowinfo = 0 + self.sin6_scope_id = try address.scopeId() + withUnsafeMutableBytes(of: &self.sin6_addr) { ptr in + ptr.copyBytes(from: address.bytes) + } + } + + public func toIPv6Address() throws -> IPv6Address { + let bytes: [UInt8] = withUnsafeBytes(of: sin6_addr) { ptr in + [UInt8](ptr) // Using bracket notation + } + return try IPv6Address(bytes) + } +} + +extension IPv6Address { + public func scopeId() throws -> UInt32 { + guard let zone else { + return 0 + } + if let scopeId = UInt32(zone) { + return scopeId + } + let scopeId = if_nametoindex(zone) + guard scopeId > 0 else { + throw AddressError.invalidZoneIdentifier + } + return scopeId + } +} + +extension MACAddress { + /// Get the MAC address for a network interface + /// - Parameter zone: Interface name (e.g., "en0") or interface index (e.g., "1") + /// - Returns: MAC address for the interface, or nil if not found + public static func fromZone(_ zone: String) -> MACAddress? { + // Convert zone to interface name if it's a numeric index + let ifname: String + if let index = UInt32(zone) { + var buffer = [CChar](repeating: 0, count: Int(IF_NAMESIZE)) + guard if_indextoname(index, &buffer) != nil else { + return nil + } + // Convert CChar to UInt8 and truncate at null terminator + let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } + guard let name = String(validating: bytes, as: UTF8.self) else { + return nil + } + ifname = name + } else { + ifname = zone + } + + #if canImport(Darwin) + // Darwin: Use getifaddrs() to find AF_LINK address + var ifaddrs: UnsafeMutablePointer? + guard getifaddrs(&ifaddrs) == 0 else { + return nil + } + defer { freeifaddrs(ifaddrs) } + + var currentIfaddr = ifaddrs + while let ifaddr = currentIfaddr { + defer { currentIfaddr = ifaddr.pointee.ifa_next } + + guard let name = ifaddr.pointee.ifa_name, + String(cString: name) == ifname, + let addr = ifaddr.pointee.ifa_addr + else { + continue + } + + guard addr.pointee.sa_family == AF_LINK else { + continue + } + + let sdl = addr.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) { $0.pointee } + let macOffset = Int(sdl.sdl_nlen) + let macLen = Int(sdl.sdl_alen) + + guard macLen == 6 else { + continue + } + + var macBytes = [UInt8](repeating: 0, count: 6) + withUnsafeBytes(of: sdl.sdl_data) { ptr in + let start = ptr.baseAddress!.advanced(by: macOffset) + macBytes.withUnsafeMutableBytes { dst in + dst.copyBytes(from: UnsafeRawBufferPointer(start: start, count: 6)) + } + } + + return try? MACAddress(macBytes) + } + + return nil + #elseif canImport(Glibc) || canImport(Musl) + // Linux: Use ioctl with SIOCGIFHWADDR to get hardware address + #if canImport(Glibc) + let osSockDgram = Int32(SOCK_DGRAM.rawValue) + #else + let osSockDgram = Int32(SOCK_DGRAM) + #endif + let fd = socket(AF_INET, osSockDgram, 0) + guard fd >= 0 else { + return nil + } + defer { close(fd) } + + var ifr = ifreq() + + // Copy interface name into ifr_ifrn.ifrn_name + guard ifname.utf8.count < MemoryLayout.size(ofValue: ifr.ifr_ifrn.ifrn_name) else { + return nil + } + + withUnsafeMutableBytes(of: &ifr.ifr_ifrn.ifrn_name) { ptr in + _ = ifname.utf8.withContiguousStorageIfAvailable { utf8 in + ptr.copyBytes(from: UnsafeRawBufferPointer(start: utf8.baseAddress, count: utf8.count)) + } + } + + // Get hardware address + guard ioctl(fd, UInt(SIOCGIFHWADDR), &ifr) >= 0 else { + return nil + } + + // Extract MAC address from ifr_hwaddr.sa_data + var macBytes = [UInt8](repeating: 0, count: 6) + withUnsafeBytes(of: ifr.ifr_ifru.ifru_hwaddr.sa_data) { ptr in + macBytes.withUnsafeMutableBytes { dst in + dst.copyBytes(from: UnsafeRawBufferPointer(start: ptr.baseAddress, count: 6)) + } + } + + return try? MACAddress(macBytes) + #endif + } +} diff --git a/Sources/ContainerizationICMP/Echo.swift b/Sources/ContainerizationICMP/Echo.swift new file mode 100644 index 00000000..33d72325 --- /dev/null +++ b/Sources/ContainerizationICMP/Echo.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +public struct Echo: Bindable { + public static let size = 4 + + public var identifier: UInt16 + public var sequenceNumber: UInt16 + + public init( + identifier: UInt16, + sequenceNumber: UInt16 + ) throws { + self.identifier = identifier + self.sequenceNumber = sequenceNumber + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(as: UInt16.self, value: identifier.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "Echo", field: "identifier") + } + guard let offset = buffer.copyIn(as: UInt16.self, value: sequenceNumber.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "Echo", field: "sequenceNumber") + } + + assert(offset - startOffset == Self.size, "BUG: echo appendBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "Echo", field: "identifier") + } + identifier = UInt16(bigEndian: value) + + guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "Echo", field: "sequenceNumber") + } + sequenceNumber = UInt16(bigEndian: value) + + assert(offset - startOffset == Self.size, "BUG: echo bindBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } +} + +package protocol EchoPayload {} + +extension EchoPayload { + public static var size: Int { + 56 + } +} + +public struct EchoPayloadRTT: EchoPayload { + public var date: Date + + public init(date: Date? = nil) { + self.date = date ?? Date() + } + + public func rtt(atDate: Date? = nil) -> TimeInterval { + (atDate ?? Date()).timeIntervalSince(date) + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + let timeInterval = date.timeIntervalSinceReferenceDate + guard let _ = buffer.copyIn(as: UInt64.self, value: timeInterval.bitPattern, offset: offset) else { + throw BindError.sendMarshalFailure(type: "EchoPayloadRTT", field: "date") + } + + let finalOffset = offset + Self.size + assert(finalOffset - startOffset == Self.size, "BUG: echo payload RTT appendBuffer length mismatch - expected \(Self.size), got \(finalOffset - startOffset)") + return finalOffset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (_, value) = buffer.copyOut(as: UInt64.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "EchoPayloadRTT", field: "date") + } + let timeInterval = Double(bitPattern: value) + date = Date(timeIntervalSinceReferenceDate: timeInterval) + + let finalOffset = offset + Self.size + assert(finalOffset - startOffset == Self.size, "BUG: echo payload RTT bindBuffer length mismatch - expected \(Self.size), got \(finalOffset - startOffset)") + return finalOffset + } +} diff --git a/Sources/ContainerizationICMP/ICMPError.swift b/Sources/ContainerizationICMP/ICMPError.swift new file mode 100644 index 00000000..3f5585bb --- /dev/null +++ b/Sources/ContainerizationICMP/ICMPError.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Errors that may occur during ICMP operations +public enum ICMPError: Swift.Error, CustomStringConvertible { + case invalidPacket(String) + case invalidResponse(String) + case timeout + case unexpectedMessageType(expected: UInt8, got: UInt8) + case bufferTooSmall(needed: Int, available: Int) + case cancelled + + public var description: String { + switch self { + case .invalidPacket(let message): + return "packet validation error: \(message)" + case .invalidResponse(let message): + return "invalid response: \(message)" + case .timeout: + return "request timed out" + case .unexpectedMessageType(let expected, let got): + return "unexpected message type: expected \(expected), got \(got)" + case .bufferTooSmall(let needed, let available): + return "buffer too small: needed \(needed), available \(available)" + case .cancelled: + return "operation was cancelled" + } + } +} diff --git a/Sources/ContainerizationICMP/ICMPMessage.swift b/Sources/ContainerizationICMP/ICMPMessage.swift new file mode 100644 index 00000000..b67cc6a2 --- /dev/null +++ b/Sources/ContainerizationICMP/ICMPMessage.swift @@ -0,0 +1,184 @@ +//===----------------------------------------------------------------------===// +// 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 + +public enum ICMPv4MessageType: UInt8, Sendable { + case echoReply = 0 + case destinationUnreachable = 3 + case sourceQuench = 4 // Deprecated (RFC 6633) + case redirect = 5 + case echoRequest = 8 + case routerAdvertisement = 9 + case routerSolicitation = 10 + case timeExceeded = 11 + case parameterProblem = 12 + case timestampRequest = 13 + case timestampReply = 14 + case informationRequest = 15 // Obsolete + case informationReply = 16 // Obsolete + case addressMaskRequest = 17 // Deprecated + case addressMaskReply = 18 // Deprecated + case traceroute = 30 // Deprecated (RFC 1393) +} + +public struct ICMPv4Header: Bindable { + public static let size: Int = 4 + + public var type: ICMPv4MessageType + + public var code: UInt8 + + public init(type: ICMPv4MessageType = .echoReply, code: UInt8 = 0) { + self.type = type + self.code = code + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(as: UInt8.self, value: type.rawValue, offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv4Header", field: "type") + } + guard let offset = buffer.copyIn(as: UInt8.self, value: code, offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv4Header", field: "code") + } + guard let offset = buffer.copyIn(as: UInt16.self, value: UInt16(0), offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv4Header", field: "checksum") + } + + assert(offset - startOffset == Self.size, "BUG: echo appendBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "ICMPv4Header", field: "type") + } + guard let type = ICMPv4MessageType(rawValue: value) else { + throw BindError.recvMarshalFailure(type: "ICMPv4Header", field: "type") + } + self.type = type + + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "ICMPv4Header", field: "code") + } + self.code = value + + let offsetSkippingChecksum = offset + MemoryLayout.size + + assert(offsetSkippingChecksum - startOffset == Self.size, "BUG: echo bindBuffer length mismatch - expected \(Self.size), got \(offsetSkippingChecksum - startOffset)") + return offsetSkippingChecksum + } + + /// Calculate ICMPv4 checksum (16-bit one's complement of one's complement sum) + public static func checksum(buffer: [UInt8], offset: Int, length: Int) -> UInt16 { + var sum: UInt32 = 0 + var i = offset + let end = offset + length + + // Sum up 16-bit words + while i < end - 1 { + let word = UInt32(buffer[i]) << 8 | UInt32(buffer[i + 1]) + sum += word + i += 2 + } + + // Add remaining byte if odd length + if i < end { + sum += UInt32(buffer[i]) << 8 + } + + // Fold 32-bit sum to 16 bits + while (sum >> 16) != 0 { + sum = (sum & 0xFFFF) + (sum >> 16) + } + + // One's complement + return ~UInt16(sum & 0xFFFF) + } +} + +public enum ICMPv6MessageType: UInt8, Sendable { + case echoRequest = 128 + case echoReply = 129 + case routerSolicitation = 133 + case routerAdvertisement = 134 + case neighborSolicitation = 135 + case neighborAdvertisement = 136 +} + +public struct ICMPv6Header: Bindable { + public static let size: Int = 4 + + public var type: ICMPv6MessageType + + public var code: UInt8 + + public init(type: ICMPv6MessageType = .echoReply, code: UInt8 = 0) { + self.type = type + self.code = code + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(as: UInt8.self, value: type.rawValue, offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv6Header", field: "type") + } + guard let offset = buffer.copyIn(as: UInt8.self, value: code, offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv6Header", field: "code") + } + guard let offset = buffer.copyIn(as: UInt16.self, value: UInt16(0), offset: offset) else { + throw BindError.sendMarshalFailure(type: "ICMPv6Header", field: "checksum") + } + + assert(offset - startOffset == Self.size, "BUG: echo appendBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "ICMPv6Header", field: "type") + } + guard let type = ICMPv6MessageType(rawValue: value) else { + throw BindError.recvMarshalFailure(type: "ICMPv6Header", field: "type") + } + self.type = type + + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "ICMPv6Header", field: "code") + } + self.code = value + + let offsetSkippingChecksum = offset + MemoryLayout.size + + assert(offsetSkippingChecksum - startOffset == Self.size, "BUG: echo bindBuffer length mismatch - expected \(Self.size), got \(offsetSkippingChecksum - startOffset)") + return offsetSkippingChecksum + } +} + +extension ICMPv4Header { + public func matches(type: ICMPv4MessageType, code: UInt8 = 0) -> Bool { + self.type == type && self.code == code + } +} + +extension ICMPv6Header { + public func matches(type: ICMPv6MessageType, code: UInt8 = 0) -> Bool { + self.type == type && self.code == code + } +} diff --git a/Sources/ContainerizationICMP/ICMPSession.swift b/Sources/ContainerizationICMP/ICMPSession.swift new file mode 100644 index 00000000..cb847374 --- /dev/null +++ b/Sources/ContainerizationICMP/ICMPSession.swift @@ -0,0 +1,205 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Session for sending and receiving ICMPv4 messages. +public final class ICMPv4Session: Sendable { + private static let receiveBufferSize = 65536 + + private let socket: ICMPv4Socket + + public init() throws { + self.socket = try ICMPv4Socket() + } + + // MARK: - ICMPv4 Echo (Ping) + + /// Send an ICMPv4 echo request and wait for a reply + /// - Parameters: + /// - host: The hostname or IP address to ping + /// - identifier: The identifier for the echo request (typically process ID) + /// - sequenceNumber: The sequence number for this ping + /// - payload: Optional payload data (default is 56-byte RTT timestamp) + /// - timeout: Maximum time to wait for a reply (default: 5 seconds) + /// - Returns: Tuple of (reply, round-trip time in seconds, source address) + public func echoRequest( + ipAddress: IPv4Address, + identifier: UInt16, + sequenceNumber: UInt16 + ) throws { + let totalSize = ICMPv4Header.size + Echo.size + EchoPayloadRTT.size + var buffer = [UInt8](repeating: 0, count: totalSize) + var offset = 0 + + let header = ICMPv4Header(type: .echoRequest, code: 0) + offset = try header.appendBuffer(&buffer, offset: offset) + + let echo = try Echo(identifier: identifier, sequenceNumber: sequenceNumber) + offset = try echo.appendBuffer(&buffer, offset: offset) + + let payload = EchoPayloadRTT() + offset = try payload.appendBuffer(&buffer, offset: offset) + + assert(offset == totalSize) + + let checksum = ICMPv4Header.checksum(buffer: buffer, offset: 0, length: totalSize) + buffer[2] = UInt8((checksum >> 8) & 0xFF) + buffer[3] = UInt8(checksum & 0xFF) + _ = try socket.send(buffer: buffer, to: ipAddress) + } + + public func recvHeader() throws -> (sourceAddr: IPv4Address, header: ICMPv4Header, bytes: [UInt8], length: Int, offset: Int) { + var buffer = [UInt8](repeating: 0, count: Self.receiveBufferSize) + let (bytesReceived, ipAddr) = try socket.receive(buffer: &buffer) + + // Skip IPv4 header + guard bytesReceived > 0 else { + throw ICMPError.bufferTooSmall(needed: 1, available: bytesReceived) + } + + let ipHeaderLength = Int((buffer[0] & 0x0F)) * 4 + var offset = ipHeaderLength + + guard bytesReceived >= offset + ICMPv4Header.size else { + throw ICMPError.bufferTooSmall(needed: offset + ICMPv4Header.size, available: bytesReceived) + } + + var header = ICMPv4Header() + offset = try header.bindBuffer(&buffer, offset: offset) + + return (sourceAddr: ipAddr, header: header, bytes: buffer, length: bytesReceived, offset: offset) + } +} + +/// Session for sending and receiving ICMPv6 messages. +public final class ICMPv6Session: Sendable { + private static let receiveBufferSize = 65536 + + private static let unspecifiedAddress = IPv6Address(0) + private static func allRoutersMulticastAddress(zone: String?) -> IPv6Address { + IPv6Address(0xFF02_0000_0000_0000_0000_0000_0000_0002, zone: zone) + } + + private let socket: ICMPv6Socket + + public init() throws { + self.socket = try ICMPv6Socket() + } + + /// Send an ICMPv6 echo request and wait for a reply + /// - Parameters: + /// - host: The hostname or IP address to ping + /// - identifier: The identifier for the echo request (typically process ID) + /// - sequenceNumber: The sequence number for this ping + /// - payload: Optional payload data (default is 56-byte RTT timestamp) + /// - timeout: Maximum time to wait for a reply (default: 5 seconds) + /// - Returns: Tuple of (reply, round-trip time in seconds, source address) + public func echoRequest( + ipAddress: IPv6Address, + identifier: UInt16, + sequenceNumber: UInt16, + ) throws { + let totalSize = ICMPv6Header.size + Echo.size + EchoPayloadRTT.size + var buffer = [UInt8](repeating: 0, count: totalSize) + var offset = 0 + + let header = ICMPv6Header(type: .echoRequest, code: 0) + offset = try header.appendBuffer(&buffer, offset: offset) + + let echo = try Echo(identifier: identifier, sequenceNumber: sequenceNumber) + offset = try echo.appendBuffer(&buffer, offset: offset) + + let payload = EchoPayloadRTT() + offset = try payload.appendBuffer(&buffer, offset: offset) + + assert(offset == totalSize) + + _ = try socket.send(buffer: buffer, to: ipAddress) + } + + public func routerSolicitation(linkLayerAddress: MACAddress?, interface: String?) throws { + let totalSize: Int + if linkLayerAddress == nil { + totalSize = ICMPv6Header.size + MemoryLayout.size + } else { + totalSize = ICMPv6Header.size + MemoryLayout.size + NDOptionHeader.size + SourceLinkLayerAddress.size + } + + var buffer = [UInt8](repeating: 0, count: totalSize) + var offset = 0 + + let header = ICMPv6Header(type: .routerSolicitation, code: 0) + offset = try header.appendBuffer(&buffer, offset: offset) + + guard var offset = buffer.copyIn(as: UInt32.self, value: 0, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterSolicitation", field: "reserved") + } + + if let address = linkLayerAddress { + let option = NDOption.sourceLinkLayerAddress(address) + offset = try option.appendBuffer(&buffer, offset: offset) + } + + assert(offset == totalSize) + + _ = try socket.send(buffer: buffer, to: Self.allRoutersMulticastAddress(zone: interface)) + } + + public func recvHeader() throws -> (sourceAddr: IPv6Address, header: ICMPv6Header, bytes: [UInt8], length: Int, offset: Int) { + var buffer = [UInt8](repeating: 0, count: Self.receiveBufferSize) + let (bytesReceived, ipAddr) = try socket.receive(buffer: &buffer) + + guard bytesReceived >= ICMPv6Header.size else { + throw ICMPError.bufferTooSmall(needed: ICMPv6Header.size, available: bytesReceived) + } + + var offset = 0 + var header = ICMPv6Header() + offset = try header.bindBuffer(&buffer, offset: offset) + + return (sourceAddr: ipAddr, header: header, bytes: buffer, length: bytesReceived, offset: offset) + } +} + +extension ICMPv4Session { + /// Receive and wait for a specific message type + public func recv(type: ICMPv4MessageType, timeout: Duration = .seconds(5)) throws -> (sourceAddr: IPv4Address, header: ICMPv4Header, bytes: [UInt8], length: Int, offset: Int) { + let deadline = Date.now + timeout / .seconds(1) + while Date.now < deadline { + let (addr, header, buffer, length, offset) = try recvHeader() + if header.type == type { + return (sourceAddr: addr, header: header, bytes: buffer, length: length, offset: offset) + } + } + throw ICMPError.timeout + } +} + +extension ICMPv6Session { + /// Receive and wait for a specific message type + public func recv(type: ICMPv6MessageType, timeout: Duration = .seconds(5)) throws -> (sourceAddr: IPv6Address, header: ICMPv6Header, bytes: [UInt8], length: Int, offset: Int) { + let deadline = Date.now + timeout / .seconds(1) + while Date.now < deadline { + let (addr, header, buffer, length, offset) = try recvHeader() + if header.type == type { + return (sourceAddr: addr, header: header, bytes: buffer, length: length, offset: offset) + } + } + throw ICMPError.timeout + } +} diff --git a/Sources/ContainerizationICMP/ICMPSocket.swift b/Sources/ContainerizationICMP/ICMPSocket.swift new file mode 100644 index 00000000..63df096f --- /dev/null +++ b/Sources/ContainerizationICMP/ICMPSocket.swift @@ -0,0 +1,215 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Synchronization + +#if canImport(Musl) +import Musl +let osClose = Musl.close +let osSocket = Musl.socket +let osSockRaw = Int32(SOCK_RAW) +#elseif canImport(Glibc) +import Glibc +let osClose = Glibc.close +let osSocket = Glibc.socket +let osSockRaw = Int32(SOCK_RAW.rawValue) +#elseif canImport(Darwin) +import Darwin +let osClose = Darwin.close +let osSocket = Darwin.socket +let osSockRaw = SOCK_RAW +#else +#error("Platform not supported.") +#endif + +public final class ICMPv4Socket: Sendable { + private let sockfd: Mutex + + public init() throws { + let fd = osSocket(AF_INET, osSockRaw, Int32(IPPROTO_ICMP)) + guard fd >= 0 else { + let err = errno + if err == EPERM { + throw ICMPSocketError.permissionDenied + } + throw ICMPSocketError.openFailed(errno: err) + } + sockfd = .init(fd) + } + + deinit { + try? close() + } + + public func close() throws { + try sockfd.withLock { fd in + guard osClose(fd) >= 0 else { + throw ICMPSocketError.closeFailed(errno: errno) + } + } + } + + public func send(buffer: [UInt8], to ipAddr: IPv4Address) throws -> Int { + guard let addr = sockaddr_in(address: ipAddr) else { + throw ICMPSocketError.invalidAddress(address: ipAddr.description) + } + let bufferLen = buffer.count + let addrLen = socklen_t(MemoryLayout.size) + let count = buffer.withUnsafeBytes { bufPtr in + withUnsafePointer(to: addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + sockfd.withLock { fd in + sendto(fd, bufPtr.baseAddress, bufferLen, 0, sockaddrPtr, addrLen) + } + } + } + } + + guard count >= 0 else { + throw ICMPSocketError.sendFailed(errno: errno) + } + + return count + } + + public func receive(buffer: inout [UInt8]) throws -> (Int, IPv4Address) { + var addr = sockaddr_in() + var addrLen = socklen_t(MemoryLayout.size) + let bufferLen = buffer.count + let count = buffer.withUnsafeMutableBytes { bufPtr in + withUnsafeMutablePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + sockfd.withLock { fd in + recvfrom(fd, bufPtr.baseAddress, bufferLen, 0, sockaddrPtr, &addrLen) + } + } + } + } + + guard count >= 0 else { + throw ICMPSocketError.receiveFailed(errno: errno) + } + + return (count, try addr.toIPv4Address()) + } +} + +public final class ICMPv6Socket: Sendable { + private let sockfd: Mutex + + public init() throws { + let fd = osSocket(AF_INET6, osSockRaw, Int32(IPPROTO_ICMPV6)) + guard fd >= 0 else { + let err = errno + if err == EPERM { + throw ICMPSocketError.permissionDenied + } + throw ICMPSocketError.openFailed(errno: err) + } + + // Set hop limit to 255 for multicast (required for Router Solicitation per RFC 4861) + var hops: Int32 = 255 + setsockopt(fd, Int32(IPPROTO_IPV6), IPV6_MULTICAST_HOPS, &hops, socklen_t(MemoryLayout.size)) + + sockfd = .init(fd) + } + + deinit { + try? close() + } + + public func close() throws { + try sockfd.withLock { fd in + guard osClose(fd) >= 0 else { + throw ICMPSocketError.closeFailed(errno: errno) + } + } + } + + public func send(buffer: [UInt8], to ipAddr: IPv6Address) throws -> Int { + let addr = try sockaddr_in6(address: ipAddr) + let addrLen = socklen_t(MemoryLayout.size) + let bufferLen = buffer.count + let count = buffer.withUnsafeBytes { bufPtr in + withUnsafePointer(to: addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + sockfd.withLock { fd in + #if os(Darwin) + if ipAddr.isMulticast { + var scopeId = try ipAddr.scopeId() + setsockopt(fd, Int32(IPPROTO_IPV6), osIPv6MulticastIf, &scopeId, socklen_t(MemoryLayout.size)) + } + #endif + sendto(fd, bufPtr.baseAddress, bufferLen, 0, sockaddrPtr, addrLen) + } + } + } + } + + guard count >= 0 else { + throw ICMPSocketError.sendFailed(errno: errno) + } + + return count + } + + public func receive(buffer: inout [UInt8]) throws -> (Int, IPv6Address) { + var addr = sockaddr_in6() + var addrLen = socklen_t(MemoryLayout.size) + let bufferLen = buffer.count + let count = buffer.withUnsafeMutableBytes { bufPtr in + withUnsafeMutablePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + sockfd.withLock { fd in + recvfrom( + fd, bufPtr.baseAddress, bufferLen, 0, + sockaddrPtr, &addrLen) + } + } + } + } + + guard count >= 0 else { + throw ICMPSocketError.receiveFailed(errno: errno) + } + + return (count, try addr.toIPv6Address()) + } +} + +struct icmp6_filter { + var data: (UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32) = (0, 0, 0, 0, 0, 0, 0, 0) +} + +extension sockaddr_in { + init?(address: IPv4Address) { + self.init() + self.sin_family = sa_family_t(AF_INET) + self.sin_port = 0 + withUnsafeMutableBytes(of: &self.sin_addr) { ptr in + ptr.copyBytes(from: address.bytes) + } + } + + func toIPv4Address() throws -> IPv4Address { + let bytes: [UInt8] = withUnsafeBytes(of: sin_addr) { ptr in + [UInt8](ptr) // Using bracket notation + } + return try IPv4Address(bytes) + } +} diff --git a/Sources/ContainerizationICMP/ICMPSocketError.swift b/Sources/ContainerizationICMP/ICMPSocketError.swift new file mode 100644 index 00000000..321dd512 --- /dev/null +++ b/Sources/ContainerizationICMP/ICMPSocketError.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Errors thrown when interacting with an ICMP socket. +public enum ICMPSocketError: Error, CustomStringConvertible, Equatable { + case openFailed(errno: Int32) + case closeFailed(errno: Int32) + case bindFailed(errno: Int32) + case sendFailed(errno: Int32) + case receiveFailed(errno: Int32) + case invalidAddress(address: String) + case permissionDenied + case notImplemented + + public var description: String { + switch self { + case .openFailed(let errno): + return "could not create ICMP socket, errno = \(errno)" + case .closeFailed(let errno): + return "could not create ICMP socket, errno = \(errno)" + case .bindFailed(let errno): + return "could not bind ICMP socket, errno = \(errno)" + case .sendFailed(let errno): + return "could not send ICMP packet, errno = \(errno)" + case .receiveFailed(let errno): + return "could not receive ICMP packet, errno = \(errno)" + case .invalidAddress(let address): + return "invalid address \(address)" + case .permissionDenied: + return "permission denied - raw sockets require root/CAP_NET_RAW" + case .notImplemented: + return "socket function not implemented for platform" + } + } +} diff --git a/Sources/ContainerizationICMP/NDOption.swift b/Sources/ContainerizationICMP/NDOption.swift new file mode 100644 index 00000000..83b200f9 --- /dev/null +++ b/Sources/ContainerizationICMP/NDOption.swift @@ -0,0 +1,492 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Neighbor Discovery option types as defined in RFC 4861 and subsequent RFCs +public enum NDOptionType: UInt8, Sendable { + case sourceLinkLayerAddress = 1 // RFC 4861 + case targetLinkLayerAddress = 2 // RFC 4861 + case prefixInformation = 3 // RFC 4861 + case redirectedHeader = 4 // RFC 4861 + case mtu = 5 // RFC 4861 + case routeInformation = 24 // RFC 4191 + case recursiveDNSServer = 25 // RFC 8106 + case dnsSearchList = 31 // RFC 8106 + case captivePortal = 37 // RFC 7710 +} + +public struct NDOptionHeader: Bindable { + public static let size: Int = 2 + + public var type: NDOptionType + + public var lengthInUnits: UInt8 + + public init(type: NDOptionType, lengthInUnits: UInt8) { + self.type = type + self.lengthInUnits = lengthInUnits + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(as: UInt8.self, value: type.rawValue, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NDOptionHeader", field: "type") + } + + guard let offset = buffer.copyIn(as: UInt8.self, value: lengthInUnits, offset: offset) else { + throw BindError.sendMarshalFailure(type: "NDOptionHeader", field: "lengthInUnits") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: NDOption tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "NDOptionHeader", field: "type") + } + guard let value = NDOptionType(rawValue: value) else { + throw BindError.recvMarshalFailure(type: "NDOptionHeader", field: "type") + } + type = value + + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "NDOptionHeader", field: "lengthInUnits") + } + lengthInUnits = value + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: NDOption rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +public struct SourceLinkLayerAddress: Bindable { + public static let size: Int = 6 + + public var address: MACAddress + + public init(address: MACAddress) { + self.address = address + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(buffer: address.bytes, offset: offset) else { + throw BindError.sendMarshalFailure(type: "SourceLinkLayerAddress", field: "address") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: source link layer address tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + var bytes = [UInt8](repeating: 0, count: 6) + guard let offset = buffer.copyOut(buffer: &bytes, offset: offset) else { + throw BindError.recvMarshalFailure(type: "SourceLinkLayerAddress", field: "bytes[0..<6]") + } + address = try MACAddress(bytes) + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: source link layer address rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +public struct PrefixInformation: Bindable { + public static let size: Int = 30 + + public var prefixLength: UInt8 + public var onLinkFlag: Bool + public var autonomousFlag: Bool + public var validLifetime: UInt32 + public var preferredLifetime: UInt32 + public var prefix: IPv6Address + + public init( + prefixLength: UInt8, + onLinkFlag: Bool, + autonomousFlag: Bool, + validLifetime: UInt32, + preferredLifetime: UInt32, + prefix: IPv6Address + ) { + self.prefixLength = prefixLength + self.onLinkFlag = onLinkFlag + self.autonomousFlag = autonomousFlag + self.validLifetime = validLifetime + self.preferredLifetime = preferredLifetime + self.prefix = prefix + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + guard let offset = buffer.copyIn(as: UInt8.self, value: prefixLength, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "prefixLength") + } + + let flags = UInt8((onLinkFlag ? 0x80 : 0x00) | (autonomousFlag ? 0x40 : 0x00)) + guard let offset = buffer.copyIn(as: UInt8.self, value: flags, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "flags") + } + + guard let offset = buffer.copyIn(as: UInt32.self, value: validLifetime.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "validLifetime") + } + + guard let offset = buffer.copyIn(as: UInt32.self, value: preferredLifetime.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "preferredLifetime") + } + + guard let offset = buffer.copyIn(as: UInt32.self, value: 0, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "reserved") + } + + guard let offset = buffer.copyIn(buffer: prefix.bytes, offset: offset) else { + throw BindError.sendMarshalFailure(type: "PrefixInformation", field: "prefix") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: prefix information tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "prefixLength") + } + prefixLength = value + + guard let (offset, flags) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "flags") + } + onLinkFlag = (flags & 0x80) != 0 + autonomousFlag = (flags & 0x40) != 0 + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "validLifetime") + } + validLifetime = UInt32(bigEndian: value) + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "preferredLifetime") + } + preferredLifetime = UInt32(bigEndian: value) + + // Skip reserved field + guard let (offset, _) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "reserved") + } + + var prefixBytes = [UInt8](repeating: 0, count: 16) + guard let offset = buffer.copyOut(buffer: &prefixBytes, offset: offset) else { + throw BindError.recvMarshalFailure(type: "PrefixInformation", field: "bytes[0..<16]") + } + prefix = try IPv6Address(prefixBytes) + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: prefix information rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +public struct MTUOption: Bindable { + public static let size: Int = 6 + + public var mtu: UInt32 + + public init(mtu: UInt32) { + self.mtu = mtu + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + guard let offset = buffer.copyIn(as: UInt16.self, value: 0, offset: offset) else { + throw BindError.sendMarshalFailure(type: "MTUOption", field: "reserved") + } + + guard let offset = buffer.copyIn(as: UInt32.self, value: mtu.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "MTUOption", field: "mtu") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: MTU option tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + // Skip reserved field + guard let (offset, _) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "MTUOption", field: "reserved") + } + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "MTUOption", field: "mtu") + } + mtu = UInt32(bigEndian: value) + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: MTU option rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +public struct RecursiveDNSServer: Bindable { + public static let size: Int = 6 + public var lifetime: UInt32 + + public init(lifetime: UInt32) { + self.lifetime = lifetime + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + guard let offset = buffer.copyIn(as: UInt16.self, value: 0, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RecursiveDNSServer", field: "reserved") + } + + guard let offset = buffer.copyIn(as: UInt32.self, value: lifetime.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RecursiveDNSServer", field: "lifetime") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: recursive DNS server option tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + // Skip reserved field + guard let (offset, _) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RecursiveDNSServer", field: "reserved") + } + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RecursiveDNSServer", field: "lifetime") + } + lifetime = UInt32(bigEndian: value) + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: recursive DNS server option rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +public struct IPv6AddressOptionData: Bindable { + public static let size: Int = 16 + + public var address: IPv6Address + + public init(address: IPv6Address) { + self.address = address + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let offset = buffer.copyIn(buffer: address.bytes, offset: offset) else { + throw BindError.sendMarshalFailure(type: "IPv6AddressOptionData", field: "address") + } + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: IPv6 address tx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + + var bytes = [UInt8](repeating: 0, count: 16) + guard let offset = buffer.copyOut(buffer: &bytes, offset: offset) else { + throw BindError.recvMarshalFailure(type: "IPv6AddressOptionData", field: "bytes[0..<16]") + } + address = try IPv6Address(bytes) + + let actualSize = offset - startOffset + assert(actualSize == Self.size, "BUG: IPv6 address rx length mismatch - expected \(Self.size), got \(actualSize)") + return offset + } +} + +// MARK: - NDOption Enum + +public enum NDOption: Sendable { + case sourceLinkLayerAddress(MACAddress) + case prefixInformation(PrefixInformation) + case mtu(UInt32) + case recursiveDNSServer(lifetime: UInt32, addresses: [IPv6Address]) + + /// Get the option type + public var type: NDOptionType { + switch self { + case .sourceLinkLayerAddress: return .sourceLinkLayerAddress + case .prefixInformation: return .prefixInformation + case .mtu: return .mtu + case .recursiveDNSServer: return .recursiveDNSServer + } + } + + /// Calculate length in 8-byte units (including 2-byte header) + public var lengthInUnits: UInt8 { + switch self { + case .sourceLinkLayerAddress: + return 1 // 2 (header) + 6 (MAC) = 8 bytes + case .prefixInformation: + return 4 // 2 (header) + 30 (data) = 32 bytes + case .mtu: + return 1 // 2 (header) + 6 (data) = 8 bytes + case .recursiveDNSServer(_, let addresses): + let totalBytes = 2 + 6 + (addresses.count * 16) + return UInt8(totalBytes / 8) + } + } + + /// Serialize option to buffer (header + payload) + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + var currentOffset = offset + + // Write header + let header = NDOptionHeader(type: type, lengthInUnits: lengthInUnits) + currentOffset = try header.appendBuffer(&buffer, offset: currentOffset) + + // Write payload based on type + switch self { + case .sourceLinkLayerAddress(let mac): + let payload = SourceLinkLayerAddress(address: mac) + currentOffset = try payload.appendBuffer(&buffer, offset: currentOffset) + + case .prefixInformation(let prefix): + currentOffset = try prefix.appendBuffer(&buffer, offset: currentOffset) + + case .mtu(let mtu): + let payload = MTUOption(mtu: mtu) + currentOffset = try payload.appendBuffer(&buffer, offset: currentOffset) + + case .recursiveDNSServer(let lifetime, let addresses): + let rdnss = RecursiveDNSServer(lifetime: lifetime) + currentOffset = try rdnss.appendBuffer(&buffer, offset: currentOffset) + for address in addresses { + let addrData = IPv6AddressOptionData(address: address) + currentOffset = try addrData.appendBuffer(&buffer, offset: currentOffset) + } + } + + return currentOffset + } +} + +// MARK: - Option Parsing + +extension Array where Element == UInt8 { + /// Parse all Neighbor Discovery options from this buffer + /// - Parameters: + /// - offset: Starting offset in the buffer + /// - length: Total length of options data in bytes + /// - Returns: Array of parsed NDOption values + /// - Throws: BindError if parsing fails + public mutating func parseNDOptions(offset: Int, length: Int) throws -> [NDOption] { + var options: [NDOption] = [] + var currentOffset = offset + let endOffset = offset + length + + while currentOffset < endOffset { + // Read header fields manually to handle unknown types + guard let (typeOffset, typeValue) = self.copyOut(as: UInt8.self, offset: currentOffset) else { + throw BindError.recvMarshalFailure(type: "NDOption", field: "type") + } + + guard let (lengthOffset, lengthInUnits) = self.copyOut(as: UInt8.self, offset: typeOffset) else { + throw BindError.recvMarshalFailure(type: "NDOption", field: "lengthInUnits") + } + + guard lengthInUnits > 0 else { + throw BindError.recvMarshalFailure(type: "NDOption", field: "lengthInUnits") + } + + currentOffset = lengthOffset + let payloadLength = Int(lengthInUnits) * 8 - NDOptionHeader.size + + // Check if this is a known option type + guard let optionType = NDOptionType(rawValue: typeValue) else { + // Unknown option type - skip it + print("Warning: skipping unknown ND option type \(typeValue)") + currentOffset += payloadLength + continue + } + + // Parse payload based on type + let option: NDOption + switch optionType { + case .sourceLinkLayerAddress: + var payload = SourceLinkLayerAddress(address: MACAddress(0)) + currentOffset = try payload.bindBuffer(&self, offset: currentOffset) + option = .sourceLinkLayerAddress(payload.address) + + case .prefixInformation: + var payload = PrefixInformation( + prefixLength: 0, onLinkFlag: false, autonomousFlag: false, + validLifetime: 0, preferredLifetime: 0, prefix: IPv6Address(0) + ) + currentOffset = try payload.bindBuffer(&self, offset: currentOffset) + option = .prefixInformation(payload) + + case .mtu: + var payload = MTUOption(mtu: 0) + currentOffset = try payload.bindBuffer(&self, offset: currentOffset) + option = .mtu(payload.mtu) + + case .recursiveDNSServer: + var rdnss = RecursiveDNSServer(lifetime: 0) + currentOffset = try rdnss.bindBuffer(&self, offset: currentOffset) + + // Parse remaining addresses + let remainingBytes = payloadLength - RecursiveDNSServer.size + let addressCount = remainingBytes / 16 + var addresses: [IPv6Address] = [] + for _ in 0.. Int { + let startOffset = offset + guard let offset = buffer.copyIn(as: UInt8.self, value: currentHopLimit, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterAdvertisement", field: "currentHopLimit") + } + let autoconfig = UInt8((managedFlag ? 0x80 : 0x00) | (otherFlag ? 0x40 : 0x00)) + guard let offset = buffer.copyIn(as: UInt8.self, value: autoconfig, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterAdvertisement", field: "flags") + } + guard let offset = buffer.copyIn(as: UInt16.self, value: routerLifetime.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterAdvertisement", field: "routerLifetime") + } + guard let offset = buffer.copyIn(as: UInt32.self, value: reachableTime.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterAdvertisement", field: "reachableTime") + } + guard let offset = buffer.copyIn(as: UInt32.self, value: retransTimer.bigEndian, offset: offset) else { + throw BindError.sendMarshalFailure(type: "RouterAdvertisement", field: "retransTimer") + } + + assert(offset - startOffset == Self.size, "BUG: router advertisement appendBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } + + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + guard let (offset, value) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RouterAdvertisement", field: "currentHopLimit") + } + currentHopLimit = value + + guard let (offset, autoconfig) = buffer.copyOut(as: UInt8.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RouterAdvertisement", field: "flags") + } + managedFlag = (autoconfig & 0x80) != 0 + otherFlag = (autoconfig & 0x40) != 0 + + guard let (offset, value) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RouterAdvertisement", field: "routerLifetime") + } + routerLifetime = UInt16(bigEndian: value) + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RouterAdvertisement", field: "reachableTime") + } + reachableTime = UInt32(bigEndian: value) + + guard let (offset, value) = buffer.copyOut(as: UInt32.self, offset: offset) else { + throw BindError.recvMarshalFailure(type: "RouterAdvertisement", field: "retransTimer") + } + retransTimer = UInt32(bigEndian: value) + + assert(offset - startOffset == Self.size, "BUG: router advertisement bindBuffer length mismatch - expected \(Self.size), got \(offset - startOffset)") + return offset + } +} diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 302d4507..7ac7b853 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4153,4 +4153,68 @@ extension IntegrationSuite { throw error } } + + @available(macOS 26.0, *) + func testRDNSSUpdatesResolvConf() async throws { + let id = "test-rdnss-updates-resolv-conf" + let bs = try await bootstrap(id) + + let network = try ContainerManager.VmnetNetwork() + var manager = try ContainerManager(vmm: bs.vmm, network: network) + defer { try? manager.delete(id) } + + let container = try await manager.create( + id, + image: bs.image, + rootfs: bs.rootfs + ) { config in + config.process.arguments = ["sleep", "30"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // The vmnet router sends Router Advertisements with RDNSS options. + // Poll resolv.conf until the DNSMonitor has received an RA and merged + // an IPv6 nameserver into the file (identified by a colon in the address). + var found = false + let deadline = Date.now.addingTimeInterval(15) + while Date.now < deadline { + try await Task.sleep(for: .seconds(1)) + + let buffer = BufferWriter() + let exec = try await container.exec("check-resolv") { config in + config.arguments = ["cat", "/etc/resolv.conf"] + config.stdout = buffer + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + if status.exitCode == 0, + let output = String(data: buffer.data, encoding: .utf8), + output.split(separator: "\n") + .contains(where: { $0.hasPrefix("nameserver") && $0.contains(":") }) + { + found = true + break + } + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + + guard found else { + throw IntegrationError.assert( + msg: "resolv.conf was not updated with an IPv6 nameserver from RDNSS within timeout" + ) + } + } catch { + try? await container.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 670f14b3..93c06084 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -264,6 +264,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container interface custom MTU", testInterfaceMTU), Test("container networking disabled", testNetworkingDisabled), Test("container networking enabled", testNetworkingEnabled), + Test("container RDNSS updates resolv.conf", testRDNSSUpdatesResolvConf), ] } return [] diff --git a/Tests/ContainerizationICMPTests/EchoTests.swift b/Tests/ContainerizationICMPTests/EchoTests.swift new file mode 100644 index 00000000..a281ddd5 --- /dev/null +++ b/Tests/ContainerizationICMPTests/EchoTests.swift @@ -0,0 +1,216 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerizationICMP + +// Tests based on RFC 792 (ICMPv4) and RFC 4443 (ICMPv6) Echo Request/Reply +struct EchoTests { + + // MARK: - Echo Header Tests (RFC 792, RFC 4443) + + @Test + func testEchoRoundtrip() throws { + let echo = try Echo(identifier: 0x1234, sequenceNumber: 0x5678) + + var buffer = [UInt8](repeating: 0, count: Echo.size) + let bytesWritten = try echo.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == Echo.size) + + // Verify wire format (Big Endian) + // Identifier 0x1234 -> 0x12, 0x34 + #expect(buffer[0] == 0x12) + #expect(buffer[1] == 0x34) + // Sequence 0x5678 -> 0x56, 0x78 + #expect(buffer[2] == 0x56) + #expect(buffer[3] == 0x78) + + var parsedEcho = try Echo(identifier: 0, sequenceNumber: 0) + let bytesRead = try parsedEcho.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == Echo.size) + #expect(parsedEcho.identifier == 0x1234) + #expect(parsedEcho.sequenceNumber == 0x5678) + } + + @Test + func testEchoPayloadRTTRoundtrip() throws { + let now = Date() + let payload = EchoPayloadRTT(date: now) + + var buffer = [UInt8](repeating: 0, count: EchoPayloadRTT.size) + let bytesWritten = try payload.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == EchoPayloadRTT.size) + + var parsedPayload = EchoPayloadRTT() + let bytesRead = try parsedPayload.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == EchoPayloadRTT.size) + // Date comparison might need tolerance due to Double precision in TimeInterval + #expect(abs(parsedPayload.date.timeIntervalSinceReferenceDate - now.timeIntervalSinceReferenceDate) < 0.001) + } + + @Test + func testEchoPayloadRTTCalculation() { + let past = Date(timeIntervalSinceReferenceDate: 1000) + let payload = EchoPayloadRTT(date: past) + + let future = Date(timeIntervalSinceReferenceDate: 1001.5) + let rtt = payload.rtt(atDate: future) + + #expect(rtt == 1.5) + } + + // MARK: - Process ID as Identifier (RFC 792 Common Practice) + + @Test + func testEchoWithProcessID() throws { + // RFC 792: Identifier is often set to process ID + let processID = UInt16(truncatingIfNeeded: 12345) + let echo = try Echo(identifier: processID, sequenceNumber: 1) + + var buffer = [UInt8](repeating: 0, count: Echo.size) + _ = try echo.appendBuffer(&buffer, offset: 0) + + var parsedEcho = try Echo(identifier: 0, sequenceNumber: 0) + _ = try parsedEcho.bindBuffer(&buffer, offset: 0) + + #expect(parsedEcho.identifier == processID) + #expect(parsedEcho.sequenceNumber == 1) + } + + @Test + func testEchoSequenceIncrement() throws { + // RFC 792: Sequence numbers typically increment for each echo + var echoes: [Echo] = [] + for seq in 1...5 { + let echo = try Echo(identifier: 100, sequenceNumber: UInt16(seq)) + echoes.append(echo) + } + + #expect(echoes[0].sequenceNumber == 1) + #expect(echoes[4].sequenceNumber == 5) + } + + // MARK: - Echo Payload Edge Cases + + @Test + func testEchoPayloadRTTWithFutureDate() { + // Test RTT with future date + let future = Date(timeIntervalSinceReferenceDate: 2000) + let payload = EchoPayloadRTT(date: future) + + let past = Date(timeIntervalSinceReferenceDate: 1500) + let rtt = payload.rtt(atDate: past) + + // RTT should be negative when "now" is before send time + #expect(rtt == -500.0) + } + + @Test + func testEchoPayloadRTTDefaultDate() { + // Test that default constructor uses current time + let before = Date() + let payload = EchoPayloadRTT() + let after = Date() + + // Payload date should be between before and after + #expect(payload.date >= before) + #expect(payload.date <= after) + } + + @Test + func testEchoPayloadRTTDefaultCalculation() { + // Test RTT calculation with default (current) date + let past = Date(timeIntervalSinceReferenceDate: Date().timeIntervalSinceReferenceDate - 1.0) + let payload = EchoPayloadRTT(date: past) + + let rtt = payload.rtt() // Uses Date() internally + + // RTT should be approximately 1 second (with some tolerance for execution time) + #expect(rtt >= 1.0) + #expect(rtt <= 1.1) + } + + // MARK: - Wire Format Tests + + @Test + func testEchoZeroValues() throws { + // RFC 792: Test with all zero values + let echo = try Echo(identifier: 0, sequenceNumber: 0) + + var buffer = [UInt8](repeating: 0, count: Echo.size) + let bytesWritten = try echo.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == Echo.size) + #expect(buffer[0] == 0) + #expect(buffer[1] == 0) + #expect(buffer[2] == 0) + #expect(buffer[3] == 0) + } + + @Test + func testEchoMaxValues() throws { + // RFC 792: Test with maximum values + let echo = try Echo(identifier: 0xFFFF, sequenceNumber: 0xFFFF) + + var buffer = [UInt8](repeating: 0, count: Echo.size) + _ = try echo.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 0xFF) + #expect(buffer[1] == 0xFF) + #expect(buffer[2] == 0xFF) + #expect(buffer[3] == 0xFF) + + var parsedEcho = try Echo(identifier: 0, sequenceNumber: 0) + _ = try parsedEcho.bindBuffer(&buffer, offset: 0) + + #expect(parsedEcho.identifier == 0xFFFF) + #expect(parsedEcho.sequenceNumber == 0xFFFF) + } + + @Test + func testEchoPayloadSize() { + // RFC 792: Verify payload is standard 56 bytes + #expect(EchoPayloadRTT.size == 56) + } + + @Test + func testEchoHeaderSize() { + // RFC 792/4443: Echo header is 4 bytes (identifier + sequence) + #expect(Echo.size == 4) + } + + @Test + func testEchoPayloadRTTPrecision() throws { + // Test that timestamp preserves microsecond precision + let preciseTime = Date(timeIntervalSinceReferenceDate: 1234567.123456) + let payload = EchoPayloadRTT(date: preciseTime) + + var buffer = [UInt8](repeating: 0, count: EchoPayloadRTT.size) + _ = try payload.appendBuffer(&buffer, offset: 0) + + var parsedPayload = EchoPayloadRTT() + _ = try parsedPayload.bindBuffer(&buffer, offset: 0) + + // Should preserve precision within Double's limits + #expect(abs(parsedPayload.date.timeIntervalSinceReferenceDate - preciseTime.timeIntervalSinceReferenceDate) < 0.000001) + } +} diff --git a/Tests/ContainerizationICMPTests/ICMPMessageTests.swift b/Tests/ContainerizationICMPTests/ICMPMessageTests.swift new file mode 100644 index 00000000..014ce307 --- /dev/null +++ b/Tests/ContainerizationICMPTests/ICMPMessageTests.swift @@ -0,0 +1,265 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerizationICMP + +// Tests based on RFC 792 (ICMPv4) and RFC 4443 (ICMPv6) +struct ICMPMessageTests { + + // MARK: - ICMPv4 Tests (RFC 792) + + @Test + func testICMPv4HeaderRoundtrip() throws { + let header = ICMPv4Header(type: .echoRequest, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv4Header.size) + let bytesWritten = try header.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == ICMPv4Header.size) + + // Verify wire format + // Type: 8 (Echo Request), Code: 0, Checksum: 0 (placeholder) + #expect(buffer[0] == 8) + #expect(buffer[1] == 0) + #expect(buffer[2] == 0) + #expect(buffer[3] == 0) + + var parsedHeader = ICMPv4Header() + let bytesRead = try parsedHeader.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == ICMPv4Header.size) + #expect(parsedHeader.type == .echoRequest) + #expect(parsedHeader.code == 0) + } + + @Test + func testICMPv4Checksum() { + // Example buffer: [Type, Code, Checksum, Checksum, ID, ID, Seq, Seq] + // 8, 0, 0, 0, 1, 2, 3, 4 + // Words: 0x0800, 0x0000, 0x0102, 0x0304 + // Sum: 0x0800 + 0x0000 + 0x0102 + 0x0304 = 0x0C06 + // One's complement: ~0x0C06 = 0xF3F9 + + let buffer: [UInt8] = [8, 0, 0, 0, 1, 2, 3, 4] + let checksum = ICMPv4Header.checksum(buffer: buffer, offset: 0, length: buffer.count) + + #expect(checksum == 0xF3F9) + } + + @Test + func testICMPv4Match() { + let header = ICMPv4Header(type: .destinationUnreachable, code: 3) + + #expect(header.matches(type: .destinationUnreachable, code: 3)) + #expect(!header.matches(type: .destinationUnreachable, code: 1)) + #expect(!header.matches(type: .echoReply, code: 3)) + } + + // MARK: - IPv6 Tests + + @Test + func testICMPv6HeaderRoundtrip() throws { + let header = ICMPv6Header(type: .echoRequest, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + let bytesWritten = try header.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == ICMPv6Header.size) + + // Verify wire format + // Type: 128 (Echo Request), Code: 0, Checksum: 0 (placeholder) + #expect(buffer[0] == 128) + #expect(buffer[1] == 0) + #expect(buffer[2] == 0) + #expect(buffer[3] == 0) + + var parsedHeader = ICMPv6Header() + let bytesRead = try parsedHeader.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == ICMPv6Header.size) + #expect(parsedHeader.type == .echoRequest) + #expect(parsedHeader.code == 0) + } + + @Test + func testICMPv6Match() { + let header = ICMPv6Header(type: .neighborAdvertisement, code: 0) + + #expect(header.matches(type: .neighborAdvertisement, code: 0)) + #expect(!header.matches(type: .neighborSolicitation, code: 0)) + } + + // MARK: - Additional ICMPv4 Message Types (RFC 792) + + @Test + func testICMPv4EchoReply() throws { + // RFC 792: Type 0 - Echo Reply + let header = ICMPv4Header(type: .echoReply, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv4Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 0) + #expect(buffer[1] == 0) + } + + @Test + func testICMPv4DestinationUnreachable() throws { + // RFC 792: Type 3 - Destination Unreachable, Code 3 - Port Unreachable + let header = ICMPv4Header(type: .destinationUnreachable, code: 3) + + var buffer = [UInt8](repeating: 0, count: ICMPv4Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 3) + #expect(buffer[1] == 3) + + var parsedHeader = ICMPv4Header() + _ = try parsedHeader.bindBuffer(&buffer, offset: 0) + + #expect(parsedHeader.type == .destinationUnreachable) + #expect(parsedHeader.code == 3) + } + + @Test + func testICMPv4TimeExceeded() throws { + // RFC 792: Type 11 - Time Exceeded, Code 0 - TTL exceeded in transit + let header = ICMPv4Header(type: .timeExceeded, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv4Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 11) + #expect(buffer[1] == 0) + } + + @Test + func testICMPv4Redirect() throws { + // RFC 792: Type 5 - Redirect, Code 1 - Redirect for Host + let header = ICMPv4Header(type: .redirect, code: 1) + + var buffer = [UInt8](repeating: 0, count: ICMPv4Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 5) + #expect(buffer[1] == 1) + } + + // MARK: - Additional ICMPv6 Message Types (RFC 4443) + + @Test + func testICMPv6EchoReply() throws { + // RFC 4443: Type 129 - Echo Reply + let header = ICMPv6Header(type: .echoReply, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 129) + #expect(buffer[1] == 0) + } + + @Test + func testICMPv6RouterSolicitation() throws { + // RFC 4861: Type 133 - Router Solicitation + let header = ICMPv6Header(type: .routerSolicitation, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 133) + #expect(buffer[1] == 0) + + var parsedHeader = ICMPv6Header() + _ = try parsedHeader.bindBuffer(&buffer, offset: 0) + + #expect(parsedHeader.type == .routerSolicitation) + #expect(parsedHeader.code == 0) + } + + @Test + func testICMPv6RouterAdvertisement() throws { + // RFC 4861: Type 134 - Router Advertisement + let header = ICMPv6Header(type: .routerAdvertisement, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 134) + #expect(buffer[1] == 0) + } + + @Test + func testICMPv6NeighborSolicitation() throws { + // RFC 4861: Type 135 - Neighbor Solicitation + let header = ICMPv6Header(type: .neighborSolicitation, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 135) + #expect(buffer[1] == 0) + } + + @Test + func testICMPv6NeighborAdvertisement() throws { + // RFC 4861: Type 136 - Neighbor Advertisement + let header = ICMPv6Header(type: .neighborAdvertisement, code: 0) + + var buffer = [UInt8](repeating: 0, count: ICMPv6Header.size) + _ = try header.appendBuffer(&buffer, offset: 0) + + #expect(buffer[0] == 136) + #expect(buffer[1] == 0) + } + + // MARK: - Checksum Edge Cases + + @Test + func testICMPv4ChecksumAllZeros() { + // RFC 792: Checksum of all zeros + let buffer: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0] + let checksum = ICMPv4Header.checksum(buffer: buffer, offset: 0, length: buffer.count) + + #expect(checksum == 0xFFFF) + } + + @Test + func testICMPv4ChecksumOddLength() { + // RFC 792: Checksum with odd length (last byte padded) + // Buffer: [1, 2, 3] -> words: 0x0102, 0x0300 + // Sum: 0x0102 + 0x0300 = 0x0402 + // Complement: ~0x0402 = 0xFBFD + let buffer: [UInt8] = [1, 2, 3] + let checksum = ICMPv4Header.checksum(buffer: buffer, offset: 0, length: buffer.count) + + #expect(checksum == 0xFBFD) + } + + @Test + func testICMPv4ChecksumWithCarry() { + // RFC 792: Test carry during checksum calculation + // Words that will generate carries + let buffer: [UInt8] = [0xFF, 0xFF, 0x00, 0x01] + let checksum = ICMPv4Header.checksum(buffer: buffer, offset: 0, length: buffer.count) + + // 0xFFFF + 0x0001 = 0x10000 -> fold to 0x0000 + 0x0001 = 0x0001 + // Complement: ~0x0001 = 0xFFFE + #expect(checksum == 0xFFFE) + } +} diff --git a/Tests/ContainerizationICMPTests/NDOptionTests.swift b/Tests/ContainerizationICMPTests/NDOptionTests.swift new file mode 100644 index 00000000..2184857b --- /dev/null +++ b/Tests/ContainerizationICMPTests/NDOptionTests.swift @@ -0,0 +1,406 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerizationICMP + +// Tests based on RFC 4861 Section 4.6 - Neighbor Discovery Options +struct NDOptionTests { + + // MARK: - Option Header Tests (RFC 4861 Section 4.6) + + @Test + func testNDOptionHeaderRoundtrip() throws { + // RFC 4861: All options have Type and Length fields + let header = NDOptionHeader(type: .sourceLinkLayerAddress, lengthInUnits: 1) + + var buffer = [UInt8](repeating: 0, count: NDOptionHeader.size) + let bytesWritten = try header.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == NDOptionHeader.size) + #expect(NDOptionHeader.size == 2) + + // Verify wire format + // Type: 1 (Source Link-layer Address) + #expect(buffer[0] == 1) + // Length: 1 (in units of 8 octets) + #expect(buffer[1] == 1) + + var parsedHeader = NDOptionHeader(type: .sourceLinkLayerAddress, lengthInUnits: 0) + let bytesRead = try parsedHeader.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == NDOptionHeader.size) + #expect(parsedHeader.type == .sourceLinkLayerAddress) + #expect(parsedHeader.lengthInUnits == 1) + } + + // MARK: - Source Link-Layer Address Option (RFC 4861 Section 4.6.1) + + @Test + func testSourceLinkLayerAddressRoundtrip() throws { + // RFC 4861 Section 4.6.1: Ethernet address is 6 octets + let macAddress = try MACAddress([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]) + let option = SourceLinkLayerAddress(address: macAddress) + + var buffer = [UInt8](repeating: 0, count: SourceLinkLayerAddress.size) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == SourceLinkLayerAddress.size) + #expect(SourceLinkLayerAddress.size == 6) + + // Verify wire format - MAC address bytes + #expect(buffer[0] == 0x00) + #expect(buffer[1] == 0x11) + #expect(buffer[2] == 0x22) + #expect(buffer[3] == 0x33) + #expect(buffer[4] == 0x44) + #expect(buffer[5] == 0x55) + + var parsedOption = SourceLinkLayerAddress(address: MACAddress(0)) + let bytesRead = try parsedOption.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == SourceLinkLayerAddress.size) + #expect(parsedOption.address.bytes == macAddress.bytes) + } + + @Test + func testSourceLinkLayerAddressOptionEnum() throws { + // RFC 4861: Complete option including header + let macAddress = try MACAddress([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + let option = NDOption.sourceLinkLayerAddress(macAddress) + + #expect(option.type == .sourceLinkLayerAddress) + #expect(option.lengthInUnits == 1) // 8 bytes total: 2 (header) + 6 (MAC) + + var buffer = [UInt8](repeating: 0, count: 8) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == 8) + + // Verify complete option format + #expect(buffer[0] == 1) // Type + #expect(buffer[1] == 1) // Length + #expect(buffer[2] == 0xAA) // MAC byte 0 + #expect(buffer[3] == 0xBB) // MAC byte 1 + #expect(buffer[4] == 0xCC) // MAC byte 2 + #expect(buffer[5] == 0xDD) // MAC byte 3 + #expect(buffer[6] == 0xEE) // MAC byte 4 + #expect(buffer[7] == 0xFF) // MAC byte 5 + } + + // MARK: - Prefix Information Option (RFC 4861 Section 4.6.2) + + @Test + func testPrefixInformationRoundtrip() throws { + // RFC 4861 Section 4.6.2: Prefix Information for SLAAC + let prefix = try IPv6Address([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + let prefixInfo = PrefixInformation( + prefixLength: 64, + onLinkFlag: true, + autonomousFlag: true, + validLifetime: 2_592_000, // 30 days + preferredLifetime: 604800, // 7 days + prefix: prefix + ) + + var buffer = [UInt8](repeating: 0, count: PrefixInformation.size) + let bytesWritten = try prefixInfo.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == PrefixInformation.size) + #expect(PrefixInformation.size == 30) + + // Verify wire format per RFC 4861 Section 4.6.2 + // Byte 0: Prefix Length + #expect(buffer[0] == 64) + + // Byte 1: Flags (L=1, A=1 -> 0xC0) + #expect(buffer[1] == 0xC0) + + // Bytes 2-5: Valid Lifetime (2592000 = 0x00278D00) + #expect(buffer[2] == 0x00) + #expect(buffer[3] == 0x27) + #expect(buffer[4] == 0x8D) + #expect(buffer[5] == 0x00) + + // Bytes 6-9: Preferred Lifetime (604800 = 0x00093A80) + #expect(buffer[6] == 0x00) + #expect(buffer[7] == 0x09) + #expect(buffer[8] == 0x3A) + #expect(buffer[9] == 0x80) + + // Bytes 10-13: Reserved2 (must be zero) + #expect(buffer[10] == 0x00) + #expect(buffer[11] == 0x00) + #expect(buffer[12] == 0x00) + #expect(buffer[13] == 0x00) + + // Bytes 14-29: Prefix (2001:db8::) + #expect(buffer[14] == 0x20) + #expect(buffer[15] == 0x01) + #expect(buffer[16] == 0x0d) + #expect(buffer[17] == 0xb8) + + var parsedInfo = PrefixInformation( + prefixLength: 0, onLinkFlag: false, autonomousFlag: false, + validLifetime: 0, preferredLifetime: 0, prefix: IPv6Address(0) + ) + let bytesRead = try parsedInfo.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == PrefixInformation.size) + #expect(parsedInfo.prefixLength == 64) + #expect(parsedInfo.onLinkFlag == true) + #expect(parsedInfo.autonomousFlag == true) + #expect(parsedInfo.validLifetime == 2_592_000) + #expect(parsedInfo.preferredLifetime == 604800) + #expect(parsedInfo.prefix.bytes == prefix.bytes) + } + + @Test + func testPrefixInformationOnLinkOnly() throws { + // RFC 4861: On-link prefix without SLAAC (L=1, A=0) + let prefix = try IPv6Address([ + 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + let prefixInfo = PrefixInformation( + prefixLength: 64, + onLinkFlag: true, + autonomousFlag: false, + validLifetime: 0xFFFF_FFFF, + preferredLifetime: 0xFFFF_FFFF, + prefix: prefix + ) + + var buffer = [UInt8](repeating: 0, count: PrefixInformation.size) + _ = try prefixInfo.appendBuffer(&buffer, offset: 0) + + // L=1, A=0 -> 0x80 + #expect(buffer[1] == 0x80) + + var parsedInfo = PrefixInformation( + prefixLength: 0, onLinkFlag: false, autonomousFlag: false, + validLifetime: 0, preferredLifetime: 0, prefix: IPv6Address(0) + ) + _ = try parsedInfo.bindBuffer(&buffer, offset: 0) + + #expect(parsedInfo.onLinkFlag == true) + #expect(parsedInfo.autonomousFlag == false) + } + + @Test + func testPrefixInformationOptionEnum() throws { + // RFC 4861: Complete prefix information option + let prefix = try IPv6Address([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]) + let prefixInfo = PrefixInformation( + prefixLength: 64, + onLinkFlag: true, + autonomousFlag: true, + validLifetime: 86400, + preferredLifetime: 43200, + prefix: prefix + ) + let option = NDOption.prefixInformation(prefixInfo) + + #expect(option.type == .prefixInformation) + #expect(option.lengthInUnits == 4) // 32 bytes: 2 (header) + 30 (data) + + var buffer = [UInt8](repeating: 0, count: 32) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == 32) + #expect(buffer[0] == 3) // Type: Prefix Information + #expect(buffer[1] == 4) // Length: 4 units + } + + // MARK: - MTU Option (RFC 4861 Section 4.6.4) + + @Test + func testMTUOptionRoundtrip() throws { + // RFC 4861 Section 4.6.4: MTU option for link MTU + let mtuOption = MTUOption(mtu: 1500) + + var buffer = [UInt8](repeating: 0, count: MTUOption.size) + let bytesWritten = try mtuOption.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == MTUOption.size) + #expect(MTUOption.size == 6) + + // Verify wire format + // Bytes 0-1: Reserved (must be zero) + #expect(buffer[0] == 0x00) + #expect(buffer[1] == 0x00) + + // Bytes 2-5: MTU (1500 = 0x000005DC) + #expect(buffer[2] == 0x00) + #expect(buffer[3] == 0x00) + #expect(buffer[4] == 0x05) + #expect(buffer[5] == 0xDC) + + var parsedOption = MTUOption(mtu: 0) + let bytesRead = try parsedOption.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == MTUOption.size) + #expect(parsedOption.mtu == 1500) + } + + @Test + func testMTUOptionEnum() throws { + // RFC 4861: Complete MTU option + let option = NDOption.mtu(9000) // Jumbo frames + + #expect(option.type == .mtu) + #expect(option.lengthInUnits == 1) // 8 bytes: 2 (header) + 6 (data) + + var buffer = [UInt8](repeating: 0, count: 8) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == 8) + #expect(buffer[0] == 5) // Type: MTU + #expect(buffer[1] == 1) // Length: 1 unit + + // MTU value 9000 = 0x00002328 + #expect(buffer[4] == 0x00) + #expect(buffer[5] == 0x00) + #expect(buffer[6] == 0x23) + #expect(buffer[7] == 0x28) + } + + // MARK: - Recursive DNS Server Option (RFC 8106 Section 5.1) + + @Test + func testRecursiveDNSServerRoundtrip() throws { + // RFC 8106: RDNSS option header (without addresses) + let rdnss = RecursiveDNSServer(lifetime: 3600) + + var buffer = [UInt8](repeating: 0, count: RecursiveDNSServer.size) + let bytesWritten = try rdnss.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == RecursiveDNSServer.size) + #expect(RecursiveDNSServer.size == 6) + + // Verify wire format + // Bytes 0-1: Reserved (must be zero) + #expect(buffer[0] == 0x00) + #expect(buffer[1] == 0x00) + + // Bytes 2-5: Lifetime (3600 = 0x00000E10) + #expect(buffer[2] == 0x00) + #expect(buffer[3] == 0x00) + #expect(buffer[4] == 0x0E) + #expect(buffer[5] == 0x10) + + var parsedRDNSS = RecursiveDNSServer(lifetime: 0) + let bytesRead = try parsedRDNSS.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == RecursiveDNSServer.size) + #expect(parsedRDNSS.lifetime == 3600) + } + + @Test + func testRecursiveDNSServerOptionWithSingleAddress() throws { + // RFC 8106: RDNSS with one DNS server + let dnsServer = try IPv6Address([ + 0x20, 0x01, 0x48, 0x60, 0x48, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, + ]) + let option = NDOption.recursiveDNSServer(lifetime: 7200, addresses: [dnsServer]) + + #expect(option.type == .recursiveDNSServer) + // 24 bytes total: 2 (header) + 6 (RDNSS) + 16 (1 address) = 24 bytes = 3 units + #expect(option.lengthInUnits == 3) + + var buffer = [UInt8](repeating: 0, count: 24) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == 24) + #expect(buffer[0] == 25) // Type: RDNSS + #expect(buffer[1] == 3) // Length: 3 units + + // Lifetime 7200 = 0x00001C20 + #expect(buffer[4] == 0x00) + #expect(buffer[5] == 0x00) + #expect(buffer[6] == 0x1C) + #expect(buffer[7] == 0x20) + + // First address starts at byte 8 + #expect(buffer[8] == 0x20) + #expect(buffer[9] == 0x01) + #expect(buffer[10] == 0x48) + #expect(buffer[11] == 0x60) + } + + @Test + func testRecursiveDNSServerOptionWithMultipleAddresses() throws { + // RFC 8106: RDNSS with two DNS servers + let dns1 = try IPv6Address([ + 0x20, 0x01, 0x48, 0x60, 0x48, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, + ]) + let dns2 = try IPv6Address([ + 0x20, 0x01, 0x48, 0x60, 0x48, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x44, + ]) + let option = NDOption.recursiveDNSServer(lifetime: 3600, addresses: [dns1, dns2]) + + // 40 bytes total: 2 (header) + 6 (RDNSS) + 32 (2 addresses) = 40 bytes = 5 units + #expect(option.lengthInUnits == 5) + + var buffer = [UInt8](repeating: 0, count: 40) + let bytesWritten = try option.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == 40) + #expect(buffer[0] == 25) // Type: RDNSS + #expect(buffer[1] == 5) // Length: 5 units + } + + // MARK: - IPv6 Address Option Data + + @Test + func testIPv6AddressOptionDataRoundtrip() throws { + let address = try IPv6Address([ + 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x11, 0x22, 0xff, 0xfe, 0x33, 0x44, 0x55, + ]) + let addrData = IPv6AddressOptionData(address: address) + + var buffer = [UInt8](repeating: 0, count: IPv6AddressOptionData.size) + let bytesWritten = try addrData.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == IPv6AddressOptionData.size) + #expect(IPv6AddressOptionData.size == 16) + + // Verify all 16 bytes + #expect(buffer[0] == 0xfe) + #expect(buffer[1] == 0x80) + #expect(buffer[8] == 0x02) + #expect(buffer[15] == 0x55) + + var parsedAddr = IPv6AddressOptionData(address: IPv6Address(0)) + let bytesRead = try parsedAddr.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == IPv6AddressOptionData.size) + #expect(parsedAddr.address.bytes == address.bytes) + } +} diff --git a/Tests/ContainerizationICMPTests/RouterAdvertisementTests.swift b/Tests/ContainerizationICMPTests/RouterAdvertisementTests.swift new file mode 100644 index 00000000..9695c94c --- /dev/null +++ b/Tests/ContainerizationICMPTests/RouterAdvertisementTests.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerizationICMP + +// Tests based on RFC 4861 Section 4.2 - Router Advertisement Message Format +struct RouterAdvertisementTests { + + @Test + func testRouterAdvertisementRoundtrip() throws { + // RFC 4861: Router Advertisement with typical values + let ra = try RouterAdvertisement( + currentHopLimit: 64, + managedFlag: false, + otherFlag: true, + routerLifetime: 1800, + reachableTime: 30000, + retransTimer: 1000 + ) + + var buffer = [UInt8](repeating: 0, count: RouterAdvertisement.size) + let bytesWritten = try ra.appendBuffer(&buffer, offset: 0) + + #expect(bytesWritten == RouterAdvertisement.size) + #expect(RouterAdvertisement.size == 12) + + // Verify wire format per RFC 4861 Section 4.2 + // Byte 0: Cur Hop Limit + #expect(buffer[0] == 64) + + // Byte 1: M (bit 7) and O (bit 6) flags + // M=0, O=1 -> 0x40 + #expect(buffer[1] == 0x40) + + // Bytes 2-3: Router Lifetime (1800 = 0x0708 in network byte order) + #expect(buffer[2] == 0x07) + #expect(buffer[3] == 0x08) + + // Bytes 4-7: Reachable Time (30000 = 0x00007530 in network byte order) + #expect(buffer[4] == 0x00) + #expect(buffer[5] == 0x00) + #expect(buffer[6] == 0x75) + #expect(buffer[7] == 0x30) + + // Bytes 8-11: Retrans Timer (1000 = 0x000003E8 in network byte order) + #expect(buffer[8] == 0x00) + #expect(buffer[9] == 0x00) + #expect(buffer[10] == 0x03) + #expect(buffer[11] == 0xE8) + + var parsedRA = try RouterAdvertisement() + let bytesRead = try parsedRA.bindBuffer(&buffer, offset: 0) + + #expect(bytesRead == RouterAdvertisement.size) + #expect(parsedRA.currentHopLimit == 64) + #expect(parsedRA.managedFlag == false) + #expect(parsedRA.otherFlag == true) + #expect(parsedRA.routerLifetime == 1800) + #expect(parsedRA.reachableTime == 30000) + #expect(parsedRA.retransTimer == 1000) + } + + @Test + func testRouterAdvertisementWithManagedFlag() throws { + // RFC 4861: M flag indicates addresses available via DHCPv6 + let ra = try RouterAdvertisement( + currentHopLimit: 64, + managedFlag: true, + otherFlag: false, + routerLifetime: 9000, + reachableTime: 0, + retransTimer: 0 + ) + + var buffer = [UInt8](repeating: 0, count: RouterAdvertisement.size) + _ = try ra.appendBuffer(&buffer, offset: 0) + + // M=1, O=0 -> 0x80 + #expect(buffer[1] == 0x80) + + var parsedRA = try RouterAdvertisement() + _ = try parsedRA.bindBuffer(&buffer, offset: 0) + + #expect(parsedRA.managedFlag == true) + #expect(parsedRA.otherFlag == false) + } + + @Test + func testRouterAdvertisementWithBothFlags() throws { + // RFC 4861: Both M and O flags set + let ra = try RouterAdvertisement( + currentHopLimit: 255, + managedFlag: true, + otherFlag: true, + routerLifetime: 0xFFFF, + reachableTime: 0xFFFF_FFFF, + retransTimer: 0xFFFF_FFFF + ) + + var buffer = [UInt8](repeating: 0, count: RouterAdvertisement.size) + _ = try ra.appendBuffer(&buffer, offset: 0) + + // M=1, O=1 -> 0xC0 + #expect(buffer[1] == 0xC0) + + var parsedRA = try RouterAdvertisement() + _ = try parsedRA.bindBuffer(&buffer, offset: 0) + + #expect(parsedRA.currentHopLimit == 255) + #expect(parsedRA.managedFlag == true) + #expect(parsedRA.otherFlag == true) + #expect(parsedRA.routerLifetime == 0xFFFF) + #expect(parsedRA.reachableTime == 0xFFFF_FFFF) + #expect(parsedRA.retransTimer == 0xFFFF_FFFF) + } + + @Test + func testRouterAdvertisementZeroLifetime() throws { + // RFC 4861 Section 6.2.5: Router Lifetime 0 means not a default router + let ra = try RouterAdvertisement( + currentHopLimit: 64, + managedFlag: false, + otherFlag: false, + routerLifetime: 0, + reachableTime: 0, + retransTimer: 0 + ) + + var buffer = [UInt8](repeating: 0, count: RouterAdvertisement.size) + _ = try ra.appendBuffer(&buffer, offset: 0) + + var parsedRA = try RouterAdvertisement() + _ = try parsedRA.bindBuffer(&buffer, offset: 0) + + #expect(parsedRA.routerLifetime == 0) + } +} diff --git a/vminitd/Package.resolved b/vminitd/Package.resolved index 9f15cbf5..42295ebc 100644 --- a/vminitd/Package.resolved +++ b/vminitd/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "c464bf94eac4273cad7424307a5dc7e44e361905", - "version" : "1.30.1" + "revision" : "4b99975677236d13f0754339864e5360142ff5a1", + "version" : "1.30.3" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", - "version" : "1.3.2" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", - "version" : "1.15.0" + "revision" : "7d5f6124c91a2d06fb63a811695a3400d15a100e", + "version" : "1.17.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", - "version" : "3.12.3" + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "db6eea3692638a65e2124990155cd220c2915903", - "version" : "1.3.0" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", - "version" : "2.87.0" + "revision" : "5e72fc102906ebe75a3487595a653e6f43725552", + "version" : "2.94.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "145db1962f4f33a4ea07a32e751d5217602eea29", - "version" : "1.28.0" + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { @@ -222,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" } }, { @@ -246,4 +246,4 @@ } ], "version" : 3 -} +} \ No newline at end of file diff --git a/vminitd/Package.swift b/vminitd/Package.swift index fe4b43db..1dc221c9 100644 --- a/vminitd/Package.swift +++ b/vminitd/Package.swift @@ -56,8 +56,10 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), - .product(name: "ContainerizationNetlink", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationICMP", package: "containerization"), .product(name: "ContainerizationIO", package: "containerization"), + .product(name: "ContainerizationNetlink", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "SystemPackage", package: "swift-system"), .product(name: "GRPCCore", package: "grpc-swift-2"), diff --git a/vminitd/Sources/vminitd/AgentCommand.swift b/vminitd/Sources/vminitd/AgentCommand.swift index 008deafa..3e23d83f 100644 --- a/vminitd/Sources/vminitd/AgentCommand.swift +++ b/vminitd/Sources/vminitd/AgentCommand.swift @@ -141,7 +141,8 @@ struct AgentCommand: AsyncParsableCommand { let eg = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) let blockingPool = NIOThreadPool(numberOfThreads: System.coreCount) blockingPool.start() - let server = Initd(log: log, group: eg, blockingPool: blockingPool) + let dnsMonitor = try DNSMonitor(log: log) + let server = Initd(log: log, group: eg, blockingPool: blockingPool, dnsMonitor: dnsMonitor) do { log.info("serving vminitd API") diff --git a/vminitd/Sources/vminitd/DNSMonitor.swift b/vminitd/Sources/vminitd/DNSMonitor.swift new file mode 100644 index 00000000..c6798ba8 --- /dev/null +++ b/vminitd/Sources/vminitd/DNSMonitor.swift @@ -0,0 +1,176 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationExtras +import ContainerizationICMP +import Foundation +import Logging +import SystemPackage + +private struct IPv6Nameserver { + let address: IPv6Address + let expiry: Date +} + +actor DNSMonitor { + private static let maxNameservers = 3 + + private var configs: [FilePath: DNS] = [:] + + private var ipv6Nameservers: [IPv6Nameserver] = [] + + private let log: Logger + + private let icmpV6Session: ICMPv6Session + + init(log: Logger) throws { + self.log = log + self.icmpV6Session = try ICMPv6Session() + } + + func update(resolvConfPath: FilePath, config: DNS) throws { + let parentPathname = resolvConfPath.removingLastComponent().string + try FileManager.default.createDirectory(atPath: parentPathname, withIntermediateDirectories: true) + + let mergedNameservers: [String] + if config.nameservers.count < Self.maxNameservers { + mergedNameservers = config.nameservers + ipv6Nameservers.map { $0.address.description } + } else { + mergedNameservers = config.nameservers.prefix(2) + ipv6Nameservers.map { $0.address.description } + } + + let mergedConfig = DNS( + nameservers: mergedNameservers, + domain: config.domain, + searchDomains: config.searchDomains, + options: config.options + ) + + let text = mergedConfig.resolvConf + log.debug("updating resolver configuration", metadata: ["path": "\(resolvConfPath)"]) + try text.write(toFile: resolvConfPath.string, atomically: true, encoding: .utf8) + configs[resolvConfPath] = config + } + + func run() async throws { + self.log.info("starting DNS monitor") + while true { + let now = Date.now + let timeInterval = + ipv6Nameservers + .map { $0.expiry.timeIntervalSince(now) } + .compactMap { $0 >= 0 ? $0 : nil } + .min() + do { + if timeInterval == nil { + self.log.info("sending router solicitation") + try sendRouterSolicitation() + } + } catch { + log.warning("router solicitation send failed", metadata: ["error": "\(error)"]) + try await Task.sleep(for: .seconds(1)) + continue + } + + do { + let timeout = Duration.seconds(timeInterval ?? 1.0) + log.info("awaiting router advertisement", metadata: ["timeoutSecs": "\(timeout)"]) + var lifetimesByAddress = try await getIpv6Nameservers(timeout: timeout) + var newNameservers: [IPv6Nameserver] = [] + let now = Date.now + for nameserver in ipv6Nameservers { + guard let lifetime = lifetimesByAddress[nameserver.address] else { + // No update, carry it over. + newNameservers.append(nameserver) + continue + } + + // Remove since we're deleting or merging. + lifetimesByAddress.removeValue(forKey: nameserver.address) + if lifetime == 0 { + // Zero lifetime, so delete. + continue + } + + // Merge new expiry into existing entry. + newNameservers.append(.init(address: nameserver.address, expiry: now.addingTimeInterval(Double(lifetime)))) + } + + // Add remaining entries. + for (address, lifetime) in lifetimesByAddress { + newNameservers.append(.init(address: address, expiry: now.addingTimeInterval(Double(lifetime)))) + } + + self.ipv6Nameservers = newNameservers + } catch { + log.warning("router advertisement receive failed", metadata: ["error": "\(error)"]) + } + + do { + for (resolvConfPath, dns) in configs { + log.info("awaiting DNS", metadata: ["path": "\(resolvConfPath)"]) + try update(resolvConfPath: resolvConfPath, config: dns) + } + } catch { + log.warning("DNS update failed", metadata: ["error": "\(error)"]) + } + } + } + + private func sendRouterSolicitation() throws { + let interface = "eth0" + guard let linkLayerAddress = MACAddress.fromZone(interface) else { + throw AddressError.invalidZoneIdentifier + } + _ = try icmpV6Session.routerSolicitation(linkLayerAddress: linkLayerAddress, interface: interface) + } + + private func getIpv6Nameservers(timeout: Duration) async throws -> [IPv6Address: UInt32] { + var result = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + let result = try self.icmpV6Session.recv( + type: .routerAdvertisement, + timeout: timeout + ) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + + // Parse router advertisement. + var routerAdvertisement = try RouterAdvertisement() + let offset = try routerAdvertisement.bindBuffer(&result.bytes, offset: result.offset) + + // Parse options, adding RDNSS server addresses and lifetimes. + let remainingBytes = result.length - offset + let options = try result.bytes.parseNDOptions(offset: offset, length: remainingBytes) + var lifetimesByAddress: [IPv6Address: UInt32] = [:] + for option in options { + switch option { + case .recursiveDNSServer(let lifetime, let addresses): + addresses.forEach { lifetimesByAddress[$0] = lifetime } + default: + continue + } + } + + return lifetimesByAddress + } +} diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index 3936a517..ec0bc2ca 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -29,6 +29,7 @@ import Logging import NIOCore import NIOPosix import SwiftProtobuf +import SystemPackage private let _setenv = Foundation.setenv @@ -1224,19 +1225,16 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ ]) do { - let etc = URL(fileURLWithPath: request.location).appendingPathComponent("etc") - try FileManager.default.createDirectory(atPath: etc.path, withIntermediateDirectories: true) - let resolvConf = etc.appendingPathComponent("resolv.conf") + let resolvConfPath = FilePath(request.location) + .appending("etc") + .appending("resolv.conf") let config = DNS( nameservers: request.nameservers, domain: domain, searchDomains: request.searchDomains, options: request.options ) - let text = config.resolvConf - log.debug("writing to path \(resolvConf.path) \(text)") - try text.write(toFile: resolvConf.path, atomically: true, encoding: .utf8) - log.debug("wrote resolver configuration", metadata: ["path": "\(resolvConf.path)"]) + try await dnsMonitor.update(resolvConfPath: resolvConfPath, config: config) } catch { log.error( "configureDns", diff --git a/vminitd/Sources/vminitd/Server.swift b/vminitd/Sources/vminitd/Server.swift index 58bafe27..df424582 100644 --- a/vminitd/Sources/vminitd/Server.swift +++ b/vminitd/Sources/vminitd/Server.swift @@ -79,14 +79,16 @@ final class Initd: Sendable { } let log: Logger - let state: State let group: EventLoopGroup let blockingPool: NIOThreadPool + let dnsMonitor: DNSMonitor + let state: State - init(log: Logger, group: EventLoopGroup, blockingPool: NIOThreadPool) { + init(log: Logger, group: EventLoopGroup, blockingPool: NIOThreadPool, dnsMonitor: DNSMonitor) { self.log = log self.group = group self.blockingPool = blockingPool + self.dnsMonitor = dnsMonitor self.state = State() } @@ -117,6 +119,9 @@ final class Initd: Sendable { "port": "\(port)" ]) + group.addTask { + try await self.dnsMonitor.run() + } group.addTask { try await server.serve() } try await group.next() From 81f288516f5e9967d95112025c02a0561cb7bd3e Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 13 Mar 2026 20:02:46 -0700 Subject: [PATCH 2/3] Start/stop RDNSS monitor. --- .../Containerization/ContainerManager.swift | 2 +- .../Containerization/DNSConfiguration.swift | 7 +- Sources/Containerization/LinuxContainer.swift | 12 +++ Sources/Containerization/LinuxPod.swift | 12 +++ .../SandboxContext/SandboxContext.pb.swift | 9 ++- .../SandboxContext/SandboxContext.proto | 1 + Sources/Containerization/Vminitd.swift | 1 + Sources/Integration/ContainerTests.swift | 46 +++++++++-- Sources/Integration/PodTests.swift | 81 +++++++++++++++++++ Sources/Integration/Suite.swift | 1 + vminitd/Sources/vminitd/DNSMonitor.swift | 60 +++++++++++--- vminitd/Sources/vminitd/Server+GRPC.swift | 3 +- vminitd/Sources/vminitd/Server.swift | 3 - 13 files changed, 213 insertions(+), 25 deletions(-) diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index bbdbd8c0..3491e747 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -483,7 +483,7 @@ public struct ContainerManager: Sendable { message: "missing ipv4 gateway for container \(id)" ) } - config.dns = .init(nameservers: [gateway.description]) + config.dns = .init(nameservers: [gateway.description], enableRDNSSMonitor: true) } config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log")) try configuration(&config) diff --git a/Sources/Containerization/DNSConfiguration.swift b/Sources/Containerization/DNSConfiguration.swift index 8c9e601f..97ea8508 100644 --- a/Sources/Containerization/DNSConfiguration.swift +++ b/Sources/Containerization/DNSConfiguration.swift @@ -29,17 +29,22 @@ public struct DNS: Sendable { public var searchDomains: [String] /// The DNS options to use. public var options: [String] + /// When true, vminitd will listen for IPv6 Router Advertisements and + /// merge RDNSS nameservers into this resolv.conf entry. + public var enableRDNSSMonitor: Bool public init( nameservers: [String] = defaultNameservers, domain: String? = nil, searchDomains: [String] = [], - options: [String] = [] + options: [String] = [], + enableRDNSSMonitor: Bool = false ) { self.nameservers = nameservers self.domain = domain self.searchDomains = searchDomains self.options = options + self.enableRDNSSMonitor = enableRDNSSMonitor } } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 9033a206..8a2fc90a 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -996,6 +996,18 @@ extension LinuxContainer { } } + /// Update the DNS configuration for this container on the running VM. + /// Replaces the current /etc/resolv.conf content and updates the RDNSS + /// monitor state to match the new config's `enableRDNSSMonitor` flag. + public func updateDNS(_ dns: DNS) async throws { + try await self.state.withLock { + let state = try $0.startedState("updateDNS") + try await state.vm.withAgent { agent in + try await agent.configureDNS(config: dns, location: Self.guestRootfsPath(self.id)) + } + } + } + /// Get statistics for the container. public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics { try await self.state.withLock { diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 7574d697..9c5de5d2 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -834,6 +834,18 @@ extension LinuxPod { } } + /// Update the DNS configuration for a container in the pod on the running VM. + /// Replaces the container's /etc/resolv.conf content and updates the RDNSS + /// monitor state to match the new config's `enableRDNSSMonitor` flag. + public func updateDNS(_ dns: DNS, containerID: String) async throws { + try await self.state.withLock { state in + let createdState = try state.phase.createdState("updateDNS") + try await createdState.vm.withAgent { agent in + try await agent.configureDNS(config: dns, location: Self.guestRootfsPath(containerID)) + } + } + } + /// Get statistics for containers in the pod. public func statistics(containerIDs: [String]? = nil, categories: StatCategory = .all) async throws -> [ContainerStatistics] { let (createdState, ids) = try await self.state.withLock { state in diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index e35e2487..876fd955 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -1072,6 +1072,8 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: Sendabl public var options: [String] = [] + public var enableRdnssMonitor: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -3046,7 +3048,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: Swift extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureDnsRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}nameservers\0\u{1}domain\0\u{1}searchDomains\0\u{1}options\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}nameservers\0\u{1}domain\0\u{1}searchDomains\0\u{1}options\0\u{3}enable_rdnss_monitor\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3059,6 +3061,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtob case 3: try { try decoder.decodeSingularStringField(value: &self._domain) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.options) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.enableRdnssMonitor) }() default: break } } @@ -3084,6 +3087,9 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtob if !self.options.isEmpty { try visitor.visitRepeatedStringField(value: self.options, fieldNumber: 5) } + if self.enableRdnssMonitor != false { + try visitor.visitSingularBoolField(value: self.enableRdnssMonitor, fieldNumber: 6) + } try unknownFields.traverse(visitor: &visitor) } @@ -3093,6 +3099,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtob if lhs._domain != rhs._domain {return false} if lhs.searchDomains != rhs.searchDomains {return false} if lhs.options != rhs.options {return false} + if lhs.enableRdnssMonitor != rhs.enableRdnssMonitor {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 5250ced7..5046856a 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -303,6 +303,7 @@ message ConfigureDnsRequest { optional string domain = 3; repeated string searchDomains = 4; repeated string options = 5; + bool enable_rdnss_monitor = 6; } message ConfigureDnsResponse {} diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index cadce2c0..d9538ebb 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -496,6 +496,7 @@ extension Vminitd { } $0.searchDomains = config.searchDomains $0.options = config.options + $0.enableRdnssMonitor = config.enableRDNSSMonitor }) } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 7ac7b853..9e9acc72 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4176,9 +4176,10 @@ extension IntegrationSuite { try await container.create() try await container.start() - // The vmnet router sends Router Advertisements with RDNSS options. + // ContainerManager sets enableRDNSSMonitor on the DNS config, so vminitd + // starts the monitor automatically when configureDNS is called. // Poll resolv.conf until the DNSMonitor has received an RA and merged - // an IPv6 nameserver into the file (identified by a colon in the address). + // an IPv6 nameserver into the file. var found = false let deadline = Date.now.addingTimeInterval(15) while Date.now < deadline { @@ -4196,22 +4197,53 @@ extension IntegrationSuite { if status.exitCode == 0, let output = String(data: buffer.data, encoding: .utf8), output.split(separator: "\n") - .contains(where: { $0.hasPrefix("nameserver") && $0.contains(":") }) + .contains(where: { + guard $0.hasPrefix("nameserver ") else { return false } + let addr = $0.dropFirst("nameserver ".count).trimmingCharacters(in: .whitespaces) + return (try? IPv6Address(addr)) != nil + }) { found = true break } } - try await container.kill(SIGKILL) - try await container.wait() - try await container.stop() - guard found else { throw IntegrationError.assert( msg: "resolv.conf was not updated with an IPv6 nameserver from RDNSS within timeout" ) } + + // Disable the RDNSS monitor by updating DNS config with the flag off. + // The monitor should stop and purge IPv6 nameservers from resolv.conf. + let staticDNS = DNS(nameservers: container.config.dns?.nameservers ?? [], enableRDNSSMonitor: false) + try await container.updateDNS(staticDNS) + + let cleanBuffer = BufferWriter() + let cleanExec = try await container.exec("check-resolv-clean") { config in + config.arguments = ["cat", "/etc/resolv.conf"] + config.stdout = cleanBuffer + } + try await cleanExec.start() + let cleanStatus = try await cleanExec.wait() + try await cleanExec.delete() + if cleanStatus.exitCode == 0, + let cleanOutput = String(data: cleanBuffer.data, encoding: .utf8), + cleanOutput.split(separator: "\n") + .contains(where: { + guard $0.hasPrefix("nameserver ") else { return false } + let addr = $0.dropFirst("nameserver ".count).trimmingCharacters(in: .whitespaces) + return (try? IPv6Address(addr)) != nil + }) + { + throw IntegrationError.assert( + msg: "resolv.conf still contains an IPv6 nameserver after disabling the RDNSS monitor" + ) + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() } catch { try? await container.stop() throw error diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 9bd03192..a6f8aa93 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 @@ -2032,4 +2033,84 @@ extension IntegrationSuite { throw error } } + + @available(macOS 26.0, *) + func testPodRDNSSUpdatesResolvConf() async throws { + let id = "test-pod-rdnss-updates-resolv-conf" + let bs = try await bootstrap(id) + + var network = try ContainerManager.VmnetNetwork() + + guard let interface = try network.create("\(id)-c1"), + let gateway = interface.ipv4Gateway + else { + throw IntegrationError.assert(msg: "failed to get vmnet interface or gateway") + } + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + config.interfaces = [interface] + config.dns = DNS(nameservers: [gateway.description], enableRDNSSMonitor: true) + } + + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["sleep", "30"] + } + + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["sleep", "30"] + } + + do { + try await pod.create() + try await pod.startContainer("container1") + try await pod.startContainer("container2") + + // Both containers share the pod's DNS config with enableRDNSSMonitor = true, + // so vminitd starts the RDNSS monitor automatically. Poll each container's + // resolv.conf until an IPv6 nameserver appears. + for containerID in ["container1", "container2"] { + var found = false + let deadline = Date.now.addingTimeInterval(15) + while Date.now < deadline { + try await Task.sleep(for: .seconds(1)) + + let buffer = BufferWriter() + let exec = try await pod.execInContainer(containerID, processID: "check-resolv-\(containerID)") { config in + config.arguments = ["cat", "/etc/resolv.conf"] + config.stdout = buffer + } + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + if status.exitCode == 0, + let output = String(data: buffer.data, encoding: .utf8), + output.split(separator: "\n") + .contains(where: { + guard $0.hasPrefix("nameserver ") else { return false } + let addr = $0.dropFirst("nameserver ".count).trimmingCharacters(in: .whitespaces) + return (try? IPv6Address(addr)) != nil + }) + { + found = true + break + } + } + + guard found else { + throw IntegrationError.assert( + msg: "\(containerID): resolv.conf was not updated with an IPv6 nameserver from RDNSS within timeout" + ) + } + } + + try await pod.stop() + } catch { + try? await pod.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 93c06084..a88388b6 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -265,6 +265,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container networking disabled", testNetworkingDisabled), Test("container networking enabled", testNetworkingEnabled), Test("container RDNSS updates resolv.conf", testRDNSSUpdatesResolvConf), + Test("pod RDNSS updates resolv.conf", testPodRDNSSUpdatesResolvConf), ] } return [] diff --git a/vminitd/Sources/vminitd/DNSMonitor.swift b/vminitd/Sources/vminitd/DNSMonitor.swift index c6798ba8..c3b6c11c 100644 --- a/vminitd/Sources/vminitd/DNSMonitor.swift +++ b/vminitd/Sources/vminitd/DNSMonitor.swift @@ -30,11 +30,10 @@ actor DNSMonitor { private static let maxNameservers = 3 private var configs: [FilePath: DNS] = [:] - private var ipv6Nameservers: [IPv6Nameserver] = [] + private var monitorTask: Task? private let log: Logger - private let icmpV6Session: ICMPv6Session init(log: Logger) throws { @@ -43,6 +42,28 @@ actor DNSMonitor { } func update(resolvConfPath: FilePath, config: DNS) throws { + configs[resolvConfPath] = config + try writeResolvConf(resolvConfPath: resolvConfPath, config: config) + try updateMonitorState() + } + + private func updateMonitorState() throws { + let shouldMonitor = configs.values.contains { $0.enableRDNSSMonitor } + if shouldMonitor && monitorTask == nil { + log.info("starting RDNSS monitor") + monitorTask = Task { try await self.runMonitor() } + } else if !shouldMonitor, monitorTask != nil { + log.info("stopping RDNSS monitor") + monitorTask?.cancel() + monitorTask = nil + ipv6Nameservers = [] + for (path, dns) in configs { + try writeResolvConf(resolvConfPath: path, config: dns) + } + } + } + + private func writeResolvConf(resolvConfPath: FilePath, config: DNS) throws { let parentPathname = resolvConfPath.removingLastComponent().string try FileManager.default.createDirectory(atPath: parentPathname, withIntermediateDirectories: true) @@ -50,7 +71,7 @@ actor DNSMonitor { if config.nameservers.count < Self.maxNameservers { mergedNameservers = config.nameservers + ipv6Nameservers.map { $0.address.description } } else { - mergedNameservers = config.nameservers.prefix(2) + ipv6Nameservers.map { $0.address.description } + mergedNameservers = Array(config.nameservers.prefix(2)) + ipv6Nameservers.map { $0.address.description } } let mergedConfig = DNS( @@ -63,12 +84,19 @@ actor DNSMonitor { let text = mergedConfig.resolvConf log.debug("updating resolver configuration", metadata: ["path": "\(resolvConfPath)"]) try text.write(toFile: resolvConfPath.string, atomically: true, encoding: .utf8) - configs[resolvConfPath] = config } - func run() async throws { - self.log.info("starting DNS monitor") + private func refreshResolvConfs() throws { + for (path, dns) in configs { + try writeResolvConf(resolvConfPath: path, config: dns) + } + } + + private func runMonitor() async throws { + log.info("DNS monitor running") while true { + try Task.checkCancellation() + let now = Date.now let timeInterval = ipv6Nameservers @@ -77,7 +105,7 @@ actor DNSMonitor { .min() do { if timeInterval == nil { - self.log.info("sending router solicitation") + log.info("sending router solicitation") try sendRouterSolicitation() } } catch { @@ -120,17 +148,27 @@ actor DNSMonitor { log.warning("router advertisement receive failed", metadata: ["error": "\(error)"]) } + // Remove any entries whose TTL has expired, whether or not we received an RA. + purgeExpiredNameservers() + do { - for (resolvConfPath, dns) in configs { - log.info("awaiting DNS", metadata: ["path": "\(resolvConfPath)"]) - try update(resolvConfPath: resolvConfPath, config: dns) - } + try refreshResolvConfs() } catch { log.warning("DNS update failed", metadata: ["error": "\(error)"]) } } } + private func purgeExpiredNameservers() { + let now = Date.now + let before = ipv6Nameservers.count + ipv6Nameservers = ipv6Nameservers.filter { $0.expiry > now } + let removed = before - ipv6Nameservers.count + if removed > 0 { + log.debug("purged expired RDNSS nameserver(s)", metadata: ["count": "\(removed)"]) + } + } + private func sendRouterSolicitation() throws { let interface = "eth0" guard let linkLayerAddress = MACAddress.fromZone(interface) else { diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index ec0bc2ca..d6980481 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -1232,7 +1232,8 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ nameservers: request.nameservers, domain: domain, searchDomains: request.searchDomains, - options: request.options + options: request.options, + enableRDNSSMonitor: request.enableRdnssMonitor ) try await dnsMonitor.update(resolvConfPath: resolvConfPath, config: config) } catch { diff --git a/vminitd/Sources/vminitd/Server.swift b/vminitd/Sources/vminitd/Server.swift index df424582..2fd16fff 100644 --- a/vminitd/Sources/vminitd/Server.swift +++ b/vminitd/Sources/vminitd/Server.swift @@ -119,9 +119,6 @@ final class Initd: Sendable { "port": "\(port)" ]) - group.addTask { - try await self.dnsMonitor.run() - } group.addTask { try await server.serve() } try await group.next() From d71f87da11769bd8a3503292b0a0d4d8125cec31 Mon Sep 17 00:00:00 2001 From: John Logan Date: Fri, 13 Mar 2026 20:12:51 -0700 Subject: [PATCH 3/3] Don't need nothing on ContainerManager now. --- Sources/Containerization/ContainerManager.swift | 2 +- Sources/Integration/ContainerTests.swift | 11 ++++++----- Sources/Integration/PodTests.swift | 12 ++++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 3491e747..bbdbd8c0 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -483,7 +483,7 @@ public struct ContainerManager: Sendable { message: "missing ipv4 gateway for container \(id)" ) } - config.dns = .init(nameservers: [gateway.description], enableRDNSSMonitor: true) + 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/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 9e9acc72..36bbdc8c 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -4176,10 +4176,11 @@ extension IntegrationSuite { try await container.create() try await container.start() - // ContainerManager sets enableRDNSSMonitor on the DNS config, so vminitd - // starts the monitor automatically when configureDNS is called. - // Poll resolv.conf until the DNSMonitor has received an RA and merged - // an IPv6 nameserver into the file. + // Enable the RDNSS monitor explicitly, then poll resolv.conf until + // the DNSMonitor has received an RA and merged an IPv6 nameserver. + let staticNameservers = container.config.dns?.nameservers ?? [] + try await container.updateDNS(DNS(nameservers: staticNameservers, enableRDNSSMonitor: true)) + var found = false let deadline = Date.now.addingTimeInterval(15) while Date.now < deadline { @@ -4216,7 +4217,7 @@ extension IntegrationSuite { // Disable the RDNSS monitor by updating DNS config with the flag off. // The monitor should stop and purge IPv6 nameservers from resolv.conf. - let staticDNS = DNS(nameservers: container.config.dns?.nameservers ?? [], enableRDNSSMonitor: false) + let staticDNS = DNS(nameservers: staticNameservers, enableRDNSSMonitor: false) try await container.updateDNS(staticDNS) let cleanBuffer = BufferWriter() diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index a6f8aa93..506ae31e 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -2047,12 +2047,13 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "failed to get vmnet interface or gateway") } + let staticNameservers = [gateway.description] let pod = try LinuxPod(id, vmm: bs.vmm) { config in config.cpus = 4 config.memoryInBytes = 1024.mib() config.bootLog = bs.bootLog config.interfaces = [interface] - config.dns = DNS(nameservers: [gateway.description], enableRDNSSMonitor: true) + config.dns = DNS(nameservers: staticNameservers) } try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in @@ -2068,9 +2069,12 @@ extension IntegrationSuite { try await pod.startContainer("container1") try await pod.startContainer("container2") - // Both containers share the pod's DNS config with enableRDNSSMonitor = true, - // so vminitd starts the RDNSS monitor automatically. Poll each container's - // resolv.conf until an IPv6 nameserver appears. + // Enable the RDNSS monitor on each container, then poll resolv.conf + // until the DNSMonitor has received an RA and merged an IPv6 nameserver. + for containerID in ["container1", "container2"] { + try await pod.updateDNS(DNS(nameservers: staticNameservers, enableRDNSSMonitor: true), containerID: containerID) + } + for containerID in ["container1", "container2"] { var found = false let deadline = Date.now.addingTimeInterval(15)