Skip to content

Commit 64bc8ef

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 dc4682b commit 64bc8ef

5 files changed

Lines changed: 503 additions & 8 deletions

File tree

Package.resolved

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
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: 85 additions & 7 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,11 +220,30 @@ 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-
try await self.startSocketForwarders(attachment: attachments[0], 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+
try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts)
241+
}
242+
await self.setState(.booted)
243+
} catch {
244+
dnsUpdateTask.cancel()
245+
throw error
222246
}
223-
await self.setState(.booted)
224247
} catch {
225248
do {
226249
try await self.cleanupContainer(containerInfo: ctrInfo)
@@ -864,11 +887,24 @@ public actor SandboxService {
864887
Self.configureInitialProcess(czConfig: &czConfig, config: config)
865888
}
866889

867-
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async throws -> [String] {
890+
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async -> [String] {
868891
for attachmentConfiguration in attachmentConfigurations {
869892
let client = NetworkClient(id: attachmentConfiguration.network)
870-
let state = try await client.state()
893+
guard let state = try? await client.state() else {
894+
log.warning(
895+
"failed to get state for network while getting default nameservers",
896+
metadata: [
897+
"network": "\(attachmentConfiguration.network)"
898+
])
899+
continue
900+
}
871901
guard case .running(_, let status) = state else {
902+
log.warning(
903+
"unexpected state for network while getting default nameservers",
904+
metadata: [
905+
"network": "\(attachmentConfiguration.network)",
906+
"state": "\(state)",
907+
])
872908
continue
873909
}
874910
return [status.ipv4Gateway.description]
@@ -877,6 +913,48 @@ public actor SandboxService {
877913
return []
878914
}
879915

916+
private func getDNSProxyAddress(attachmentConfigurations: [AttachmentConfiguration]) async throws -> IPv6Address? {
917+
for attachmentConfiguration in attachmentConfigurations {
918+
let client = NetworkClient(id: attachmentConfiguration.network)
919+
guard let state = try? await client.state() else {
920+
log.warning(
921+
"failed to get state for network while getting default nameservers",
922+
metadata: [
923+
"network": "\(attachmentConfiguration.network)"
924+
])
925+
continue
926+
}
927+
guard case .running(_, let status) = state else {
928+
log.warning(
929+
"unexpected state for network while getting default nameservers",
930+
metadata: [
931+
"network": "\(attachmentConfiguration.network)",
932+
"state": "\(state)",
933+
])
934+
continue
935+
}
936+
guard let ipv6Prefix = status.ipv6Subnet else {
937+
continue
938+
}
939+
940+
self.log.info("starting DNS proxy search", metadata: ["ipv6Prefix": "\(ipv6Prefix)"])
941+
let keyPatterns = ["State:/Network/Interface/.*/IPv6"]
942+
let monitor = try SystemConfigurationMonitor(keys: keyPatterns, log: self.log)
943+
let initialProperties = monitor.get(keyPatterns: keyPatterns)
944+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: initialProperties, ipv6Prefix: ipv6Prefix, log: self.log) {
945+
return dnsAddress
946+
}
947+
for await keys in monitor {
948+
let properties = monitor.get(keyPatterns: keys)
949+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: properties, ipv6Prefix: ipv6Prefix, log: self.log) {
950+
return dnsAddress
951+
}
952+
}
953+
}
954+
955+
return nil
956+
}
957+
880958
private static func configureInitialProcess(
881959
czConfig: inout LinuxContainer.Configuration,
882960
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)