Fix container DNS resolution broken by AAAA/IPv6 NXDOMAIN handling (#786)

- In Alpine Linux containers (commonly used as Docker base images),
  standard DNS resolution is provided by **musl**, a lightweight C
  standard library (libc). Musl implements DNS lookups via
  `getaddrinfo()`, which queries AAAA (IPv6) records first.
- Problem: DNS did not work correctly **inside containers**. Any
  system command attempting to resolve hostnames
  (e.g., `ping dynamodb-admin`) **failed** when the DNS server
  responded NXDOMAIN for AAAA records, even if A (IPv4) records
  existed. Explicitly forcing IPv4 (`ping -4dynamodb-admin`) worked
  correctly, showing the issue is specific to musl’s IPv6-first behavior.
- Consequence: In IPv4-only environments, Alpine-based containers
  cannot resolve hostnames using standard tools or libraries.
  Applications relying on `getaddrinfo()` fail with ENOTFOUND,
  breaking networking and inter-container communication.
- Root cause:  Following RFC 8305 / RFC 6724, musl treats NXDOMAIN
  for AAAA as “hostname does not exist” and does not fallback to A
  (IPv4) records.
- Fix: The Apple Container DNS engine now behaves as follows:
  * If an **A record exists**, AAAA queries return **NOERROR with empty
    answer (NODATA)**.
  * If neither **A nor AAAA** exist, NXDOMAIN is returned.
  This ensures that Alpine-based containers in IPv4-only networks can
  correctly resolve hostnames inside containers without modifying
  container images or application code.
This commit is contained in:
Rado Kubiak | Rado x Tech
2025-10-30 17:03:17 -07:00
committed by GitHub
parent 13a2f1a9e3
commit 745cc1813e
4 changed files with 80 additions and 5 deletions
+4 -2
View File
@@ -55,8 +55,10 @@ extension DNSServer {
answers: []
)
// no responses
if response.answers.isEmpty {
// Only set NXDOMAIN if handler didn't explicitly set noError (NODATA response).
// This preserves NODATA responses for AAAA queries when A record exists,
// which prevents musl libc from treating empty AAAA as "domain doesn't exist".
if response.answers.isEmpty && response.returnCode != .noError {
response.returnCode = .nonExistentDomain
}
@@ -32,13 +32,28 @@ public struct HostTableResolver: DNSHandler {
switch question.type {
case ResourceRecordType.host:
record = answerHost(question: question)
case ResourceRecordType.host6:
// Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
if hosts4[question.name] != nil {
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
}
// If hostname doesn't exist, return nil which will become NXDOMAIN
return nil
case ResourceRecordType.nameServer,
ResourceRecordType.alias,
ResourceRecordType.startOfAuthority,
ResourceRecordType.pointer,
ResourceRecordType.mailExchange,
ResourceRecordType.text,
ResourceRecordType.host6,
ResourceRecordType.service,
ResourceRecordType.incrementalZoneTransfer,
ResourceRecordType.standardZoneTransfer,
@@ -34,13 +34,28 @@ struct ContainerDNSHandler: DNSHandler {
switch question.type {
case ResourceRecordType.host:
record = try await answerHost(question: question)
case ResourceRecordType.host6:
// Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
if try await networkService.lookup(hostname: question.name) != nil {
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
}
// If hostname doesn't exist, return nil which will become NXDOMAIN
return nil
case ResourceRecordType.nameServer,
ResourceRecordType.alias,
ResourceRecordType.startOfAuthority,
ResourceRecordType.pointer,
ResourceRecordType.mailExchange,
ResourceRecordType.text,
ResourceRecordType.host6,
ResourceRecordType.service,
ResourceRecordType.incrementalZoneTransfer,
ResourceRecordType.standardZoneTransfer,
@@ -30,7 +30,7 @@ struct HostTableResolverTest {
id: UInt16(1),
type: .query,
questions: [
Question(name: "foo", type: .host6)
Question(name: "foo", type: .mailExchange)
])
let response = try await handler.answer(query: query)
@@ -42,6 +42,49 @@ struct HostTableResolverTest {
#expect(0 == response?.answers.count)
}
@Test func testAAAAQueryReturnsNoDataWhenARecordExists() async throws {
guard let ip = IPv4("1.2.3.4") else {
throw DNSResolverError.serverError("cannot create IP address in test")
}
let handler = HostTableResolver(hosts4: ["foo": ip])
let query = Message(
id: UInt16(1),
type: .query,
questions: [
Question(name: "foo", type: .host6)
])
let response = try await handler.answer(query: query)
// AAAA queries should return NODATA (noError with empty answers) when A record exists
// to avoid musl libc issues where NXDOMAIN causes complete DNS resolution failure
#expect(.noError == response?.returnCode)
#expect(1 == response?.id)
#expect(.response == response?.type)
#expect(1 == response?.questions.count)
#expect(0 == response?.answers.count)
}
@Test func testAAAAQueryReturnsNilWhenHostDoesNotExist() async throws {
guard let ip = IPv4("1.2.3.4") else {
throw DNSResolverError.serverError("cannot create IP address in test")
}
let handler = HostTableResolver(hosts4: ["foo": ip])
let query = Message(
id: UInt16(1),
type: .query,
questions: [
Question(name: "bar", type: .host6)
])
let response = try await handler.answer(query: query)
// AAAA queries for non-existent hosts should return nil (which becomes NXDOMAIN)
#expect(nil == response)
}
@Test func testHostNotPresent() async throws {
guard let ip = IPv4("1.2.3.4") else {
throw DNSResolverError.serverError("cannot create IP address in test")