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
@@ -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",