diff --git a/Package.resolved b/Package.resolved index b525b0e3..582d5a0c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "538f97f4c9123825536afb15abcaa4d9d865563e402bcb7fd26be69f55461c1d", + "originHash" : "1e0a816e5d6688284f56a007aac9f1b5f5d8930740c3628048d8d405559a71cf", "pins" : [ { "identity" : "async-http-client", diff --git a/Sources/Services/ContainerSandboxService/Server/IPv6DNSLocator.swift b/Sources/Services/ContainerSandboxService/Server/IPv6DNSLocator.swift new file mode 100644 index 00000000..44bf885f --- /dev/null +++ b/Sources/Services/ContainerSandboxService/Server/IPv6DNSLocator.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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 Logging + +public struct IPv6DNSProxyLocator { + public static func findDNSProxy(scProperties: [String: [String: Any]], ipv6Prefix: CIDRv6, log: Logger) -> IPv6Address? { + for (key, ipv6Properties) in scProperties { + log.debug("finding DNS proxy", metadata: ["key": "\(key)"]) + guard let ipv6Addresses = ipv6Properties["Addresses"] as? [CFString] else { + log.warning("skipping invalid property", metadata: ["name": "Addresses"]) + continue + } + guard let ipv6Flags = ipv6Properties["Flags"] as? [CFNumber] else { + log.warning("skipping invalid property", metadata: ["name": "Flags"]) + continue + } + guard let prefixes = ipv6Properties["PrefixLength"] as? [CFNumber] else { + log.warning("skipping invalid property", metadata: ["name": "PrefixLength"]) + continue + } + + let prefixIndex = (0.. [String] { + private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async -> [String] { for attachmentConfiguration in attachmentConfigurations { let client = NetworkClient(id: attachmentConfiguration.network) - let state = try await client.state() + guard let state = try? await client.state() else { + log.warning( + "failed to get state for network while getting default nameservers", + metadata: [ + "network": "\(attachmentConfiguration.network)" + ]) + continue + } guard case .running(_, let status) = state else { + log.warning( + "unexpected state for network while getting default nameservers", + metadata: [ + "network": "\(attachmentConfiguration.network)", + "state": "\(state)", + ]) continue } return [status.ipv4Gateway.description] @@ -877,6 +913,48 @@ public actor SandboxService { return [] } + private func getDNSProxyAddress(attachmentConfigurations: [AttachmentConfiguration]) async throws -> IPv6Address? { + for attachmentConfiguration in attachmentConfigurations { + let client = NetworkClient(id: attachmentConfiguration.network) + guard let state = try? await client.state() else { + log.warning( + "failed to get state for network while getting default nameservers", + metadata: [ + "network": "\(attachmentConfiguration.network)" + ]) + continue + } + guard case .running(_, let status) = state else { + log.warning( + "unexpected state for network while getting default nameservers", + metadata: [ + "network": "\(attachmentConfiguration.network)", + "state": "\(state)", + ]) + continue + } + guard let ipv6Prefix = status.ipv6Subnet else { + continue + } + + self.log.info("starting DNS proxy search", metadata: ["ipv6Prefix": "\(ipv6Prefix)"]) + let keyPatterns = ["State:/Network/Interface/.*/IPv6"] + let monitor = try SystemConfigurationMonitor(keys: keyPatterns, log: self.log) + let initialProperties = monitor.get(keyPatterns: keyPatterns) + if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: initialProperties, ipv6Prefix: ipv6Prefix, log: self.log) { + return dnsAddress + } + for await keys in monitor { + let properties = monitor.get(keyPatterns: keys) + if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: properties, ipv6Prefix: ipv6Prefix, log: self.log) { + return dnsAddress + } + } + } + + return nil + } + private static func configureInitialProcess( czConfig: inout LinuxContainer.Configuration, config: ContainerConfiguration diff --git a/Sources/Services/ContainerSandboxService/Server/SystemConfigurationMonitor.swift b/Sources/Services/ContainerSandboxService/Server/SystemConfigurationMonitor.swift new file mode 100644 index 00000000..b00fe126 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/Server/SystemConfigurationMonitor.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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 Logging +import Synchronization +import SystemConfiguration + +final public class SystemConfigurationMonitor: AsyncSequence { + public typealias Element = [String] + public typealias AsyncIterator = AsyncStream<[String]>.Iterator + + private let stream: AsyncStream<[String]> + private let cleanup: () -> Void + private let configStore: SCDynamicStore + + public init(keys: [String], log: Logger) throws { + let eventInfo = EventInfo(log: log) + let callback: SCDynamicStoreCallBack = { _, modifiedKeys, opaqueInfo in + guard let opaqueInfo else { return } + let eventInfo = Unmanaged.fromOpaque(opaqueInfo).takeUnretainedValue() + guard let keys = modifiedKeys as? [String] else { + eventInfo.log.warning("keys not present, skipping") + return + } + eventInfo.continuationMutex.withLock { wrapper in + eventInfo.log.debug("enter callback") + guard let continuation = wrapper.continuation else { + eventInfo.log.warning("continuation not present, skipping") + return + } + continuation.yield(keys) + eventInfo.log.debug("exit callback") + } + } + + var context: SCDynamicStoreContext = .init( + version: 0, + info: Unmanaged.passUnretained(eventInfo).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + let name = "com.apple.birdsc.\(UUID())" as CFString + guard let configStore = SCDynamicStoreCreate(nil, name, callback, &context) else { + throw DynamicStoreError.cannotCreate + } + self.configStore = configStore + + SCDynamicStoreSetNotificationKeys(configStore, nil, keys as CFArray) + SCDynamicStoreSetDispatchQueue(configStore, DispatchQueue.main) + + let stream = AsyncStream<[String]> { continuation in + eventInfo.continuationMutex.withLock { wrapper in + eventInfo.log.debug("enter continuation mutex - stream") + wrapper.continuation = continuation + eventInfo.log.debug("exit continuation mutex - stream") + } + } + + self.stream = stream + self.cleanup = { + SCDynamicStoreSetNotificationKeys(configStore, nil, nil) + eventInfo.continuationMutex.withLock { wrapper in + wrapper.continuation = nil + } + } + } + + deinit { + cleanup() + } + + public func makeAsyncIterator() -> AsyncIterator { + stream.makeAsyncIterator() + } + + public func get(keyPatterns: [String]) -> [String: [String: Any]] { + var keys: [CFString] = [] + for keyPattern in keyPatterns { + keys.append(contentsOf: (SCDynamicStoreCopyKeyList(configStore, keyPattern as CFString) as? [CFString]) ?? []) + } + + let values = + keys + .map { SCDynamicStoreCopyValue(configStore, $0 as CFString) } + .map { $0 as? [CFString: Any] } + + var result: [String: [String: Any]] = [:] + for (key, cfDict) in zip(keys, values) { + guard let cfDict else { + continue + } + result[key as String] = Dictionary(uniqueKeysWithValues: cfDict.map { ($0.key as String, $0.value) }) + } + + return result + } +} + +final class ContinuationWrapper { + var continuation: AsyncStream<[String]>.Continuation? +} + +final class EventInfo { + let log: Logger + let continuationMutex: Mutex + + init(log: Logger) { + self.log = log + self.continuationMutex = .init(ContinuationWrapper()) + } +} + +public enum DynamicStoreError: Error { + case cannotCreate +} diff --git a/Tests/ContainerSandboxServiceTests/IPv6DNSLocatorTests.swift b/Tests/ContainerSandboxServiceTests/IPv6DNSLocatorTests.swift new file mode 100644 index 00000000..a58d68c7 --- /dev/null +++ b/Tests/ContainerSandboxServiceTests/IPv6DNSLocatorTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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 ContainerSandboxService +import ContainerizationExtras +import Foundation +import Logging +import Testing + +@Suite("IPv6DNSProxyLocator Tests") +struct IPv6DNSProxyLocatorTests { + let logger = Logger(label: "test") + + @Test("Finds DNS proxy when network matches prefix and has flags 1088") + func findsMatchingDNSProxy() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fe80::603e:5fff:fe94:4e64", + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01", + ] as CFArray, + "Flags": [0, 1088] as CFArray, + "PrefixLength": [64, 64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result != nil) + #expect(result?.description == "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01") + } + + @Test("Returns nil when prefix does not match any network") + func returnsNilWhenPrefixDoesNotMatch() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fe80::603e:5fff:fe94:4e64", + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01", + ] as CFArray, + "Flags": [0, 1088] as CFArray, + "PrefixLength": [64, 64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd00:1234:5678::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } + + @Test("Returns nil when flags are not 1088") + func returnsNilWhenFlagsAreWrong() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01" + ] as CFArray, + "Flags": [0] as CFArray, + "PrefixLength": [64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } + + @Test("Returns nil when Addresses property is missing") + func returnsNilWhenAddressesAreMissing() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Flags": [0, 1088] as CFArray, + "PrefixLength": [64, 64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } + + @Test("Returns nil when Flags property is missing") + func returnsNilWhenFlagsAreMissing() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01" + ] as CFArray, + "PrefixLength": [64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } + + @Test("Returns nil when PrefixLength property is missing") + func returnsNilWhenPrefixLengthIsMissing() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01" + ] as CFArray, + "Flags": [1088] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } + + @Test("Finds DNS proxy across multiple interfaces") + func findsProxyAcrossMultipleInterfaces() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/en0/IPv6": [ + "Addresses": [ + "fe80::1" + ] as CFArray, + "Flags": [0] as CFArray, + "PrefixLength": [64] as CFArray, + ], + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "fe80::603e:5fff:fe94:4e64", + "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01", + ] as CFArray, + "Flags": [0, 1088] as CFArray, + "PrefixLength": [64, 64] as CFArray, + ], + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result != nil) + #expect(result?.description == "fd97:7b15:d62e:75ac:4fa:6b2d:4f21:fd01") + } + + @Test("Returns nil when address cannot be parsed") + func returnsNilWhenAddressCannotBeParsed() throws { + let scProperties: [String: [String: Any]] = [ + "State:/Network/Interface/bridge100/IPv6": [ + "Addresses": [ + "invalid-address" + ] as CFArray, + "Flags": [1088] as CFArray, + "PrefixLength": [64] as CFArray, + ] + ] + + let prefix = try CIDRv6("fd97:7b15:d62e:75ac::/64") + let result = IPv6DNSProxyLocator.findDNSProxy( + scProperties: scProperties, + ipv6Prefix: prefix, + log: logger + ) + + #expect(result == nil) + } +}