Skip to content
Merged
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
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ let package = Package(
"ContainerBuild",
"ContainerAPIClient",
"ContainerLog",
"ContainerNetworkService",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
Expand Down Expand Up @@ -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: [
Expand Down
26 changes: 19 additions & 7 deletions Sources/ContainerCommands/Network/NetworkCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
21 changes: 12 additions & 9 deletions Sources/ContainerResource/Network/Attachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerResource/Network/NetworkState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 22 additions & 7 deletions Sources/Helpers/APIServer/ContainerDNSHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
7 changes: 4 additions & 3 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,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)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import ContainerResource
import ContainerXPC
import ContainerizationError
import ContainerizationExtras
import Foundation

/// A client for interacting with a single network.
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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()
}
Expand All @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions Tests/CLITests/Subcommands/Containers/TestCLICreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationExtras
import Foundation
import Testing

Expand All @@ -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)
Expand All @@ -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)"
)
}
}
Expand Down
Loading