Skip to content
Open
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
21 changes: 21 additions & 0 deletions Sources/ContainerClient/HostDNSResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ public struct HostDNSResolver {
.sorted()
}

/// Returns the host system's DNS search domains from /etc/resolv.conf.
public static func getHostSearchDomains() -> [String] {
let resolvConfPath = "/etc/resolv.conf"
guard let content = try? String(contentsOfFile: resolvConfPath, encoding: .utf8) else {
return []
}

var searchDomains: [String] = []
for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.hasPrefix("#") else { continue }

if trimmed.hasPrefix("search ") {
let domains = trimmed.dropFirst(7).split(whereSeparator: { $0.isWhitespace })
searchDomains.append(contentsOf: domains.map { String($0) })
}
}

return searchDomains
}

/// Reinitializes the macOS DNS daemon.
public static func reinitialize() throws {
do {
Expand Down
41 changes: 24 additions & 17 deletions Sources/ContainerCommands/Builder/BuilderStart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ extension Application {
)
var memory: String = "2048MB"

@Flag(name: .long, help: "Force DNS queries to use TCP instead of UDP")
var dnsTcp: Bool = false

@OptionGroup
var global: Flags.Global

Expand All @@ -60,11 +63,11 @@ extension Application {
progress.finish()
}
progress.start()
try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler)
try await Self.start(cpus: self.cpus, memory: self.memory, dnsTcp: self.dnsTcp, progressUpdate: progress.handler)
progress.finish()
}

