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 442aa8b9a..f8d66e63e 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,6 +178,22 @@ let package = Package( ], path: "Sources/APIServer" ), + // APIServerCore: pure-logic helpers extracted from the container-apiserver + // executable so that unit tests can reach them without importing @main. CHAOS-1478. + .target( + name: "APIServerCore", + dependencies: [ + "ContainerAPIService", + "DNSServer", + ], + path: "Sources/APIServerCore" + ), + .testTarget( + name: "APIServerTests", + dependencies: [ + "APIServerCore" + ] + ), .target( name: "ContainerAPIService", dependencies: [ @@ -203,6 +220,7 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + "ContainerAPIClient", "ContainerResource", "ContainerRuntimeLinuxClient", "ContainerRuntimeClient", @@ -436,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/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..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 @@ -21,10 +22,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 } @@ -76,7 +79,8 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost(question: Question) async throws -> ResourceRecord? { - guard let ipAllocation = try await networkService.lookup(hostname: 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 @@ -88,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: 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/APIServerCore/RegistrationKey.swift b/Sources/APIServerCore/RegistrationKey.swift new file mode 100644 index 000000000..f799b65fe --- /dev/null +++ b/Sources/APIServerCore/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/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 cf5b8d6df..187a6bb9e 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) @@ -227,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 ) } @@ -275,7 +285,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 +293,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) @@ -309,19 +309,14 @@ 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: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: registrationHostname, macAddress: macAddress, mtu: mtu) ) } } @@ -330,7 +325,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)] } } diff --git a/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift b/Tests/APIServerTests/ContainerDNSHandlerRegistrationKeyTest.swift new file mode 100644 index 000000000..e14d58e3f --- /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. +//===----------------------------------------------------------------------===// + +// `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 { + // 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 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