mirror of
https://github.com/trycua/lume.git
synced 2026-02-15 02:39:32 -06:00
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:
committed by
GitHub
parent
8c574c9260
commit
220c888069
@@ -4,5 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
<key>com.apple.vm.networking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
8
libs/lume/resources/lume.local.entitlements
Normal file
8
libs/lume/resources/lume.local.entitlements
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,8 @@ extension VMDirectory {
|
||||
ipAddress: ipAddress,
|
||||
sshAvailable: sshAvailable,
|
||||
locationName: locationName,
|
||||
sharedDirectories: nil
|
||||
sharedDirectories: nil,
|
||||
networkMode: config.networkMode.description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
libs/lume/src/VM/NetworkMode.swift
Normal file
85
libs/lume/src/VM/NetworkMode.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]
|
||||
|
||||
Reference in New Issue
Block a user