Skip to content

Commit e6914fd

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 580d853 commit e6914fd

7 files changed

Lines changed: 515 additions & 12 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.

Package.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ let package = Package(
157157
],
158158
path: "Sources/Services/ContainerSandboxService"
159159
),
160+
.testTarget(
161+
name: "ContainerSandboxServiceTests",
162+
dependencies: [
163+
"ContainerSandboxService",
164+
.product(name: "ContainerizationExtras", package: "Containerization"),
165+
.product(name: "Logging", package: "swift-log"),
166+
]
167+
),
160168
.executableTarget(
161169
name: "container-network-vmnet",
162170
dependencies: [

Sources/Services/ContainerAPIService/Containers/ContainersService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2025 Apple Inc. and the container project authors.
2+
// Copyright © 2025-2026 Apple Inc. and the container project authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
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/SandboxService.swift

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2025 Apple Inc. and the container project authors.
2+
// Copyright © 2025-2026 Apple Inc. and the container project authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -130,8 +130,9 @@ public actor SandboxService {
130130
)
131131

132132
// Dynamically configure the DNS nameserver from a network if no explicit configuration
133+
let configureIpv6DNS: Bool
133134
if let dns = config.dns, dns.nameservers.isEmpty {
134-
let defaultNameservers = try await self.getDefaultNameservers(attachmentConfigurations: config.networks)
135+
let defaultNameservers = await self.getDefaultNameservers(attachmentConfigurations: config.networks)
135136
if !defaultNameservers.isEmpty {
136137
config.dns = ContainerConfiguration.DNSConfiguration(
137138
nameservers: defaultNameservers,
@@ -140,6 +141,9 @@ public actor SandboxService {
140141
options: dns.options
141142
)
142143
}
144+
configureIpv6DNS = true
145+
} else {
146+
configureIpv6DNS = false
143147
}
144148

145149
var attachments: [Attachment] = []
@@ -215,13 +219,32 @@ public actor SandboxService {
215219

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

841-
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async throws -> [String] {
864+
private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async -> [String] {
842865
for attachmentConfiguration in attachmentConfigurations {
843866
let client = NetworkClient(id: attachmentConfiguration.network)
844-
let state = try await client.state()
867+
guard let state = try? await client.state() else {
868+
log.warning(
869+
"failed to get state for network while getting default nameservers",
870+
metadata: [
871+
"network": "\(attachmentConfiguration.network)"
872+
])
873+
continue
874+
}
845875
guard case .running(_, let status) = state else {
876+
log.warning(
877+
"unexpected state for network while getting default nameservers",
878+
metadata: [
879+
"network": "\(attachmentConfiguration.network)",
880+
"state": "\(state)",
881+
])
846882
continue
847883
}
848884
return [status.ipv4Gateway.description]
@@ -851,6 +887,48 @@ public actor SandboxService {
851887
return []
852888
}
853889

890+
private func getDNSProxyAddress(attachmentConfigurations: [AttachmentConfiguration]) async throws -> IPv6Address? {
891+
for attachmentConfiguration in attachmentConfigurations {
892+
let client = NetworkClient(id: attachmentConfiguration.network)
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+
}
901+
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+
])
908+
continue
909+
}
910+
guard let ipv6Prefix = status.ipv6Subnet else {
911+
continue
912+
}
913+
914+
self.log.info("starting DNS proxy search", metadata: ["ipv6Prefix": "\(ipv6Prefix)"])
915+
let keyPatterns = ["State:/Network/Interface/.*/IPv6"]
916+
let monitor = try SystemConfigurationMonitor(keys: keyPatterns, log: self.log)
917+
let initialProperties = monitor.get(keyPatterns: keyPatterns)
918+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: initialProperties, ipv6Prefix: ipv6Prefix, log: self.log) {
919+
return dnsAddress
920+
}
921+
for await keys in monitor {
922+
let properties = monitor.get(keyPatterns: keys)
923+
if let dnsAddress = IPv6DNSProxyLocator.findDNSProxy(scProperties: properties, ipv6Prefix: ipv6Prefix, log: self.log) {
924+
return dnsAddress
925+
}
926+
}
927+
}
928+
929+
return nil
930+
}
931+
854932
private static func configureInitialProcess(
855933
czConfig: inout LinuxContainer.Configuration,
856934
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)