close
Skip to content

Use Sendable DNS types.#1269

Merged
jglogan merged 24 commits intoapple:mainfrom
jglogan:dns-records
Mar 19, 2026
Merged

Use Sendable DNS types.#1269
jglogan merged 24 commits intoapple:mainfrom
jglogan:dns-records

Conversation

@jglogan
Copy link
Copy Markdown
Contributor

@jglogan jglogan commented Feb 26, 2026

  • Closes [Request]: Eliminate external DNS library dependencies. #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.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Testing

  • Tested locally
  • Added/updated tests
  • Added/updated docs


/// The fully-qualified domain name with trailing dot.
public var description: String {
labels.isEmpty ? "." : labels.joined(separator: ".") + "."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: some lookup paths appear to use plain string (no normalization), will this change cause any issue? For example in AttachmentAllocator;

// store
func allocate(hostname: String).. 

// lookup
func lookup(hostname: String) async throws -> UInt32? {
        hostnames[hostname]
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manojmahapatra Thanks for looking at this! I converted it to draft.

This one was done by telling Claude to create the types we needed using the bit-fiddling code that ContainerizationNetlink uses, and I pushed it primarily to back the change up and get something started for the issue.

I still need to review the work myself and sanity check it, but I'll bet I would have missed this!

I'll have a look through the relevant RFCs as I review this myself. This isn't an area I'm super expert in so if you've got experience and suggestions I'd welcome them!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert in this area either, but I reviewed the PR in detail against current behavior. Overall this is moving in the right direction.

I see two gaps worth addressing here:

  • Hostname normalization consistency (e.g. foo vs foo.) across parse/store/lookup paths. (additional focused unit tests will be nice)
  • Compression-pointer safety in DNSName decode to avoid malformed packet loops. Something like this maybe;
// Calculate pointer offset from message start
                let pointerLocation = offset
                let pointer = Int(length & 0x3F) << 8 | Int(buffer[offset + 1])
                let pointerTarget = messageStart + pointer
                guard pointerTarget < pointerLocation else {
                    throw DNSBindError.unmarshalFailure(type: "DNSName", field: "compression pointer not prior")
                }
                offset = pointerTarget

@jglogan jglogan marked this pull request as draft March 4, 2026 03:33
@jglogan jglogan force-pushed the dns-records branch 3 times, most recently from 0d90e59 to 32eac7e Compare March 12, 2026 21:17
jglogan added 3 commits March 12, 2026 17:33
- Closes apple#1268.
- The types we were using weren't very usable
  with Swift 6 structured concurrency.
- Use notImplemented instead of formatError for
  unknown record types.
- Use pure actor for LocalhostDNSHandler now
  that we have sendable types.
- Require all input domain names to carry
  trailing dot.
- Fold labels to lowercase.
- Reject compression pointer recursion.
@jglogan jglogan marked this pull request as ready for review March 13, 2026 00:45
@jglogan
Copy link
Copy Markdown
Contributor Author

jglogan commented Mar 13, 2026

@manojmahapatra I've addressed what you called out, and also we're now folding to lowercase when creating domain name records.

The larger change (requiring a trailing dot on all the DNS APIs) is in the first follow-up commit, and the smaller one for case folding and compression pointer recursion preventions is in the second one.

Finally got myself out from under some other obligations which've kept me busy elsewhere. I'll look over your apple/containerization#577 early tomorrow. Thank you again for your scrutiny on this PR!

///
/// - Parameter dnsHostname: A DNS-format hostname, optionally with a trailing dot
/// (e.g. `"example.com."` or `"example.com"`). The trailing dot is stripped before lookup.
public func lookup(dnsHostname: String) async throws -> Attachment? {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Looks like it's a public API break due to the function rename? Do we need a shim?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, my bad. With the docc description we don't really need to change the parameter name.

/// Handler that uses table lookup to resolve hostnames.
///
/// All keys in `hosts4` must be canonical DNS names — fully-qualified with a
/// trailing dot (e.g. `"example.com."`). This matches the canonical form used
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment thread Sources/DNSServer/Records/DNSName.swift Outdated
public init(_ string: String) {
// Remove trailing dot if present, then split
let normalized = string.hasSuffix(".") ? String(string.dropLast()) : string
self.labels = normalized.isEmpty ? [] : normalized.split(separator: ".").map { String($0).lowercased() }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: So here labels are lowercases when parsing names. However, HostTableResolver does not normalize keys on init, which I think might be an issue. can you please confirm?

Copy link
Copy Markdown
Contributor Author

@jglogan jglogan Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching that. We're not using HostTableResolver yet, but it would have been broken. I reworked the case folding and the HostTableResolver a little to do things correctly, and a little more cleanly.

@manojmahapatra
Copy link
Copy Markdown
Contributor

@jglogan overall the changes looks good, just two questions.

///
/// - Parameter hosts4: A dictionary mapping fully-qualified domain names (with trailing dot)
/// to IPv4 addresses. Keys without a trailing dot will not match wire-decoded queries.
/// - Parameter ttl: The TTL in seconds to set on answer records.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The TTL in seconds to set on answer records. Defaults to 300.

Comment thread Tests/DNSServerTests/RecordsTests.swift Outdated
let endOffset = try record.appendBuffer(&buffer, offset: 0)

// Should have written: name + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(4)
#expect(endOffset > 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check that the end offset is > 4 instead?

Comment thread Tests/DNSServerTests/RecordsTests.swift
Comment thread Tests/DNSServerTests/RecordsTests.swift
Copy link
Copy Markdown
Contributor

@katiewasnothere katiewasnothere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple test requests, otherwise LGTM

Copy link
Copy Markdown
Contributor

@manojmahapatra manojmahapatra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jglogan jglogan modified the milestone: 2026-03 Mar 18, 2026
Copy link
Copy Markdown
Contributor

@katiewasnothere katiewasnothere left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

jglogan added 8 commits March 18, 2026 17:19
- Removes the Bindable prototype as we're not
  using it.
- Fix a double-optional expression, use flatMap.
- Validate the DNS name extracted from the
  resolv.conf option, and then use the lowercased
  name as the DNS dict key.
- Return NODATA on AAAA query if an A record exists,
  which matches HostTableResolver and
  ContainerDNSHandler behavior.
- Lookup now matches HostTableResolver.
/// are case-insensitive and trailing dots are optional.
public struct HostTableResolver: DNSHandler {
public let hosts4: [String: IPv4]
public let hosts4: [DNSName: IPv4Address]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary:

  • Replace IPv4 with our IPv4Address everywhere.
  • Use DNSName for keys to validate and normalize hostnames for lookup.


public func answer(query: Message) async throws -> Message? {
let question = query.questions[0]
guard let question = query.questions.first else {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: updated all handlers to ensure questions[0] is present, and if not, defer to the next handler

This would result in a NXDOMAIN when the last handler returns nil, when a .formatError would be more appropriate. The typical usage, though, is to put a StandardQueryValidator at the front of the chain which ensures that exactly one question is present and returns a .formatError otherwise.

guard let question = query.questions.first else {
return nil
}
let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: for the DNS names that come in on the wire, we don't perform hostname validation - we just split into labels and form a DNSName, and look them up. If they have weird characters or are otherwise invalid hostnames, they'll just miss as L35 ensures that our keys are always valid hostnames.

}
// If hostname doesn't exist, return nil which will become NXDOMAIN
return nil
case ResourceRecordType.nameServer,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: all unhandled question types in handlers should return .notImplemented

//===----------------------------------------------------------------------===//

/// Errors that can occur during DNS message serialization/deserialization.
public enum DNSBindError: Error, CustomStringConvertible {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: extracted types from Bindable.swift into individual files

public var labels: [String]

/// Creates a DNS name representing the root (empty label list).
public init() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: used to building DNS names label by label from wire data

- Ensures labels are normalized.
// We can look at refining this later to see if we can use some common
// bit fiddling code everywhere.

extension [UInt8] {
Copy link
Copy Markdown
Contributor Author

@jglogan jglogan Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: removed Bindable.swift, extracting types into individual files, this code is brought over from containerization

// EDNS0 (RFC 6891), and this server only resolves host A/AAAA queries, so a
// legitimate query will never approach this limit. Reject oversized packets
// before reading to avoid allocating memory for malformed or malicious datagrams.
let maxPacketSize = 512
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: added for safety


self.log?.debug("serializing response")
responseData = try response.serialize()
} catch let error as DNSBindError {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: we now differentiate between structural errors in DNS queries (.formatError) and unsupported query values (.notImplemented)

responseData = try response.serialize()
} catch {
self.log?.error("error processing message from \(query): \(error)")
let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: our "500" path

private let watcher: DirectoryWatcher

private let dns: Mutex<[String: IPv4]>
private var dns: [DNSName: IPv4Address]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: no need for Mutex, actor isolation is fine here, and use DNSName keys to ensure hostname validity


if let match = content.firstMatch(of: regex),
let ipv4 = (match[1].substring.map { String($0) })
let ipv4 = (match[1].substring.flatMap { try? IPv4Address(String($0)) })
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: flatMap should be used here since substring produces an optional

guard let question = query.questions.first else {
return nil
}
let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as HostTableHandler, don't need to do hostname validation on the wire data.

? ""
: HostDNSResolver.localhostOptionsRegex.replacingOccurrences(
of: #"\((.*?)\)"#, with: localhost!.description, options: .regularExpression)
localhost.map {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summary: remove force-unwrap

@jglogan jglogan merged commit 4a8a8a6 into apple:main Mar 19, 2026
3 checks passed
@jglogan jglogan deleted the dns-records branch March 19, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Request]: Eliminate external DNS library dependencies.

3 participants