static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws {
static func start(cpus: Int64?, memory: String?, dnsTcp: Bool = false, progressUpdate: @escaping ProgressUpdateHandler) async throws {
await progressUpdate([
.setDescription("Fetching BuildKit image"),
.setItemsName("blobs"),
Expand Down Expand Up @@ -97,6 +100,12 @@ extension Application {
}
targetEnvVars.sort()

let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName)
guard case .running(_, let networkStatus) = network else {
throw ContainerizationError(.invalidState, message: "default network is not running")
}
let gatewayNameserver = IPv4Address(networkStatus.ipv4Subnet.lower.value + 1).description

let existingContainer = try? await ClientContainer.get(id: "buildkit")
if let existingContainer {
let existingImage = existingContainer.configuration.image.reference
Expand All @@ -109,7 +118,6 @@ extension Application {

let envChanged = existingManagedEnv != targetEnvVars

// Check if we need to recreate the builder due to different image
let imageChanged = existingImage != builderImage
let cpuChanged = {
if let cpus {
Expand All @@ -129,19 +137,19 @@ extension Application {
return false
}()

let existingNameserver = existingContainer.configuration.dns?.nameservers.first
let existingTcpEnabled = existingContainer.configuration.dns?.options.contains("use-vc") ?? false
let dnsChanged = existingNameserver != gatewayNameserver || existingTcpEnabled != dnsTcp

switch existingContainer.status {
case .running:
guard imageChanged || cpuChanged || memChanged || envChanged else {
// If image, mem and cpu are the same, continue using the existing builder
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
return
}
// If they changed, stop and delete the existing builder
try await existingContainer.stop()
try await existingContainer.delete()
case .stopped:
// If the builder is stopped and matches our requirements, start it
// Otherwise, delete it and create a new one
guard imageChanged || cpuChanged || memChanged || envChanged else {
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
try await existingContainer.startBuildKit(progressUpdate, nil)
return
}
Expand Down Expand Up @@ -225,15 +233,14 @@ extension Application {
// Enable Rosetta only if the user didn't ask to disable it
config.rosetta = useRosetta

let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName)
guard case .running(_, let networkStatus) = network else {
throw ContainerizationError(.invalidState, message: "default network is not running")
}
config.networks = [AttachmentConfiguration(network: network.id, options: AttachmentOptions(hostname: id))]
let subnet = networkStatus.ipv4Subnet
let nameserver = IPv4Address(subnet.lower.value + 1).description
let nameservers = [nameserver]
config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers)
let dnsOptions = dnsTcp ? ["use-vc"] : []
let hostSearchDomains = HostDNSResolver.getHostSearchDomains()
config.dns = ContainerConfiguration.DNSConfiguration(
nameservers: [gatewayNameserver],
searchDomains: hostSearchDomains,
options: dnsOptions
)

let kernel = try await {
await progressUpdate([
Expand Down
2 changes: 1 addition & 1 deletion Sources/DNSServer/DNSHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//===----------------------------------------------------------------------===//

/// Protocol for implementing custom DNS handlers.
public protocol DNSHandler {
public protocol DNSHandler: Sendable {
/// Attempt to answer a DNS query
/// - Parameter query: the query message
/// - Throws: a server failure occurred during the query
Expand Down
160 changes: 160 additions & 0 deletions Sources/DNSServer/DNSServerManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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 DNS
import Foundation
import Logging
import NIOCore
import NIOPosix

/// Manages DNS server listeners on multiple addresses.
/// Supports dynamic addition of listeners when networks become available.
public actor DNSServerManager {
private let handler: DNSHandler
private let port: Int
private let log: Logger?
private var listeners: [String: Task<Void, Error>] = [:]
private var channels: [String: NIOAsyncChannel<AddressedEnvelope<ByteBuffer>, AddressedEnvelope<ByteBuffer>>] = [:]

public init(handler: DNSHandler, port: Int, log: Logger? = nil) {
self.handler = handler
self.port = port
self.log = log
}

/// Adds a DNS listener on the specified host address.
/// - Parameter host: The IP address to bind to (e.g., "127.0.0.1" or "192.168.64.1")
public func addListener(host: String) async throws {
guard listeners[host] == nil else {
log?.debug("DNS listener already exists for \(host)")
return
}

log?.info("Adding DNS listener", metadata: ["host": "\(host)", "port": "\(port)"])

let channel = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.bind(host: host, port: port)
.flatMapThrowing { channel in
try NIOAsyncChannel(
wrappingChannelSynchronously: channel,
configuration: NIOAsyncChannel.Configuration(
inboundType: AddressedEnvelope<ByteBuffer>.self,
outboundType: AddressedEnvelope<ByteBuffer>.self
)
)
}
.get()

channels[host] = channel

// Capture handler reference for use in task
let handlerCopy = self.handler
let logCopy = self.log

let task = Task {
try await channel.executeThenClose { inbound, outbound in
for try await var packet in inbound {
try await Self.handlePacket(handler: handlerCopy, outbound: outbound, packet: &packet, log: logCopy)
}
}
}

listeners[host] = task
log?.info("DNS listener started", metadata: ["host": "\(host)", "port": "\(port)"])
}

/// Removes a DNS listener for the specified host address.
/// - Parameter host: The IP address to stop listening on
public func removeListener(host: String) async {
guard let task = listeners[host] else {
log?.debug("No DNS listener found for \(host)")
return
}

log?.info("Removing DNS listener", metadata: ["host": "\(host)"])
task.cancel()
listeners.removeValue(forKey: host)

if let channel = channels.removeValue(forKey: host) {
try? await channel.channel.close()
}
}

/// Returns the list of hosts currently being listened on.
public func activeHosts() -> [String] {
Array(listeners.keys)
}

/// Waits for all listeners to complete (used for graceful shutdown).
public func waitForAll() async {
for (_, task) in listeners {
_ = try? await task.value
}
}

private static func handlePacket(
handler: DNSHandler,
outbound: NIOAsyncChannelOutboundWriter<AddressedEnvelope<ByteBuffer>>,
packet: inout AddressedEnvelope<ByteBuffer>,
log: Logger?
) async throws {
let chunkSize = 512
var data = Data()

while packet.data.readableBytes > 0 {
if let chunk = packet.data.readBytes(length: min(chunkSize, packet.data.readableBytes)) {
data.append(contentsOf: chunk)
}
}

let query = try Message(deserialize: data)
log?.debug("processing query: \(query.questions)")

let responseData: Data
do {
var response =
try await handler.answer(query: query)
?? Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)

// Only set NXDOMAIN if handler didn't explicitly set noError (NODATA response).
if response.answers.isEmpty && response.returnCode != .noError {
response.returnCode = .nonExistentDomain
}

responseData = try response.serialize()
} catch {
log?.error("error processing DNS message: \(error)")
let response = Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
responseData = try response.serialize()
}

let rData = ByteBuffer(bytes: responseData)
try? await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData))
}
}
4 changes: 2 additions & 2 deletions Sources/DNSServer/Handlers/HostTableResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import DNS
@preconcurrency import DNS

/// Handler that uses table lookup to resolve hostnames.
public struct HostTableResolver: DNSHandler {
public struct HostTableResolver: DNSHandler, @unchecked Sendable {
public let hosts4: [String: IPv4]
private let ttl: UInt32

Expand Down
32 changes: 18 additions & 14 deletions Sources/Helpers/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ extension APIServer {
routes: &routes
)

// Set up DNS server manager with dynamic binding support
let hostsResolver = ContainerDNSHandler(networkService: networkService)
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsManager = DNSServerManager(handler: hostsQueryValidator, port: Self.dnsPort, log: log)

// Connect NetworksService to DNS manager for dynamic binding
let dnsBindingAdapter = DNSBindingAdapter(dnsManager: dnsManager, log: log)
await networkService.setDNSDelegate(dnsBindingAdapter)

// Start listening on localhost for /etc/resolver lookups
try await dnsManager.addListener(host: Self.listenAddress)

// Bind to gateway IPs of any networks that started during init
await networkService.notifyDNSDelegateOfRunningNetworks()

let server = XPCServer(
identifier: "com.apple.container.apiserver",
routes: routes.reduce(
Expand All @@ -88,21 +105,8 @@ extension APIServer {
log.info("starting XPC server")
try await server.listen()
}
// start up host table DNS
group.addTask {
let hostsResolver = ContainerDNSHandler(networkService: networkService)
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS host query resolver",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.dnsPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
await dnsManager.waitForAll()
}
}
} catch {
Expand Down
46 changes: 46 additions & 0 deletions Sources/Helpers/APIServer/DNSBindingAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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 ContainerAPIService
import ContainerizationExtras
import DNSServer
import Logging

/// Adapter that connects NetworksService events to DNSServerManager.
/// Binds DNS listeners to network gateway addresses when networks start.
public struct DNSBindingAdapter: NetworkDNSDelegate, Sendable {
private let dnsManager: DNSServerManager
private let log: Logger?

public init(dnsManager: DNSServerManager, log: Logger? = nil) {
self.dnsManager = dnsManager
self.log = log
}

public func networkDidStart(gateway: IPv4Address) async {
let host = gateway.description
do {
try await dnsManager.addListener(host: host)
} catch {
log?.error("Failed to add DNS listener for \(host): \(error)")
}
}

public func networkDidStop(gateway: IPv4Address) async {
let host = gateway.description
await dnsManager.removeListener(host: host)
}
}
Loading