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()]