Skip to content

Commit 5c2ac78

Browse files
committed
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.
1 parent 356c8d2 commit 5c2ac78

4 files changed

Lines changed: 504 additions & 9 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerizationExtras
18+
import Foundation
19+
import Logging
20+
21+
public struct IPv6DNSProxyLocator {
22+
public static func findDNSProxy(scProperties: [String: [String: Any]], ipv6Prefix: CIDRv6, log: Logger) -> IPv6Address? {
23+
for (key, ipv6Properties) in scProperties {
24+
log.debug("finding DNS proxy", metadata: ["key": "\(key)"])
25+
guard let ipv6Addresses = ipv6Properties["Addresses"] as? [CFString] else {
26+
log.warning("skipping invalid property", metadata: ["name": "Addresses"])
27+
continue
28+
}
29+
guard let ipv6Flags = ipv6Properties["Flags"] as? [CFNumber] else {
30+
log.warning("skipping invalid property", metadata: ["name": "Flags"])
31+
continue
32+
}
33+
guard let prefixes = ipv6Properties["PrefixLength"] as? [CFNumber] else {
34+
log.warning("skipping invalid property", metadata: ["name": "PrefixLength"])
35+
continue
36+
}
37+
38+
let prefixIndex = (0..<ipv6Addresses.count)
39+
.filter {
40+
let candidateText = "\(ipv6Addresses[$0])/\(prefixes[$0])"
41+
guard let candidate = try? CIDRv6(candidateText) else {
42+
return false
43+
}
44+
return ipv6Prefix.contains(candidate.lower) && ipv6Prefix.contains(candidate.upper)
45+
}
46+
.first
47+
48+
guard prefixIndex != nil else {
49+
log.debug("IPv6 prefix not found", metadata: ["cidrv6": "\(ipv6Prefix)"])
50+
continue
51+
}
52+
53+
let flagsIndex = (0..<ipv6Addresses.count)
54+
.filter {
55+
guard let flags = ipv6Flags[$0] as? Int else {
56+
return false
57+
}
58+
return flags == 1088
59+
}
60+
.first
61+
62+
guard let flagsIndex else {
63+
log.debug("IPv6 prefix found with non-secured flags", metadata: ["cidrv6": "\(ipv6Prefix)"])
64+
continue
65+
}
66+
67+
guard let dnsAddress = try? IPv6Address("\(ipv6Addresses[flagsIndex])") else {
68+
log.debug("cannot create DNS address for IPv6 prefix", metadata: ["cidrv6": "\(ipv6Prefix)"])
69+
continue
70+
}
71+
72+
return dnsAddress
73+
}
74+
75+
return nil
76+
}
77+
}

Sources/Services/ContainerSandboxService/Server/SandboxService.swift

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,9 @@ public actor SandboxService {
131131
)
132132

