diff --git a/Sources/DNSServer/DNSHandler.swift b/Sources/DNSServer/DNSHandler.swift index 4a5008c62..fbd080c4d 100644 --- a/Sources/DNSServer/DNSHandler.swift +++ b/Sources/DNSServer/DNSHandler.swift @@ -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 @@ -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) + } } diff --git a/Sources/DNSServer/DNSServer+Handle.swift b/Sources/DNSServer/DNSServer+Handle.swift index 9b2bc9fa5..0a656034c 100644 --- a/Sources/DNSServer/DNSServer+Handle.swift +++ b/Sources/DNSServer/DNSServer+Handle.swift @@ -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, diff --git a/Sources/DNSServer/DNSServer.swift b/Sources/DNSServer/DNSServer.swift index d38e55459..d9411cc76 100644 --- a/Sources/DNSServer/DNSServer.swift +++ b/Sources/DNSServer/DNSServer.swift @@ -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 } diff --git a/Sources/DNSServer/Handlers/CompositeResolver.swift b/Sources/DNSServer/Handlers/CompositeResolver.swift index e090ce1fb..c5b5387fc 100644 --- a/Sources/DNSServer/Handlers/CompositeResolver.swift +++ b/Sources/DNSServer/Handlers/CompositeResolver.swift @@ -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 + } } diff --git a/Sources/DNSServer/Handlers/ForwardingResolver.swift b/Sources/DNSServer/Handlers/ForwardingResolver.swift new file mode 100644 index 000000000..7cc98ec46 --- /dev/null +++ b/Sources/DNSServer/Handlers/ForwardingResolver.swift @@ -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.self, + outboundType: AddressedEnvelope.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 + } + } + } +} diff --git a/Sources/DNSServer/Records/Message.swift b/Sources/DNSServer/Records/Message.swift index 3442cca40..8927ab501 100644 --- a/Sources/DNSServer/Records/Message.swift +++ b/Sources/DNSServer/Records/Message.swift @@ -74,6 +74,11 @@ public struct Message: Sendable { /// Additional resource records. public var additional: [ResourceRecord] + private var rawAnswerCount: UInt16? + private var rawAuthorityCount: UInt16? + private var rawAdditionalCount: UInt16? + private var rawResourceRecords: Data? + /// Creates a new DNS message. public init( id: UInt16 = 0, @@ -101,6 +106,10 @@ public struct Message: Sendable { self.answers = answers self.authorities = authorities self.additional = additional + self.rawAnswerCount = nil + self.rawAuthorityCount = nil + self.rawAdditionalCount = nil + self.rawResourceRecords = nil } /// Deserialize a DNS message from raw data. @@ -153,15 +162,13 @@ public struct Message: Sendable { guard let (newOffset, rawNsCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "nscount") } - // nsCount not used for now, but we need to read past it - _ = UInt16(bigEndian: rawNsCount) + let nsCount = UInt16(bigEndian: rawNsCount) offset = newOffset guard let (newOffset, rawArCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { throw DNSBindError.unmarshalFailure(type: "Message", field: "arcount") } - // arCount not used for now, but we need to read past it - _ = UInt16(bigEndian: rawArCount) + let arCount = UInt16(bigEndian: rawArCount) offset = newOffset // Read questions @@ -172,13 +179,16 @@ public struct Message: Sendable { self.questions.append(question) } - // Read answers (simplified - skip for now as we only need to parse queries) + // Resource-record parsing is intentionally minimal. Preserve the raw + // section so forwarded DNS responses can be serialized without dropping + // upstream answers. self.answers = [] self.authorities = [] self.additional = [] - - // Skip answer parsing for now - we primarily receive queries and send responses - _ = anCount + self.rawAnswerCount = anCount + self.rawAuthorityCount = nsCount + self.rawAdditionalCount = arCount + self.rawResourceRecords = offset < buffer.count ? Data(buffer[offset...]) : nil } /// Serialize this message to raw data. @@ -196,6 +206,9 @@ public struct Message: Sendable { let rdataSize = answer.type == .host ? 4 : 16 bufferSize += (try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))).size + 10 + rdataSize } + if shouldSerializeRawResourceRecords, let rawResourceRecords { + bufferSize += rawResourceRecords.count + } var buffer = [UInt8](repeating: 0, count: bufferSize) var offset = 0 @@ -240,17 +253,21 @@ public struct Message: Sendable { } offset = newOffset - guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(answers.count).bigEndian, offset: offset) else { + let answerCount = shouldSerializeRawResourceRecords ? (rawAnswerCount ?? 0) : UInt16(answers.count) + let authorityCount = shouldSerializeRawResourceRecords ? (rawAuthorityCount ?? 0) : UInt16(authorities.count) + let additionalCount = shouldSerializeRawResourceRecords ? (rawAdditionalCount ?? 0) : UInt16(additional.count) + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: answerCount.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "ancount") } offset = newOffset - guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(authorities.count).bigEndian, offset: offset) else { + guard let newOffset = buffer.copyIn(as: UInt16.self, value: authorityCount.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "nscount") } offset = newOffset - guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(additional.count).bigEndian, offset: offset) else { + guard let newOffset = buffer.copyIn(as: UInt16.self, value: additionalCount.bigEndian, offset: offset) else { throw DNSBindError.marshalFailure(type: "Message", field: "arcount") } offset = newOffset @@ -260,6 +277,17 @@ public struct Message: Sendable { offset = try question.appendBuffer(&buffer, offset: offset) } + if shouldSerializeRawResourceRecords, let rawResourceRecords { + guard let newOffset = buffer.copyIn(buffer: Array(rawResourceRecords), offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "resourceRecords") + } + offset = newOffset + guard offset == bufferSize else { + throw DNSBindError.unexpectedOffset(type: "Message", expected: bufferSize, actual: offset) + } + return Data(buffer[0.. UInt32 { + let hostname = Self.normalized(hostname: hostname) // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists if let index = hostnames[hostname] { return index @@ -44,6 +45,7 @@ actor AttachmentAllocator { /// Free an allocated network address by hostname. @discardableResult func deallocate(hostname: String) async throws -> UInt32? { + let hostname = Self.normalized(hostname: hostname) guard let index = hostnames.removeValue(forKey: hostname) else { return nil } @@ -54,6 +56,12 @@ actor AttachmentAllocator { /// Retrieve the allocator index for a hostname. func lookup(hostname: String) async throws -> UInt32? { - hostnames[hostname] + let hostname = Self.normalized(hostname: hostname) + return hostnames[hostname] + } + + private static func normalized(hostname: String) -> String { + let hostname = hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname + return hostname.lowercased() } } diff --git a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift index 86ea3eff0..8fadef3a2 100644 --- a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift +++ b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift @@ -58,6 +58,33 @@ struct AttachmentAllocatorTest { #expect(lookedUpAddress == allocatedAddress) } + @Test func testLookupAllocatedHostnameWithTrailingDot() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let lookedUpAddress = try await allocator.lookup(hostname: "test-host.") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testHostnameLookupIsCaseInsensitive() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "Test-Host.") + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testAllocateEquivalentHostnames() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "test-host") + let address2 = try await allocator.allocate(hostname: "TEST-HOST.") + + #expect(address1 == address2) + } + @Test func testLookupNonExistentHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) @@ -79,6 +106,18 @@ struct AttachmentAllocatorTest { #expect(lookedUpAddress == nil) } + @Test func testDeallocateAllocatedHostnameWithEquivalentName() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let deallocatedAddress = try await allocator.deallocate(hostname: "TEST-HOST.") + + #expect(deallocatedAddress == allocatedAddress) + + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + #expect(lookedUpAddress == nil) + } + @Test func testDeallocateNonExistentHostname() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) diff --git a/Tests/DNSServerTests/CompositeResolverTest.swift b/Tests/DNSServerTests/CompositeResolverTest.swift index df9a4fe1b..e907ea3c7 100644 --- a/Tests/DNSServerTests/CompositeResolverTest.swift +++ b/Tests/DNSServerTests/CompositeResolverTest.swift @@ -63,4 +63,47 @@ struct CompositeResolverTest { let otherResponse = try await resolver.answer(query: otherQuery) #expect(nil == otherResponse) } + + @Test func testCompositeResolverPropagatesContext() async throws { + let resolver = CompositeResolver(handlers: [ContextHandler()]) + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "context.", type: .host) + ]) + + let withoutContext = try await resolver.answer(query: query) + #expect(nil == withoutContext) + + let response = try await resolver.answer( + query: query, + context: DNSRequestContext(remoteIPAddress: "192.168.64.2", remotePort: 53) + ) + + #expect(.noError == response?.returnCode) + #expect(1 == response?.answers.count) + let answer = response?.answers[0] as? HostRecord + #expect(try IPv4Address("9.8.7.6") == answer?.ip) + } +} + +private struct ContextHandler: DNSHandler { + func answer(query: Message) async throws -> Message? { + nil + } + + func answer(query: Message, context: DNSRequestContext) async throws -> Message? { + guard context.remoteIPAddress == "192.168.64.2" else { + return nil + } + let ip = try IPv4Address("9.8.7.6") + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [HostRecord(name: query.questions[0].name, ttl: 0, ip: ip)] + ) + } } diff --git a/Tests/DNSServerTests/ForwardingResolverTest.swift b/Tests/DNSServerTests/ForwardingResolverTest.swift new file mode 100644 index 000000000..6dc59a7b0 --- /dev/null +++ b/Tests/DNSServerTests/ForwardingResolverTest.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import DNSServer + +struct ForwardingResolverTest { + @Test func testSystemUpstreamsParsesNameservers() throws { + let resolvConf = try temporaryFile(contents: """ + # comment + nameserver 127.0.0.1 + nameserver 127.0.0.53 + nameserver localhost + nameserver 192.0.2.53 + nameserver 2001:db8::53 + search example.com + """) + defer { try? FileManager.default.removeItem(at: resolvConf) } + + let upstreams = ForwardingResolver.systemUpstreams(resolvConfPath: resolvConf.path) + + #expect([ + ForwardingResolver.Upstream(host: "192.0.2.53"), + ForwardingResolver.Upstream(host: "2001:db8::53"), + ] == upstreams) + } + + @Test func testSystemUpstreamsFallsBackWhenNoUsableNameservers() throws { + let resolvConf = try temporaryFile(contents: """ + nameserver 127.0.0.1 + nameserver 127.0.0.53 + nameserver ::1 + nameserver 0.0.0.0 + nameserver :: + nameserver not-an-ip + """) + defer { try? FileManager.default.removeItem(at: resolvConf) } + + let upstreams = ForwardingResolver.systemUpstreams(resolvConfPath: resolvConf.path) + + #expect([ForwardingResolver.Upstream(host: "1.1.1.1")] == upstreams) + } + + private func temporaryFile(contents: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("container-dns-forwarding-\(UUID().uuidString)") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/DNSServerTests/RecordsTests.swift b/Tests/DNSServerTests/RecordsTests.swift index d1fc0cc8d..dafd96eb8 100644 --- a/Tests/DNSServerTests/RecordsTests.swift +++ b/Tests/DNSServerTests/RecordsTests.swift @@ -586,6 +586,36 @@ struct RecordsTests { #expect(msg.questions[0].recordClass == .internet) } + @Test("Roundtrip preserves raw response answers") + func roundtripPreservesRawResponseAnswers() throws { + let responseBytes: [UInt8] = [ + 0x12, 0x34, // ID + 0x81, 0x80, // Flags: response, recursion desired/available + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x01, // ANCOUNT=1 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + // Question: example.com A IN + 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x03, 0x63, 0x6f, 0x6d, + 0x00, + 0x00, 0x01, + 0x00, 0x01, + // Answer: compressed name pointer to offset 12, A IN, TTL 60, 93.184.216.34 + 0xc0, 0x0c, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x3c, + 0x00, 0x04, + 0x5d, 0xb8, 0xd8, 0x22, + ] + + let message = try Message(deserialize: Data(responseBytes)) + let serialized = try message.serialize() + + #expect(Data(responseBytes) == serialized) + } + @Test("Roundtrip preserves data") func roundtrip() throws { let ip = try IPv4Address("1.2.3.4") diff --git a/docs/design/dns.md b/docs/design/dns.md new file mode 100644 index 000000000..ceb7a42ff --- /dev/null +++ b/docs/design/dns.md @@ -0,0 +1,213 @@ +# DNS Design + +Status: Draft + +This document describes how `container` handles DNS today and records the +behavioral requirements that should hold as DNS support changes. It is intended +to be updated as related hostname, alias, forwarding, and host networking work +lands. + +## Goals + +- Describe the current DNS paths for host-to-container, container-to-host, and + workload-to-upstream resolution. +- Keep DNS server configuration distinct from per-container resolver + configuration. +- Record invariants for name normalization, conflict checks, DNS record + generation, and forwarding behavior. +- Provide a place to evaluate container-facing DNS designs before they are + wired into the runtime path. + +## Non-Goals + +- This document does not specify a full Docker Compose compatibility layer. +- This document does not require a single implementation strategy for a + container-facing DNS listener. +- This document does not change user-facing command behavior by itself. + +## Terms + +- Workload: a process running inside a Linux container. +- Workload resolver configuration: the DNS configuration written into the + container filesystem, such as `/etc/resolv.conf`. +- Host resolver configuration: macOS resolver state, including files under + `/etc/resolver`. +- Host DNS service: the `container-apiserver` DNS listeners that answer scoped + host queries on loopback ports. +- Hostname database: the per-network mapping from container DNS names to + allocated network attachments. +- Container-facing resolver: a future DNS service reachable from workloads on + an attached container network. + +## Current Behavior + +### Workload to Upstream Resolvers + +When a container has a network attachment but no explicit DNS nameservers, +the Linux runtime configures the container DNS nameserver from the first +attachment gateway. For vmnet NAT networks, workload DNS queries go to the +NAT bridge IP, where the vmnet DNS proxy forwards them through mDNSResponder +to the host's configured upstream resolvers. + +Explicit DNS settings from container configuration are separate from that +default. Flags such as `--dns`, `--dns-search`, `--dns-option`, `--dns-domain`, +and `--no-dns` control the workload resolver configuration. They do not create +or modify host-side DNS listeners. + +### Host to Container Names + +`container system dns create ` writes a scoped macOS resolver +configuration file under `/etc/resolver` using the +`containerization.` prefix, then signals mDNSResponder to reload. +The standard scoped domain points at the host DNS service on +`127.0.0.1:2053`. + +`container-apiserver` starts that host DNS service and uses +`ContainerDNSHandler` to resolve scoped container names through +`NetworksService.lookup(hostname:)`. A records come from IPv4 attachments. +AAAA records come from IPv6 attachments when present. + +### Host to Localhost Names + +`container system dns create --localhost ` writes a scoped +resolver file that points at `127.0.0.1:1053` and records the localhost +mapping in an `options localhost:` entry. `LocalhostDNSHandler` monitors +these resolver files and answers those names with the configured IPv4 address. + +The traffic path for this feature also depends on packet-filter rules managed +through `pfctl`. DNS resolution and packet redirection are separate pieces of +the feature and should report failures separately. + +### Container Names and Hostname Records + +Container creation maps each network attachment to a hostname. The default +network attachment hostname is derived from the configured DNS domain and the +container id. The runtime also uses the first network attachment hostname, or +the container id when no attachment hostname exists, as the Linux guest +hostname. + +Before container creation succeeds, the API server checks existing containers +for conflicting attachment hostnames. The network helper also keeps an +in-memory hostname database for allocated attachments. Hostname lookup is +normalized consistently so case differences and optional trailing dots do not +create distinct records for the same DNS name. + +## Behavioral Requirements + +DNS-001: If a workload has no explicit DNS nameservers and has at least one +network attachment, the runtime MUST configure the workload nameserver from the +first attachment gateway. + +DNS-002: Explicit workload DNS configuration MUST be preserved. Host resolver +configuration and workload resolver configuration MUST NOT silently imply each +other. + +DNS-003: Hostnames used for allocation, lookup, and conflict checks MUST be +normalized consistently. Lookup MUST treat case differences and a single +trailing dot as equivalent. + +DNS-004: For a known hostname with no IPv6 attachment, an AAAA query MUST +return NODATA with `noError`, not NXDOMAIN. Some resolvers treat NXDOMAIN for +AAAA as proof that the name does not exist. + +DNS-005: Host-scoped container DNS MUST only answer names backed by the +hostname database for container networks. + +DNS-006: Container creation MUST fail before start when the requested +attachment hostnames conflict with existing container attachment hostnames. +Future `--alias` or `--hostname` features SHOULD preserve this create-time +conflict policy. + +DNS-007: A container-facing resolver, if added, MUST scope answers and +forwarding to traffic from its attached container network. + +DNS-008: A container-facing resolver MUST prefer local container records before +forwarding to upstream resolvers. + +DNS-009: If vmnet's DNS proxy is disabled for a network, the replacement +resolver MUST start successfully before the network is reported healthy. +Failure to bind or serve the replacement path MUST fail network startup. + +DNS-010: A container-facing resolver MUST NOT rely on wildcard UDP/53 binding +as its primary design. The design needs to account for mDNSResponder, vmnet +bridge addresses, privileges, and third-party DNS, VPN, and network-security +software. + +DNS-011: `host.docker.internal` or `host.container.internal` style support +MUST keep DNS record generation separate from packet-filter redirection. A DNS +success must not hide a `pfctl` failure. + +## Extension Points + +### Name Normalization + +Hostname normalization is shared by the network attachment allocator and +network lookup path. Future DNS features should use the same normalization +rules for container names, aliases, and hostnames before doing conflict checks +or DNS lookups. + +### Aliases + +Aliases should add additional names for an attachment without changing the +container management id. Alias records should participate in the same +create-time conflict checks as primary attachment hostnames. + +### Guest Hostname + +The guest hostname currently comes from the first network attachment hostname +or container id. A distinct user-configured guest hostname is a separate +question from DNS aliases and should not be required for basic service +discovery. + +### Container-Facing DNS + +A container-facing resolver could allow workloads to resolve other containers +on the same network while preserving upstream DNS forwarding. A candidate +design is a per-network resolver owned by the vmnet helper, with local records +served from the network service and upstream queries forwarded to system +resolvers. + +Validation so far shows that binding the resolver directly to the reported +vmnet gateway address is not sufficient: + +- With vmnet DNS proxy left enabled, the signed vmnet helper started the + network, then binding `192.168.64.1:53` failed with `EADDRNOTAVAIL`. +- With `vmnet_network_configuration_disable_dns_proxy` applied before + `vmnet_network_create`, the signed vmnet helper still started the network, + then binding `192.168.64.1:53` still failed with `EADDRNOTAVAIL`. + +Disabling the vmnet DNS proxy alone therefore does not make the gateway address +host-bindable in this environment. + +## Open Questions + +- Does vmnet expose a host-bindable address or socket path for DNS service on + the network gateway, or does this need an mDNSResponder or packet-filter + integration? +- If packet-filter redirection is used, how should rules avoid conflicts with + existing localhost forwarding rules and third-party network software? +- How should custom workload `--dns` settings interact with future + container-facing DNS? +- Which names should be reserved for host access, such as + `host.container.internal`, and where should conflicts be enforced? +- What diagnostics should be shown when mDNSResponder, `/etc/resolver`, vmnet, + or `pfctl` state prevents DNS from working? + +## Test Matrix + +- Host scoped domain: create a domain, start a container, resolve its name from + the host, then delete the domain and verify cleanup. +- Workload upstream DNS: start a container without explicit DNS nameservers and + resolve an external domain from the workload. +- Explicit DNS configuration: verify `--dns`, `--dns-search`, `--dns-option`, + `--dns-domain`, and `--no-dns` preserve the requested workload resolver + configuration. +- Record behavior: verify A, AAAA, NODATA-for-missing-IPv6, and NXDOMAIN for + unknown names. +- Conflict policy: verify duplicate container hostnames and aliases fail before + container start. +- Container-facing resolver candidates: test vmnet proxy enabled and disabled, + default and custom subnets, host-only and NAT networks, and third-party VPN + or DNS software. +- Host access names: verify DNS records and packet-filter redirection failures + are reported independently.