Skip to content

Commit cd7a14f

Browse files
committed
Add MAC address customization
This commit adds support for customizing MAC addresses for containers.
1 parent 375e83c commit cd7a14f

File tree

19 files changed

+298
-20
lines changed

19 files changed

+298
-20
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ api-docs/
1313
workdir/
1414
installer/
1515
.venv/
16+
.claude/
1617
.clitests/
1718
test_results/
1819
*.pid

Sources/ContainerClient/Core/ContainerConfiguration.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ public struct ContainerConfiguration: Sendable, Codable {
8989
networks = try container.decode([AttachmentConfiguration].self, forKey: .networks)
9090
} catch {
9191
let networkIds = try container.decode([String].self, forKey: .networks)
92-
networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds)
92+
// Parse old network IDs as simple network names without properties
93+
let parsedNetworks = networkIds.map { Parser.ParsedNetwork(name: $0, macAddress: nil) }
94+
networks = try Utility.getAttachmentConfigurations(containerId: id, networks: parsedNetworks)
9395
}
9496
} else {
9597
networks = []

Sources/ContainerClient/Flags.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public struct Flags {
151151
@Option(name: .long, help: "Use the specified name as the container ID")
152152
public var name: String?
153153

154-
@Option(name: [.customLong("network")], help: "Attach the container to a network")
154+
@Option(name: [.customLong("network")], help: "Attach the container to a network (format: <name>[,mac=XX:XX:XX:XX:XX:XX])")
155155
public var networks: [String] = []
156156

157157
@Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container")

Sources/ContainerClient/Parser.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,76 @@ public struct Parser {
712712
}
713713
}
714714

715+
// MARK: Networks
716+
717+
/// Parsed network attachment with optional properties
718+
public struct ParsedNetwork {
719+
public let name: String
720+
public let macAddress: String?
721+
722+
public init(name: String, macAddress: String? = nil) {
723+
self.name = name
724+
self.macAddress = macAddress
725+
}
726+
}
727+
728+
/// Parse network attachment with optional properties
729+
/// Format: network_name[,mac=XX:XX:XX:XX:XX:XX]
730+
/// Example: "backend,mac=02:42:ac:11:00:02"
731+
public static func network(_ networkSpec: String) throws -> ParsedNetwork {
732+
guard !networkSpec.isEmpty else {
733+
throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty")
734+
}
735+
736+
let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false)
737+
738+
guard !parts.isEmpty else {
739+
throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty")
740+
}
741+
742+
let networkName = String(parts[0])
743+
if networkName.isEmpty {
744+
throw ContainerizationError(.invalidArgument, message: "network name cannot be empty")
745+
}
746+
747+
var macAddress: String?
748+
749+
// Parse properties if any
750+
for part in parts.dropFirst() {
751+
let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false)
752+
753+
let key: String
754+
let value: String
755+
756+
guard keyVal.count == 2 else {
757+
throw ContainerizationError(
758+
.invalidArgument,
759+
message: "invalid property format '\(part)' in network specification '\(networkSpec)'"
760+
)
761+
}
762+
key = String(keyVal[0])
763+
value = String(keyVal[1])
764+
765+
switch key {
766+
case "mac":
767+
if value.isEmpty {
768+
throw ContainerizationError(
769+
.invalidArgument,
770+
message: "mac address value cannot be empty"
771+
)
772+
}
773+
macAddress = value
774+
default:
775+
throw ContainerizationError(
776+
.invalidArgument,
777+
message: "unknown network property '\(key)'. Available properties: mac"
778+
)
779+
}
780+
}
781+
782+
return ParsedNetwork(name: networkName, macAddress: macAddress)
783+
}
784+
715785
// MARK: DNS
716786

717787
public static func isValidDomainName(_ name: String) -> Bool {

Sources/ContainerClient/Utility.swift

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ public struct Utility {
6262
}
6363
}
6464

