Skip to content

Commit 4a8a8a6

Browse files
authored
Use Sendable DNS types. (#1269)
- Closes #1268. - The types we were using weren't very usable with Swift 6 structured concurrency. - Implements just the subset of records that we use. - Use notImplemented instead of formatError for unknown record types. - Use pure actor for LocalhostDNSHandler now that we have sendable types. - Use DNSName as key for table lookups in LocalhostDNSHandler and HostTableResolver. - Utilize dot-suffixed domain names everywhere in the lookup chain. - Huge thanks to @manojmahapatra and @katiewasnothere for their diligent and patient review :D
1 parent 63434ce commit 4a8a8a6

24 files changed

+1928
-216
lines changed

Package.resolved

Lines changed: 1 addition & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ let package = Package(
4747
.library(name: "TerminalProgress", targets: ["TerminalProgress"]),
4848
],
4949
dependencies: [
50-
.package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"),
5150
.package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)),
5251
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
5352
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"),
@@ -56,7 +55,6 @@ let package = Package(
5655
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"),
5756
.package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"),
5857
.package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"),
59-
.package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"),
6058
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"),
6159
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"),
6260
],
@@ -427,17 +425,15 @@ let package = Package(
427425
dependencies: [
428426
.product(name: "NIOCore", package: "swift-nio"),
429427
.product(name: "NIOPosix", package: "swift-nio"),
430-
.product(name: "DNSClient", package: "DNSClient"),
431-
.product(name: "DNS", package: "DNS"),
432428
.product(name: "Logging", package: "swift-log"),
429+
.product(name: "ContainerizationExtras", package: "containerization"),
433430
.product(name: "ContainerizationOS", package: "containerization"),
434431
]
435432
),
436433
.testTarget(
437434
name: "DNSServerTests",
438435
dependencies: [
439-
.product(name: "DNS", package: "DNS"),
440-
"DNSServer",
436+
"DNSServer"
441437
]
442438
),
443439
.testTarget(

Sources/DNSServer/DNSServer+Handle.swift

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,32 @@ extension DNSServer {
2727
outbound: NIOAsyncChannelOutboundWriter<AddressedEnvelope<ByteBuffer>>,
2828
packet: inout AddressedEnvelope<ByteBuffer>
2929
) async throws {
30-
let chunkSize = 512
31-
var data = Data()
30+
// RFC 1035 §2.3.4 limits UDP DNS messages to 512 bytes. We don't implement
31+
// EDNS0 (RFC 6891), and this server only resolves host A/AAAA queries, so a
32+
// legitimate query will never approach this limit. Reject oversized packets
33+
// before reading to avoid allocating memory for malformed or malicious datagrams.
34+
let maxPacketSize = 512
35+
guard packet.data.readableBytes <= maxPacketSize else {
36+
self.log?.error("dropping oversized DNS packet: \(packet.data.readableBytes) bytes")
37+
return
38+
}
3239

40+
var data = Data()
3341
self.log?.debug("reading data")
3442
while packet.data.readableBytes > 0 {
35-
if let chunk = packet.data.readBytes(length: min(chunkSize, packet.data.readableBytes)) {
43+
if let chunk = packet.data.readBytes(length: packet.data.readableBytes) {
3644
data.append(contentsOf: chunk)
3745
}
3846
}
3947

4048
self.log?.debug("deserializing message")
41-
let query = try Message(deserialize: data)
42-
self.log?.debug("processing query: \(query.questions)")
4349

4450
// always send response
4551
let responseData: Data
4652
do {
53+
let query = try Message(deserialize: data)
54+
self.log?.debug("processing query: \(query.questions)")
55+
4756
self.log?.debug("awaiting processing")
4857
var response =
4958
try await handler.answer(query: query)
@@ -64,21 +73,48 @@ extension DNSServer {
6473

6574
self.log?.debug("serializing response")
6675
responseData = try response.serialize()
76+
} catch let error as DNSBindError {
77+
// Best-effort: echo the transaction ID from the first two bytes of the raw packet.
78+
let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0
79+
let id = UInt16(bigEndian: rawId)
80+
let returnCode: ReturnCode
81+
switch error {
82+
case .unsupportedValue:
83+
self.log?.error("not implemented processing DNS message: \(error)")
84+
returnCode = .notImplemented
85+
default:
86+
self.log?.error("format error processing DNS message: \(error)")
87+
returnCode = .formatError
88+
}
89+
let response = Message(
90+
id: id,
91+
type: .response,
92+
returnCode: returnCode,
93+
questions: [],
94+
answers: []
95+
)
96+
responseData = try response.serialize()
6797
} catch {
68-
self.log?.error("error processing message from \(query): \(error)")
98+
let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0
99+
let id = UInt16(bigEndian: rawId)
100+
self.log?.error("error processing DNS message: \(error)")
69101
let response = Message(
70-
id: query.id,
102+
id: id,
71103
type: .response,
72-
returnCode: .notImplemented,
73-
questions: query.questions,
104+
returnCode: .serverFailure,
105+
questions: [],
74106
answers: []
75107
)
76108
responseData = try response.serialize()
77109
}
78110

79-
self.log?.debug("sending response for \(query.id)")
111+
self.log?.debug("sending response")
80112
let rData = ByteBuffer(bytes: responseData)
81-
try? await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData))
113+
do {
114+
try await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData))
115+
} catch {
116+
self.log?.error("failed to send DNS response: \(error)")
117+
}
82118

