diff --git a/libs/lume/resources/lume.entitlements b/libs/lume/resources/lume.entitlements index d7d0d6e8..dccbe21c 100644 --- a/libs/lume/resources/lume.entitlements +++ b/libs/lume/resources/lume.entitlements @@ -4,5 +4,7 @@ com.apple.security.virtualization + com.apple.vm.networking + diff --git a/libs/lume/resources/lume.local.entitlements b/libs/lume/resources/lume.local.entitlements new file mode 100644 index 00000000..38764dff --- /dev/null +++ b/libs/lume/resources/lume.local.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.virtualization + + + diff --git a/libs/lume/scripts/install-local.sh b/libs/lume/scripts/install-local.sh index c72d5479..7a37ae88 100755 --- a/libs/lume/scripts/install-local.sh +++ b/libs/lume/scripts/install-local.sh @@ -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 diff --git a/libs/lume/src/Commands/Config.swift b/libs/lume/src/Commands/Config.swift index 3d21e9de..2e499024 100644 --- a/libs/lume/src/Commands/Config.swift +++ b/libs/lume/src/Commands/Config.swift @@ -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 --network bridged:\(interfaces.first?.identifier ?? "en0")") + } + } + } } diff --git a/libs/lume/src/Commands/Create.swift b/libs/lume/src/Commands/Create.swift index 4084eba8..05ff21fb 100644 --- a/libs/lume/src/Commands/Create.swift +++ b/libs/lume/src/Commands/Create.swift @@ -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:' (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:'." + ) + } + 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 ) } } diff --git a/libs/lume/src/Commands/Run.swift b/libs/lume/src/Commands/Run.swift index 15ea38d5..dcf4df54 100644 --- a/libs/lume/src/Commands/Run.swift +++ b/libs/lume/src/Commands/Run.swift @@ -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:' (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:'." + ) + } + 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 ) } } diff --git a/libs/lume/src/Errors/Errors.swift b/libs/lume/src/Errors/Errors.swift index cb82ede1..10ee6c4e 100644 --- a/libs/lume/src/Errors/Errors.swift +++ b/libs/lume/src/Errors/Errors.swift @@ -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 } } } diff --git a/libs/lume/src/FileSystem/VMConfig.swift b/libs/lume/src/FileSystem/VMConfig.swift index 395f3ac4..8fa21238 100644 --- a/libs/lume/src/FileSystem/VMConfig.swift +++ b/libs/lume/src/FileSystem/VMConfig.swift @@ -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) } } diff --git a/libs/lume/src/FileSystem/VMDirectory.swift b/libs/lume/src/FileSystem/VMDirectory.swift index 7bc5663f..5c8d28c8 100644 --- a/libs/lume/src/FileSystem/VMDirectory.swift +++ b/libs/lume/src/FileSystem/VMDirectory.swift @@ -330,7 +330,8 @@ extension VMDirectory { ipAddress: ipAddress, sshAvailable: sshAvailable, locationName: locationName, - sharedDirectories: nil + sharedDirectories: nil, + networkMode: config.networkMode.description ) } } diff --git a/libs/lume/src/LumeController.swift b/libs/lume/src/LumeController.swift index 78a1b066..2608a8f6 100644 --- a/libs/lume/src/LumeController.swift +++ b/libs/lume/src/LumeController.swift @@ -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( diff --git a/libs/lume/src/Server/Handlers.swift b/libs/lume/src/Server/Handlers.swift index 6b7726af..5c49eb3c 100644 --- a/libs/lume/src/Server/Handlers.swift +++ b/libs/lume/src/Server/Handlers.swift @@ -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 { diff --git a/libs/lume/src/Server/Requests.swift b/libs/lume/src/Server/Requests.swift index 38a708b2..1a39bdb5 100644 --- a/libs/lume/src/Server/Requests.swift +++ b/libs/lume/src/Server/Requests.swift @@ -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:'." + ) + } + 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:" + 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 { diff --git a/libs/lume/src/VM/DarwinVM.swift b/libs/lume/src/VM/DarwinVM.swift index 90a60fef..7d4316e9 100644 --- a/libs/lume/src/VM/DarwinVM.swift +++ b/libs/lume/src/VM/DarwinVM.swift @@ -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 ) ) diff --git a/libs/lume/src/VM/LinuxVM.swift b/libs/lume/src/VM/LinuxVM.swift index bb6d9214..76de4995 100644 --- a/libs/lume/src/VM/LinuxVM.swift +++ b/libs/lume/src/VM/LinuxVM.swift @@ -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) } -} \ No newline at end of file +} diff --git a/libs/lume/src/VM/NetworkMode.swift b/libs/lume/src/VM/NetworkMode.swift new file mode 100644 index 00000000..7ecb5c21 --- /dev/null +++ b/libs/lume/src/VM/NetworkMode.swift @@ -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:"` → 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:'." + ) + } + self = mode + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(configString) + } +} diff --git a/libs/lume/src/VM/VM.swift b/libs/lume/src/VM/VM.swift index dbd6f151..9716b4a9 100644 --- a/libs/lume/src/VM/VM.swift +++ b/libs/lume/src/VM/VM.swift @@ -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 ) } diff --git a/libs/lume/src/VM/VMDetails.swift b/libs/lume/src/VM/VMDetails.swift index 189bf827..a30c6d6f 100644 --- a/libs/lume/src/VM/VMDetails.swift +++ b/libs/lume/src/VM/VMDetails.swift @@ -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) } } diff --git a/libs/lume/src/VM/VMDetailsPrinter.swift b/libs/lume/src/VM/VMDetailsPrinter.swift index eb5daaee..6bc2268a 100644 --- a/libs/lume/src/VM/VMDetailsPrinter.swift +++ b/libs/lume/src/VM/VMDetailsPrinter.swift @@ -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, diff --git a/libs/lume/src/Virtualization/VMVirtualizationService.swift b/libs/lume/src/Virtualization/VMVirtualizationService.swift index 7a0ebcf6..59f5d9fe 100644 --- a/libs/lume/src/Virtualization/VMVirtualizationService.swift +++ b/libs/lume/src/Virtualization/VMVirtualizationService.swift @@ -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()]