133133
// Dynamically configure the DNS nameserver from a network if no explicit configuration
134+
let configureIpv6DNS: Bool
134135
if let dns = config.dns, dns.nameservers.isEmpty {
135-
let defaultNameservers = try await self.getDefaultNameservers(attachmentConfigurations: config.networks)
136+
let defaultNameservers = await self.getDefaultNameservers(attachmentConfigurations: config.networks)
136137
if !defaultNameservers.isEmpty {
137138
config.dns = ContainerConfiguration.DNSConfiguration(
138139
nameservers: defaultNameservers,
@@ -141,6 +142,9 @@ public actor SandboxService {
141142
options: dns.options
142143
)
143144
}
145+
configureIpv6DNS = true
146+
} else {
147+
configureIpv6DNS = false
144148
}
145149

146150
var attachments: [Attachment] = []
@@ -216,13 +220,32 @@ public actor SandboxService {
216220

217221
do {
218222
try await container.create()
219-
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
220-
if !container.interfaces.isEmpty {
221-
let firstCidr = container.interfaces[0].ipv4Address
222-
let ipAddress = firstCidr.address.description
223-
try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts)
223+
let dnsUpdateTask = Task { [config] in
224+
guard configureIpv6DNS else {
225+
return
226+
}
227+
guard let proxyAddress = try await self.getDNSProxyAddress(attachmentConfigurations: config.networks) else {
228+
return
229+
}
230+
self.log.info(
231+
"confguring IPv6 proxy DNS server",
232+
metadata: [
233+
"ipv6Address": "\(proxyAddress)"
234+
]
235+
)
236+
}
237+
do {
238+
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
239+
if !container.interfaces.isEmpty {
240+
let firstCidr = container.interfaces[0].ipv4Address
241+
let ipAddress = firstCidr.address.description
242+
try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts)
243+
}
244+
await self.setState(.booted)
245+
} catch {
246+
dnsUpdateTask.cancel()
247+
throw error
224248
}
225-
await self.setState(.booted)
226249
} catch {
227250
do {
228251
try await self.cleanupContainer(containerInfo: ctrInfo)
@@ -842,11 +865,24 @@ public actor SandboxService {
842865
Self.configureInitialProcess(czConfig: &czConfig, config: config)
843866
}
844867

845-
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async throws -> [String] {
868+
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async -> [String] {
846869
for attachmentConfiguration in attachmentConfigurations {
847870
let client = NetworkClient(id: attachmentConfiguration.network)
848-
let state = try await client.state()
871+
guard let state = try? await client.state() else {
872+
log.warning(
873+
"failed to get state for network while getting default nameservers",
874+
metadata: [
875+
"network": "\(attachmentConfiguration.network)"
876+
])
877+
continue
878+
}
849879
guard case .running(_, let status) = state else {
880+
log.warning(
881+
"unexpected state for network while getting default nameservers",
882+
metadata: [
883+
"network": "\(attachmentConfiguration.network)",
884+
"state": "\(state)",
885+
])
850886
continue
851887
}
852888
return [status.ipv4Gateway.description]
@@ -855,6 +891,48 @@ public actor SandboxService {
855891
return []
856892
}
857893

894+
private func getDNSProxyAddress(attachmentConfigurations: [AttachmentConfiguration]) async throws -> IPv6Address? {
895+
for attachmentConfiguration in attachmentConfigurations {
896+
let client = NetworkClient(id: attachmentConfiguration.network)
897+
guard let state = try? await client.state() else {
898+
log.warning(
899+
"failed to get state for network while getting default nameservers",
900+
metadata: [
901+
"network": "\(attachmentConfiguration.network)"
902+
])
903+
continue
904+
}
905+
guard case .running(_, let status) = state else {
906+
log.warning(
907+
"unexpected state for network while getting default nameservers",
908+
metadata: [
909+
"network": "\(attachmentConfiguration.network)",
910+
"state": "\(state)",
911+
])
912+
continue
913+
}
914+
guard let ipv6Prefix = status.ipv6Subnet else {
915+
continue
916+
}
917+
918+
self.log.info("starting DNS proxy search", metadata: ["ipv6Prefix": "\(ipv6Prefix)"])
919+
let keyPatterns = ["State:/Network/Interface/.*/IPv6"]
920+
let monitor = try SystemConfigurationMonitor(keys: keyPatterns, log: self.log)
921+
let initialProperties = monitor.get(keyPatterns: keyPatterns)
922+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: initialProperties, ipv6Prefix: ipv6Prefix, log: self.log) {
923+
return dnsAddress
924+
}
925+
for await keys in monitor {
926+
let properties = monitor.get(keyPatterns: keys)
927+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: properties, ipv6Prefix: ipv6Prefix, log: self.log) {
928+
return dnsAddress
929+
}
930+
}
931+
}
932+
933+
return nil
934+
}
935+
858936
private static func configureInitialProcess(
859937
czConfig: inout LinuxContainer.Configuration,
860938
config: ContainerConfiguration
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Logging
19+
import Synchronization
20+
import SystemConfiguration
21+
22+
final public class SystemConfigurationMonitor: AsyncSequence {
23+
public typealias Element = [String]
24+
public typealias AsyncIterator = AsyncStream<[String]>.Iterator
25+
26+
private let stream: AsyncStream<[String]>
27+
private let cleanup: () -> Void
28+
private let configStore: SCDynamicStore
29+
30+
public init(keys: [String], log: Logger) throws {
31+
let eventInfo = EventInfo(log: log)
32+
let callback: SCDynamicStoreCallBack = { _, modifiedKeys, opaqueInfo in
33+
guard let opaqueInfo else { return }
34+
let eventInfo = Unmanaged<EventInfo>.fromOpaque(opaqueInfo).takeUnretainedValue()
35+
guard let keys = modifiedKeys as? [String] else {
36+
eventInfo.log.warning("keys not present, skipping")
37+
return
38+
}
39+
eventInfo.continuationMutex.withLock { wrapper in
40+
eventInfo.log.debug("enter callback")
41+
guard let continuation = wrapper.continuation else {
42+
eventInfo.log.warning("continuation not present, skipping")
43+
return
44+
}
45+
continuation.yield(keys)
46+
eventInfo.log.debug("exit callback")
47+
}
48+
}
49+
50+
var context: SCDynamicStoreContext = .init(
51+
version: 0,
52+
info: Unmanaged.passUnretained(eventInfo).toOpaque(),
53+
retain: nil,
54+
release: nil,
55+
copyDescription: nil
56+
)
57+
58+
let name = "com.apple.birdsc.\(UUID())" as CFString
59+
guard let configStore = SCDynamicStoreCreate(nil, name, callback, &context) else {
60+
throw DynamicStoreError.cannotCreate
61+
}
62+
self.configStore = configStore
63+
64+
SCDynamicStoreSetNotificationKeys(configStore, nil, keys as CFArray)
65+
SCDynamicStoreSetDispatchQueue(configStore, DispatchQueue.main)
66+
67+
let stream = AsyncStream<[String]> { continuation in
68+
eventInfo.continuationMutex.withLock { wrapper in
69+
eventInfo.log.debug("enter continuation mutex - stream")
70+
wrapper.continuation = continuation
71+
eventInfo.log.debug("exit continuation mutex - stream")
72+
}
73+
}
74+
75+
self.stream = stream
76+
self.cleanup = {
77+
SCDynamicStoreSetNotificationKeys(configStore, nil, nil)
78+
eventInfo.continuationMutex.withLock { wrapper in
79+
wrapper.continuation = nil
80+
}
81+
}
82+
}
83+
84+
deinit {
85+
cleanup()
86+
}
87+
88+
public func makeAsyncIterator() -> AsyncIterator {
89+
stream.makeAsyncIterator()
90+
}
91+
92+
public func get(keyPatterns: [String]) -> [String: [String: Any]] {
93+
var keys: [CFString] = []
94+
for keyPattern in keyPatterns {
95+
keys.append(contentsOf: (SCDynamicStoreCopyKeyList(configStore, keyPattern as CFString) as? [CFString]) ?? [])
96+
}
97+
98+
let values =
99+
keys
100+
.map { SCDynamicStoreCopyValue(configStore, $0 as CFString) }
101+
.map { $0 as? [CFString: Any] }
102+
103+
var result: [String: [String: Any]] = [:]
104+
for (key, cfDict) in zip(keys, values) {
105+
guard let cfDict else {
106+
continue
107+
}
108+
result[key as String] = Dictionary(uniqueKeysWithValues: cfDict.map { ($0.key as String, $0.value) })
109+
}
110+
111+
return result
112+
}
113+
}
114+
115+
final class ContinuationWrapper {
116+
var continuation: AsyncStream<[String]>.Continuation?
117+
}
118+
119+
final class EventInfo {
120+
let log: Logger
121+
let continuationMutex: Mutex<ContinuationWrapper>
122+
123+
init(log: Logger) {
124+
self.log = log
125+
self.continuationMutex = .init(ContinuationWrapper())
126+
}
127+
}
128+
129+
public enum DynamicStoreError: Error {
130+
case cannotCreate
131+
}

0 commit comments

Comments
 (0)