From 64bc8ef07c9d842ba9e485cf79f6738e951292da Mon Sep 17 00:00:00 2001 From: John Logan Date: Tue, 6 Jan 2026 08:48:24 -0800 Subject: [PATCH] Autoconfigure IPv6 DNS proxy. - Closes #1004. - If DNS is not explicitly configured in the sandbox service, bootstrap the container and then find the IPv6 prefix for the first running network. Monitor the system configuration dynamic store until IPv6 is fully initialized for the network (this can take seconds for the first container attaching to the network), and then add the DNS proxy address for that network to the container DNS configuration. --- Package.resolved | 2 +- .../Server/IPv6DNSLocator.swift | 77 +++++++ .../Server/SandboxService.swift | 92 +++++++- .../Server/SystemConfigurationMonitor.swift | 131 +++++++++++ .../IPv6DNSLocatorTests.swift | 209 ++++++++++++++++++ 5 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 Sources/Services/ContainerSandboxService/Server/IPv6DNSLocator.swift create mode 100644 Sources/Services/ContainerSandboxService/Server/SystemConfigurationMonitor.swift create mode 100644 Tests/ContainerSandboxServiceTests/IPv6DNSLocatorTests.swift 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) + } +}