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
9 changes: 9 additions & 0 deletions Sources/ContainerResource/Network/Attachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public struct Attachment: Codable, Sendable {
public let network: String
/// The hostname associated with the attachment.
public let hostname: String
/// Additional DNS names associated with the attachment.
public let aliases: [String]
/// The CIDR address describing the interface IPv4 address, with the prefix length of the subnet.
public let ipv4Address: CIDRv4
/// The IPv4 gateway address.
Expand All @@ -39,6 +41,7 @@ public struct Attachment: Codable, Sendable {
public init(
network: String,
hostname: String,
aliases: [String] = [],
ipv4Address: CIDRv4,
ipv4Gateway: IPv4Address,
ipv6Address: CIDRv6?,
Expand All @@ -48,6 +51,7 @@ public struct Attachment: Codable, Sendable {
) {
self.network = network
self.hostname = hostname
self.aliases = aliases
self.ipv4Address = ipv4Address
self.ipv4Gateway = ipv4Gateway
self.ipv6Address = ipv6Address
Expand All @@ -59,6 +63,7 @@ public struct Attachment: Codable, Sendable {
enum CodingKeys: String, CodingKey {
case network
case hostname
case aliases
case ipv4Address
case ipv4Gateway
case ipv6Address
Expand All @@ -77,6 +82,7 @@ public struct Attachment: Codable, Sendable {

network = try container.decode(String.self, forKey: .network)
hostname = try container.decode(String.self, forKey: .hostname)
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
if let address = try? container.decode(CIDRv4.self, forKey: .ipv4Address) {
ipv4Address = address
} else {
Expand All @@ -99,6 +105,9 @@ public struct Attachment: Codable, Sendable {

try container.encode(network, forKey: .network)
try container.encode(hostname, forKey: .hostname)
if !aliases.isEmpty {
try container.encode(aliases, forKey: .aliases)
}
try container.encode(ipv4Address, forKey: .ipv4Address)
try container.encode(ipv4Gateway, forKey: .ipv4Gateway)
try container.encodeIfPresent(ipv6Address, forKey: .ipv6Address)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,18 @@ public struct AttachmentOptions: Codable, Sendable {
/// The hostname associated with the attachment.
public let hostname: String

/// Additional DNS names associated with the attachment.
public let aliases: [String]

/// The MAC address associated with the attachment (optional).
public let macAddress: MACAddress?

/// The MTU for the network interface.
public let mtu: UInt32?

public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil) {
public init(hostname: String, aliases: [String] = [], macAddress: MACAddress? = nil, mtu: UInt32? = nil) {
self.hostname = hostname
self.aliases = aliases
self.macAddress = macAddress
self.mtu = mtu
}
Expand Down
29 changes: 29 additions & 0 deletions Sources/DNSServer/DNSHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

/// Context for a DNS request.
public struct DNSRequestContext: Sendable {
/// The source IP address for the datagram, if available.
public let remoteIPAddress: String?

/// The source port for the datagram, if available.
public let remotePort: Int?

public init(remoteIPAddress: String? = nil, remotePort: Int? = nil) {
self.remoteIPAddress = remoteIPAddress
self.remotePort = remotePort
}
}

/// Protocol for implementing custom DNS handlers.
public protocol DNSHandler {
/// Attempt to answer a DNS query
Expand All @@ -22,4 +36,19 @@ public protocol DNSHandler {
/// - Returns: The response message for the query, or nil if the request
/// is not within the scope of the handler.
func answer(query: Message) async throws -> Message?

/// Attempt to answer a DNS query with request context.
/// - Parameters:
/// - query: the query message
/// - context: request context such as the source address.
/// - Throws: a server failure occurred during the query
/// - Returns: The response message for the query, or nil if the request
/// is not within the scope of the handler.
func answer(query: Message, context: DNSRequestContext) async throws -> Message?
}

extension DNSHandler {
public func answer(query: Message, context: DNSRequestContext) async throws -> Message? {
try await answer(query: query)
}
}
16 changes: 13 additions & 3 deletions Sources/DNSServer/DNSServer+Handle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,25 @@ extension DNSServer {
self.log?.debug("processing query: \(query.questions)")

self.log?.debug("awaiting processing")
var response =
try await handler.answer(query: query)
?? Message(
let context = DNSRequestContext(
remoteIPAddress: packet.remoteAddress.ipAddress,
remotePort: packet.remoteAddress.port
)
var response: Message
if let handlerResponse = try await handler.answer(query: query, context: context) {
response = handlerResponse
} else if respondWhenUnhandled {
response = Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
} else {
self.log?.debug("dropping unhandled DNS query")
return
}

// Only set NXDOMAIN if handler didn't explicitly set noError (NODATA response).
// This preserves NODATA responses for AAAA queries when A record exists,
Expand Down
3 changes: 3 additions & 0 deletions Sources/DNSServer/DNSServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ import NIOPosix
/// - port: The port for the server to listen.
public struct DNSServer {
public var handler: DNSHandler
let respondWhenUnhandled: Bool
let log: Logger?

public init(
handler: DNSHandler,
respondWhenUnhandled: Bool = true,
log: Logger? = nil
) {
self.handler = handler
self.respondWhenUnhandled = respondWhenUnhandled
self.log = log
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/DNSServer/Handlers/CompositeResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ public struct CompositeResolver: DNSHandler {

return nil
}

public func answer(query: Message, context: DNSRequestContext) async throws -> Message? {
for handler in self.handlers {
if let response = try await handler.answer(query: query, context: context) {
return response
}
}

return nil
}
}
142 changes: 142 additions & 0 deletions Sources/DNSServer/Handlers/ForwardingResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
// 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 NIOCore
import NIOPosix

/// Handler that forwards DNS queries to upstream DNS servers.
public struct ForwardingResolver: DNSHandler {
public struct Upstream: Sendable, Equatable {
public let host: String
public let port: Int

public init(host: String, port: Int = 53) {
self.host = host
self.port = port
}
}

private let upstreams: [Upstream]
private let timeoutNanoseconds: UInt64
private let log: Logger?

public init(
upstreams: [Upstream],
timeoutNanoseconds: UInt64 = 2_000_000_000,
log: Logger? = nil
) {
self.upstreams = upstreams
self.timeoutNanoseconds = timeoutNanoseconds
self.log = log
}

public func answer(query: Message) async throws -> Message? {
for upstream in upstreams {
do {
if let response = try await forward(query: query, to: upstream) {
return response
}
} catch {
log?.debug(
"DNS upstream query failed",
metadata: [
"host": "\(upstream.host)",
"port": "\(upstream.port)",
"error": "\(error)",
])
}
}
return nil
}

public static func systemUpstreams(resolvConfPath: String = "/etc/resolv.conf") -> [Upstream] {
guard let contents = try? String(contentsOfFile: resolvConfPath, encoding: .utf8) else {
return [Upstream(host: "1.1.1.1")]
}

let upstreams = contents
.split(whereSeparator: \.isNewline)
.compactMap { line -> Upstream? in
let stripped = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
let parts = stripped.split(whereSeparator: \.isWhitespace)
guard parts.count >= 2, parts[0] == "nameserver" else {
return nil
}
let host = String(parts[1])
guard isUsableUpstreamHost(host) else {
return nil
}
return Upstream(host: host)
}

return upstreams.isEmpty ? [Upstream(host: "1.1.1.1")] : upstreams
}

private static func isUsableUpstreamHost(_ host: String) -> Bool {
guard !host.hasPrefix("127."), host != "::1", host != "0.0.0.0", host != "::" else {
return false
}
return (try? SocketAddress(ipAddress: host, port: 53)) != nil
}

private func forward(query: Message, to upstream: Upstream) async throws -> Message? {
let queryData = try query.serialize()
let upstreamAddress = try SocketAddress(ipAddress: upstream.host, port: upstream.port)
let bindHost = upstream.host.contains(":") ? "::" : "0.0.0.0"
let channel = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup)
.bind(host: bindHost, port: 0)
.flatMapThrowing { channel in
try NIOAsyncChannel(
wrappingChannelSynchronously: channel,
configuration: NIOAsyncChannel.Configuration(
inboundType: AddressedEnvelope<ByteBuffer>.self,
outboundType: AddressedEnvelope<ByteBuffer>.self
)
)
}
.get()

return try await channel.executeThenClose { inbound, outbound in
try await outbound.write(AddressedEnvelope(remoteAddress: upstreamAddress, data: ByteBuffer(bytes: queryData)))
let timeoutNanoseconds = self.timeoutNanoseconds

return try await withThrowingTaskGroup(of: Message?.self) { group in
group.addTask {
for try await var packet in inbound {
var data = Data()
while packet.data.readableBytes > 0 {
if let chunk = packet.data.readBytes(length: packet.data.readableBytes) {
data.append(contentsOf: chunk)
}
}
return try Message(deserialize: data)
}
return nil
}
group.addTask {
try await Task.sleep(nanoseconds: timeoutNanoseconds)
return nil
}

let result = try await group.next() ?? nil
group.cancelAll()
return result
}
}
}
}
Loading