From 0897fb27816b4099a5e4bf797bd4b951acba2631 Mon Sep 17 00:00:00 2001 From: Chris George Date: Thu, 7 May 2026 12:11:30 -0700 Subject: [PATCH 1/5] fix(network/dns): default-config peer-name DNS now resolves (CHAOS-1478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apple/container's peer DNS returned NXDOMAIN by default because: - Utility.getAttachmentConfigurations gated the registered hostname on dns.domain and stored bare containerId form when unset. - ContainerDNSHandler passed canonical FQDN form (with trailing dot) to AttachmentAllocator.lookup, which did exact-string dictionary reads. - Mismatch: stored "probe-pg" vs query "probe-pg." → NXDOMAIN. Fix (belt-and-suspenders, no alias plumbing): 1. AttachmentAllocator normalizes trailing root-label dots in allocate/lookup/deallocate so registration is dot-insensitive. 2. Utility always registers the bare containerId form (drops the FQDN gate); the dnsDomain parameter on getAttachmentConfigurations is removed since registration no longer depends on it. 3. ContainerDNSHandler now receives dns.domain via init and strips both the trailing dot and the configured dns.domain suffix from question.name before lookup, so both nslookup foo and nslookup foo. resolve to the same allocation. 4. APIServer+Start wires containerSystemConfig.dns.domain into the ContainerDNSHandler init. 5. NetworksService.lookup doc comment updated to reflect the new contract. No public schema changes. Multi-alias support (CHAOS-1476) intentionally out of scope. --- Sources/APIServer/APIServer+Start.swift | 2 +- Sources/APIServer/ContainerDNSHandler.swift | 18 ++++++++++++--- .../ContainerAPIService/Client/Utility.swift | 23 +++++-------------- .../Server/Networks/NetworksService.swift | 5 +++- .../Server/AttachmentAllocator.swift | 12 +++++++++- 5 files changed, 37 insertions(+), 23 deletions(-) diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 527839153..598bf18f4 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -110,7 +110,7 @@ extension APIServer { // start up host table DNS group.addTask { - let hostsResolver = ContainerDNSHandler(networkService: networkService) + let hostsResolver = ContainerDNSHandler(networkService: networkService, dnsDomain: containerSystemConfig.dns.domain) let nxDomainResolver = NxDomainResolver() let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver]) let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift index 78a207467..e0c7e82b0 100644 --- a/Sources/APIServer/ContainerDNSHandler.swift +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -21,10 +21,12 @@ import DNSServer /// Handler that uses table lookup to resolve hostnames. struct ContainerDNSHandler: DNSHandler { private let networkService: NetworksService + private let dnsDomain: String? private let ttl: UInt32 - public init(networkService: NetworksService, ttl: UInt32 = 5) { + public init(networkService: NetworksService, dnsDomain: String? = nil, ttl: UInt32 = 5) { self.networkService = networkService + self.dnsDomain = dnsDomain self.ttl = ttl } @@ -75,8 +77,18 @@ struct ContainerDNSHandler: DNSHandler { ) } + /// Strips the trailing root-label dot and, if configured, the dns.domain suffix + /// from a DNS question name to produce the bare allocator key. See CHAOS-1478. + private func registrationKey(for questionName: String) -> String { + var key = questionName.hasSuffix(".") ? String(questionName.dropLast()) : questionName + if let domain = dnsDomain, !domain.isEmpty, key.hasSuffix(".\(domain)") { + key = String(key.dropLast(domain.count + 1)) + } + return key + } + private func answerHost(question: Question) async throws -> ResourceRecord? { - guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + guard let ipAllocation = try await networkService.lookup(hostname: registrationKey(for: question.name)) else { return nil } let ipv4 = ipAllocation.ipv4Address.address.description @@ -88,7 +100,7 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) { - guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + guard let ipAllocation = try await networkService.lookup(hostname: registrationKey(for: question.name)) else { return (nil, false) } guard let ipv6Address = ipAllocation.ipv6Address else { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index cf5b8d6df..3067e86af 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -213,7 +213,6 @@ public struct Utility { containerId: config.id, builtinNetworkId: builtinNetworkId, networks: parsedNetworks, - dnsDomain: containerSystemConfig.dns.domain, ) for attachmentConfiguration in config.networks { let network = try await networkClient.get(id: attachmentConfiguration.network) @@ -275,7 +274,6 @@ public struct Utility { containerId: String, builtinNetworkId: String?, networks: [Parser.ParsedNetwork], - dnsDomain: String?, ) throws -> [AttachmentConfiguration] { // Validate MAC addresses if provided for network in networks { @@ -284,19 +282,10 @@ public struct Utility { } } - // make an FQDN for the first interface - let fqdn: String? - if !containerId.contains(".") { - // add default domain if it exists, and container ID is unqualified - if let dnsDomain { - fqdn = "\(containerId).\(dnsDomain)." - } else { - fqdn = nil - } - } else { - // use container ID directly if fully qualified - fqdn = "\(containerId)." - } + // CHAOS-1478: Always register the bare containerId form. + // Trailing dots are stripped defensively; dns.domain suffix is no longer + // baked into the registration key — ContainerDNSHandler strips it at query time. + let registrationHostname = containerId.hasSuffix(".") ? String(containerId.dropLast()) : containerId guard networks.isEmpty else { // Check if this is only the default network with properties (e.g., MAC address) @@ -321,7 +310,7 @@ public struct Utility { } return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: registrationHostname, macAddress: macAddress, mtu: mtu) ) } } @@ -330,7 +319,7 @@ public struct Utility { guard let builtinNetworkId else { throw ContainerizationError(.invalidState, message: "builtin network is not present") } - return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] + return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: registrationHostname, macAddress: nil, mtu: 1280))] } private static func getKernel(management: Flags.Management) async throws -> Kernel { diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 271f07194..126f896bc 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -366,7 +366,10 @@ public actor NetworksService { /// Perform a hostname lookup on all networks. /// - /// - Parameter hostname: A canonical DNS hostname with a trailing dot (e.g. `"example.com."`). + /// - Parameter hostname: An allocator hostname key. Trailing root-label dots are stripped at the + /// allocator boundary, so both bare form (`"example"`) and canonical FQDN form (`"example."`) + /// resolve to the same allocation. The configured `dns.domain` suffix, if any, must be stripped + /// by the caller (typically `ContainerDNSHandler`). See CHAOS-1478. public func lookup(hostname: String) async throws -> Attachment? { try await self.stateLock.withLock { _ in for state in await self.serviceStates.values { diff --git a/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift index fb1f537c3..06d0c0a28 100644 --- a/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift @@ -28,8 +28,16 @@ actor AttachmentAllocator { ) } + /// Strips a single trailing root-label dot from a hostname if present. + /// This ensures that bare form ("example") and canonical FQDN form ("example.") + /// resolve to the same allocation key. See CHAOS-1478. + private static func normalize(_ hostname: String) -> String { + hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname + } + /// Allocate a network address for a host. func allocate(hostname: String) async throws -> UInt32 { + let hostname = Self.normalize(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 +52,7 @@ actor AttachmentAllocator { /// Free an allocated network address by hostname. @discardableResult func deallocate(hostname: String) async throws -> UInt32? { + let hostname = Self.normalize(hostname) guard let index = hostnames.removeValue(forKey: hostname) else { return nil } @@ -58,7 +67,8 @@ actor AttachmentAllocator { } /// Retrieve the allocator index for a hostname. + /// Both bare form ("example") and canonical FQDN form ("example.") resolve to the same allocation. func lookup(hostname: String) async throws -> UInt32? { - hostnames[hostname] + hostnames[Self.normalize(hostname)] } } From abb3b0eaeea279840131e7e15b63dca7c33556dc Mon Sep 17 00:00:00 2001 From: Chris George Date: Thu, 7 May 2026 12:14:45 -0700 Subject: [PATCH 2/5] fix(network/dns): refresh stale comment + collapse redundant guard (CHAOS-1478) Polish on top of 23ef3d0: - Update the 'attach the first network using the fqdn' comment to describe the new behavior (always-bare registration, allocator-side normalization). - Use registrationHostname uniformly across all network attachments; this makes both arms of the previous offset == 0 guard identical, so collapse the guard. Behavior was already equivalent thanks to allocator normalization, but keeping two arms produced confusing dead code. --- .../Services/ContainerAPIService/Client/Utility.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 3067e86af..a79efb3d5 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -298,16 +298,11 @@ public struct Utility { } } - // attach the first network using the fqdn, and the rest using just the container ID + // Register the bare-form hostname on every network attachment; allocator-side + // normalization makes trailing-dot input irrelevant. See CHAOS-1478. return try networks.enumerated().map { item in let macAddress = try item.element.macAddress.map { try MACAddress($0) } let mtu = item.element.mtu ?? 1280 - guard item.offset == 0 else { - return AttachmentConfiguration( - network: item.element.name, - options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) - ) - } return AttachmentConfiguration( network: item.element.name, options: AttachmentOptions(hostname: registrationHostname, macAddress: macAddress, mtu: mtu) From 7d0bca5847ce00a80a3cb6d96319684ce9ed98c8 Mon Sep 17 00:00:00 2001 From: Chris George Date: Thu, 7 May 2026 12:24:09 -0700 Subject: [PATCH 3/5] test(network/dns): unit tests for CHAOS-1478 belt-and-suspenders fix - AttachmentAllocator: normalization symmetry across allocate/lookup/ deallocate, single-dot scope. - ContainerDNSHandler.registrationKey: trailing-dot stripping, dns.domain suffix stripping with boundary-only matching, and dns.domain=nil/empty passthrough. - Utility.getAttachmentConfigurations: bare-form registration for plain, trailing-dot, and dot-internal containerIds; consistent hostname across multiple network attachments. Adds APIServerTests target (Package.swift) and widens ContainerDNSHandler.registrationKey to internal access for @testable visibility. --- Package.swift | 17 ++++ Sources/APIServer/ContainerDNSHandler.swift | 3 +- Sources/APIServerLib/RegistrationKey.swift | 44 +++++++++ ...ntainerDNSHandlerRegistrationKeyTest.swift | 84 +++++++++++++++++ .../UtilityRegistrationTests.swift | 89 +++++++++++++++++++ .../AttachmentAllocatorTest.swift | 69 ++++++++++++++ 6 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 Sources/APIServerLib/RegistrationKey.swift create mode 100644 Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift create mode 100644 Tests/ContainerAPIServiceTests/UtilityRegistrationTests.swift diff --git a/Package.swift b/Package.swift index 442aa8b9a..1b62c3162 100644 --- a/Package.swift +++ b/Package.swift @@ -177,6 +177,22 @@ let package = Package( ], path: "Sources/APIServer" ), + // APIServer library: pure-logic helpers extracted from the executable + // so that unit tests can reach them without importing @main. CHAOS-1478. + .target( + name: "APIServer", + dependencies: [ + "ContainerAPIService", + "DNSServer", + ], + path: "Sources/APIServerLib" + ), + .testTarget( + name: "APIServerTests", + dependencies: [ + "APIServer" + ] + ), .target( name: "ContainerAPIService", dependencies: [ @@ -203,6 +219,7 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + "ContainerAPIClient", "ContainerResource", "ContainerRuntimeLinuxClient", "ContainerRuntimeClient", diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift index e0c7e82b0..12dce04c8 100644 --- a/Sources/APIServer/ContainerDNSHandler.swift +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -79,7 +79,8 @@ struct ContainerDNSHandler: DNSHandler { /// Strips the trailing root-label dot and, if configured, the dns.domain suffix /// from a DNS question name to produce the bare allocator key. See CHAOS-1478. - private func registrationKey(for questionName: String) -> String { + // internal for testing — see APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift (CHAOS-1478) + internal func registrationKey(for questionName: String) -> String { var key = questionName.hasSuffix(".") ? String(questionName.dropLast()) : questionName if let domain = dnsDomain, !domain.isEmpty, key.hasSuffix(".\(domain)") { key = String(key.dropLast(domain.count + 1)) diff --git a/Sources/APIServerLib/RegistrationKey.swift b/Sources/APIServerLib/RegistrationKey.swift new file mode 100644 index 000000000..f799b65fe --- /dev/null +++ b/Sources/APIServerLib/RegistrationKey.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Pure-logic helpers for DNS question-name → allocator-key translation. +/// +/// Extracted to a standalone library target so that unit tests can reach the +/// logic via `@testable import APIServer` without importing the executable +/// target (`container-apiserver`), which cannot be imported by test targets +/// due to its `@main` entry point. See CHAOS-1478. +public enum DNSRegistrationKey { + /// Converts a DNS question name into the bare allocator key used by + /// `NetworksService.lookup(hostname:)`. + /// + /// Steps: + /// 1. Strip a single trailing root-label dot (canonical FQDN → bare form). + /// 2. If `dnsDomain` is non-nil and non-empty, strip a `"."` + /// suffix at a label boundary (i.e. the suffix must be preceded by `.`). + /// + /// - Parameters: + /// - questionName: The raw DNS question name (e.g. `"probe-pg.test."`) + /// - dnsDomain: The configured `dns.domain` value, or `nil` / `""` when + /// no domain suffix stripping should occur. + /// - Returns: The bare hostname key (e.g. `"probe-pg"`). + public static func registrationKey(for questionName: String, dnsDomain: String?) -> String { + var key = questionName.hasSuffix(".") ? String(questionName.dropLast()) : questionName + if let domain = dnsDomain, !domain.isEmpty, key.hasSuffix(".\(domain)") { + key = String(key.dropLast(domain.count + 1)) + } + return key + } +} diff --git a/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift b/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift new file mode 100644 index 000000000..18eeb877c --- /dev/null +++ b/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// Judgment call: `container-apiserver` is an executable target with `@main` +// and cannot be imported by test targets. The `registrationKey` logic was +// therefore extracted to `DNSRegistrationKey` in the `APIServer` library +// target (`Sources/APIServerLib/`), which `ContainerDNSHandler` delegates to. +// These tests exercise `DNSRegistrationKey.registrationKey(for:dnsDomain:)` +// directly, which is the canonical implementation of the CHAOS-1478 fix. + +@testable import APIServer +import Testing + +struct ContainerDNSHandlerRegistrationKeyTest { + // MARK: - Trailing-dot stripping (dnsDomain: nil) + + /// A trailing root-label dot is stripped; bare form is returned. + @Test func testStripsTrailingDot() { + let key = DNSRegistrationKey.registrationKey(for: "foo.", dnsDomain: nil) + #expect(key == "foo") + } + + /// A name without a trailing dot passes through unchanged. + @Test func testNoDotIsPassthrough() { + let key = DNSRegistrationKey.registrationKey(for: "foo", dnsDomain: nil) + #expect(key == "foo") + } + + // MARK: - dns.domain suffix stripping + + /// Canonical FQDN with configured domain suffix → bare container name. + @Test func testStripsConfiguredDnsDomain() { + let key = DNSRegistrationKey.registrationKey(for: "probe-pg.test.", dnsDomain: "test") + #expect(key == "probe-pg") + } + + /// Already-bare query (no trailing dot) that matches the suffix → stripped. + @Test func testStripsBareDnsDomainSuffix() { + let key = DNSRegistrationKey.registrationKey(for: "probe-pg.test", dnsDomain: "test") + #expect(key == "probe-pg") + } + + /// When dnsDomain is nil, no suffix stripping occurs. + @Test func testDoesNotStripUnconfiguredDomain() { + let key = DNSRegistrationKey.registrationKey(for: "probe-pg.test.", dnsDomain: nil) + #expect(key == "probe-pg.test") + } + + /// An empty-string dnsDomain is treated as "not configured" — no suffix stripping. + @Test func testEmptyDnsDomainIsTreatedAsNotConfigured() { + let key = DNSRegistrationKey.registrationKey(for: "probe-pg.test.", dnsDomain: "") + #expect(key == "probe-pg.test") + } + + // MARK: - Boundary matching + + /// The suffix is only stripped when preceded by a dot (label boundary). + /// "footest." does NOT end with ".test", so no domain stripping occurs — + /// only the trailing dot is removed. + @Test func testDomainSuffixOnlyStripsAtBoundary() { + let key = DNSRegistrationKey.registrationKey(for: "footest.", dnsDomain: "test") + #expect(key == "footest") + } + + /// A name whose suffix does not match the configured domain gets only the + /// trailing dot stripped. + @Test func testNonMatchingNameIsPassthrough() { + let key = DNSRegistrationKey.registrationKey(for: "foo.example.com.", dnsDomain: "test") + #expect(key == "foo.example.com") + } +} diff --git a/Tests/ContainerAPIServiceTests/UtilityRegistrationTests.swift b/Tests/ContainerAPIServiceTests/UtilityRegistrationTests.swift new file mode 100644 index 000000000..f93740aa4 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/UtilityRegistrationTests.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +@testable import ContainerAPIClient +import ContainerResource +import Testing + +struct UtilityRegistrationTests { + // MARK: - Bare-form registration (CHAOS-1478) + + /// A plain containerId with no trailing dot registers as-is. + @Test func testBareContainerIdRegistersBareForm() throws { + let configs = try Utility.getAttachmentConfigurations( + containerId: "probe-pg", + builtinNetworkId: "default", + networks: [] + ) + #expect(configs.count == 1) + #expect(configs[0].options.hostname == "probe-pg") + } + + /// A containerId with a trailing dot has the dot stripped before registration. + @Test func testTrailingDotContainerIdIsStripped() throws { + let configs = try Utility.getAttachmentConfigurations( + containerId: "probe-pg.", + builtinNetworkId: "default", + networks: [] + ) + #expect(configs.count == 1) + #expect(configs[0].options.hostname == "probe-pg") + } + + /// A fully-qualified containerId has exactly one trailing dot stripped; + /// internal dots are preserved. + @Test func testFqdnContainerIdIsStrippedToBareWithDots() throws { + let configs = try Utility.getAttachmentConfigurations( + containerId: "probe-pg.svc.cluster.local.", + builtinNetworkId: "default", + networks: [] + ) + #expect(configs.count == 1) + #expect(configs[0].options.hostname == "probe-pg.svc.cluster.local") + } + + /// When no networks are specified the function falls back to the builtin network. + @Test func testEmptyNetworksFallsBackToBuiltinNetwork() throws { + let configs = try Utility.getAttachmentConfigurations( + containerId: "mycontainer", + builtinNetworkId: "bridge0", + networks: [] + ) + #expect(configs.count == 1) + #expect(configs[0].network == "bridge0") + } + + /// When multiple networks are provided (macOS 26+), every returned + /// AttachmentConfiguration carries the same bare registration hostname. + @Test func testMultipleNetworksAllUseSameRegistrationHostname() throws { + guard #available(macOS 26, *) else { + // Non-default multi-network configuration requires macOS 26+. + return + } + let networks = [ + Parser.ParsedNetwork(name: "net0"), + Parser.ParsedNetwork(name: "net1"), + ] + let configs = try Utility.getAttachmentConfigurations( + containerId: "probe-pg", + builtinNetworkId: "default", + networks: networks + ) + #expect(configs.count == 2) + let hostnames = configs.map(\.options.hostname) + #expect(hostnames.allSatisfy { $0 == "probe-pg" }) + } +} diff --git a/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift index 9db889763..828811827 100644 --- a/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift +++ b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift @@ -205,3 +205,72 @@ struct AttachmentAllocatorTest { #expect(secondDeallocate == nil) } } + +// MARK: - CHAOS-1478 normalization tests + +extension AttachmentAllocatorTest { + /// allocate("foo") then lookup("foo.") must return the same index. + @Test func testAllocateBareLookupCanonicalReturnsSameIndex() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocated = try await allocator.allocate(hostname: "foo") + let lookedUp = try await allocator.lookup(hostname: "foo.") + + #expect(lookedUp == allocated) + } + + /// allocate("foo.") then lookup("foo") must return the same index. + @Test func testAllocateCanonicalLookupBareReturnsSameIndex() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocated = try await allocator.allocate(hostname: "foo.") + let lookedUp = try await allocator.lookup(hostname: "foo") + + #expect(lookedUp == allocated) + } + + /// Calling allocate twice with "foo" then "foo." must return the SAME index. + @Test func testAllocateBareThenCanonicalIsIdempotent() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let first = try await allocator.allocate(hostname: "foo") + let second = try await allocator.allocate(hostname: "foo.") + + #expect(first == second) + } + + /// allocate("foo") then deallocate("foo.") must return the index; + /// subsequent lookup of either form must return nil. + @Test func testDeallocateCanonicalRemovesBareEntry() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocated = try await allocator.allocate(hostname: "foo") + let deallocated = try await allocator.deallocate(hostname: "foo.") + + #expect(deallocated == allocated) + #expect(try await allocator.lookup(hostname: "foo") == nil) + #expect(try await allocator.lookup(hostname: "foo.") == nil) + } + + /// normalize() strips exactly ONE trailing dot. + /// Multi-dot inputs ("foo..") are NOT equivalent to the bare form ("foo"). + /// They ARE equivalent to the single-dot canonical form ("foo.") because + /// normalize("foo..") == "foo." and normalize("foo.") == "foo.". + @Test func testNormalizationIsSingleDot() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + // Allocate with double-dot form: normalize("foo..") → "foo." is stored. + let allocated = try await allocator.allocate(hostname: "foo..") + + // lookup("foo..") normalises to "foo." → finds the entry (same key). + let lookedUpDoubleDot = try await allocator.lookup(hostname: "foo..") + #expect(lookedUpDoubleDot == allocated) + + // lookup("foo.") normalises to "foo" → does NOT find "foo." → nil. + // This confirms "foo.." is NOT equivalent to the bare form. + #expect(try await allocator.lookup(hostname: "foo.") == nil) + + // lookup("foo") normalises to "foo" → also not found. + #expect(try await allocator.lookup(hostname: "foo") == nil) +} +} // extension AttachmentAllocatorTest From 02549fbce0efc3b1ddbf4399e10482ec24b149c0 Mon Sep 17 00:00:00 2001 From: Chris George Date: Thu, 7 May 2026 12:29:49 -0700 Subject: [PATCH 4/5] refactor(network/dns): collapse duplicate registrationKey, rename APIServer lib to APIServerCore (CHAOS-1478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 19355b7. The previous test commit landed two parallel implementations of the registrationKey logic: - inline 'internal func registrationKey' on ContainerDNSHandler - 'DNSRegistrationKey.registrationKey' in the new APIServer library Tests covered the library copy, but production ran the inline copy — silent-drift risk if the two ever diverged. Changes: - Sources/APIServer/ContainerDNSHandler.swift now imports APIServerCore and delegates directly to DNSRegistrationKey.registrationKey from answerHost / answerHost6. Inline duplicate removed. - Library target renamed APIServer -> APIServerCore to avoid the module-name / type-name ambiguity with 'struct APIServer' (the @main executable's main type at Sources/APIServer/APIServer.swift). - Sources/APIServerLib -> Sources/APIServerCore (path matches target). - container-apiserver executable now depends on APIServerCore. - Test imports updated to '@testable import APIServerCore'. swift build clean. 32/32 tests pass across the three filtered suites. --- Package.swift | 11 ++++++----- Sources/APIServer/ContainerDNSHandler.swift | 18 +++++------------- .../RegistrationKey.swift | 0 ...ontainerDNSHandlerRegistrationKeyTest.swift | 16 ++++++++-------- 4 files changed, 19 insertions(+), 26 deletions(-) rename Sources/{APIServerLib => APIServerCore}/RegistrationKey.swift (100%) diff --git a/Package.swift b/Package.swift index 1b62c3162..4aecab357 100644 --- a/Package.swift +++ b/Package.swift @@ -163,6 +163,7 @@ let package = Package( .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), + "APIServerCore", "ContainerAPIService", "ContainerAPIClient", "ContainerLog", @@ -177,20 +178,20 @@ let package = Package( ], path: "Sources/APIServer" ), - // APIServer library: pure-logic helpers extracted from the executable - // so that unit tests can reach them without importing @main. CHAOS-1478. + // APIServerCore: pure-logic helpers extracted from the container-apiserver + // executable so that unit tests can reach them without importing @main. CHAOS-1478. .target( - name: "APIServer", + name: "APIServerCore", dependencies: [ "ContainerAPIService", "DNSServer", ], - path: "Sources/APIServerLib" + path: "Sources/APIServerCore" ), .testTarget( name: "APIServerTests", dependencies: [ - "APIServer" + "APIServerCore" ] ), .target( diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift index 12dce04c8..958c6a2b8 100644 --- a/Sources/APIServer/ContainerDNSHandler.swift +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import APIServerCore import ContainerAPIService import ContainerizationExtras import DNSServer @@ -77,19 +78,9 @@ struct ContainerDNSHandler: DNSHandler { ) } - /// Strips the trailing root-label dot and, if configured, the dns.domain suffix - /// from a DNS question name to produce the bare allocator key. See CHAOS-1478. - // internal for testing — see APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift (CHAOS-1478) - internal func registrationKey(for questionName: String) -> String { - var key = questionName.hasSuffix(".") ? String(questionName.dropLast()) : questionName - if let domain = dnsDomain, !domain.isEmpty, key.hasSuffix(".\(domain)") { - key = String(key.dropLast(domain.count + 1)) - } - return key - } - private func answerHost(question: Question) async throws -> ResourceRecord? { - guard let ipAllocation = try await networkService.lookup(hostname: registrationKey(for: question.name)) else { + let key = DNSRegistrationKey.registrationKey(for: question.name, dnsDomain: dnsDomain) + guard let ipAllocation = try await networkService.lookup(hostname: key) else { return nil } let ipv4 = ipAllocation.ipv4Address.address.description @@ -101,7 +92,8 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) { - guard let ipAllocation = try await networkService.lookup(hostname: registrationKey(for: question.name)) else { + let key = DNSRegistrationKey.registrationKey(for: question.name, dnsDomain: dnsDomain) + guard let ipAllocation = try await networkService.lookup(hostname: key) else { return (nil, false) } guard let ipv6Address = ipAllocation.ipv6Address else { diff --git a/Sources/APIServerLib/RegistrationKey.swift b/Sources/APIServerCore/RegistrationKey.swift similarity index 100% rename from Sources/APIServerLib/RegistrationKey.swift rename to Sources/APIServerCore/RegistrationKey.swift diff --git a/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift b/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift index 18eeb877c..e14d58e3f 100644 --- a/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift +++ b/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift @@ -14,14 +14,14 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// Judgment call: `container-apiserver` is an executable target with `@main` -// and cannot be imported by test targets. The `registrationKey` logic was -// therefore extracted to `DNSRegistrationKey` in the `APIServer` library -// target (`Sources/APIServerLib/`), which `ContainerDNSHandler` delegates to. -// These tests exercise `DNSRegistrationKey.registrationKey(for:dnsDomain:)` -// directly, which is the canonical implementation of the CHAOS-1478 fix. - -@testable import APIServer +// `container-apiserver` is an executable target with `@main` and cannot be imported +// by test targets. The `registrationKey` logic was therefore extracted to +// `DNSRegistrationKey` in the `APIServerCore` library target +// (`Sources/APIServerCore/`), which `ContainerDNSHandler.answerHost` and +// `answerHost6` delegate to. These tests exercise +// `DNSRegistrationKey.registrationKey(for:dnsDomain:)` directly — the canonical +// implementation of the CHAOS-1478 fix. +@testable import APIServerCore import Testing struct ContainerDNSHandlerRegistrationKeyTest { From 359d70df3dc972bf1f0af8c3418a9ed4970aaaee Mon Sep 17 00:00:00 2001 From: Chris George Date: Thu, 7 May 2026 16:08:59 -0700 Subject: [PATCH 5/5] fix(network/dns): default-config peer DNS routing via /etc/resolver (CHAOS-1478) Completes the CHAOS-1478 fix: ``container run --rm busybox nslookup `` now resolves a sibling container in the default configuration, with no ``sudo``, no ``container system dns create``, and no manual ``dns.domain`` configuration. The earlier source change (23ef3d0) made the embedded DNS handler register peer hostnames in their bare form. That fix was necessary but not sufficient: queries from inside containers still had no path to reach ``127.0.0.1:2053`` because ``vmnet``'s built-in DNS proxy on ``192.168.x.1:53`` forwards unmatched queries upstream rather than to the embedded handler. Three architectural options were evaluated. (A) Bind the handler on ``192.168.x.1:53`` and (B) run a UDP forwarder there from inside the ``container-network-vmnet`` plugin both turned out to be infeasible: ``com.apple.security.virtualization`` does not grant privileged-port binding, and every helper - apiserver and ``container-network-vmnet`` included - runs as the invoking uid (verified empirically via ``bind('192.168.x.1', 53)`` -> EACCES from uid 501). macOS 26 does expose ``vmnet_network_configuration_disable_dns_proxy()``, but disabling the proxy still leaves the handler unable to claim ``:53``. We therefore fall through to (C), the documented ``/etc/resolver`` mechanism, but automate it at install time so the user never sees a runtime sudo prompt. The chain at runtime: VM glibc/musl appends ``.test`` via ``search`` directive vmnet :53 proxy forwards ``probe-pg.test`` to macOS system resolver macOS resolver matches /etc/resolver/containerization.test, routes to 127.0.0.1 port 2053 apiserver :2053 strips ``.test`` (DNSRegistrationKey), looks up bare ``probe-pg``, returns 192.168.x.y Changes: - ``ContainerPersistence/ContainerSystemConfig.swift``: ``DNSConfig`` now defaults ``domain`` to ``\"test\"`` (centralised in ``defaultDomain``). Both the no-arg initialiser and the TOML decode path fall back to the default; an explicit nil/empty string from config is still honoured. The default propagates to the embedded handler's ``dnsDomain`` argument and to per-container DNS plumbing. - ``ContainerAPIService/Client/Utility.swift``: when constructing a container's ``DNSConfiguration`` and the user has not specified any search domains, inject ``[domain]`` so bare-name queries actually pick up the suffix and traverse the resolver chain above. Honours user overrides; explicit empty strings remain empty. - ``scripts/pkg-scripts/postinstall``: new ``.pkg`` postinstall script that writes ``/etc/resolver/containerization.test`` (filename and contents matching ``HostDNSResolver`` so existing add/remove tooling composes cleanly) and refreshes ``mDNSResponder``. The .pkg installer already runs as root, so this introduces no new sudo prompt. - ``Makefile``: pass ``--scripts scripts/pkg-scripts`` to ``pkgbuild`` so the postinstall is bundled into the installer. - ``scripts/uninstall-container.sh``: best-effort cleanup of the resolver entry on uninstall, keeping the host's ``/etc/resolver`` state symmetric with install. - ``Tests/ContainerPersistenceTests/ContainerSystemConfigDNSDefault\ Tests.swift``: 8 new unit tests covering the default value, explicit overrides, the empty-string opt-out, and the TOML decode paths (missing section, empty section, explicit override). The 32 tests from the earlier CHAOS-1478 commits continue to pass (40 / 40). Live repro on a clean container restart (debug build, macOS 26): $ container system stop && sudo make all install && container system start $ container run --rm -d --name probe-pg -e POSTGRES_PASSWORD=test postgres:alpine $ container run --rm busybox nslookup probe-pg Server: 192.168.65.1 Address: 192.168.65.1:53 Name: probe-pg.test Address: 192.168.65.8 $ dig @127.0.0.1 -p 2053 probe-pg. +short # key-form fix intact 192.168.65.8 $ dig @127.0.0.1 -p 2053 probe-pg.test. +short # suffix-strip works 192.168.65.8 $ dscacheutil -q host -a name probe-pg.test # /etc/resolver host path ip_address: 192.168.65.8 Constraints honoured: * No new public Codable schema changes; ``DNSConfig.domain`` remains ``String?`` and existing TOMLs decode unchanged. * No runtime sudo prompts. Sudo is required only at ``.pkg`` install time, which the user already accepts via ``installer -pkg``. * The 32 pre-existing CHAOS-1478 unit tests continue to pass. * CHAOS-1476 (multi-alias) remains out of scope. --- Makefile | 2 +- Package.swift | 1 + .../ContainerSystemConfig.swift | 11 +- .../ContainerAPIService/Client/Utility.swift | 13 ++- ...ContainerSystemConfigDNSDefaultTests.swift | 106 ++++++++++++++++++ scripts/pkg-scripts/postinstall | 78 +++++++++++++ scripts/uninstall-container.sh | 8 ++ 7 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 Tests/ContainerPersistenceTests/ContainerSystemConfigDNSDefaultTests.swift create mode 100755 scripts/pkg-scripts/postinstall diff --git a/Makefile b/Makefile index e177fef95..ebf528bb5 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,7 @@ installer-pkg: $(STAGING_DIR) @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" @echo Creating application installer - @pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) + @pkgbuild --root "$(STAGING_DIR)" --scripts scripts/pkg-scripts --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @rm -rf "$(STAGING_DIR)" .PHONY: dsym diff --git a/Package.swift b/Package.swift index 4aecab357..f8d66e63e 100644 --- a/Package.swift +++ b/Package.swift @@ -454,6 +454,7 @@ let package = Package( .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "Logging", package: "swift-log"), .product(name: "SystemPackage", package: "swift-system"), + .product(name: "TOML", package: "swift-toml"), "ContainerPersistence", "ContainerTestSupport", ] diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index 1b7ee0f46..875caf6e0 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -127,15 +127,22 @@ final public class ContainerConfig: Codable, Sendable { } final public class DNSConfig: Codable, Sendable { + /// Default DNS suffix used to route container peer-name queries + /// through the embedded handler. The matching `/etc/resolver/` + /// entry is installed by the macOS package postinstall script + /// (see `scripts/pkg-scripts/postinstall`) so peer DNS works in the + /// default configuration without any runtime sudo prompt. + public static let defaultDomain = "test" + public let domain: String? - public init(domain: String? = nil) { + public init(domain: String? = defaultDomain) { self.domain = domain } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.domain = try container.decodeIfPresent(String.self, forKey: .domain) + self.domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? Self.defaultDomain } } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index a79efb3d5..187a6bb9e 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -226,10 +226,21 @@ public struct Utility { config.dns = nil } else { let domain = management.dns.domain ?? containerSystemConfig.dns.domain + // Auto-inject the configured DNS domain as a search-domain when + // the user has not specified any. This is what makes bare-name + // peer queries (e.g. `nslookup probe-pg`) hit the embedded DNS + // handler in the default configuration: glibc/musl appends the + // search suffix, the FQDN flows through vmnet's DNS proxy to the + // host system resolver, and the matching `/etc/resolver/` + // entry routes it to `127.0.0.1:2053`. See CHAOS-1478. + var searchDomains = management.dns.searchDomains + if searchDomains.isEmpty, let domain, !domain.isEmpty { + searchDomains = [domain] + } config.dns = .init( nameservers: management.dns.nameservers, domain: domain, - searchDomains: management.dns.searchDomains, + searchDomains: searchDomains, options: management.dns.options ) } diff --git a/Tests/ContainerPersistenceTests/ContainerSystemConfigDNSDefaultTests.swift b/Tests/ContainerPersistenceTests/ContainerSystemConfigDNSDefaultTests.swift new file mode 100644 index 000000000..b9b000ef8 --- /dev/null +++ b/Tests/ContainerPersistenceTests/ContainerSystemConfigDNSDefaultTests.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// 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 +import TOML + +@testable import ContainerPersistence + +/// Tests for the default `dns.domain` baked into `ContainerSystemConfig.DNSConfig` +/// as part of the CHAOS-1478 routing fix. +/// +/// The default value (`"test"`) MUST stay in sync with the resolver file written +/// by `scripts/pkg-scripts/postinstall` (`/etc/resolver/containerization.test`). +/// If you change the default here, also update the postinstall script. +struct ContainerSystemConfigDNSDefaultTests { + // MARK: - Default value + + /// `DNSConfig()` (no arguments) yields the default domain. This is the + /// path used when `ContainerSystemConfig` is constructed without a TOML + /// file (e.g. fresh install, no user config). + @Test func testDefaultDomainIsTest() { + let config = DNSConfig() + #expect(config.domain == "test") + #expect(config.domain == DNSConfig.defaultDomain) + } + + /// Explicit `nil` is preserved — caller can intentionally clear the domain. + /// This is needed for tests and for advanced configurations where the + /// caller wants to skip search-domain injection entirely. + @Test func testExplicitNilIsPreserved() { + let config = DNSConfig(domain: nil) + #expect(config.domain == nil) + } + + /// Explicit non-default domain is preserved. + @Test func testExplicitDomainIsPreserved() { + let config = DNSConfig(domain: "example.com") + #expect(config.domain == "example.com") + } + + // MARK: - TOML decode + + /// TOML with no `[dns]` section at all → top-level config has the default. + /// This is the most common case: a user with a minimal runtime-config.toml. + @Test func testTOMLDecodeMissingDnsSectionUsesDefault() throws { + let toml = "" + let decoded = try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(toml.utf8)) + #expect(decoded.dns.domain == "test") + } + + /// TOML with `[dns]` but no `domain` key → defaults to `"test"`. + @Test func testTOMLDecodeEmptyDnsSectionUsesDefault() throws { + let toml = """ + [dns] + """ + let decoded = try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(toml.utf8)) + #expect(decoded.dns.domain == "test") + } + + /// TOML with explicit `dns.domain = "foo"` → user override takes effect. + /// This guards the "user can opt out / replace" pathway. + @Test func testTOMLDecodeExplicitDomainOverridesDefault() throws { + let toml = """ + [dns] + domain = "internal.example" + """ + let decoded = try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(toml.utf8)) + #expect(decoded.dns.domain == "internal.example") + } + + /// TOML with explicit empty-string domain → preserved as empty string. + /// Empty string is treated as "no domain" by the search-domain injection + /// logic in `Utility.containerConfigFromFlags`. Decoding must NOT silently + /// substitute the default in this case — the user explicitly opted out. + @Test func testTOMLDecodeExplicitEmptyStringDomainIsPreserved() throws { + let toml = """ + [dns] + domain = "" + """ + let decoded = try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(toml.utf8)) + #expect(decoded.dns.domain == "") + } + + // MARK: - Top-level ContainerSystemConfig wiring + + /// `ContainerSystemConfig()` with all defaults exposes the DNS default at + /// the top level — verifies the construction chain doesn't drop it. + @Test func testTopLevelDefaultExposesDNSDefault() { + let config = ContainerSystemConfig() + #expect(config.dns.domain == "test") + } +} diff --git a/scripts/pkg-scripts/postinstall b/scripts/pkg-scripts/postinstall new file mode 100755 index 000000000..208796569 --- /dev/null +++ b/scripts/pkg-scripts/postinstall @@ -0,0 +1,78 @@ +#!/bin/bash +# Copyright © 2025-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. + +# Postinstall hook for the container .pkg installer (CHAOS-1478 routing fix). +# +# The embedded DNS handler in `container-apiserver` listens on `127.0.0.1:2053` +# but containers query DNS at `192.168.64.1:53` (the vmnet bridge gateway). +# Apple's vmnet framework intercepts `:53` on the bridge gateway and forwards +# unmatched queries to the macOS system resolver, which by default does not +# know about peer container hostnames. +# +# Binding the embedded handler directly on the bridge gateway is not possible: +# port 53 is privileged and the daemon runs as the invoking user (uid != 0). +# Routing the queries via `/etc/resolver/` is the supported macOS path. +# The resolver file must be installed by a privileged context, so we drop it +# here at install time (the .pkg installer already runs with administrator +# privileges that the user has accepted). +# +# This is the matching server-side configuration for the default `dns.domain` +# value baked into `ContainerSystemConfig.DNSConfig.defaultDomain`. Containers +# get `search test` injected automatically (see Utility.swift), so a bare-name +# query like `nslookup probe-pg` resolves through the chain: +# +# VM glibc/musl : appends ".test" via search domain +# vmnet :53 proxy : forwards "probe-pg.test" to macOS system resolver +# macOS resolver : matches /etc/resolver/containerization.test -> +# 127.0.0.1 port 2053 +# apiserver :2053 : strips ".test" suffix, looks up "probe-pg", +# returns 192.168.64.x +# +# The matching `containerizationPrefix` and resolver-file format come from +# `Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift`. + +set -euo pipefail + +# Default DNS suffix for container peer-name resolution. +# MUST stay in sync with `ContainerSystemConfig.DNSConfig.defaultDomain`. +readonly DOMAIN="test" + +# `/etc/resolver/` filename convention from `HostDNSResolver.containerizationPrefix`. +readonly RESOLVER_DIR="/etc/resolver" +readonly RESOLVER_FILE="${RESOLVER_DIR}/containerization.${DOMAIN}" + +# Embedded handler bind from `APIServer+Start.swift` (`Self.dnsPort`). +readonly DNS_PORT=2053 + +mkdir -p "${RESOLVER_DIR}" + +# Idempotent: overwrite to ensure the file matches the current expected +# contents even after a partial / older install. +cat >"${RESOLVER_FILE}" </dev/null || true +fi + +exit 0 diff --git a/scripts/uninstall-container.sh b/scripts/uninstall-container.sh index 60a86f3f8..fe263975b 100755 --- a/scripts/uninstall-container.sh +++ b/scripts/uninstall-container.sh @@ -80,6 +80,14 @@ for ((i=${#DIRS[@]}-1; i>=0; i--)); do done sudo pkgutil --forget com.apple.container-installer > /dev/null + +# Remove the /etc/resolver/ entry installed by scripts/pkg-scripts/postinstall (CHAOS-1478). +# Best-effort: missing file is fine; mDNSResponder reload is best-effort too. +if [ -f /etc/resolver/containerization.test ]; then + sudo rm -f /etc/resolver/containerization.test + sudo killall -HUP mDNSResponder 2>/dev/null || true +fi + echo 'Removed `container` tool and helpers' if [ "$DELETE_DATA" = true ]; then