fix(lume): follow-up for bridged networking + local installer signing (#1063)

## Summary
Follow-up to #1008 that keeps the original bridged-networking work intact and addresses issues found during review/testing.

### Networking correctness and validation
- Added strict request parsing helpers for network mode
- API handlers now validate `network` and return a bad request on invalid values (no silent NAT fallback)
- Fixed run-handler fallback initializer to include `network: nil`

### Respect persisted VM network config
- `lume run --network` is now an optional override
- If `--network` is omitted, run uses the VM's configured network mode

### Preserve network mode across VM creation/setup
- Threaded `networkMode` through temporary VM config creation
- Ensured Darwin/Linux setup config rewrites preserve existing config

### Local installer signing safety
- Added `lume.local.entitlements` (virtualization-only entitlement for local ad-hoc installs)
- `install-local.sh` now signs with local-safe entitlements by default, with `--bridged-entitlement` opt-in

Co-authored-by: festen <6270156+festen@users.noreply.github.com>
This commit is contained in:
Francesco Bonacci
2026-02-10 21:09:22 -08:00
committed by GitHub
parent 8c574c9260
commit 220c888069
19 changed files with 365 additions and 38 deletions

View File

@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.virtualization</key>
<true/>
<key>com.apple.vm.networking</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>

View File

@@ -31,6 +31,11 @@ INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
# Build configuration (debug or release)
BUILD_CONFIG="debug"
# Entitlement profile to use for local ad-hoc signing.
# Default excludes com.apple.vm.networking because macOS kills ad-hoc binaries
# carrying that restricted entitlement.
USE_BRIDGED_ENTITLEMENT=false
# Option to skip background service setup (default: install it)
INSTALL_BACKGROUND_SERVICE=true
@@ -51,6 +56,9 @@ while [ "$#" -gt 0 ]; do
--release)
BUILD_CONFIG="release"
;;
--bridged-entitlement)
USE_BRIDGED_ENTITLEMENT=true
;;
--no-background-service)
INSTALL_BACKGROUND_SERVICE=false
;;
@@ -62,6 +70,7 @@ while [ "$#" -gt 0 ]; do
echo " --install-dir DIR Install to the specified directory (default: $DEFAULT_INSTALL_DIR)"
echo " --port PORT Specify the port for lume serve (default: 7777)"
echo " --release Build release configuration instead of debug"
echo " --bridged-entitlement Sign with com.apple.vm.networking entitlement (requires proper Apple-approved signing)"
echo " --no-background-service Do not setup the Lume background service (LaunchAgent)"
echo " --help Display this help message"
echo ""
@@ -111,9 +120,24 @@ build_lume() {
BUILD_PATH="$LUME_DIR/.build/debug"
fi
# Use local-safe entitlements by default.
ENTITLEMENTS_FILE="$LUME_DIR/resources/lume.local.entitlements"
if [ "$USE_BRIDGED_ENTITLEMENT" = true ]; then
ENTITLEMENTS_FILE="$LUME_DIR/resources/lume.entitlements"
fi
# Codesign the binary
echo "Codesigning binary..."
codesign --force --entitlement "$LUME_DIR/resources/lume.entitlements" --sign - "$BUILD_PATH/lume"
codesign --force --entitlements "$ENTITLEMENTS_FILE" --sign - "$BUILD_PATH/lume"
# Verify the signed binary can launch.
if ! "$BUILD_PATH/lume" --version >/dev/null 2>&1; then
if [ "$USE_BRIDGED_ENTITLEMENT" = true ]; then
echo "${YELLOW}Warning: binary did not launch with bridged entitlement; falling back to local-safe entitlements.${NORMAL}"
ENTITLEMENTS_FILE="$LUME_DIR/resources/lume.local.entitlements"
codesign --force --entitlements "$ENTITLEMENTS_FILE" --sign - "$BUILD_PATH/lume"
fi
fi
echo "${GREEN}Build complete!${NORMAL}"
}
@@ -241,6 +265,9 @@ main() {
echo ""
echo "${GREEN}${BOLD}Lume ($BUILD_CONFIG) has been successfully installed!${NORMAL}"
echo "Run ${BOLD}lume${NORMAL} to get started."
if [ "$USE_BRIDGED_ENTITLEMENT" = false ]; then
echo "${YELLOW}Note: local installer signs without com.apple.vm.networking by default.${NORMAL}"
fi
if [ "$INSTALL_BACKGROUND_SERVICE" = true ]; then
setup_background_service

View File

@@ -1,11 +1,12 @@
import ArgumentParser
import Foundation
import Virtualization
struct Config: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Get or set lume configuration",
subcommands: [Get.self, Storage.self, Cache.self, Caching.self, Telemetry.self],
subcommands: [Get.self, Storage.self, Cache.self, Caching.self, Telemetry.self, Network.self],
defaultSubcommand: Get.self
)
@@ -289,4 +290,41 @@ struct Config: ParsableCommand {
}
}
}
// MARK: - Network Management Subcommands
struct Network: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "network",
abstract: "Manage network settings",
subcommands: [Interfaces.self]
)
struct Interfaces: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "interfaces",
abstract: "List available network interfaces for bridged networking"
)
func run() throws {
let interfaces = VZBridgedNetworkInterface.networkInterfaces
if interfaces.isEmpty {
print("No bridgeable network interfaces found.")
print("")
print("Note: Bridged networking requires the com.apple.vm.networking entitlement.")
return
}
print("Available network interfaces for bridged networking:")
print("")
for iface in interfaces {
let name = iface.localizedDisplayName ?? "Unknown"
print(" \(iface.identifier)\(name)")
}
print("")
print("Usage: lume run <vm-name> --network bridged:\(interfaces.first?.identifier ?? "en0")")
}
}
}
}

