diff --git a/.gitignore b/.gitignore index 43bf9f1e..7f5b4f47 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ api-docs/ workdir/ installer/ .venv/ +.claude/ .clitests/ test_results/ *.pid diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerClient/Core/ContainerConfiguration.swift index d4de4d43..468ca050 100644 --- a/Sources/ContainerClient/Core/ContainerConfiguration.swift +++ b/Sources/ContainerClient/Core/ContainerConfiguration.swift @@ -89,7 +89,9 @@ public struct ContainerConfiguration: Sendable, Codable { networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) } catch { let networkIds = try container.decode([String].self, forKey: .networks) - networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds) + // Parse old network IDs as simple network names without properties + let parsedNetworks = networkIds.map { Parser.ParsedNetwork(name: $0, macAddress: nil) } + networks = try Utility.getAttachmentConfigurations(containerId: id, networks: parsedNetworks) } } else { networks = [] diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 3a01a2a3..a218deb2 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -151,7 +151,7 @@ public struct Flags { @Option(name: .long, help: "Use the specified name as the container ID") public var name: String? - @Option(name: [.customLong("network")], help: "Attach the container to a network") + @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,mac=XX:XX:XX:XX:XX:XX])") public var networks: [String] = [] @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index f635e7fb..4ddc65c5 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -712,6 +712,76 @@ public struct Parser { } } + // MARK: Networks + + /// Parsed network attachment with optional properties + public struct ParsedNetwork { + public let name: String + public let macAddress: String? + + public init(name: String, macAddress: String? = nil) { + self.name = name + self.macAddress = macAddress + } + } + + /// Parse network attachment with optional properties + /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX] + /// Example: "backend,mac=02:42:ac:11:00:02" + public static func network(_ networkSpec: String) throws -> ParsedNetwork { + guard !networkSpec.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") + } + + let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false) + + guard !parts.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") + } + + let networkName = String(parts[0]) + if networkName.isEmpty { + throw ContainerizationError(.invalidArgument, message: "network name cannot be empty") + } + + var macAddress: String? + + // Parse properties if any + for part in parts.dropFirst() { + let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false) + + let key: String + let value: String + + guard keyVal.count == 2 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid property format '\(part)' in network specification '\(networkSpec)'" + ) + } + key = String(keyVal[0]) + value = String(keyVal[1]) + + switch key { + case "mac": + if value.isEmpty { + throw ContainerizationError( + .invalidArgument, + message: "mac address value cannot be empty" + ) + } + macAddress = value + default: + throw ContainerizationError( + .invalidArgument, + message: "unknown network property '\(key)'. Available properties: mac" + ) + } + } + + return ParsedNetwork(name: networkName, macAddress: macAddress) + } + // MARK: DNS public static func isValidDomainName(_ name: String) -> Bool { diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index b212ad6b..ed4fc7e7 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -62,6 +62,14 @@ public struct Utility { } } + public static func validMACAddress(_ macAddress: String) throws { + let pattern = #"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"# + let regex = try Regex(pattern) + if try regex.firstMatch(in: macAddress) == nil { + throw ContainerizationError(.invalidArgument, message: "invalid MAC address format \(macAddress), expected format: XX:XX:XX:XX:XX:XX") + } + } + public static func containerConfigFromFlags( id: String, image: String, @@ -176,13 +184,15 @@ public struct Utility { config.virtualization = management.virtualization + // Parse network specifications with properties + let parsedNetworks = try management.networks.map { try Parser.network($0) } if management.networks.contains(ClientNetwork.noNetworkName) { guard management.networks.count == 1 else { throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(ClientNetwork.noNetworkName)") } config.networks = [] } else { - config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks) + config.networks = try getAttachmentConfigurations(containerId: config.id, networks: parsedNetworks) for attachmentConfiguration in config.networks { let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) guard case .running(_, _) = network else { @@ -220,7 +230,14 @@ public struct Utility { return (config, kernel) } - static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] { + static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] { + // Validate MAC addresses if provided + for network in networks { + if let mac = network.macAddress { + try validMACAddress(mac) + } + } + // make an FQDN for the first interface let fqdn: String? if !containerId.contains(".") { @@ -235,22 +252,33 @@ public struct Utility { fqdn = "\(containerId)." } - guard networkIds.isEmpty else { - // networks may only be specified for macOS 26+ - guard #available(macOS 26, *) else { - throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") + guard networks.isEmpty else { + // Check if this is only the default network with properties (e.g., MAC address) + let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == ClientNetwork.defaultNetworkName + + // networks may only be specified for macOS 26+ (except for default network with properties) + if !isOnlyDefaultNetwork { + guard #available(macOS 26, *) else { + throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") + } } // attach the first network using the fqdn, and the rest using just the container ID - return networkIds.enumerated().map { item in + return networks.enumerated().map { item in guard item.offset == 0 else { - return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId)) + return AttachmentConfiguration( + network: item.element.name, + options: AttachmentOptions(hostname: containerId, macAddress: item.element.macAddress) + ) } - return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId)) + return AttachmentConfiguration( + network: item.element.name, + options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: item.element.macAddress) + ) } } // if no networks specified, attach to the default network - return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId))] + return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil))] } private static func getKernel(management: Flags.Management) async throws -> Kernel { diff --git a/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift index 5d673a6a..ebccd01d 100644 --- a/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift +++ b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift @@ -25,6 +25,6 @@ import Containerization struct IsolatedInterfaceStrategy: InterfaceStrategy { public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface { let gateway = interfaceIndex == 0 ? attachment.gateway : nil - return NATInterface(address: attachment.address, gateway: gateway) + return NATInterface(address: attachment.address, gateway: gateway, macAddress: attachment.macAddress) } } diff --git a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift index 922dd6da..e447ab91 100644 --- a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift +++ b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift @@ -44,6 +44,6 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy { log.info("creating NATNetworkInterface with network reference") let gateway = interfaceIndex == 0 ? attachment.gateway : nil - return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef) + return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef, macAddress: attachment.macAddress) } } diff --git a/Sources/Services/ContainerNetworkService/Attachment.swift b/Sources/Services/ContainerNetworkService/Attachment.swift index 8984d2a1..d2d06e13 100644 --- a/Sources/Services/ContainerNetworkService/Attachment.swift +++ b/Sources/Services/ContainerNetworkService/Attachment.swift @@ -24,11 +24,14 @@ public struct Attachment: Codable, Sendable { public let address: String /// The IPv4 gateway address. public let gateway: String + /// The MAC address associated with the attachment (optional). + public let macAddress: String? - public init(network: String, hostname: String, address: String, gateway: String) { + public init(network: String, hostname: String, address: String, gateway: String, macAddress: String? = nil) { self.network = network self.hostname = hostname self.address = address self.gateway = gateway + self.macAddress = macAddress } } diff --git a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift index bf9f1dd9..7194856c 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift @@ -33,7 +33,11 @@ public struct AttachmentOptions: Codable, Sendable { /// The hostname associated with the attachment. public let hostname: String - public init(hostname: String) { + /// The MAC address associated with the attachment (optional). + public let macAddress: String? + + public init(hostname: String, macAddress: String? = nil) { self.hostname = hostname + self.macAddress = macAddress } } diff --git a/Sources/Services/ContainerNetworkService/NetworkClient.swift b/Sources/Services/ContainerNetworkService/NetworkClient.swift index e72f7ef3..e579f3e0 100644 --- a/Sources/Services/ContainerNetworkService/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/NetworkClient.swift @@ -46,9 +46,12 @@ extension NetworkClient { return state } - public func allocate(hostname: String) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + public func allocate(hostname: String, macAddress: String? = 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) + } let client = createClient() diff --git a/Sources/Services/ContainerNetworkService/NetworkKeys.swift b/Sources/Services/ContainerNetworkService/NetworkKeys.swift index ccbeb8db..13def7ab 100644 --- a/Sources/Services/ContainerNetworkService/NetworkKeys.swift +++ b/Sources/Services/ContainerNetworkService/NetworkKeys.swift @@ -19,6 +19,7 @@ public enum NetworkKeys: String { case allocatorDisabled case attachment case hostname + case macAddress case network case state } diff --git a/Sources/Services/ContainerNetworkService/NetworkService.swift b/Sources/Services/ContainerNetworkService/NetworkService.swift index 9267dfe8..a6bc6f8e 100644 --- a/Sources/Services/ContainerNetworkService/NetworkService.swift +++ b/Sources/Services/ContainerNetworkService/NetworkService.swift @@ -59,6 +59,7 @@ public actor NetworkService: Sendable { } let hostname = try message.hostname() + let macAddress = message.string(key: NetworkKeys.macAddress.rawValue) let index = try await allocator.allocate(hostname: hostname) let subnet = try CIDRAddress(status.address) let ip = IPv4Address(fromValue: index) @@ -66,7 +67,8 @@ public actor NetworkService: Sendable { network: state.id, hostname: hostname, address: try CIDRAddress(ip, prefixLength: subnet.prefixLength).description, - gateway: status.gateway + gateway: status.gateway, + macAddress: macAddress ) log?.info( "allocated attachment", @@ -74,6 +76,7 @@ public actor NetworkService: Sendable { "hostname": "\(hostname)", "address": "\(attachment.address)", "gateway": "\(attachment.gateway)", + "macAddress": "\(macAddress ?? "auto")", ]) let reply = message.reply() try reply.setAttachment(attachment) diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index e808a776..2fa46c8f 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -143,7 +143,7 @@ public actor SandboxService { for index in 0.. 0, "expected at least one network attachment") + #expect(inspectResp.networks[0].macAddress == expectedMAC, "expected MAC address \(expectedMAC), got \(inspectResp.networks[0].macAddress ?? "nil")") + } + } } diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 90888fa4..42fd7dc2 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -268,7 +268,7 @@ class CLITest { arguments += ["-v", volume] } - // Add networks + // Add networks (can include properties like "network,mac=XX:XX:XX:XX:XX:XX") for network in networks { arguments += ["--network", network] } diff --git a/Tests/ContainerClientTests/ParserTest.swift b/Tests/ContainerClientTests/ParserTest.swift index 4067706b..398dea7f 100644 --- a/Tests/ContainerClientTests/ParserTest.swift +++ b/Tests/ContainerClientTests/ParserTest.swift @@ -420,4 +420,87 @@ struct ParserTest { return error.description.contains("no variable name") } } + + // MARK: Network Parser Tests + + @Test + func testParseNetworkSimpleName() throws { + let result = try Parser.network("default") + #expect(result.name == "default") + #expect(result.macAddress == nil) + } + + @Test + func testParseNetworkWithMACAddress() throws { + let result = try Parser.network("backend,mac=02:42:ac:11:00:02") + #expect(result.name == "backend") + #expect(result.macAddress == "02:42:ac:11:00:02") + } + + @Test + func testParseNetworkWithMACAddressHyphenSeparator() throws { + let result = try Parser.network("backend,mac=02-42-ac-11-00-02") + #expect(result.name == "backend") + #expect(result.macAddress == "02-42-ac-11-00-02") + } + + @Test + func testParseNetworkEmptyString() throws { + #expect { + _ = try Parser.network("") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("network specification cannot be empty") + } + } + + @Test + func testParseNetworkEmptyName() throws { + #expect { + _ = try Parser.network(",mac=02:42:ac:11:00:02") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("network name cannot be empty") + } + } + + @Test + func testParseNetworkEmptyMACAddress() throws { + #expect { + _ = try Parser.network("backend,mac=") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("mac address value cannot be empty") + } + } + + @Test + func testParseNetworkUnknownProperty() throws { + #expect { + _ = try Parser.network("backend,unknown=value") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("unknown network property") && error.description.contains("unknown") + } + } + + @Test + func testParseNetworkInvalidPropertyFormat() throws { + #expect { + _ = try Parser.network("backend,invalidproperty") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid property format") + } + } } diff --git a/Tests/ContainerClientTests/UtilityTests.swift b/Tests/ContainerClientTests/UtilityTests.swift index 6651256a..96962979 100644 --- a/Tests/ContainerClientTests/UtilityTests.swift +++ b/Tests/ContainerClientTests/UtilityTests.swift @@ -51,4 +51,40 @@ struct UtilityTests { #expect(result["standalone"] == "") #expect(result["key2"] == "value2") } + + @Test("Valid MAC address with colons") + func testValidMACAddressWithColons() throws { + try Utility.validMACAddress("02:42:ac:11:00:02") + try Utility.validMACAddress("AA:BB:CC:DD:EE:FF") + try Utility.validMACAddress("00:00:00:00:00:00") + try Utility.validMACAddress("ff:ff:ff:ff:ff:ff") + } + + @Test("Valid MAC address with hyphens") + func testValidMACAddressWithHyphens() throws { + try Utility.validMACAddress("02-42-ac-11-00-02") + try Utility.validMACAddress("AA-BB-CC-DD-EE-FF") + } + + @Test("Invalid MAC address format") + func testInvalidMACAddressFormat() { + #expect(throws: Error.self) { + try Utility.validMACAddress("invalid") + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00") // Too short + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00:02:03") // Too long + } + #expect(throws: Error.self) { + try Utility.validMACAddress("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") // Invalid hex + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00:") // Incomplete + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02.42.ac.11.00.02") // Wrong separator + } + } } diff --git a/docs/command-reference.md b/docs/command-reference.md index cb87589e..85923584 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -92,6 +92,9 @@ container run -d --name web -p 8080:80 nginx:latest # set environment variables and limit resources container run -e NODE_ENV=production --cpus 2 --memory 1G node:18 + +# run a container with a specific MAC address +container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest ``` ### `container build` diff --git a/docs/how-to.md b/docs/how-to.md index 12e9334b..3d70c1ec 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -196,6 +196,30 @@ A `curl` to `localhost:8000` outputs: ``` +## Set a custom MAC address for your container + +Use the `mac` option to specify a custom MAC address for your container's network interface. This is useful for: +- 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): + +```bash +container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest +``` + +To verify the MAC address is set correctly, run `ip addr show` inside the container: + +```console +% container run --rm --mac-address 02:42:ac:11:00:02 ubuntu:latest ip addr show eth0 +2: eth0: mtu 1500 qdisc fq_codel state UP group default qlen 1000 + link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff + inet 192.168.64.2/24 brd 192.168.64.255 scope global eth0 + valid_lft forever preferred_lft forever +``` + +If you don't specify a MAC address, the system will auto-generate one for you. + ## Mount your host SSH authentication socket in your container Use the `--ssh` option to mount the macOS SSH authentication socket into your container, so that you can clone private git repositories and perform other tasks requiring passwordless SSH authentication.