Resolve IPv6 address queries for container names. (#1016)

- Closes #1005.
- Adapt everything to use MACAddress type from containerization 0.20.0.
- Allocate MAC addresses for every container so that we have
deterministic IPv6 link local addresses.
- Add AAAA handling to ContainerDNSHandler.
- NOTE: Only works on Tahoe. On Sequoia, we don't have a good way to set
or determine the IPv6 network prefix when networks are created, so we
can't infer the IPv6 link local addresses for AAAA responses and we
instead return `NODATA`.
This commit is contained in:
J Logan
2026-01-07 15:35:35 -08:00
committed by GitHub
parent 5d6c750708
commit db8932ab0f
13 changed files with 322 additions and 46 deletions
+9
View File
@@ -91,6 +91,7 @@ let package = Package(
"ContainerBuild",
"ContainerAPIClient",
"ContainerLog",
"ContainerNetworkService",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
@@ -263,6 +264,14 @@ let package = Package(
],
path: "Sources/Services/ContainerNetworkService/Server"
),
.testTarget(
name: "ContainerNetworkServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerNetworkService",
]
),
.target(
name: "ContainerNetworkServiceClient",
dependencies: [
@@ -31,11 +31,19 @@ extension Application {
@Option(name: .customLong("label"), help: "Set metadata for a network")
var labels: [String] = []
@Option(name: .customLong("subnet"), help: "Set subnet for a network")
var ipv4Subnet: String? = nil
@Option(
name: .customLong("subnet"), help: "Set subnet for a network",
transform: {
try CIDRv4($0)
})
var ipv4Subnet: CIDRv4? = nil
@Option(name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network")
var ipv6Subnet: String? = nil
@Option(
name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network",
transform: {
try CIDRv6($0)
})
var ipv6Subnet: CIDRv6? = nil
@OptionGroup
var global: Flags.Global
@@ -47,9 +55,13 @@ extension Application {
public func run() async throws {
let parsedLabels = Utility.parseKeyValuePairs(labels)
let ipv4Subnet = try ipv4Subnet.map { try CIDRv4($0) }
let ipv6Subnet = try ipv6Subnet.map { try CIDRv6($0) }
let config = try NetworkConfiguration(id: self.name, mode: .nat, ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, labels: parsedLabels)
let config = try NetworkConfiguration(
id: self.name,
mode: .nat,
ipv4Subnet: ipv4Subnet,
ipv6Subnet: ipv6Subnet,
labels: parsedLabels
)
let state = try await ClientNetwork.create(configuration: config)
print(state.id)
}
@@ -26,22 +26,25 @@ public struct Attachment: Codable, Sendable {
public let ipv4Address: CIDRv4
/// The IPv4 gateway address.
public let ipv4Gateway: IPv4Address
/// The CIDR address describing the interface IPv6 address, with the prefix length of the subnet.
/// The address is nil if the IPv6 subnet could not be determined at network creation time.
public let ipv6Address: CIDRv6?
/// The MAC address associated with the attachment (optional).
public let macAddress: MACAddress?
public init(network: String, hostname: String, ipv4Address: CIDRv4, ipv4Gateway: IPv4Address, macAddress: MACAddress? = nil) {
public init(
network: String,
hostname: String,
ipv4Address: CIDRv4,
ipv4Gateway: IPv4Address,
ipv6Address: CIDRv6?,
macAddress: MACAddress?
) {
self.network = network
self.hostname = hostname
self.ipv4Address = ipv4Address
self.ipv4Gateway = ipv4Gateway
self.ipv6Address = ipv6Address
self.macAddress = macAddress
}
enum CodingKeys: String, CodingKey {
case network
case hostname
case ipv4Address
case ipv4Gateway
case macAddress
}
}
@@ -14,6 +14,8 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
/// Configuration information for attaching a container network interface to a network.
public struct AttachmentConfiguration: Codable, Sendable {
/// The network ID associated with the attachment.
@@ -34,9 +36,9 @@ public struct AttachmentOptions: Codable, Sendable {
public let hostname: String
/// The MAC address associated with the attachment (optional).
public let macAddress: String?
public let macAddress: MACAddress?
public init(hostname: String, macAddress: String? = nil) {
public init(hostname: String, macAddress: MACAddress? = nil) {
self.hostname = hostname
self.macAddress = macAddress
}
@@ -27,6 +27,7 @@ public struct NetworkStatus: Codable, Sendable {
/// The address allocated for the IPv6 network if no subnet was specified at
/// creation time; otherwise, the IPv6 subnet from the configuration.
/// The value is nil if the IPv6 subnet cannot be determined at creation time.
public let ipv6Subnet: CIDRv6?
public init(
@@ -35,11 +35,12 @@ struct ContainerDNSHandler: DNSHandler {
case ResourceRecordType.host:
record = try await answerHost(question: question)
case ResourceRecordType.host6:
// Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
if try await networkService.lookup(hostname: question.name) != nil {
let result = try await answerHost6(question: question)
if result.record == nil && result.hostnameExists {
// Return NODATA (noError with empty answers) when hostname exists but has no IPv6.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
return Message(
id: query.id,
type: .response,
@@ -48,8 +49,7 @@ struct ContainerDNSHandler: DNSHandler {
answers: []
)
}
// If hostname doesn't exist, return nil which will become NXDOMAIN
return nil
record = result.record
case ResourceRecordType.nameServer,
ResourceRecordType.alias,
ResourceRecordType.startOfAuthority,
@@ -101,4 +101,19 @@ struct ContainerDNSHandler: DNSHandler {
return HostRecord<IPv4>(name: question.name, ttl: ttl, ip: ip)
}
private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) {
guard let ipAllocation = try await networkService.lookup(hostname: question.name) else {
return (nil, false)
}
guard let ipv6Address = ipAllocation.ipv6Address else {
return (nil, true)
}
let ipv6 = ipv6Address.address.description
guard let ip = IPv6(ipv6) else {
throw DNSResolverError.serverError("failed to parse IPv6 address: \(ipv6)")
}
return (HostRecord<IPv6>(name: question.name, ttl: ttl, ip: ip), true)
}
}
@@ -278,16 +278,17 @@ public struct Utility {
}
// attach the first network using the fqdn, and the rest using just the container ID
return networks.enumerated().map { item in
return try networks.enumerated().map { item in
let macAddress = try item.element.macAddress.map { try MACAddress($0) }
guard item.offset == 0 else {
return AttachmentConfiguration(
network: item.element.name,
options: AttachmentOptions(hostname: containerId, macAddress: item.element.macAddress)
options: AttachmentOptions(hostname: containerId, macAddress: macAddress)
)
}
return AttachmentConfiguration(
network: item.element.name,
options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: item.element.macAddress)
options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress)
)
}
}
@@ -17,6 +17,7 @@
import ContainerResource
import ContainerXPC
import ContainerizationError
import ContainerizationExtras
import Foundation
/// A client for interacting with a single network.
@@ -47,11 +48,14 @@ extension NetworkClient {
return state
}
public func allocate(hostname: String, macAddress: String? = nil) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
public func allocate(
hostname: String,
macAddress: MACAddress? = nil
) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
let request = XPCMessage(route: NetworkRoutes.allocate.rawValue)
request.set(key: NetworkKeys.hostname.rawValue, value: hostname)
if let macAddress = macAddress {
request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress)
request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description)
}
let client = createClient()
@@ -42,10 +42,14 @@ actor AttachmentAllocator {
}
/// Free an allocated network address by hostname.
func deallocate(hostname: String) async throws {
if let index = hostnames.removeValue(forKey: hostname) {
try allocator.release(index)
@discardableResult
func deallocate(hostname: String) async throws -> UInt32? {
guard let index = hostnames.removeValue(forKey: hostname) else {
return nil
}
try allocator.release(index)
return index
}
/// If no addresses are allocated, prevent future allocations and return true.
@@ -26,6 +26,7 @@ public actor NetworkService: Sendable {
private let network: any Network
private let log: Logger?
private var allocator: AttachmentAllocator
private var macAddresses: [UInt32: MACAddress]
/// Set up a network service for the specified network.
public init(
@@ -41,6 +42,7 @@ public actor NetworkService: Sendable {
let size = Int(subnet.upper.value - subnet.lower.value - 3)
self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size)
self.macAddresses = [:]
self.network = network
self.log = log
}
@@ -61,16 +63,20 @@ public actor NetworkService: Sendable {
}
let hostname = try message.hostname()
let macAddress = try message.string(key: NetworkKeys.macAddress.rawValue)
let macAddress =
try message.string(key: NetworkKeys.macAddress.rawValue)
.map { try MACAddress($0) }
?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000)
let index = try await allocator.allocate(hostname: hostname)
let subnet = status.ipv4Subnet
let ipv6Address = try status.ipv6Subnet
.map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) }
let ip = IPv4Address(index)
let attachment = Attachment(
network: state.id,
hostname: hostname,
ipv4Address: try CIDRv4(ip, prefix: subnet.prefix),
ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix),
ipv4Gateway: status.ipv4Gateway,
ipv6Address: ipv6Address,
macAddress: macAddress
)
log?.info(
@@ -79,7 +85,8 @@ public actor NetworkService: Sendable {
"hostname": "\(hostname)",
"ipv4Address": "\(attachment.ipv4Address)",
"ipv4Gateway": "\(attachment.ipv4Gateway)",
"macAddress": "\(macAddress?.description ?? "unspecified")",
"ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")",
"macAddress": "\(attachment.macAddress?.description ?? "unspecified")",
])
let reply = message.reply()
try reply.setAttachment(attachment)
@@ -88,13 +95,16 @@ public actor NetworkService: Sendable {
try reply.setAdditionalData(additionalData.underlying)
}
}
macAddresses[index] = macAddress
return reply
}
@Sendable
public func deallocate(_ message: XPCMessage) async throws -> XPCMessage {
let hostname = try message.hostname()
try await allocator.deallocate(hostname: hostname)
if let index = try await allocator.deallocate(hostname: hostname) {
macAddresses.removeValue(forKey: index)
}
log?.info("released attachments", metadata: ["hostname": "\(hostname)"])
return message.reply()
}
@@ -112,14 +122,21 @@ public actor NetworkService: Sendable {
guard let index else {
return reply
}
guard let macAddress = macAddresses[index] else {
return reply
}
let address = IPv4Address(index)
let subnet = status.ipv4Subnet
let ipv4Address = try CIDRv4(address, prefix: subnet.prefix)
let ipv6Address = try status.ipv6Subnet
.map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) }
let attachment = Attachment(
network: state.id,
hostname: hostname,
ipv4Address: try CIDRv4(address, prefix: subnet.prefix),
ipv4Gateway: status.ipv4Gateway
ipv4Address: ipv4Address,
ipv4Gateway: status.ipv4Gateway,
ipv6Address: ipv6Address,
macAddress: macAddress
)
log?.debug(
"lookup attachment",
@@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
import Foundation
import Testing
@@ -32,7 +33,7 @@ class TestCLICreateCommand: CLITest {
@Test func testCreateWithMACAddress() throws {
let name = getTestName()
let expectedMAC = "02:42:ac:11:00:03"
let expectedMAC = try MACAddress("02:42:ac:11:00:03")
#expect(throws: Never.self, "expected container create with MAC address to succeed") {
try doCreate(name: name, networks: ["default,mac=\(expectedMAC)"])
try doStart(name: name)
@@ -43,9 +44,9 @@ class TestCLICreateCommand: CLITest {
try waitForContainerRunning(name)
let inspectResp = try inspectContainer(name)
#expect(inspectResp.networks.count > 0, "expected at least one network attachment")
let actualMAC = inspectResp.networks[0].macAddress?.description ?? "nil"
#expect(
inspectResp.networks[0].macAddress?.description == expectedMAC,
"expected MAC address \(expectedMAC), got \(inspectResp.networks[0].macAddress?.description ?? "nil")"
actualMAC == expectedMAC.description, "expected MAC address \(expectedMAC), got \(actualMAC)"
)
}
}
@@ -0,0 +1,207 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import ContainerizationExtras
import Testing
@testable import ContainerNetworkService
struct AttachmentAllocatorTest {
@Test func testAllocateSingleHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address = try await allocator.allocate(hostname: "test-host")
#expect(address >= 100)
#expect(address < 110)
}
@Test func testAllocateSameHostnameTwice() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address1 = try await allocator.allocate(hostname: "test-host")
let address2 = try await allocator.allocate(hostname: "test-host")
#expect(address1 == address2)
}
@Test func testAllocateMultipleHostnames() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address1 = try await allocator.allocate(hostname: "host1")
let address2 = try await allocator.allocate(hostname: "host2")
let address3 = try await allocator.allocate(hostname: "host3")
#expect(address1 != address2)
#expect(address2 != address3)
#expect(address1 != address3)
}
@Test func testLookupAllocatedHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let allocatedAddress = try await allocator.allocate(hostname: "test-host")
let lookedUpAddress = try await allocator.lookup(hostname: "test-host")
#expect(lookedUpAddress == allocatedAddress)
}
@Test func testLookupNonExistentHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address = try await allocator.lookup(hostname: "non-existent")
#expect(address == nil)
}
@Test func testDeallocateAllocatedHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let allocatedAddress = try await allocator.allocate(hostname: "test-host")
let deallocatedAddress = try await allocator.deallocate(hostname: "test-host")
#expect(deallocatedAddress == allocatedAddress)
// After deallocation, lookup should return nil
let lookedUpAddress = try await allocator.lookup(hostname: "test-host")
#expect(lookedUpAddress == nil)
}
@Test func testDeallocateNonExistentHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let deallocatedAddress = try await allocator.deallocate(hostname: "non-existent")
#expect(deallocatedAddress == nil)
}
@Test func testReallocateAfterDeallocation() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address1 = try await allocator.allocate(hostname: "test-host")
let released1 = try await allocator.deallocate(hostname: "test-host")
#expect(address1 == released1)
let address2 = try await allocator.allocate(hostname: "test-host")
// After deallocation, allocating the same hostname should give a new address
#expect(address2 >= 100)
#expect(address2 < 110)
}
@Test func testAllocateUntilFull() async throws {
let size = 5
let allocator = try AttachmentAllocator(lower: 100, size: size)
// Allocate up to the limit
for i in 0..<size {
_ = try await allocator.allocate(hostname: "host\(i)")
}
// Attempting to allocate one more should throw
await #expect(throws: Error.self) {
try await allocator.allocate(hostname: "extra-host")
}
}
@Test func testDeallocateAndReallocateDifferentHostname() async throws {
let size = 3
let allocator = try AttachmentAllocator(lower: 100, size: size)
// Fill up the allocator
let address1 = try await allocator.allocate(hostname: "host1")
let address2 = try await allocator.allocate(hostname: "host2")
let address3 = try await allocator.allocate(hostname: "host3")
// Deallocate one
let released2 = try await allocator.deallocate(hostname: "host2")
#expect(address2 == released2)
// Should be able to allocate a new hostname now
let newAddress = try await allocator.allocate(hostname: "host4")
#expect(newAddress >= 100)
#expect(newAddress < 103)
// The three remaining allocations should all be different
let finalAddress1 = try await allocator.lookup(hostname: "host1")
let finalAddress3 = try await allocator.lookup(hostname: "host3")
let finalAddress4 = try await allocator.lookup(hostname: "host4")
#expect(finalAddress1 == address1)
#expect(finalAddress3 == address3)
#expect(finalAddress4 == newAddress)
}
@Test func testDisableAllocatorWhenEmpty() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let disabled = await allocator.disableAllocator()
#expect(disabled == true)
// After disabling, allocation should fail
await #expect(throws: Error.self) {
try await allocator.allocate(hostname: "test-host")
}
}
@Test func testDisableAllocatorWhenNotEmpty() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
_ = try await allocator.allocate(hostname: "test-host")
let disabled = await allocator.disableAllocator()
#expect(disabled == false)
// Since disable failed, should still be able to allocate
let address = try await allocator.allocate(hostname: "another-host")
#expect(address >= 100)
#expect(address < 110)
}
@Test func testDisableAfterDeallocatingAll() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
_ = try await allocator.allocate(hostname: "host1")
_ = try await allocator.allocate(hostname: "host2")
try await allocator.deallocate(hostname: "host1")
try await allocator.deallocate(hostname: "host2")
let disabled = await allocator.disableAllocator()
#expect(disabled == true)
// After disabling, allocation should fail
await #expect(throws: Error.self) {
try await allocator.allocate(hostname: "test-host")
}
}
@Test func testMultipleDeallocationsOfSameHostname() async throws {
let allocator = try AttachmentAllocator(lower: 100, size: 10)
let address = try await allocator.allocate(hostname: "test-host")
let firstDeallocate = try await allocator.deallocate(hostname: "test-host")
#expect(firstDeallocate == address)
// Second deallocation should return nil since it's already deallocated
let secondDeallocate = try await allocator.deallocate(hostname: "test-host")
#expect(secondDeallocate == nil)
}
}
+2 -2
View File
@@ -207,7 +207,7 @@ Use the `mac` option to specify a custom MAC address for your container's networ
- Network testing scenarios requiring predictable MAC addresses
- Consistent network configuration across container restarts
The MAC address must be in the format `XX:XX:XX:XX:XX:XX` (with colons or hyphens as separators):
The MAC address must be in the format `XX:XX:XX:XX:XX:XX` (with colons or hyphens as separators). Set the two least significant bits of the first octet to `10` (locally signed, unicast address).
```bash
container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest
@@ -223,7 +223,7 @@ To verify the MAC address is set correctly, run `ip addr show` inside the contai
valid_lft forever preferred_lft forever
```
If you don't specify a MAC address, the system will auto-generate one for you.
If you don't specify a MAC address, `container` will generate one for you. The generated address has a first nibble set to hexadecimal `f` (`fX:XX:XX:XX:XX:XX`) in case you want to minimize the very small chance of conflict between your MAC address and generated addresses.
## Mount your host SSH authentication socket in your container