From ee3f5778e8a7dda3714ca8fda3260ca435ffbaac Mon Sep 17 00:00:00 2001 From: s3rj1k Date: Sun, 7 Jun 2026 18:48:58 +0200 Subject: [PATCH] Add bridged physical networking via vmnet-helper with static IP and alias support Signed-off-by: s3rj1k --- Makefile | 5 + Package.swift | 30 ++ .../Network/AttachmentConfiguration.swift | 7 +- .../Network/VmnetHelperNetworkKeys.swift | 44 +++ .../NetworkVmnetHelperPlugin+Start.swift | 155 ++++++++++ .../NetworkVmnetHelperPlugin.swift | 30 ++ .../Plugins/NetworkVmnetHelper/config.toml | 11 + .../RuntimeLinuxHelper+Start.swift | 3 +- .../ContainerAPIService/Client/Parser.swift | 21 +- .../ContainerAPIService/Client/Utility.swift | 11 +- .../Server/Networks/NetworksService.swift | 66 +++- .../Network/Client/NetworkClient.swift | 4 + .../Services/Network/Client/NetworkKeys.swift | 1 + .../Server/DefaultNetworkService.swift | 5 + .../Network/Server/NetworkHarness.swift | 4 + .../Network/Server/NetworkService.swift | 5 + .../Server/VmnetHelperNetworkService.swift | 219 +++++++++++++ .../Server/VmnetHelperPhysicalNetwork.swift | 115 +++++++ .../RuntimeClient/InterfaceStrategy.swift | 2 +- .../RuntimeLinux/Server/RuntimeService.swift | 14 +- .../Server/VmnetHelperInterface.swift | 105 +++++++ .../Server/VmnetHelperInterfaceStrategy.swift | 290 ++++++++++++++++++ .../ContainerAPIClientTests/ParserTest.swift | 19 ++ 23 files changed, 1140 insertions(+), 26 deletions(-) create mode 100644 Sources/ContainerResource/Network/VmnetHelperNetworkKeys.swift create mode 100644 Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin+Start.swift create mode 100644 Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin.swift create mode 100644 Sources/Plugins/NetworkVmnetHelper/config.toml create mode 100644 Sources/Services/NetworkVmnetHelper/Server/VmnetHelperNetworkService.swift create mode 100644 Sources/Services/NetworkVmnetHelper/Server/VmnetHelperPhysicalNetwork.swift create mode 100644 Sources/Services/RuntimeLinux/Server/VmnetHelperInterface.swift create mode 100644 Sources/Services/RuntimeLinux/Server/VmnetHelperInterfaceStrategy.swift diff --git a/Makefile b/Makefile index f91bdee96..42d20a3b1 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,7 @@ $(STAGING_DIR): @mkdir -p "$(join $(STAGING_DIR), bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet-helper/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)" @install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)" @@ -107,6 +108,8 @@ $(STAGING_DIR): @install Sources/Plugins/RuntimeLinux/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/config.toml)" @install "$(BUILD_BIN_DIR)/container-network-vmnet" "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" @install Sources/Plugins/NetworkVmnet/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.toml)" + @install "$(BUILD_BIN_DIR)/container-network-vmnet-helper" "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet-helper/bin/container-network-vmnet-helper)" + @install Sources/Plugins/NetworkVmnetHelper/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet-helper/config.toml)" @install "$(BUILD_BIN_DIR)/container-core-images" "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @install Sources/Plugins/CoreImages/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.toml)" @@ -123,6 +126,7 @@ installer-pkg: $(STAGING_DIR) @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-runtime-linux.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" + @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet-helper/bin/container-network-vmnet-helper)" @echo Creating application installer @pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @@ -135,6 +139,7 @@ dsym: @mkdir -p "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-runtime-linux.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-network-vmnet.dSYM" "$(DSYM_DIR)" + @cp -a "$(BUILD_BIN_DIR)/container-network-vmnet-helper.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-core-images.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-apiserver.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container.dSYM" "$(DSYM_DIR)" diff --git a/Package.swift b/Package.swift index 161879239..d4897f2a1 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,7 @@ let package = Package( .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), .library(name: "ContainerNetworkClient", targets: ["ContainerNetworkClient"]), .library(name: "ContainerNetworkServer", targets: ["ContainerNetworkServer"]), + .library(name: "ContainerNetworkVmnetHelperServer", targets: ["ContainerNetworkVmnetHelperServer"]), .library(name: "ContainerNetworkVmnetServer", targets: ["ContainerNetworkVmnetServer"]), .library(name: "ContainerResource", targets: ["ContainerResource"]), .library(name: "ContainerLog", targets: ["ContainerLog"]), @@ -307,6 +308,24 @@ let package = Package( path: "Sources/Plugins/NetworkVmnet", exclude: ["config.toml"] ), + .executableTarget( + name: "container-network-vmnet-helper", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerLog", + "ContainerNetworkVmnetHelperServer", + "ContainerNetworkClient", + "ContainerNetworkServer", + "ContainerPlugin", + "ContainerResource", + "ContainerVersion", + "ContainerXPC", + ], + path: "Sources/Plugins/NetworkVmnetHelper", + exclude: ["config.toml"] + ), .target( name: "ContainerNetworkClient", dependencies: [ @@ -345,6 +364,17 @@ let package = Package( ], path: "Sources/Services/NetworkVmnet/Server" ), + .target( + name: "ContainerNetworkVmnetHelperServer", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerNetworkServer", + "ContainerResource", + "ContainerXPC", + ], + path: "Sources/Services/NetworkVmnetHelper/Server" + ), .target( name: "ContainerRuntimeLinuxClient", dependencies: [], diff --git a/Sources/ContainerResource/Network/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift index d7e3dff65..7cad1c6ed 100644 --- a/Sources/ContainerResource/Network/AttachmentConfiguration.swift +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -41,9 +41,14 @@ public struct AttachmentOptions: Codable, Sendable { /// The MTU for the network interface. public let mtu: UInt32? - public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil) { + /// A specific IPv4 address requested for the attachment (optional). + /// Only supported by network plugins that allow static address assignment. + public let ip: IPv4Address? + + public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil, ip: IPv4Address? = nil) { self.hostname = hostname self.macAddress = macAddress self.mtu = mtu + self.ip = ip } } diff --git a/Sources/ContainerResource/Network/VmnetHelperNetworkKeys.swift b/Sources/ContainerResource/Network/VmnetHelperNetworkKeys.swift new file mode 100644 index 000000000..8117f9f8e --- /dev/null +++ b/Sources/ContainerResource/Network/VmnetHelperNetworkKeys.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Constants shared between the bridged network plugin and the runtime +/// interface strategy. +public enum VmnetHelperNetwork { + /// The name of the bridged network plugin. + public static let pluginName = "container-network-vmnet-helper" + + /// Network creation options (`container network create --option key=value`). + public enum Option: String { + /// The physical host interface to bridge (e.g. `en0`). Required. + case interface + /// The IPv4 gateway address on the bridged subnet. Required. + case gateway + /// An optional pool for automatic allocation, formatted as + /// `START-END` (e.g. `192.168.1.200-192.168.1.220`). + case pool + /// An optional override for the vmnet-helper binary path. + case helperPath = "helper-path" + } + + /// Keys for the additional data message returned with each + /// allocation. + public enum AdditionalDataKey: String { + /// The physical host interface to bridge. + case interface + /// The vmnet-helper binary path override, if configured. + case helperPath + } +} diff --git a/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin+Start.swift b/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin+Start.swift new file mode 100644 index 000000000..024826ffa --- /dev/null +++ b/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin+Start.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerLog +import ContainerNetworkClient +import ContainerNetworkServer +import ContainerNetworkVmnetHelperServer +import ContainerPlugin +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging + +extension NetworkVmnetHelperPlugin { + struct Start: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Starts the bridged network plugin" + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(name: .long, help: "XPC service identifier") + var serviceIdentifier: String + + @Option(name: .shortAndLong, help: "Network identifier") + var id: String + + // Bridged networks define their own semantics, so the mode + // from the API server is accepted and ignored. + @Option(name: .long, help: "Network mode (ignored)") + var mode: String = "nat" + + @Option(name: .customLong("subnet"), help: "CIDR address of the physical IPv4 subnet") + var ipv4Subnet: String? + + @Option( + name: .customLong("option"), + help: "Plugin option as key=value (interface=NAME, gateway=ADDR, pool=START-END, helper-path=PATH)" + ) + var options: [String] = [] + + var logRoot = LogRoot.path + + func run() async throws { + let commandName = NetworkVmnetHelperPlugin._commandName + let logPath = logRoot.map { $0.appending("\(commandName)-\(id).log") } + let log = ServiceLogger.bootstrap(category: "NetworkVmnetHelperPlugin", metadata: ["id": "\(id)"], debug: debug, logPath: logPath) + log.info("starting helper", metadata: ["name": "\(commandName)"]) + defer { + log.info("stopping helper", metadata: ["name": "\(commandName)"]) + } + + do { + let configuration = try parseConfiguration() + let network = VmnetHelperPhysicalNetwork(configuration: configuration, log: log) + try await network.start() + let service = VmnetHelperNetworkService(network: network, log: log) + let harness = NetworkHarness(service: service) + let xpc = XPCServer( + identifier: serviceIdentifier, + routes: [ + NetworkRoutes.status.rawValue: XPCServer.route(harness.status), + NetworkRoutes.allocate.rawValue: harness.allocate, + NetworkRoutes.lookup.rawValue: XPCServer.route(harness.lookup), + ], + log: log + ) + + log.info("starting XPC server") + try await xpc.listen() + } catch { + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) + NetworkVmnetHelperPlugin.exit(withError: error) + } + } + + private func parseConfiguration() throws -> VmnetHelperPhysicalNetwork.Configuration { + guard let subnetText = ipv4Subnet else { + throw ContainerizationError( + .invalidArgument, + message: "bridged networks require the physical subnet, create the network with --subnet" + ) + } + let subnet = try CIDRv4(subnetText) + + var optionValues: [String: String] = [:] + for option in options { + let keyVal = option.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard keyVal.count == 2, !keyVal[0].isEmpty else { + throw ContainerizationError(.invalidArgument, message: "invalid option format '\(option)': expected key=value") + } + optionValues[String(keyVal[0])] = String(keyVal[1]) + } + + guard let hostInterface = optionValues[VmnetHelperNetwork.Option.interface.rawValue], !hostInterface.isEmpty else { + throw ContainerizationError( + .invalidArgument, + message: "bridged networks require the host interface, create the network with --option interface=NAME" + ) + } + guard let gatewayText = optionValues[VmnetHelperNetwork.Option.gateway.rawValue] else { + throw ContainerizationError( + .invalidArgument, + message: "bridged networks require the gateway address, create the network with --option gateway=ADDR" + ) + } + let gateway = try IPv4Address(gatewayText) + + var pool: ClosedRange? + if let poolText = optionValues[VmnetHelperNetwork.Option.pool.rawValue] { + let bounds = poolText.split(separator: "-", omittingEmptySubsequences: false) + guard bounds.count == 2, + let lower = try? IPv4Address(String(bounds[0])), + let upper = try? IPv4Address(String(bounds[1])), + lower.value <= upper.value + else { + throw ContainerizationError(.invalidArgument, message: "invalid pool '\(poolText)': expected START-END IPv4 addresses") + } + pool = lower.value...upper.value + } + + return try VmnetHelperPhysicalNetwork.Configuration( + id: id, + ipv4Subnet: subnet, + ipv4Gateway: gateway, + hostInterface: hostInterface, + pool: pool, + helperPath: optionValues[VmnetHelperNetwork.Option.helperPath.rawValue] + ) + } + } +} diff --git a/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin.swift b/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin.swift new file mode 100644 index 000000000..d739d0fe9 --- /dev/null +++ b/Sources/Plugins/NetworkVmnetHelper/NetworkVmnetHelperPlugin.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerVersion + +@main +struct NetworkVmnetHelperPlugin: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "container-network-vmnet-helper", + abstract: "XPC service for managing a bridged physical network", + version: ReleaseVersion.singleLine(appName: "container-network-vmnet-helper"), + subcommands: [ + Start.self + ] + ) +} diff --git a/Sources/Plugins/NetworkVmnetHelper/config.toml b/Sources/Plugins/NetworkVmnetHelper/config.toml new file mode 100644 index 000000000..fc2b948ad --- /dev/null +++ b/Sources/Plugins/NetworkVmnetHelper/config.toml @@ -0,0 +1,11 @@ +abstract = "bridged physical network management plugin" +author = "Apple" +version = 0.1 + +[servicesConfig] +loadAtBoot = false +runAtLoad = true +defaultArguments = [] + +[[servicesConfig.services]] +type = "network" diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 3c7938b8e..8c1d329ec 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -65,7 +65,8 @@ extension RuntimeLinuxHelper { // FIXME: The network plugins that the runtime supports should be configurable elsewhere var interfaceStrategies: [NetworkInterfaceKey: InterfaceStrategy] = [ - NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "allocationOnly"): IsolatedInterfaceStrategy() + NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "allocationOnly"): IsolatedInterfaceStrategy(), + NetworkInterfaceKey(plugin: VmnetHelperNetwork.pluginName, variant: nil): VmnetHelperInterfaceStrategy(log: log), ] if #available(macOS 26, *) { interfaceStrategies[NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "reserved")] = NonisolatedInterfaceStrategy(log: log) diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 61779498e..01713773d 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -795,17 +795,19 @@ public struct Parser { public let name: String public let macAddress: String? public let mtu: UInt32? + public let ip: IPv4Address? - public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { + public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil, ip: IPv4Address? = nil) { self.name = name self.macAddress = macAddress self.mtu = mtu + self.ip = ip } } /// Parse network attachment with optional properties - /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] - /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" + /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE][,ip=ADDR] + /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500,ip=192.168.1.50" public static func network(_ networkSpec: String) throws -> ParsedNetwork { guard !networkSpec.isEmpty else { throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") @@ -824,6 +826,7 @@ public struct Parser { var macAddress: String? var mtu: UInt32? + var ip: IPv4Address? // Parse properties if any for part in parts.dropFirst() { @@ -858,15 +861,23 @@ public struct Parser { ) } mtu = mtuValue + case "ip": + guard let address = try? IPv4Address(value) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid ip value '\(value)': must be an IPv4 address" + ) + } + ip = address default: throw ContainerizationError( .invalidArgument, - message: "unknown network property '\(key)'. Available properties: mac, mtu" + message: "unknown network property '\(key)'. Available properties: mac, mtu, ip" ) } } - return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) + return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu, ip: ip) } // MARK: DNS diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index bfea2bbc0..0edd328eb 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -308,16 +308,19 @@ public struct Utility { // attach the first network using the fqdn, and the rest using just the container ID return try networks.enumerated().map { item in let macAddress = try item.element.macAddress.map { try MACAddress($0) } - let mtu = item.element.mtu ?? 1280 + // nil when unspecified, so each interface strategy + // applies its own default. + let mtu = item.element.mtu + let ip = item.element.ip guard item.offset == 0 else { return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu, ip: ip) ) } return AttachmentConfiguration( network: item.element.name, - options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu, ip: ip) ) } } @@ -326,7 +329,7 @@ public struct Utility { guard let builtinNetworkId else { throw ContainerizationError(.invalidState, message: "builtin network is not present") } - return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] + return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: nil))] } private static func getKernel(management: Flags.Management) async throws -> Kernel { diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index ac21b5247..8d168e0e7 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -339,12 +339,19 @@ public actor NetworksService { } } + /// The builtin vmnet plugin predates generic option forwarding. It has + /// dedicated arguments instead of `--option`, owns the `variant` + /// concept, and is the only plugin whose subnets the apiserver manages. + private static let builtinVmnetPlugin = "container-network-vmnet" + public func pluginConfiguration(id: String) throws -> (plugin: String, options: [String: String]) { guard let serviceState = serviceStates[id] else { throw ContainerizationError(.notFound, message: "no network for id \(id)") } var options = serviceState.configuration.options - if options["variant"] == nil { + // The variant default applies to the builtin vmnet plugin only. + // Other plugins select their interface strategy by name alone. + if serviceState.configuration.plugin == Self.builtinVmnetPlugin, options["variant"] == nil { if #available(macOS 26, *) { options["variant"] = "reserved" } else { @@ -363,6 +370,25 @@ public actor NetworksService { throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") } + let isBuiltinVmnet = configuration.plugin == Self.builtinVmnetPlugin + + // Only the builtin vmnet plugin accepts the variant and IPv6 + // subnet arguments. Passing them to another plugin would crash + // its launchd service on an unknown argument, so reject them at + // creation time instead. + guard isBuiltinVmnet || configuration.options["variant"] == nil else { + throw ContainerizationError( + .invalidArgument, + message: "the variant option is specific to the \(Self.builtinVmnetPlugin) plugin" + ) + } + guard isBuiltinVmnet || configuration.ipv6Subnet == nil else { + throw ContainerizationError( + .invalidArgument, + message: "network plugin \(configuration.plugin) does not support IPv6 subnets" + ) + } + guard let networkPlugin = self.networkPlugins.first(where: { $0.name == configuration.plugin }) else { throw ContainerizationError( .notFound, @@ -387,18 +413,23 @@ public actor NetworksService { } if let ipv4Subnet = configuration.ipv4Subnet { - var existingCidrs: [CIDRv4] = [] - for serviceState in serviceStates.values { - existingCidrs.append(serviceState.status.ipv4Subnet) - } - let overlap = existingCidrs.first { - $0.contains(ipv4Subnet.lower) - || $0.contains(ipv4Subnet.upper) - || ipv4Subnet.contains($0.lower) - || ipv4Subnet.contains($0.upper) - } - if let overlap { - throw ContainerizationError(.exists, message: "IPv4 subnet \(ipv4Subnet) overlaps an existing network with subnet \(overlap)") + // Overlap validation covers managed subnets only. Other + // plugins describe external networks that may legitimately + // share address space with a managed subnet. + if isBuiltinVmnet { + var existingCidrs: [CIDRv4] = [] + for serviceState in serviceStates.values { + existingCidrs.append(serviceState.status.ipv4Subnet) + } + let overlap = existingCidrs.first { + $0.contains(ipv4Subnet.lower) + || $0.contains(ipv4Subnet.upper) + || ipv4Subnet.contains($0.lower) + || ipv4Subnet.contains($0.upper) + } + if let overlap { + throw ContainerizationError(.exists, message: "IPv4 subnet \(ipv4Subnet) overlaps an existing network with subnet \(overlap)") + } } args += ["--subnet", ipv4Subnet.description] @@ -428,6 +459,15 @@ public actor NetworksService { args += ["--variant", variant] } + // Forward plugin-specific options to plugins other than the builtin + // vmnet helper, which predates generic option forwarding and rejects + // unknown arguments. + if !isBuiltinVmnet { + for (key, value) in configuration.options.sorted(by: { $0.key < $1.key }) where key != "variant" { + args += ["--option", "\(key)=\(value)"] + } + } + let entityPath = try store.entityPath(configuration.id) try pluginLoader.registerWithLaunchd( plugin: networkPlugin, diff --git a/Sources/Services/Network/Client/NetworkClient.swift b/Sources/Services/Network/Client/NetworkClient.swift index 97598a786..1cca5d828 100644 --- a/Sources/Services/Network/Client/NetworkClient.swift +++ b/Sources/Services/Network/Client/NetworkClient.swift @@ -70,6 +70,7 @@ extension NetworkClient { public func allocate( hostname: String, macAddress: MACAddress? = nil, + ip: IPv4Address? = nil, on session: XPCClientSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) @@ -77,6 +78,9 @@ extension NetworkClient { if let macAddress = macAddress { request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) } + if let ip { + request.set(key: NetworkKeys.ip.rawValue, value: ip.description) + } let response = try await session.send(request) let attachment = try response.attachment() let additionalData = response.additionalData() diff --git a/Sources/Services/Network/Client/NetworkKeys.swift b/Sources/Services/Network/Client/NetworkKeys.swift index b2ec74180..54690459e 100644 --- a/Sources/Services/Network/Client/NetworkKeys.swift +++ b/Sources/Services/Network/Client/NetworkKeys.swift @@ -18,6 +18,7 @@ public enum NetworkKeys: String { case additionalData case attachment case hostname + case ip case macAddress case network case status diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 29f3fbe82..2fbe652b1 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -57,11 +57,16 @@ public actor DefaultNetworkService: NetworkService { public func allocate( hostname: String, macAddress: MACAddress?, + ip: IPv4Address?, session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { log.debug("enter", metadata: ["func": "\(#function)"]) defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + guard ip == nil else { + throw ContainerizationError(.unsupported, message: "network \(network.id) does not support static IP assignment") + } + guard let status = await network.status else { throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") } diff --git a/Sources/Services/Network/Server/NetworkHarness.swift b/Sources/Services/Network/Server/NetworkHarness.swift index 6c9a96a16..dc92f8cde 100644 --- a/Sources/Services/Network/Server/NetworkHarness.swift +++ b/Sources/Services/Network/Server/NetworkHarness.swift @@ -41,10 +41,14 @@ public actor NetworkHarness: Sendable { let macAddress = try message.string(key: NetworkKeys.macAddress.rawValue) .map { try MACAddress($0) } + let ip = + try message.string(key: NetworkKeys.ip.rawValue) + .map { try IPv4Address($0) } let (attachment:attachment, additionalData:additionalData) = try await service.allocate( hostname: hostname, macAddress: macAddress, + ip: ip, session: session ) diff --git a/Sources/Services/Network/Server/NetworkService.swift b/Sources/Services/Network/Server/NetworkService.swift index 6e9f2b784..6984c21d5 100644 --- a/Sources/Services/Network/Server/NetworkService.swift +++ b/Sources/Services/Network/Server/NetworkService.swift @@ -24,9 +24,14 @@ public protocol NetworkService: Sendable { func status() async throws -> NetworkStatus /// Register a hostname and allocate associated addresses. + /// + /// The optional `ip` parameter requests a specific IPv4 address. + /// Plugins that do not support static assignment must throw when it + /// is present. func allocate( hostname: String, macAddress: MACAddress?, + ip: IPv4Address?, session: XPCServerSession ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) diff --git a/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperNetworkService.swift b/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperNetworkService.swift new file mode 100644 index 000000000..d176609a9 --- /dev/null +++ b/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperNetworkService.swift @@ -0,0 +1,219 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkServer +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Logging + +/// A network service that hands out static IPv4 addresses on a bridged +/// physical network. +/// +/// Addresses are either requested explicitly per attachment (`ip=`) or +/// allocated from the optional `pool` configured on the network. The +/// service performs no DHCP, so the operator must keep assigned addresses +/// outside the LAN's DHCP range. +/// +/// ContainerizationExtras' `AddressAllocator` is deliberately not used. +/// It manages one contiguous span, while this service combines a pool +/// with explicit addresses anywhere in the subnet plus hostname and +/// session ownership tracking. +public actor VmnetHelperNetworkService: NetworkService { + private struct Allocation { + let address: UInt32 + let owner: XPCServerSession + } + + private let network: VmnetHelperPhysicalNetwork + private let log: Logger + private var allocationsByHostname: [String: Allocation] = [:] + private var allocatedAddresses: Set = [] + private var hostnamesBySession: [XPCServerSession: [String]] = [:] + /// Rotating cursor into the pool. Allocation stays O(1) amortized + /// and a released address is not immediately reused. + private var poolCursor: UInt32? + + public init(network: VmnetHelperPhysicalNetwork, log: Logger) { + self.network = network + self.log = log + self.poolCursor = network.configuration.pool?.lowerBound + } + + @Sendable + public func status() async throws -> NetworkStatus { + guard let status = network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) is not running") + } + return status + } + + @Sendable + public func allocate( + hostname: String, + macAddress: MACAddress?, + ip: IPv4Address?, + session: XPCServerSession + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + log.debug("enter", metadata: ["func": "\(#function)"]) + defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard macAddress == nil else { + throw ContainerizationError( + .unsupported, + message: "network \(network.id) does not support explicit MAC addresses; vmnet assigns the MAC for bridged interfaces" + ) + } + + let configuration = network.configuration + let address: UInt32 + + if let existing = allocationsByHostname[hostname] { + // Repeated allocations for a hostname are valid only from + // the owning session. Honoring another session would later + // release an address that is still in use. + guard existing.owner === session else { + throw ContainerizationError( + .exists, + message: "hostname \(hostname) is already allocated by another client on network \(network.id)" + ) + } + if let requested = ip { + guard requested.value == existing.address else { + throw ContainerizationError( + .invalidArgument, + message: "hostname \(hostname) is already allocated address \(IPv4Address(existing.address)) on network \(network.id)" + ) + } + } + address = existing.address + } else if let requested = ip { + try validate(address: requested, configuration: configuration) + guard !allocatedAddresses.contains(requested.value) else { + throw ContainerizationError(.exists, message: "address \(requested) is already allocated on network \(network.id)") + } + address = requested.value + } else { + address = try allocateFromPool(configuration: configuration) + } + + allocationsByHostname[hostname] = Allocation(address: address, owner: session) + allocatedAddresses.insert(address) + + if hostnamesBySession[session] == nil { + await session.onDisconnect { [weak self] in + await self?.releaseSession(session) + } + } + hostnamesBySession[session, default: []].append(hostname) + + let attachment = try makeAttachment(hostname: hostname, address: address) + log.info( + "allocated attachment", + metadata: [ + "hostname": "\(hostname)", + "ipv4Address": "\(attachment.ipv4Address)", + "ipv4Gateway": "\(attachment.ipv4Gateway)", + ]) + + var additionalData: XPCMessage? + try network.withAdditionalData { + additionalData = $0 + } + + return (attachment: attachment, additionalData: additionalData) + } + + @Sendable + public func lookup(hostname: String) async throws -> Attachment? { + guard let allocation = allocationsByHostname[hostname] else { + return nil + } + return try makeAttachment(hostname: hostname, address: allocation.address) + } + + /// Allocate the next free address from the pool, scanning at most + /// one full rotation from the cursor. + private func allocateFromPool(configuration: VmnetHelperPhysicalNetwork.Configuration) throws -> UInt32 { + guard let pool = configuration.pool, var candidate = poolCursor else { + throw ContainerizationError( + .invalidArgument, + message: "network \(network.id) has no address pool; request a specific address with the ip= attachment option" + ) + } + let poolSize = pool.upperBound - pool.lowerBound + 1 + for _ in 0.. Attachment { + let configuration = network.configuration + return Attachment( + network: network.id, + hostname: hostname, + ipv4Address: try CIDRv4(IPv4Address(address), prefix: configuration.ipv4Subnet.prefix), + ipv4Gateway: configuration.ipv4Gateway, + ipv6Address: nil, + // The MAC address is assigned by vmnet when the runtime starts + // the per-container helper, so it is unknown at allocation time. + macAddress: nil + ) + } + + private func isAssignable(address: IPv4Address, configuration: VmnetHelperPhysicalNetwork.Configuration) -> Bool { + let subnet = configuration.ipv4Subnet + return subnet.contains(address) + && address != configuration.ipv4Gateway + && address != subnet.lower + && address != subnet.upper + } + + private func validate(address: IPv4Address, configuration: VmnetHelperPhysicalNetwork.Configuration) throws { + let subnet = configuration.ipv4Subnet + guard subnet.contains(address) else { + throw ContainerizationError(.invalidArgument, message: "address \(address) is not in subnet \(subnet)") + } + guard address != configuration.ipv4Gateway else { + throw ContainerizationError(.invalidArgument, message: "address \(address) is the gateway address") + } + guard address != subnet.lower, address != subnet.upper else { + throw ContainerizationError(.invalidArgument, message: "address \(address) is the network or broadcast address") + } + } + + private func releaseSession(_ session: XPCServerSession) { + guard let hostnames = hostnamesBySession.removeValue(forKey: session) else { + return + } + for hostname in hostnames { + // Release only allocations this session still owns. + guard let allocation = allocationsByHostname[hostname], allocation.owner === session else { + continue + } + allocationsByHostname.removeValue(forKey: hostname) + allocatedAddresses.remove(allocation.address) + } + log.info("released session", metadata: ["hostnames": "\(hostnames.count)"]) + } +} diff --git a/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperPhysicalNetwork.swift b/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperPhysicalNetwork.swift new file mode 100644 index 000000000..e19aea741 --- /dev/null +++ b/Sources/Services/NetworkVmnetHelper/Server/VmnetHelperPhysicalNetwork.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkServer +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import XPC + +/// Describes an existing physical network that containers join through +/// vmnet bridged mode. +/// +/// The subnet, gateway, and address assignments describe the external +/// LAN. The runtime creates the data path per container by launching a +/// vmnet-helper process on the configured host interface. +public final class VmnetHelperPhysicalNetwork: ContainerNetworkServer.Network, Sendable { + /// The static configuration describing the external network. + public struct Configuration: Sendable { + public let id: String + public let ipv4Subnet: CIDRv4 + public let ipv4Gateway: IPv4Address + public let hostInterface: String + public let pool: ClosedRange? + public let helperPath: String? + + public init( + id: String, + ipv4Subnet: CIDRv4, + ipv4Gateway: IPv4Address, + hostInterface: String, + pool: ClosedRange?, + helperPath: String? + ) throws { + guard ipv4Subnet.contains(ipv4Gateway) else { + throw ContainerizationError(.invalidArgument, message: "gateway \(ipv4Gateway) is not in subnet \(ipv4Subnet)") + } + // The network and broadcast addresses are not usable + // gateways. In /31 and /32 networks every address is a + // host address. + if ipv4Subnet.prefix.length < 31 { + guard ipv4Gateway != ipv4Subnet.lower, ipv4Gateway != ipv4Subnet.upper else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(ipv4Gateway) is the network or broadcast address of \(ipv4Subnet)" + ) + } + } + if let pool { + guard ipv4Subnet.contains(IPv4Address(pool.lowerBound)), ipv4Subnet.contains(IPv4Address(pool.upperBound)) else { + throw ContainerizationError(.invalidArgument, message: "address pool is not in subnet \(ipv4Subnet)") + } + } + self.id = id + self.ipv4Subnet = ipv4Subnet + self.ipv4Gateway = ipv4Gateway + self.hostInterface = hostInterface + self.pool = pool + self.helperPath = helperPath + } + } + + public let configuration: Configuration + private let log: Logger + + public init(configuration: Configuration, log: Logger) { + self.configuration = configuration + self.log = log + } + + public var id: String { configuration.id } + + public var status: NetworkStatus? { + NetworkStatus( + ipv4Subnet: configuration.ipv4Subnet, + ipv4Gateway: configuration.ipv4Gateway, + ipv6Subnet: nil + ) + } + + public func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + let message = XPCMessage(object: xpc_dictionary_create(nil, nil, 0)) + message.set(key: VmnetHelperNetwork.AdditionalDataKey.interface.rawValue, value: configuration.hostInterface) + if let helperPath = configuration.helperPath { + message.set(key: VmnetHelperNetwork.AdditionalDataKey.helperPath.rawValue, value: helperPath) + } + try handler(message) + } + + public func start() async throws { + log.info( + "started vmnet-helper network", + metadata: [ + "id": "\(configuration.id)", + "subnet": "\(configuration.ipv4Subnet)", + "gateway": "\(configuration.ipv4Gateway)", + "hostInterface": "\(configuration.hostInterface)", + ]) + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift b/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift index 24d842df3..8fe323ffc 100644 --- a/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift +++ b/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift @@ -41,5 +41,5 @@ public protocol InterfaceStrategy: Sendable { /// specific for the network to which the container will attach. /// /// - Returns: An XPC message with no parameters. - func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface + func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) async throws -> Interface } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index b320d5815..beb19fd42 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -183,6 +183,7 @@ public actor RuntimeService { var (attachment, additionalData) = try await client.allocate( hostname: attachmentConfig.options.hostname, macAddress: attachmentConfig.options.macAddress, + ip: attachmentConfig.options.ip, on: session ) if let mtu = attachmentConfig.options.mtu { @@ -201,7 +202,7 @@ public actor RuntimeService { .internalError, message: "no available interface strategy for network \(attachment.network), plugin=\(info.plugin) variant=\(info.options["variant"] ?? "nil")") } - let interface = try iStrategy.toInterface( + let interface = try await iStrategy.toInterface( attachment: attachment, interfaceIndex: index, additionalData: additionalData @@ -210,6 +211,10 @@ public actor RuntimeService { interfaces.append(interface) } } catch { + // Reap interfaces already created before the failure. + for case let managed as ManagedInterface in interfaces { + managed.teardown() + } for session in sessions { session.close() } throw error } @@ -1253,6 +1258,13 @@ public actor RuntimeService { await self.stopSocketForwarders() + // Reap interfaces backed by external processes. They also clean + // up after themselves when their data path closes, so this is + // belt and suspenders. + for case let managed as ManagedInterface in container.interfaces { + managed.teardown() + } + for session in networkSessions { session.close() } networkSessions = [] diff --git a/Sources/Services/RuntimeLinux/Server/VmnetHelperInterface.swift b/Sources/Services/RuntimeLinux/Server/VmnetHelperInterface.swift new file mode 100644 index 000000000..ba2cde60c --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/VmnetHelperInterface.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import Foundation +import Synchronization +import Virtualization + +/// A network interface backed by an external resource that needs +/// explicit teardown. +/// +/// The runtime calls `teardown()` during container cleanup and on +/// bootstrap failure, so external resources are reaped deterministically +/// instead of relying on deallocation order. +public protocol ManagedInterface: Interface { + func teardown() +} + +/// A network interface backed by a datagram socket connected to a +/// vmnet-helper process that bridges guest traffic onto a physical host +/// interface. +/// +/// The interface owns one end of a `SOCK_DGRAM` socketpair and the helper +/// holds the other. Closing the file handle or terminating the helper +/// tears down the data path. +public final class VmnetHelperInterface: ManagedInterface, @unchecked Sendable { + public let ipv4Address: CIDRv4 + public let ipv4Gateway: IPv4Address? + public let macAddress: MACAddress? + /// The guest link MTU, applied to the interface inside the container. + public let mtu: UInt32 + /// The frame size of the vmnet data path, as reported by + /// vmnet-helper. Independent of the guest link MTU. + private let vmnetMtu: UInt32 + + private let fileHandle: FileHandle + private let processMutex: Mutex + + init( + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address?, + macAddress: MACAddress?, + mtu: UInt32, + vmnetMtu: UInt32, + fileHandle: FileHandle, + helperProcess: Process + ) { + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway + self.macAddress = macAddress + self.mtu = mtu + self.vmnetMtu = vmnetMtu + self.fileHandle = fileHandle + self.processMutex = Mutex(helperProcess) + } + + /// Terminate the vmnet-helper process. The helper also exits on its + /// own when both socket ends close. `terminate()` is documented as a + /// no op for a finished process, so no liveness check is needed. + public func teardown() { + processMutex.withLock { process in + guard let p = process else { + return + } + process = nil + p.terminate() + } + } +} + +extension VmnetHelperInterface: VZInterface { + public func device() throws -> VZVirtioNetworkDeviceConfiguration { + let config = VZVirtioNetworkDeviceConfiguration() + if let macAddress = self.macAddress { + guard let mac = VZMACAddress(string: macAddress.description) else { + throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)") + } + config.macAddress = mac + } + let attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: self.fileHandle) + // The frame size ceiling of the data path, not the guest link + // MTU. The framework accepts only 1500 through 65535 and rejects + // the VM configuration otherwise, so clamp the reported value. + if self.vmnetMtu > 1500 { + attachment.maximumTransmissionUnit = Int(min(self.vmnetMtu, 65535)) + } + config.attachment = attachment + return config + } +} diff --git a/Sources/Services/RuntimeLinux/Server/VmnetHelperInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/VmnetHelperInterfaceStrategy.swift new file mode 100644 index 000000000..5b0f32146 --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/VmnetHelperInterfaceStrategy.swift @@ -0,0 +1,290 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerRuntimeClient +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationExtras +import CryptoKit +import Foundation +import Logging + +/// Creates network interfaces for bridged networks by launching a +/// vmnet-helper process per interface. +/// +/// vmnet-helper (https://github.com/nirs/vmnet-helper) attaches to the +/// physical host interface in vmnet bridged mode and relays Ethernet +/// frames over a `SOCK_DGRAM` socketpair. The strategy spawns the helper, +/// reads the interface parameters it reports on stdout, and hands the VM +/// end of the socketpair to the virtual machine. The MAC address comes +/// from vmnet, not from the caller. +public struct VmnetHelperInterfaceStrategy: InterfaceStrategy { + /// Default helper locations for Homebrew on macOS 26 and newer, + /// then the install script location for macOS 15 and earlier. + private static let defaultHelperPaths = [ + "/opt/homebrew/opt/vmnet-helper/libexec/vmnet-helper", + "/usr/local/opt/vmnet-helper/libexec/vmnet-helper", + "/opt/vmnet-helper/bin/vmnet-helper", + ] + + private static let startupTimeout = Duration.seconds(15) + // The Virtualization framework requires SO_RCVBUF of at least twice + // SO_SNDBUF on the attachment socket and recommends four times. + private static let socketSendBufferSize: Int32 = 1024 * 1024 + private static let socketReceiveBufferSize: Int32 = 4 * 1024 * 1024 + + private let log: Logger + + public init(log: Logger) { + self.log = log + } + + public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) async throws -> Interface { + guard let additionalData else { + throw ContainerizationError(.invalidState, message: "bridged network attachment has no additional data") + } + guard let hostInterface = additionalData.string(key: VmnetHelperNetwork.AdditionalDataKey.interface.rawValue) else { + throw ContainerizationError(.invalidState, message: "bridged network attachment does not specify a host interface") + } + let helperPath = try Self.resolveHelperPath( + override: additionalData.string(key: VmnetHelperNetwork.AdditionalDataKey.helperPath.rawValue) + ) + + // A stable interface ID gives the attachment a stable vmnet MAC + // address across container restarts. + let interfaceId = Self.stableInterfaceId(network: attachment.network, hostname: attachment.hostname, interfaceIndex: interfaceIndex) + + // The helper gets one end of the socketpair as its stdin and + // the VM gets the other. + var fds: [Int32] = [-1, -1] + guard socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds) == 0 else { + throw ContainerizationError(.internalError, message: "socketpair failed with errno \(errno)") + } + let vmFd = fds[0] + let helperFd = fds[1] + for fd in fds { + var sendSize = Self.socketSendBufferSize + var receiveSize = Self.socketReceiveBufferSize + _ = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendSize, socklen_t(MemoryLayout.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveSize, socklen_t(MemoryLayout.size)) + } + + let stdoutPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: helperPath) + process.arguments = [ + "--fd", "0", + "--operation-mode", "bridged", + "--shared-interface", hostInterface, + "--interface-id", interfaceId, + ] + process.standardInput = FileHandle(fileDescriptor: helperFd, closeOnDealloc: false) + process.standardOutput = stdoutPipe + process.standardError = FileHandle.nullDevice + + log.info( + "starting vmnet-helper", + metadata: [ + "path": "\(helperPath)", + "hostInterface": "\(hostInterface)", + "interfaceId": "\(interfaceId)", + "network": "\(attachment.network)", + ]) + + // The vm end is handed off to the returned interface on + // success. Every failure path below reaps the helper and the fd. + var handedOff = false + var launched = false + defer { + if !handedOff { + if launched { + // A no op when the process has already exited. + process.terminate() + } + close(vmFd) + } + } + + do { + try process.run() + launched = true + } catch { + close(helperFd) + throw ContainerizationError(.internalError, message: "failed to start vmnet-helper at \(helperPath)", cause: error) + } + + // The child holds its own copies of the helper end and the + // pipe's write end. Close the parent's copies so peer close and + // EOF detection work. + close(helperFd) + try? stdoutPipe.fileHandleForWriting.close() + + // The handshake read blocks for up to `startupTimeout`. Run it + // on a GCD queue so it does not stall a concurrency pool thread + // and with it the runtime's other XPC routes. + let readFd = stdoutPipe.fileHandleForReading.fileDescriptor + let parameters: [String: Any] = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + let result = Result { try Self.readInterfaceParameters(fd: readFd, process: process) } + continuation.resume(with: result) + } + } + + guard let macString = parameters["vmnet_mac_address"] as? String, + let macAddress = try? MACAddress(macString) + else { + throw ContainerizationError(.internalError, message: "vmnet-helper reported no valid MAC address") + } + let vmnetMtu = Self.reportedMtu(parameters["vmnet_mtu"]) ?? 1500 + // The guest link MTU honors the attachment option but cannot + // exceed the frame size of the vmnet data path. + let mtu = min(attachment.mtu ?? vmnetMtu, vmnetMtu) + + log.info( + "started vmnet-helper", + metadata: [ + "macAddress": "\(macAddress)", + "mtu": "\(mtu)", + "vmnetMtu": "\(vmnetMtu)", + "network": "\(attachment.network)", + ]) + + let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil + let interface = VmnetHelperInterface( + ipv4Address: attachment.ipv4Address, + ipv4Gateway: ipv4Gateway, + // The guest must use the MAC address vmnet assigned to the + // helper's interface. Frames from any other source address + // would not pass the bridge. + macAddress: macAddress, + mtu: mtu, + vmnetMtu: vmnetMtu, + fileHandle: FileHandle(fileDescriptor: vmFd, closeOnDealloc: true), + helperProcess: process + ) + handedOff = true + return interface + } + + /// The MTU as reported in the helper's parameter JSON. Tolerates a + /// string-encoded number rather than silently substituting a default. + private static func reportedMtu(_ value: Any?) -> UInt32? { + if let number = value as? NSNumber { + return number.uint32Value + } + if let string = value as? String { + return UInt32(string) + } + return nil + } + + private static func resolveHelperPath(override: String?) throws -> String { + if let override { + guard FileManager.default.isExecutableFile(atPath: override) else { + throw ContainerizationError(.notFound, message: "vmnet-helper not found at configured path \(override)") + } + return override + } + for path in Self.defaultHelperPaths { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + throw ContainerizationError( + .notFound, + message: "vmnet-helper not found; install it with `brew install nirs/tap/vmnet-helper` or set the helper-path network option" + ) + } + + /// Derive a stable UUID from the attachment identity so vmnet + /// assigns the same MAC address on every container start. The first + /// 16 digest bytes form the UUID. vmnet only parses the string, so + /// RFC 4122 version bits are not needed. + private static func stableInterfaceId(network: String, hostname: String, interfaceIndex: Int) -> String { + let digest = SHA256.hash(data: Data("container-bridged:\(network):\(hostname):\(interfaceIndex)".utf8)) + return digest.withUnsafeBytes { UUID(uuid: $0.load(as: uuid_t.self)) }.uuidString + } + + /// Read the single-line JSON interface description that vmnet-helper + /// prints to stdout once the vmnet interface starts. + private static func readInterfaceParameters(fd: Int32, process: Process) throws -> [String: Any] { + var buffer = Data() + var scanned = 0 + let deadline = ContinuousClock.now + Self.startupTimeout + + func parse(_ line: Data) throws -> [String: Any] { + guard let object = try? JSONSerialization.jsonObject(with: line), + let parameters = object as? [String: Any] + else { + throw ContainerizationError(.internalError, message: "cannot parse vmnet-helper interface description") + } + return parameters + } + + while true { + // Only newly appended bytes need scanning for the terminator. + if let newlineIndex = buffer[scanned...].firstIndex(of: UInt8(ascii: "\n")) { + return try parse(buffer[.. .zero else { + throw ContainerizationError(.internalError, message: "timed out waiting for vmnet-helper to start") + } + + var pollFd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let timeoutMs = Int32(min(remaining / .milliseconds(1), 1000)) + let rc = poll(&pollFd, 1, max(timeoutMs, 1)) + if rc < 0 { + if errno == EINTR { + continue + } + throw ContainerizationError(.internalError, message: "poll on vmnet-helper output failed with errno \(errno)") + } + if rc == 0 { + continue + } + + var chunk = [UInt8](repeating: 0, count: 4096) + let count = read(fd, &chunk, chunk.count) + if count < 0 { + if errno == EINTR { + continue + } + throw ContainerizationError(.internalError, message: "read on vmnet-helper output failed with errno \(errno)") + } + if count == 0 { + // EOF. A complete description may lack a trailing newline, + // so try the buffered bytes before failing. + if !buffer.isEmpty, let parameters = try? parse(buffer) { + return parameters + } + // The helper exited before reporting parameters, which + // typically means a bad host interface or missing + // privileges on macOS 15 and earlier. + process.waitUntilExit() + throw ContainerizationError( + .internalError, + message: "vmnet-helper exited with status \(process.terminationStatus) before reporting interface parameters" + ) + } + buffer.append(contentsOf: chunk[0..