Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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..<ipv6Addresses.count)
.filter {
let candidateText = "\(ipv6Addresses[$0])/\(prefixes[$0])"
guard let candidate = try? CIDRv6(candidateText) else {
return false
}
return ipv6Prefix.contains(candidate.lower) && ipv6Prefix.contains(candidate.upper)
}
.first

guard prefixIndex != nil else {
log.debug("IPv6 prefix not found", metadata: ["cidrv6": "\(ipv6Prefix)"])
continue
}

let flagsIndex = (0..<ipv6Addresses.count)
.filter {
guard let flags = ipv6Flags[$0] as? Int else {
return false
}
return flags == 1088
}
.first

guard let flagsIndex else {
log.debug("IPv6 prefix found with non-secured flags", metadata: ["cidrv6": "\(ipv6Prefix)"])
continue
}

guard let dnsAddress = try? IPv6Address("\(ipv6Addresses[flagsIndex])") else {
log.debug("cannot create DNS address for IPv6 prefix", metadata: ["cidrv6": "\(ipv6Prefix)"])
continue
}

return dnsAddress
}

return nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ public actor SandboxService {
)

// Dynamically configure the DNS nameserver from a network if no explicit configuration
let configureIpv6DNS: Bool
if let dns = config.dns, dns.nameservers.isEmpty {
let defaultNameservers = try await self.getDefaultNameservers(attachmentConfigurations: config.networks)
let defaultNameservers = await self.getDefaultNameservers(attachmentConfigurations: config.networks)
if !defaultNameservers.isEmpty {
config.dns = ContainerConfiguration.DNSConfiguration(
nameservers: defaultNameservers,
Expand All @@ -141,6 +142,9 @@ public actor SandboxService {
options: dns.options
)
}
configureIpv6DNS = true
} else {
configureIpv6DNS = false
}

var attachments: [Attachment] = []
Expand Down Expand Up @@ -216,11 +220,30 @@ public actor SandboxService {

do {
try await container.create()
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
if !container.interfaces.isEmpty {
try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts)
let dnsUpdateTask = Task { [config] in
guard configureIpv6DNS else {
return
}
guard let proxyAddress = try await self.getDNSProxyAddress(attachmentConfigurations: config.networks) else {
return
}
self.log.info(
"confguring IPv6 proxy DNS server",
metadata: [
"ipv6Address": "\(proxyAddress)"
]
)
}
do {
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
if !container.interfaces.isEmpty {
try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts)
}
await self.setState(.booted)
} catch {
dnsUpdateTask.cancel()
throw error
}
await self.setState(.booted)
} catch {
do {
try await self.cleanupContainer(containerInfo: ctrInfo)
Expand Down Expand Up @@ -864,11 +887,24 @@ public actor SandboxService {
Self.configureInitialProcess(czConfig: &czConfig, config: config)
}

private func getDefaultNameservers(attachmentConfigurations: [AttachmentConfiguration]) async throws -> [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]
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventInfo>.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<ContinuationWrapper>

init(log: Logger) {
self.log = log
self.continuationMutex = .init(ContinuationWrapper())
}
}

public enum DynamicStoreError: Error {
case cannotCreate
}
Loading