From 9286756677f6670607e8bee165d78f9f83799210 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Sat, 27 Jun 2026 06:58:11 +0600 Subject: [PATCH 1/4] Normalize network hostname lookup --- .../Network/Server/AttachmentAllocator.swift | 10 ++++- .../AttachmentAllocatorTest.swift | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift index b7d3aeebb..80551a328 100644 --- a/Sources/Services/Network/Server/AttachmentAllocator.swift +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -30,6 +30,7 @@ actor AttachmentAllocator { /// Allocate a network address for a host. func allocate(hostname: String) async throws -> 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) From 712f517485f762d14e1dfecd536408239e9140dd Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Sat, 27 Jun 2026 06:58:19 +0600 Subject: [PATCH 2/4] Add DNS forwarding groundwork --- Sources/DNSServer/DNSHandler.swift | 29 ++++ Sources/DNSServer/DNSServer+Handle.swift | 16 +- Sources/DNSServer/DNSServer.swift | 3 + .../Handlers/CompositeResolver.swift | 10 ++ .../Handlers/ForwardingResolver.swift | 142 ++++++++++++++++++ Sources/DNSServer/Records/Message.swift | 54 +++++-- .../CompositeResolverTest.swift | 43 ++++++ .../ForwardingResolverTest.swift | 65 ++++++++ Tests/DNSServerTests/RecordsTests.swift | 30 ++++ 9 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 Sources/DNSServer/Handlers/ForwardingResolver.swift create mode 100644 Tests/DNSServerTests/ForwardingResolverTest.swift 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.. + #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") From ff1da85971e657812d54edf73f1b1615dc47495b Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Sun, 28 Jun 2026 10:12:41 +0600 Subject: [PATCH 3/4] Document DNS subsystem design --- docs/design/dns.md | 213 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/design/dns.md 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. From f8f149b0be54f3ce09fa86fc4c6b898e2c342876 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Sat, 27 Jun 2026 06:58:29 +0600 Subject: [PATCH 4/4] Add network attachment aliases --- .../Network/Attachment.swift | 9 +++ .../Network/AttachmentConfiguration.swift | 6 +- .../ContainerAPIService/Client/Flags.swift | 2 +- .../ContainerAPIService/Client/Parser.swift | 21 +++++-- .../ContainerAPIService/Client/Utility.swift | 14 ++++- .../Server/Containers/ContainersService.swift | 21 ++++++- .../Network/Client/NetworkClient.swift | 12 ++++ .../Services/Network/Client/NetworkKeys.swift | 1 + .../Network/Server/AttachmentAllocator.swift | 49 ++++++++++++++-- .../Server/DefaultNetworkService.swift | 6 +- .../Network/Server/NetworkHarness.swift | 2 + .../Network/Server/NetworkService.swift | 1 + .../RuntimeLinux/Server/RuntimeService.swift | 2 + .../ContainerAPIClientTests/ParserTest.swift | 22 +++++++ .../UtilityTests.swift | 22 +++++++ .../AttachmentAllocatorTest.swift | 58 +++++++++++++++++++ .../NetworkConfigurationTest.swift | 39 +++++++++++++ 17 files changed, 269 insertions(+), 18 deletions(-) diff --git a/Sources/ContainerResource/Network/Attachment.swift b/Sources/ContainerResource/Network/Attachment.swift index a6351ab3b..4e718baa8 100644 --- a/Sources/ContainerResource/Network/Attachment.swift +++ b/Sources/ContainerResource/Network/Attachment.swift @@ -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. @@ -39,6 +41,7 @@ public struct Attachment: Codable, Sendable { public init( network: String, hostname: String, + aliases: [String] = [], ipv4Address: CIDRv4, ipv4Gateway: IPv4Address, ipv6Address: CIDRv6?, @@ -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 @@ -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 @@ -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 { @@ -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) diff --git a/Sources/ContainerResource/Network/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift index d7e3dff65..da33ef13b 100644 --- a/Sources/ContainerResource/Network/AttachmentConfiguration.swift +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -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 } diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index d26eddd75..0cfde17b9 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -286,7 +286,7 @@ public struct Flags { @Option(name: .long, help: "Use the specified name as the container ID") public var name: String? - @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE])") + @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,alias=NAME][,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE])") public var networks: [String] = [] @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index ef209df5c..5b6cfe54c 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -812,19 +812,21 @@ public struct Parser { /// Parsed network attachment with optional properties public struct ParsedNetwork { public let name: String + public let aliases: [String] public let macAddress: String? public let mtu: UInt32? - public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { + public init(name: String, aliases: [String] = [], macAddress: String? = nil, mtu: UInt32? = nil) { self.name = name + self.aliases = aliases self.macAddress = macAddress self.mtu = mtu } } /// Parse network attachment with optional properties - /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] - /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" + /// Format: network_name[,alias=NAME][,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] + /// Example: "backend,alias=db,mac=02:42:ac:11:00:02,mtu=1500" public static func network(_ networkSpec: String) throws -> ParsedNetwork { guard !networkSpec.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") @@ -841,6 +843,7 @@ public struct Parser { throw ContainerizationError(.invalidArgument, message: "network name cannot be empty") } + var aliases: [String] = [] var macAddress: String? var mtu: UInt32? @@ -861,6 +864,14 @@ public struct Parser { value = String(keyVal[1]) switch key { + case "alias": + if value.isEmpty { + throw ContainerizationError( + .invalidArgument, + message: "alias value cannot be empty" + ) + } + aliases.append(value) case "mac": if value.isEmpty { throw ContainerizationError( @@ -880,12 +891,12 @@ public struct Parser { default: throw ContainerizationError( .invalidArgument, - message: "unknown network property '\(key)'. Available properties: mac, mtu" + message: "unknown network property '\(key)'. Available properties: alias, mac, mtu" ) } } - return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) + return ParsedNetwork(name: networkName, aliases: aliases, macAddress: macAddress, mtu: mtu) } // MARK: DNS diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index ce937ebdf..f5a04882f 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -317,12 +317,22 @@ public struct Utility { guard item.offset == 0 else { return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions( + hostname: containerId, + aliases: item.element.aliases, + macAddress: macAddress, + mtu: mtu + ) ) } return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions( + hostname: fqdn ?? containerId, + aliases: item.element.aliases, + macAddress: macAddress, + mtu: mtu + ) ) } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 41f33d491..5e477d5bc 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -65,6 +65,15 @@ public actor ContainersService { // FIXME: Find a better mechanism for services running on the APIServer to work with each other private weak var networksService: NetworksService? + private static func dnsNames(for options: AttachmentOptions) -> [String] { + ([options.hostname] + options.aliases).map(normalizedDNSName) + } + + private static func normalizedDNSName(_ name: String) -> String { + let name = name.hasSuffix(".") ? String(name.dropLast()) : name + return name.lowercased() + } + public init( appRoot: URL, pluginLoader: PluginLoader, @@ -293,14 +302,20 @@ public actor ContainersService { var allHostnames = Set() for container in await self.containers.values { for attachmentConfiguration in container.snapshot.configuration.networks { - allHostnames.insert(attachmentConfiguration.options.hostname) + for name in Self.dnsNames(for: attachmentConfiguration.options) { + allHostnames.insert(name) + } } } var conflictingHostnames = [String]() for attachmentConfiguration in configuration.networks { - if allHostnames.contains(attachmentConfiguration.options.hostname) { - conflictingHostnames.append(attachmentConfiguration.options.hostname) + for name in Self.dnsNames(for: attachmentConfiguration.options) { + if allHostnames.contains(name) { + conflictingHostnames.append(name) + } else { + allHostnames.insert(name) + } } } diff --git a/Sources/Services/Network/Client/NetworkClient.swift b/Sources/Services/Network/Client/NetworkClient.swift index 97598a786..b6fd7f7ad 100644 --- a/Sources/Services/Network/Client/NetworkClient.swift +++ b/Sources/Services/Network/Client/NetworkClient.swift @@ -69,11 +69,16 @@ extension NetworkClient { /// releases the allocation on the network helper automatically. public func allocate( hostname: String, + aliases: [String] = [], macAddress: MACAddress? = nil, on session: XPCClientSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + if !aliases.isEmpty { + let data = try JSONEncoder().encode(aliases) + request.set(key: NetworkKeys.aliases.rawValue, value: data) + } if let macAddress = macAddress { request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) } @@ -124,6 +129,13 @@ extension XPCMessage { return hostname } + public func aliases() throws -> [String] { + guard let data = self.dataNoCopy(key: NetworkKeys.aliases.rawValue) else { + return [] + } + return try JSONDecoder().decode([String].self, from: data) + } + public func status() throws -> NetworkStatus { let data = self.dataNoCopy(key: NetworkKeys.status.rawValue) guard let data else { diff --git a/Sources/Services/Network/Client/NetworkKeys.swift b/Sources/Services/Network/Client/NetworkKeys.swift index b2ec74180..9e852f66e 100644 --- a/Sources/Services/Network/Client/NetworkKeys.swift +++ b/Sources/Services/Network/Client/NetworkKeys.swift @@ -16,6 +16,7 @@ public enum NetworkKeys: String { case additionalData + case aliases case attachment case hostname case macAddress diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift index 80551a328..c3164b32e 100644 --- a/Sources/Services/Network/Server/AttachmentAllocator.swift +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -28,16 +28,29 @@ actor AttachmentAllocator { ) } - /// Allocate a network address for a host. - func allocate(hostname: String) async throws -> UInt32 { - let hostname = Self.normalized(hostname: hostname) + /// Allocate a network address for a host and any additional DNS aliases. + func allocate(hostname: String, aliases: [String] = []) async throws -> UInt32 { + let names = Self.normalizedNames(hostname: hostname, aliases: aliases) + guard names.allSatisfy({ !$0.isEmpty }) else { + throw ContainerizationError(.invalidArgument, message: "hostname and aliases cannot be empty") + } + let hostname = names[0] + // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists if let index = hostnames[hostname] { + try ensureNames(names, canMapTo: index) + for name in names { + hostnames[name] = index + } return index } + try ensureNames(names, canMapTo: nil) + let index = try allocator.allocate() - hostnames[hostname] = index + for name in names { + hostnames[name] = index + } return index } @@ -46,10 +59,17 @@ actor AttachmentAllocator { @discardableResult func deallocate(hostname: String) async throws -> UInt32? { let hostname = Self.normalized(hostname: hostname) - guard let index = hostnames.removeValue(forKey: hostname) else { + guard let index = hostnames[hostname] else { return nil } + let names = hostnames.compactMap { name, mappedIndex in + mappedIndex == index ? name : nil + } + for name in names { + hostnames.removeValue(forKey: name) + } + try allocator.release(index) return index } @@ -64,4 +84,23 @@ actor AttachmentAllocator { let hostname = hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname return hostname.lowercased() } + + private static func normalizedNames(hostname: String, aliases: [String]) -> [String] { + var seen = Set() + return ([hostname] + aliases) + .map(Self.normalized(hostname:)) + .filter { seen.insert($0).inserted } + } + + private func ensureNames(_ names: [String], canMapTo expectedIndex: UInt32?) throws { + for name in names { + guard let index = hostnames[name] else { + continue + } + if let expectedIndex, expectedIndex == index { + continue + } + throw ContainerizationError(.exists, message: "hostname already exists: \(name)") + } + } } diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 70d17d396..bed3300d9 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -56,6 +56,7 @@ public actor DefaultNetworkService: NetworkService { @Sendable public func allocate( hostname: String, + aliases: [String], macAddress: MACAddress?, session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { @@ -67,13 +68,14 @@ public actor DefaultNetworkService: NetworkService { } let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) - let index = try await allocator.allocate(hostname: hostname) + let index = try await allocator.allocate(hostname: hostname, aliases: aliases) let ipv6Address = try status.ipv6Subnet .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } let ip = IPv4Address(index) let attachment = Attachment( network: network.id, hostname: hostname, + aliases: aliases, ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix), ipv4Gateway: status.ipv4Gateway, ipv6Address: ipv6Address, @@ -84,6 +86,7 @@ public actor DefaultNetworkService: NetworkService { "allocated attachment", metadata: [ "hostname": "\(hostname)", + "aliases": "\(aliases)", "ipv4Address": "\(attachment.ipv4Address)", "ipv4Gateway": "\(attachment.ipv4Gateway)", "ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")", @@ -144,6 +147,7 @@ public actor DefaultNetworkService: NetworkService { let attachment = Attachment( network: network.id, hostname: hostname, + aliases: [], ipv4Address: ipv4Address, ipv4Gateway: status.ipv4Gateway, ipv6Address: ipv6Address, diff --git a/Sources/Services/Network/Server/NetworkHarness.swift b/Sources/Services/Network/Server/NetworkHarness.swift index 6c9a96a16..b7afe1ebf 100644 --- a/Sources/Services/Network/Server/NetworkHarness.swift +++ b/Sources/Services/Network/Server/NetworkHarness.swift @@ -38,12 +38,14 @@ public actor NetworkHarness: Sendable { @Sendable public func allocate(_ message: XPCMessage, _ session: XPCServerSession) async throws -> XPCMessage { let hostname = try message.hostname() + let aliases = try message.aliases() let macAddress = try message.string(key: NetworkKeys.macAddress.rawValue) .map { try MACAddress($0) } let (attachment:attachment, additionalData:additionalData) = try await service.allocate( hostname: hostname, + aliases: aliases, macAddress: macAddress, session: session ) diff --git a/Sources/Services/Network/Server/NetworkService.swift b/Sources/Services/Network/Server/NetworkService.swift index 6e9f2b784..f4647a665 100644 --- a/Sources/Services/Network/Server/NetworkService.swift +++ b/Sources/Services/Network/Server/NetworkService.swift @@ -26,6 +26,7 @@ public protocol NetworkService: Sendable { /// Register a hostname and allocate associated addresses. func allocate( hostname: String, + aliases: [String], macAddress: MACAddress?, session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 2320ff455..42d3a3472 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -182,6 +182,7 @@ public actor RuntimeService { sessions.append(session) var (attachment, additionalData) = try await client.allocate( hostname: attachmentConfig.options.hostname, + aliases: attachmentConfig.options.aliases, macAddress: attachmentConfig.options.macAddress, on: session ) @@ -189,6 +190,7 @@ public actor RuntimeService { attachment = Attachment( network: attachment.network, hostname: attachment.hostname, + aliases: attachment.aliases, ipv4Address: attachment.ipv4Address, ipv4Gateway: attachment.ipv4Gateway, ipv6Address: attachment.ipv6Address, diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 0dcc6f7cf..ca1c397c5 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -832,6 +832,15 @@ struct ParserTest { func testParseNetworkSimpleName() throws { let result = try Parser.network("default") #expect(result.name == "default") + #expect(result.aliases.isEmpty) + #expect(result.macAddress == nil) + } + + @Test + func testParseNetworkWithAliases() throws { + let result = try Parser.network("backend,alias=db,alias=database") + #expect(result.name == "backend") + #expect(result.aliases == ["db", "database"]) #expect(result.macAddress == nil) } @@ -839,6 +848,7 @@ struct ParserTest { func testParseNetworkWithMACAddress() throws { let result = try Parser.network("backend,mac=02:42:ac:11:00:02") #expect(result.name == "backend") + #expect(result.aliases.isEmpty) #expect(result.macAddress == "02:42:ac:11:00:02") } @@ -885,6 +895,18 @@ struct ParserTest { } } + @Test + func testParseNetworkEmptyAlias() throws { + #expect { + _ = try Parser.network("backend,alias=") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("alias value cannot be empty") + } + } + @Test func testParseNetworkUnknownProperty() throws { #expect { diff --git a/Tests/ContainerAPIClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift index 3a9b3a495..cbd731d2a 100644 --- a/Tests/ContainerAPIClientTests/UtilityTests.swift +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -113,6 +113,28 @@ struct UtilityTests { #expect(Utility.trimDigest(digest: "sha256:abc") == "abc") } + @Test("Attachment configurations preserve network aliases") + func testAttachmentConfigurationsPreserveAliases() throws { + let attachments = try Utility.getAttachmentConfigurations( + containerId: "web", + builtinNetworkId: "default", + networks: [ + Parser.ParsedNetwork( + name: "default", + aliases: ["db", "database"], + mtu: 1500 + ) + ], + dnsDomain: "test" + ) + + #expect(attachments.count == 1) + #expect(attachments[0].network == "default") + #expect(attachments[0].options.hostname == "web.test.") + #expect(attachments[0].options.aliases == ["db", "database"]) + #expect(attachments[0].options.mtu == 1500) + } + @Test func testPublishPortParser() throws { let ports = try Parser.publishPorts([ diff --git a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift index 8fadef3a2..e69758b11 100644 --- a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift +++ b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift @@ -58,6 +58,42 @@ struct AttachmentAllocatorTest { #expect(lookedUpAddress == allocatedAddress) } + @Test func testLookupAllocatedAlias() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host", aliases: ["alias-host"]) + let lookedUpAddress = try await allocator.lookup(hostname: "alias-host") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testAliasLookupIsNormalized() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host", aliases: ["Alias-Host."]) + let lookedUpAddress = try await allocator.lookup(hostname: "alias-host") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testAliasConflictThrows() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + _ = try await allocator.allocate(hostname: "host1") + + await #expect(throws: Error.self) { + try await allocator.allocate(hostname: "host2", aliases: ["host1"]) + } + } + + @Test func testEmptyAliasThrows() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + await #expect(throws: Error.self) { + try await allocator.allocate(hostname: "host", aliases: [""]) + } + } + @Test func testLookupAllocatedHostnameWithTrailingDot() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) @@ -106,6 +142,28 @@ struct AttachmentAllocatorTest { #expect(lookedUpAddress == nil) } + @Test func testDeallocateAllocatedHostnameRemovesAliases() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host", aliases: ["alias-host"]) + let deallocatedAddress = try await allocator.deallocate(hostname: "test-host") + + #expect(deallocatedAddress == allocatedAddress) + #expect(try await allocator.lookup(hostname: "test-host") == nil) + #expect(try await allocator.lookup(hostname: "alias-host") == nil) + } + + @Test func testDeallocateAliasRemovesHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host", aliases: ["alias-host"]) + let deallocatedAddress = try await allocator.deallocate(hostname: "alias-host") + + #expect(deallocatedAddress == allocatedAddress) + #expect(try await allocator.lookup(hostname: "test-host") == nil) + #expect(try await allocator.lookup(hostname: "alias-host") == nil) + } + @Test func testDeallocateAllocatedHostnameWithEquivalentName() async throws { let allocator = try AttachmentAllocator(lower: 100, size: 10) diff --git a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift index e3fd1faa2..7ac7f07ef 100644 --- a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift +++ b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift @@ -16,6 +16,7 @@ import ContainerizationError import ContainerizationExtras +import Foundation import Testing @testable import ContainerResource @@ -83,3 +84,41 @@ struct NetworkConfigurationTest { } } + +struct AttachmentTest { + @Test func roundTripsAliases() throws { + let attachment = try makeAttachment(aliases: ["db", "database"]) + let data = try JSONEncoder().encode(attachment) + let obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(obj["aliases"] as? [String] == ["db", "database"]) + + let decoded = try JSONDecoder().decode(ContainerResource.Attachment.self, from: data) + #expect(decoded.aliases == ["db", "database"]) + } + + @Test func decodesMissingAliasesAsEmpty() throws { + let attachment = try makeAttachment(aliases: ["db"]) + let data = try JSONEncoder().encode(attachment) + var obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + obj.removeValue(forKey: "aliases") + + let legacyData = try JSONSerialization.data(withJSONObject: obj) + let decoded = try JSONDecoder().decode(ContainerResource.Attachment.self, from: legacyData) + + #expect(decoded.aliases.isEmpty) + } + + private func makeAttachment(aliases: [String]) throws -> ContainerResource.Attachment { + ContainerResource.Attachment( + network: "default", + hostname: "web.test.", + aliases: aliases, + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: try IPv4Address("192.0.2.1"), + ipv6Address: nil, + macAddress: nil, + mtu: 1500 + ) + } +}