diff --git a/Package.swift b/Package.swift index c18be884..c9897a1c 100644 --- a/Package.swift +++ b/Package.swift @@ -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: [ diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index 3a565e5e..56076994 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -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) } diff --git a/Sources/ContainerResource/Network/Attachment.swift b/Sources/ContainerResource/Network/Attachment.swift index f1ff7839..f7178f2d 100644 --- a/Sources/ContainerResource/Network/Attachment.swift +++ b/Sources/ContainerResource/Network/Attachment.swift @@ -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 - } } diff --git a/Sources/ContainerResource/Network/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift index 28caf5fe..7deb9e16 100644 --- a/Sources/ContainerResource/Network/AttachmentConfiguration.swift +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -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 } diff --git a/Sources/ContainerResource/Network/NetworkState.swift b/Sources/ContainerResource/Network/NetworkState.swift index 13111290..24404148 100644 --- a/Sources/ContainerResource/Network/NetworkState.swift +++ b/Sources/ContainerResource/Network/NetworkState.swift @@ -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( diff --git a/Sources/Helpers/APIServer/ContainerDNSHandler.swift b/Sources/Helpers/APIServer/ContainerDNSHandler.swift index 53e80ca7..4fcf8a2c 100644 --- a/Sources/Helpers/APIServer/ContainerDNSHandler.swift +++ b/Sources/Helpers/APIServer/ContainerDNSHandler.swift @@ -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(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(name: question.name, ttl: ttl, ip: ip), true) + } } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 73626527..5fd0bed0 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -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) ) } } diff --git a/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift index b6195b8e..69b8207e 100644 --- a/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/Client/NetworkClient.swift @@ -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() diff --git a/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift index d52438c9..fb1f537c 100644 --- a/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift @@ -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. diff --git a/Sources/Services/ContainerNetworkService/Server/NetworkService.swift b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift index e7031c3c..f40bdf77 100644 --- a/Sources/Services/ContainerNetworkService/Server/NetworkService.swift +++ b/Sources/Services/ContainerNetworkService/Server/NetworkService.swift @@ -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", diff --git a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift b/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift index bff96412..e688e51d 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLICreate.swift @@ -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)" ) } } diff --git a/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift new file mode 100644 index 00000000..9db88976 --- /dev/null +++ b/Tests/ContainerNetworkServiceTests/AttachmentAllocatorTest.swift @@ -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..= 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) + } +} diff --git a/docs/how-to.md b/docs/how-to.md index ed415eeb..94f37233 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -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