65+
public static func validMACAddress(_ macAddress: String) throws {
66+
let pattern = #"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"#
67+
let regex = try Regex(pattern)
68+
if try regex.firstMatch(in: macAddress) == nil {
69+
throw ContainerizationError(.invalidArgument, message: "invalid MAC address format \(macAddress), expected format: XX:XX:XX:XX:XX:XX")
70+
}
71+
}
72+
6573
public static func containerConfigFromFlags(
6674
id: String,
6775
image: String,
@@ -176,13 +184,15 @@ public struct Utility {
176184

177185
config.virtualization = management.virtualization
178186

187+
// Parse network specifications with properties
188+
let parsedNetworks = try management.networks.map { try Parser.network($0) }
179189
if management.networks.contains(ClientNetwork.noNetworkName) {
180190
guard management.networks.count == 1 else {
181191
throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(ClientNetwork.noNetworkName)")
182192
}
183193
config.networks = []
184194
} else {
185-
config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks)
195+
config.networks = try getAttachmentConfigurations(containerId: config.id, networks: parsedNetworks)
186196
for attachmentConfiguration in config.networks {
187197
let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network)
188198
guard case .running(_, _) = network else {
@@ -220,7 +230,14 @@ public struct Utility {
220230
return (config, kernel)
221231
}
222232

223-
static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] {
233+
static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] {
234+
// Validate MAC addresses if provided
235+
for network in networks {
236+
if let mac = network.macAddress {
237+
try validMACAddress(mac)
238+
}
239+
}
240+
224241
// make an FQDN for the first interface
225242
let fqdn: String?
226243
if !containerId.contains(".") {
@@ -235,22 +252,33 @@ public struct Utility {
235252
fqdn = "\(containerId)."
236253
}
237254

238-
guard networkIds.isEmpty else {
239-
// networks may only be specified for macOS 26+
240-
guard #available(macOS 26, *) else {
241-
throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer")
255+
guard networks.isEmpty else {
256+
// Check if this is only the default network with properties (e.g., MAC address)
257+
let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == ClientNetwork.defaultNetworkName
258+
259+
// networks may only be specified for macOS 26+ (except for default network with properties)
260+
if !isOnlyDefaultNetwork {
261+
guard #available(macOS 26, *) else {
262+
throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer")
263+
}
242264
}
243265

244266
// attach the first network using the fqdn, and the rest using just the container ID
245-
return networkIds.enumerated().map { item in
267+
return networks.enumerated().map { item in
246268
guard item.offset == 0 else {
247-
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId))
269+
return AttachmentConfiguration(
270+
network: item.element.name,
271+
options: AttachmentOptions(hostname: containerId, macAddress: item.element.macAddress)
272+
)
248273
}
249-
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId))
274+
return AttachmentConfiguration(
275+
network: item.element.name,
276+
options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: item.element.macAddress)
277+
)
250278
}
251279
}
252280
// if no networks specified, attach to the default network
253-
return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId))]
281+
return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil))]
254282
}
255283

256284
private static func getKernel(management: Flags.Management) async throws -> Kernel {

Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ import Containerization
2525
struct IsolatedInterfaceStrategy: InterfaceStrategy {
2626
public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface {
2727
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
28-
return NATInterface(address: attachment.address, gateway: gateway)
28+
return NATInterface(address: attachment.address, gateway: gateway, macAddress: attachment.macAddress)
2929
}
3030
}

Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,6 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy {
4444

4545
log.info("creating NATNetworkInterface with network reference")
4646
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
47-
return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef)
47+
return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef, macAddress: attachment.macAddress)
4848
}
4949
}

Sources/Services/ContainerNetworkService/Attachment.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ public struct Attachment: Codable, Sendable {
2424
public let address: String
2525
/// The IPv4 gateway address.
2626
public let gateway: String
27+
/// The MAC address associated with the attachment (optional).
28+
public let macAddress: String?
2729

28-
public init(network: String, hostname: String, address: String, gateway: String) {
30+
public init(network: String, hostname: String, address: String, gateway: String, macAddress: String? = nil) {
2931
self.network = network
3032
self.hostname = hostname
3133
self.address = address
3234
self.gateway = gateway
35+
self.macAddress = macAddress
3336
}
3437
}

Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ public struct AttachmentOptions: Codable, Sendable {
3333
/// The hostname associated with the attachment.
3434
public let hostname: String
3535

36-
public init(hostname: String) {
36+
/// The MAC address associated with the attachment (optional).
37+
public let macAddress: String?
38+
39+
public init(hostname: String, macAddress: String? = nil) {
3740
self.hostname = hostname
41+
self.macAddress = macAddress
3842
}
3943
}

Sources/Services/ContainerNetworkService/NetworkClient.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ extension NetworkClient {
4646
return state
4747
}
4848

49-
public func allocate(hostname: String) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
49+
public func allocate(hostname: String, macAddress: String? = nil) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
5050
let request = XPCMessage(route: NetworkRoutes.allocate.rawValue)
5151
request.set(key: NetworkKeys.hostname.rawValue, value: hostname)
52+
if let macAddress = macAddress {
53+
request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress)
54+
}
5255

5356
let client = createClient()
5457

0 commit comments

Comments
 (0)