Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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: [
Expand All @@ -203,6 +220,7 @@ let package = Package(
name: "ContainerAPIServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerAPIClient",
"ContainerResource",
"ContainerRuntimeLinuxClient",
"ContainerRuntimeClient",
Expand Down Expand Up @@ -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",
]
Expand Down
2 changes: 1 addition & 1 deletion Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions Sources/APIServer/ContainerDNSHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,20 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import APIServerCore
import ContainerAPIService
import ContainerizationExtras
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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions Sources/APIServerCore/RegistrationKey.swift
Original file line number Diff line number Diff line change
@@ -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 `".<dnsDomain>"`
/// 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
}
}
11 changes: 9 additions & 2 deletions Sources/ContainerPersistence/ContainerSystemConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
45 changes: 20 additions & 25 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
)
}
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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)]
}
}
Loading