View File

@@ -72,6 +72,22 @@ struct Create: AsyncParsableCommand {
help: "Port to use for the VNC server during unattended setup. Defaults to 0 (auto-assign)")
var vncPort: Int = 0
@Option(
name: .customLong("network"),
help: "Network mode: 'nat' (default), 'bridged' (auto-select interface), or 'bridged:<interface>' (e.g. 'bridged:en0')")
var network: String = "nat"
private var parsedNetworkMode: NetworkMode {
get throws {
guard let mode = NetworkMode.parse(network) else {
throw ValidationError(
"Invalid network mode '\(network)'. Expected 'nat', 'bridged', or 'bridged:<interface>'."
)
}
return mode
}
}
init() {
}
@@ -124,7 +140,8 @@ struct Create: AsyncParsableCommand {
debug: debug,
debugDir: debugDir,
noDisplay: unattendedConfig != nil ? true : noDisplay, // Default to no-display for unattended
vncPort: vncPort
vncPort: vncPort,
networkMode: parsedNetworkMode
)
}
}

View File

@@ -51,6 +51,25 @@ struct Run: AsyncParsableCommand {
@Option(name: .customLong("storage"), help: "VM storage location to use or direct path to VM location")
var storage: String?
@Option(
name: .customLong("network"),
help: "Optional network override: 'nat', 'bridged', or 'bridged:<interface>' (e.g. 'bridged:en0'). Defaults to the VM's configured mode.")
var network: String?
private var parsedNetworkMode: NetworkMode? {
get throws {
guard let network else {
return nil
}
guard let mode = NetworkMode.parse(network) else {
throw ValidationError(
"Invalid network mode '\(network)'. Expected 'nat', 'bridged', or 'bridged:<interface>'."
)
}
return mode
}
}
private var parsedSharedDirectories: [SharedDirectory] {
get throws {
try sharedDirectories.map { dirString -> SharedDirectory in
@@ -113,7 +132,8 @@ struct Run: AsyncParsableCommand {
vncPort: vncPort,
recoveryMode: recoveryMode,
storage: storage,
usbMassStoragePaths: parsedUSBStorageDevices.isEmpty ? nil : parsedUSBStorageDevices
usbMassStoragePaths: parsedUSBStorageDevices.isEmpty ? nil : parsedUSBStorageDevices,
networkMode: parsedNetworkMode
)
}
}

View File

@@ -96,6 +96,7 @@ enum VMConfigError: CustomNSError, LocalizedError {
case invalidHardwareModel
case invalidDiskSize
case malformedSizeInput(String)
case noBridgeInterfaceFound(requested: String?, available: String)
var errorDescription: String? {
switch self {
@@ -113,6 +114,11 @@ enum VMConfigError: CustomNSError, LocalizedError {
return "Invalid disk size"
case .malformedSizeInput(let input):
return "Malformed size input: \(input)"
case .noBridgeInterfaceFound(let requested, let available):
if let requested = requested {
return "Bridge network interface '\(requested)' not found. Available interfaces: \(available)"
}
return "No bridge network interfaces available on this host. Available: \(available)"
}
}
@@ -127,6 +133,7 @@ enum VMConfigError: CustomNSError, LocalizedError {
case .invalidHardwareModel: return 5
case .invalidDiskSize: return 6
case .malformedSizeInput: return 7
case .noBridgeInterfaceFound: return 8
}
}
}

View File

@@ -25,6 +25,7 @@ struct VMConfig: Codable {
private var _display: VMDisplayResolution
private var _hardwareModel: Data?
private var _machineIdentifier: Data?
private var _networkMode: NetworkMode
// MARK: - Initialization
init(
@@ -35,7 +36,8 @@ struct VMConfig: Codable {
macAddress: String? = nil,
display: String,
hardwareModel: Data? = nil,
machineIdentifier: Data? = nil
machineIdentifier: Data? = nil,
networkMode: NetworkMode = .nat
) throws {
self.os = os
self._cpuCount = cpuCount
@@ -45,6 +47,7 @@ struct VMConfig: Codable {
self._display = VMDisplayResolution(string: display) ?? VMDisplayResolution(string: "1024x768")!
self._hardwareModel = hardwareModel
self._machineIdentifier = machineIdentifier
self._networkMode = networkMode
}
var display: VMDisplayResolution {
@@ -81,6 +84,11 @@ struct VMConfig: Codable {
get { _macAddress }
set { _macAddress = newValue }
}
var networkMode: NetworkMode {
get { _networkMode }
set { _networkMode = newValue }
}
mutating func setCpuCount(_ count: Int) {
_cpuCount = count
@@ -110,6 +118,10 @@ struct VMConfig: Codable {
self._display = newDisplay
}
mutating func setNetworkMode(_ newNetworkMode: NetworkMode) {
self._networkMode = newNetworkMode
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case _cpuCount = "cpuCount"
@@ -120,6 +132,7 @@ struct VMConfig: Codable {
case _hardwareModel = "hardwareModel"
case _machineIdentifier = "machineIdentifier"
case os
case networkMode
}
init(from decoder: Decoder) throws {
@@ -133,6 +146,8 @@ struct VMConfig: Codable {
_display = VMDisplayResolution(string: try container.decode(String.self, forKey: .display))!
_hardwareModel = try container.decodeIfPresent(Data.self, forKey: ._hardwareModel)
_machineIdentifier = try container.decodeIfPresent(Data.self, forKey: ._machineIdentifier)
// Default to .nat for backward compatibility with existing configs
_networkMode = try container.decodeIfPresent(NetworkMode.self, forKey: .networkMode) ?? .nat
}
func encode(to encoder: Encoder) throws {
@@ -146,5 +161,6 @@ struct VMConfig: Codable {
try container.encode(display.string, forKey: .display)
try container.encodeIfPresent(_hardwareModel, forKey: ._hardwareModel)
try container.encodeIfPresent(_machineIdentifier, forKey: ._machineIdentifier)
try container.encode(_networkMode, forKey: .networkMode)
}
}

View File

@@ -330,7 +330,8 @@ extension VMDirectory {
ipAddress: ipAddress,
sshAvailable: sshAvailable,
locationName: locationName,
sharedDirectories: nil
sharedDirectories: nil,
networkMode: config.networkMode.description
)
}
}

View File

@@ -400,7 +400,8 @@ final class LumeController {
debug: Bool = false,
debugDir: String? = nil,
noDisplay: Bool = true,
vncPort: Int = 0
vncPort: Int = 0,
networkMode: NetworkMode = .nat
) async throws {
Logger.info(
"Creating VM",
@@ -437,7 +438,8 @@ final class LumeController {
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
display: display
display: display,
networkMode: networkMode
)
try vmDir.saveConfig(config)
@@ -467,7 +469,8 @@ final class LumeController {
debugDir: debugDir,
noDisplay: noDisplay,
vncPort: vncPort,
vmDir: vmDir
vmDir: vmDir,
networkMode: networkMode
)
// Clear provisioning marker on success
@@ -506,7 +509,8 @@ final class LumeController {
display: String,
ipsw: String?,
storage: String? = nil,
unattendedConfig: UnattendedConfig? = nil
unattendedConfig: UnattendedConfig? = nil,
networkMode: NetworkMode = .nat
) throws {
Logger.info(
"Starting async VM creation",
@@ -536,7 +540,8 @@ final class LumeController {
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
display: display
display: display,
networkMode: networkMode
)
try vmDir.saveConfig(config)
@@ -571,7 +576,8 @@ final class LumeController {
ipsw: ipsw,
storage: storage,
unattendedConfig: unattendedConfig,
vmDir: vmDir
vmDir: vmDir,
networkMode: networkMode
)
// Clear marker on success
@@ -609,7 +615,8 @@ final class LumeController {
debugDir: String? = nil,
noDisplay: Bool = true,
vncPort: Int = 0,
vmDir: VMDirectory? = nil
vmDir: VMDirectory? = nil,
networkMode: NetworkMode = .nat
) async throws {
Logger.info(
"Creating VM (internal)",
@@ -624,7 +631,8 @@ final class LumeController {
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
display: display
display: display,
networkMode: networkMode
)
// Track the temp directory for cleanup on failure
@@ -891,7 +899,8 @@ final class LumeController {
vncPort: Int = 0,
recoveryMode: Bool = false,
storage: String? = nil,
usbMassStoragePaths: [Path]? = nil
usbMassStoragePaths: [Path]? = nil,
networkMode: NetworkMode? = nil
) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info(
@@ -906,6 +915,7 @@ final class LumeController {
"recovery_mode": "\(recoveryMode)",
"storage_param": storage ?? "home", // Log the original param
"usb_storage_devices": "\(usbMassStoragePaths?.count ?? 0)",
"network_override": networkMode?.description ?? "vm-config",
])
do {
@@ -987,7 +997,8 @@ final class LumeController {
mount: mount,
vncPort: vncPort,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths)
usbMassStoragePaths: usbMassStoragePaths,
networkMode: networkMode)
Logger.info("VM started successfully", metadata: ["name": normalizedName])
} catch {
SharedVM.shared.removeVM(name: normalizedName)
@@ -1309,7 +1320,8 @@ final class LumeController {
cpuCount: Int,
memorySize: UInt64,
diskSize: UInt64,
display: String
display: String,
networkMode: NetworkMode = .nat
) async throws -> VM {
let config = try VMConfig(
os: os,
@@ -1317,7 +1329,8 @@ final class LumeController {
memorySize: memorySize,
diskSize: diskSize,
macAddress: VZMACAddress.randomLocallyAdministered().string,
display: display
display: display,
networkMode: networkMode
)
let vmDirContext = VMDirContext(

View File

@@ -65,6 +65,8 @@ extension Server {
unattendedConfig = try UnattendedConfig.load(from: unattendedArg)
}
let networkMode = try request.parseNetworkMode()
// Use async create - returns immediately while VM is provisioned in background
try vmController.createAsync(
name: request.name,
@@ -75,7 +77,8 @@ extension Server {
display: request.display,
ipsw: request.ipsw,
storage: request.storage,
unattendedConfig: unattendedConfig
unattendedConfig: unattendedConfig,
networkMode: networkMode
)
// Return 202 Accepted - VM creation is in progress
@@ -336,7 +339,8 @@ extension Server {
let request =
body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) }
?? RunVMRequest(
noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil)
noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil,
network: nil)
// Record telemetry
TelemetryClient.shared.record(event: TelemetryEvent.apiVMRun, properties: [
@@ -358,6 +362,8 @@ extension Server {
"Successfully parsed shared directories",
metadata: ["name": name, "count": "\(dirs.count)"])
let networkMode = try request.parseNetworkMode()
// Start VM in background
Logger.info("Starting VM in background", metadata: ["name": name])
startVM(
@@ -365,7 +371,8 @@ extension Server {
noDisplay: request.noDisplay ?? false,
sharedDirectories: dirs,
recoveryMode: request.recoveryMode ?? false,
storage: request.storage
storage: request.storage,
networkMode: networkMode
)
Logger.info("VM start initiated in background", metadata: ["name": name])
@@ -817,7 +824,8 @@ extension Server {
noDisplay: Bool,
sharedDirectories: [SharedDirectory] = [],
recoveryMode: Bool = false,
storage: String? = nil
storage: String? = nil,
networkMode: NetworkMode? = nil
) {
Logger.info(
"Starting VM in detached task",
@@ -826,6 +834,7 @@ extension Server {
"noDisplay": "\(noDisplay)",
"recoveryMode": "\(recoveryMode)",
"storage": String(describing: storage),
"networkMode": networkMode?.description ?? "vm-config",
])
Task.detached { @MainActor @Sendable in
@@ -845,7 +854,8 @@ extension Server {
noDisplay: noDisplay,
sharedDirectories: sharedDirectories,
recoveryMode: recoveryMode,
storage: storage
storage: storage,
networkMode: networkMode
)
Logger.info("VM started successfully in background task", metadata: ["name": name])
} catch {

View File

@@ -2,11 +2,21 @@ import ArgumentParser
import Foundation
import Virtualization
private func parseNetworkModeString(_ value: String) throws -> NetworkMode {
guard let mode = NetworkMode.parse(value) else {
throw ValidationError(
"Invalid network mode '\(value)'. Expected 'nat', 'bridged', or 'bridged:<interface>'."
)
}
return mode
}
struct RunVMRequest: Codable {
let noDisplay: Bool?
let sharedDirectories: [SharedDirectoryRequest]?
let recoveryMode: Bool?
let storage: String?
let network: String?
struct SharedDirectoryRequest: Codable {
let hostPath: String
@@ -33,6 +43,13 @@ struct RunVMRequest: Codable {
)
}
}
func parseNetworkMode() throws -> NetworkMode? {
guard let network else {
return nil
}
return try parseNetworkModeString(network)
}
}
struct PullRequest: Codable {
@@ -67,6 +84,8 @@ struct CreateVMRequest: Codable {
let storage: String?
/// Preset name or path to YAML config file for unattended macOS Setup Assistant automation
let unattended: String?
/// Network mode: "nat" (default), "bridged", or "bridged:<interface>"
let network: String?
func parse() throws -> (memory: UInt64, diskSize: UInt64) {
return (
@@ -74,6 +93,13 @@ struct CreateVMRequest: Codable {
diskSize: try parseSize(diskSize)
)
}
func parseNetworkMode() throws -> NetworkMode {
guard let network else {
return .nat
}
return try parseNetworkModeString(network)
}
}
struct SetVMRequest: Codable {

View File

@@ -59,7 +59,8 @@ final class DarwinVM: VM {
macAddress: DarwinVirtualizationService.generateMacAddress(),
display: display,
hardwareModel: requirements.hardwareModel,
machineIdentifier: DarwinVirtualizationService.generateMachineIdentifier()
machineIdentifier: DarwinVirtualizationService.generateMachineIdentifier(),
networkMode: vmDirContext.config.networkMode
)
)

View File

@@ -46,10 +46,11 @@ final class LinuxVM: VM {
memorySize: memorySize,
diskSize: diskSize,
macAddress: linuxService.generateMacAddress(),
display: display
display: display,
networkMode: vmDirContext.config.networkMode
))
// Create NVRAM store for EFI
try linuxService.createNVRAM(at: vmDirContext.nvramPath)
}
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
/// Represents the networking mode for a virtual machine.
///
/// Supports NAT (default) and bridged networking modes.
/// Bridged networking allows the VM to get its own IP on the LAN via DHCP,
/// bypassing the host's routing table entirely.
///
/// Usage:
/// - `"nat"` `.nat`
/// - `"bridged"` `.bridged(interface: nil)` (auto-select first available)
/// - `"bridged:en0"` `.bridged(interface: "en0")` (specific interface)
enum NetworkMode: Equatable, CustomStringConvertible {
case nat
case bridged(interface: String?)
/// Parse a network mode string.
///
/// Accepted formats:
/// - `"nat"` NAT mode (default)
/// - `"bridged"` Bridged mode with auto-detected interface
/// - `"bridged:<interface>"` Bridged mode on a specific interface (e.g. `"bridged:en0"`)
///
/// - Parameter string: The string to parse.
/// - Returns: A `NetworkMode` value, or `nil` if the string is invalid.
static func parse(_ string: String) -> NetworkMode? {
let lowercased = string.lowercased()
if lowercased == "nat" {
return .nat
}
if lowercased == "bridged" {
return .bridged(interface: nil)
}
if lowercased.hasPrefix("bridged:") {
let interface = String(string.dropFirst("bridged:".count))
guard !interface.isEmpty else {
return .bridged(interface: nil)
}
return .bridged(interface: interface)
}
return nil
}
var description: String {
switch self {
case .nat:
return "nat"
case .bridged(let interface):
if let interface = interface {
return "bridged:\(interface)"
}
return "bridged"
}
}
/// The string representation used for config persistence.
var configString: String {
return description
}
}
// MARK: - Codable
extension NetworkMode: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let mode = NetworkMode.parse(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid network mode: \(string). Expected 'nat', 'bridged', or 'bridged:<interface>'."
)
}
self = mode
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(configString)
}
}

View File

@@ -126,7 +126,8 @@ class VM {
vncUrl: vncUrl,
ipAddress: ipAddress,
sshAvailable: sshAvailable,
locationName: vmDirContext.storage ?? "home"
locationName: vmDirContext.storage ?? "home",
networkMode: vmDirContext.config.networkMode.description
)
}
@@ -134,7 +135,8 @@ class VM {
func run(
noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0,
recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil
recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil,
networkMode: NetworkMode? = nil
) async throws {
Logger.info(
"VM.run method called",
@@ -221,7 +223,8 @@ class VM {
sharedDirectories: sharedDirectories,
mount: mount,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths
usbMassStoragePaths: usbMassStoragePaths,
networkMode: networkMode
)
Logger.info(
"Successfully created virtualization service context",
@@ -709,11 +712,15 @@ class VM {
sharedDirectories: [SharedDirectory] = [],
mount: Path? = nil,
recoveryMode: Bool = false,
usbMassStoragePaths: [Path]? = nil
usbMassStoragePaths: [Path]? = nil,
networkMode: NetworkMode? = nil
) throws -> VMVirtualizationServiceContext {
// This is a diagnostic log to track actual file paths on disk for debugging
try validateDiskState()
// Use provided networkMode, falling back to config value
let effectiveNetworkMode = networkMode ?? vmDirContext.config.networkMode
return VMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
@@ -726,7 +733,8 @@ class VM {
diskPath: vmDirContext.diskPath,
nvramPath: vmDirContext.nvramPath,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths
usbMassStoragePaths: usbMassStoragePaths,
networkMode: effectiveNetworkMode
)
}

View File

@@ -44,10 +44,12 @@ struct VMDetails: Codable {
let sshAvailable: Bool?
let locationName: String
let sharedDirectories: [SharedDirectory]?
let networkMode: String?
enum CodingKeys: String, CodingKey {
case name, os, cpuCount, memorySize, diskSize, display, status
case provisioningOperation, vncUrl, ipAddress, sshAvailable, locationName, sharedDirectories
case networkMode
}
init(
@@ -63,7 +65,8 @@ struct VMDetails: Codable {
ipAddress: String?,
sshAvailable: Bool? = nil,
locationName: String,
sharedDirectories: [SharedDirectory]? = nil
sharedDirectories: [SharedDirectory]? = nil,
networkMode: String? = nil
) {
self.name = name
self.os = os
@@ -78,6 +81,7 @@ struct VMDetails: Codable {
self.sshAvailable = sshAvailable
self.locationName = locationName
self.sharedDirectories = sharedDirectories
self.networkMode = networkMode
}
// Custom encoder to always include optional fields (even when nil)
@@ -96,5 +100,6 @@ struct VMDetails: Codable {
try container.encode(sshAvailable, forKey: .sshAvailable)
try container.encode(locationName, forKey: .locationName)
try container.encode(sharedDirectories, forKey: .sharedDirectories)
try container.encode(networkMode, forKey: .networkMode)
}
}

View File

@@ -34,6 +34,7 @@ enum VMDetailsPrinter {
}
return vm.status
}),
Column(header: "network", width: 12, getValue: { $0.networkMode ?? "nat" }),
Column(header: "storage", width: 16, getValue: { $0.locationName }),
Column(
header: "shared_dirs", width: 54,

View File

@@ -16,6 +16,7 @@ struct VMVirtualizationServiceContext {
let nvramPath: Path
let recoveryMode: Bool
let usbMassStoragePaths: [Path]?
let networkMode: NetworkMode
}
/// Protocol defining the interface for virtualization operations
@@ -154,14 +155,48 @@ class BaseVirtualizationService: VMVirtualizationService {
}
}
static func createNetworkDeviceConfiguration(macAddress: String) throws
-> VZNetworkDeviceConfiguration
{
static func createNetworkDeviceConfiguration(
macAddress: String,
networkMode: NetworkMode = .nat
) throws -> VZNetworkDeviceConfiguration {
let network = VZVirtioNetworkDeviceConfiguration()
guard let vzMacAddress = VZMACAddress(string: macAddress) else {
throw VMConfigError.invalidMachineIdentifier
}
network.attachment = VZNATNetworkDeviceAttachment()
switch networkMode {
case .nat:
network.attachment = VZNATNetworkDeviceAttachment()
case .bridged(let interfaceName):
let availableInterfaces = VZBridgedNetworkInterface.networkInterfaces
guard let bridgeInterface = availableInterfaces.first(where: { iface in
if let name = interfaceName {
return iface.identifier == name
}
// Auto-select: prefer the first active interface
return true
}) else {
if let name = interfaceName {
let available = availableInterfaces.map { $0.identifier }.joined(separator: ", ")
throw VMConfigError.noBridgeInterfaceFound(
requested: name,
available: available.isEmpty ? "none" : available
)
}
throw VMConfigError.noBridgeInterfaceFound(
requested: nil,
available: "none"
)
}
Logger.info(
"Using bridged network interface",
metadata: [
"interface": bridgeInterface.identifier,
"localizedName": bridgeInterface.localizedDisplayName ?? "unknown",
])
network.attachment = VZBridgedNetworkDeviceAttachment(interface: bridgeInterface)
}
network.macAddress = vzMacAddress
return network
}
@@ -253,7 +288,10 @@ final class DarwinVirtualizationService: BaseVirtualizationService {
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [
try createNetworkDeviceConfiguration(macAddress: config.macAddress)
try createNetworkDeviceConfiguration(
macAddress: config.macAddress,
networkMode: config.networkMode
)
]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
@@ -404,7 +442,10 @@ final class LinuxVirtualizationService: BaseVirtualizationService {
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [
try createNetworkDeviceConfiguration(macAddress: config.macAddress)
try createNetworkDeviceConfiguration(
macAddress: config.macAddress,
networkMode: config.networkMode
)
]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]