83119
self.log?.debug("processing done")
84120

Sources/DNSServer/Handlers/HostTableResolver.swift

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,44 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17-
import DNS
17+
import ContainerizationExtras
1818

1919
/// Handler that uses table lookup to resolve hostnames.
20+
///
21+
/// Keys in `hosts4` are normalized to `DNSName` on construction, so lookups
22+
/// are case-insensitive and trailing dots are optional.
2023
public struct HostTableResolver: DNSHandler {
21-
public let hosts4: [String: IPv4]
24+
public let hosts4: [DNSName: IPv4Address]
2225
private let ttl: UInt32
2326

24-
public init(hosts4: [String: IPv4], ttl: UInt32 = 300) {
25-
self.hosts4 = hosts4
27+
/// Creates a resolver backed by a static IPv4 host table.
28+
///
29+
/// - Parameter hosts4: A dictionary mapping domain names to IPv4 addresses.
30+
/// Keys are normalized to `DNSName` (lowercased, trailing dot stripped), so
31+
/// `"FOO."`, `"foo."`, and `"foo"` all refer to the same entry.
32+
/// - Parameter ttl: The TTL in seconds to set on answer records (default is 300).
33+
/// - Throws: `DNSBindError.invalidName` if any key is not a valid DNS name.
34+
public init(hosts4: [String: IPv4Address], ttl: UInt32 = 300) throws {
35+
self.hosts4 = try Dictionary(uniqueKeysWithValues: hosts4.map { (try DNSName($0.key), $0.value) })
2636
self.ttl = ttl
2737
}
2838

2939
public func answer(query: Message) async throws -> Message? {
30-
let question = query.questions[0]
40+
guard let question = query.questions.first else {
41+
return nil
42+
}
43+
let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name
44+
let key = try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))
3145
let record: ResourceRecord?
3246
switch question.type {
3347
case ResourceRecordType.host:
34-
record = answerHost(question: question)
48+
record = answerHost(question: question, key: key)
3549
case ResourceRecordType.host6:
3650
// Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists.
3751
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
3852
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
3953
// NODATA correctly indicates "no IPv6 address available, but domain exists".
40-
if hosts4[question.name] != nil {
54+
if hosts4[key] != nil {
4155
return Message(
4256
id: query.id,
4357
type: .response,
@@ -48,28 +62,11 @@ public struct HostTableResolver: DNSHandler {
4862
}
4963
// If hostname doesn't exist, return nil which will become NXDOMAIN
5064
return nil
51-
case ResourceRecordType.nameServer,
52-
ResourceRecordType.alias,
53-
ResourceRecordType.startOfAuthority,
54-
ResourceRecordType.pointer,
55-
ResourceRecordType.mailExchange,
56-
ResourceRecordType.text,
57-
ResourceRecordType.service,
58-
ResourceRecordType.incrementalZoneTransfer,
59-
ResourceRecordType.standardZoneTransfer,
60-
ResourceRecordType.all:
61-
return Message(
62-
id: query.id,
63-
type: .response,
64-
returnCode: .notImplemented,
65-
questions: query.questions,
66-
answers: []
67-
)
6865
default:
6966
return Message(
7067
id: query.id,
7168
type: .response,
72-
returnCode: .formatError,
69+
returnCode: .notImplemented,
7370
questions: query.questions,
7471
answers: []
7572
)
@@ -88,11 +85,11 @@ public struct HostTableResolver: DNSHandler {
8885
)
8986
}
9087

91-
private func answerHost(question: Question) -> ResourceRecord? {
92-
guard let ip = hosts4[question.name] else {
88+
private func answerHost(question: Question, key: DNSName) -> ResourceRecord? {
89+
guard let ip = hosts4[key] else {
9390
return nil
9491
}
9592

96-
return HostRecord<IPv4>(name: question.name, ttl: ttl, ip: ip)
93+
return HostRecord<IPv4Address>(name: question.name, ttl: ttl, ip: ip)
9794
}
9895
}

Sources/DNSServer/Handlers/NxDomainResolver.swift

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17-
import DNS
18-
1917
/// Handler that returns NXDOMAIN for all hostnames.
2018
public struct NxDomainResolver: DNSHandler {
2119
private let ttl: UInt32
@@ -35,29 +33,11 @@ public struct NxDomainResolver: DNSHandler {
3533
questions: query.questions,
3634
answers: []
3735
)
38-
case ResourceRecordType.nameServer,
39-
ResourceRecordType.alias,
40-
ResourceRecordType.startOfAuthority,
41-
ResourceRecordType.pointer,
42-
ResourceRecordType.mailExchange,
43-
ResourceRecordType.text,
44-
ResourceRecordType.host6,
45-
ResourceRecordType.service,
46-
ResourceRecordType.incrementalZoneTransfer,
47-
ResourceRecordType.standardZoneTransfer,
48-
ResourceRecordType.all:
49-
return Message(
50-
id: query.id,
51-
type: .response,
52-
returnCode: .notImplemented,
53-
questions: query.questions,
54-
answers: []
55-
)
5636
default:
5737
return Message(
5838
id: query.id,
5939
type: .response,
60-
returnCode: .formatError,
40+
returnCode: .notImplemented,
6141
questions: query.questions,
6242
answers: []
6343
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
/// Errors that can occur during DNS message serialization/deserialization.
18+
public enum DNSBindError: Error, CustomStringConvertible {
19+
case marshalFailure(type: String, field: String)
20+
case unmarshalFailure(type: String, field: String)
21+
case unsupportedValue(type: String, field: String)
22+
case invalidName(String)
23+
case unexpectedOffset(type: String, expected: Int, actual: Int)
24+
25+
public var description: String {
26+
switch self {
27+
case .marshalFailure(let type, let field):
28+
return "failed to marshal \(type).\(field)"
29+
case .unmarshalFailure(let type, let field):
30+
return "failed to unmarshal \(type).\(field)"
31+
case .unsupportedValue(let type, let field):
32+
return "unsupported value for \(type).\(field)"
33+
case .invalidName(let reason):
34+
return "invalid DNS name: \(reason)"
35+
case .unexpectedOffset(let type, let expected, let actual):
36+
return "unexpected offset serializing \(type): expected \(expected), got \(actual)"
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)