diff --git a/sources/VPhoneCore/VPhoneBundleOps.swift b/sources/VPhoneCore/VPhoneBundleOps.swift index ceaaf5f..fbd2407 100644 --- a/sources/VPhoneCore/VPhoneBundleOps.swift +++ b/sources/VPhoneCore/VPhoneBundleOps.swift @@ -88,13 +88,22 @@ public enum VPhoneBundleOps { public static func updateConfig( bundleNamed name: String, in library: VPhoneLibrary, - cpuCount: UInt?, memoryMB: UInt64? + cpuCount: UInt?, memoryMB: UInt64?, + networkMode: VPhoneVirtualMachineManifest.NetworkConfig.NetworkMode? = nil, + bridgeInterface: String? = nil ) throws -> VPhoneBundle { let bundle = try library.bundle(named: name) + let editsNetwork = networkMode != nil || bridgeInterface != nil + let network = editsNetwork + ? try VPhoneNetworking.merge( + into: bundle.manifest.networkConfig, + mode: networkMode, bridgeInterface: bridgeInterface) + : nil let updated = bundle.manifest.updating( cpuCount: cpuCount, memorySize: memoryMB.map { $0 * 1024 * 1024 }, - screenConfig: nil) + screenConfig: nil, + networkConfig: network) try updated.write(to: bundle.configURL) return VPhoneBundle(url: bundle.url, manifest: updated) } diff --git a/sources/VPhoneCore/VPhoneNetworking.swift b/sources/VPhoneCore/VPhoneNetworking.swift new file mode 100644 index 0000000..5448e85 --- /dev/null +++ b/sources/VPhoneCore/VPhoneNetworking.swift @@ -0,0 +1,118 @@ +import Foundation +import Virtualization + +// MARK: - Errors + +public enum VPhoneNetworkingError: Error, Equatable { + /// hostOnly has no native Virtualization.framework attachment. + case hostOnlyUnsupported + /// A bridge interface was requested but no such interface exists on the host. + case bridgeInterfaceNotFound(requested: String, available: [String]) + /// bridged mode was selected but the host exposes no bridgeable interfaces. + case noBridgeInterfaces + /// `--bridge-interface` was given without selecting bridged mode. + case bridgeInterfaceWithoutBridgedMode +} + +extension VPhoneNetworkingError: CustomStringConvertible, LocalizedError { + public var description: String { + switch self { + case .hostOnlyUnsupported: + "network mode 'hostOnly' is not supported (Virtualization.framework has no host-only attachment); use nat, bridged, or none" + case let .bridgeInterfaceNotFound(requested, available): + "bridge interface '\(requested)' not found; available: \(available.isEmpty ? "(none)" : available.joined(separator: ", "))" + case .noBridgeInterfaces: + "bridged mode requires a host interface, but none are available for bridging" + case .bridgeInterfaceWithoutBridgedMode: + "--bridge-interface is only valid with --network bridged" + } + } + public var errorDescription: String? { description } +} + +// MARK: - Networking helpers + +/// Host-side helpers for validating and realizing a VM's `NetworkConfig`. +/// Shared between config-time editing (`VPhoneBundleOps.updateConfig`) and boot-time +/// device construction so both agree on validation and interface resolution. +public enum VPhoneNetworking { + public typealias NetworkConfig = VPhoneVirtualMachineManifest.NetworkConfig + public typealias NetworkMode = NetworkConfig.NetworkMode + + /// Identifiers of host interfaces available for bridging (empty without the + /// `com.apple.vm.networking` entitlement, e.g. in unsigned test binaries). + public static func availableBridgeInterfaces() -> [String] { + VZBridgedNetworkInterface.networkInterfaces.map(\.identifier) + } + + /// Resolve the concrete bridge interface to persist for bridged mode. + /// - `requested`: an explicit `--bridge-interface`, validated against the host. + /// - `current`: the interface already stored on the bundle, kept if still present. + /// - otherwise the first available interface is auto-picked. + public static func resolveBridgeInterface(requested: String?, current: String?) throws -> String { + let available = availableBridgeInterfaces() + if let requested { + guard available.contains(requested) else { + throw VPhoneNetworkingError.bridgeInterfaceNotFound(requested: requested, available: available) + } + return requested + } + if let current, available.contains(current) { + return current + } + guard let first = available.first else { + throw VPhoneNetworkingError.noBridgeInterfaces + } + return first + } + + /// Merge partial edits onto an existing config, validating the result. + /// A nil argument leaves that field unchanged. + public static func merge( + into current: NetworkConfig, + mode: NetworkMode?, + bridgeInterface: String? + ) throws -> NetworkConfig { + let newMode = mode ?? current.mode + if newMode == .hostOnly { + throw VPhoneNetworkingError.hostOnlyUnsupported + } + let newBridge: String? + if newMode == .bridged { + newBridge = try resolveBridgeInterface(requested: bridgeInterface, current: current.bridgeInterface) + } else { + if bridgeInterface != nil { + throw VPhoneNetworkingError.bridgeInterfaceWithoutBridgedMode + } + newBridge = current.bridgeInterface + } + return NetworkConfig(mode: newMode, macAddress: current.macAddress, bridgeInterface: newBridge) + } + + /// Build the VZ network device for a config, or nil for `.off` (no NIC). + /// The MAC is left framework-assigned; a forced MAC breaks guest networking. + /// Throws if the config cannot be realized (missing bridge interface, hostOnly). + public static func makeNetworkDevice(_ cfg: NetworkConfig) throws -> VZVirtioNetworkDeviceConfiguration? { + switch cfg.mode { + case .off: + return nil + case .hostOnly: + throw VPhoneNetworkingError.hostOnlyUnsupported + case .nat: + let net = VZVirtioNetworkDeviceConfiguration() + net.attachment = VZNATNetworkDeviceAttachment() + return net + case .bridged: + guard let id = cfg.bridgeInterface else { + throw VPhoneNetworkingError.noBridgeInterfaces + } + guard let iface = VZBridgedNetworkInterface.networkInterfaces.first(where: { $0.identifier == id }) else { + throw VPhoneNetworkingError.bridgeInterfaceNotFound( + requested: id, available: availableBridgeInterfaces()) + } + let net = VZVirtioNetworkDeviceConfiguration() + net.attachment = VZBridgedNetworkDeviceAttachment(interface: iface) + return net + } + } +} diff --git a/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift b/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift index 3279f00..3d16a2c 100644 --- a/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift +++ b/sources/VPhoneCore/VPhoneVirtualMachineManifest.swift @@ -105,19 +105,24 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable { public struct NetworkConfig: Codable, Sendable { public let mode: NetworkMode public let macAddress: String + /// Host interface identifier to bridge (bridged mode only); nil otherwise. + public let bridgeInterface: String? public enum NetworkMode: String, Codable, Sendable { case nat case bridged case hostOnly - case none + /// No network device. Named `off` (not `none`) so a `NetworkMode?` + /// literal `.none` can't silently bind to `Optional.none`. + case off = "none" } public static let `default` = NetworkConfig(mode: .nat, macAddress: "") - public init(mode: NetworkMode, macAddress: String) { + public init(mode: NetworkMode, macAddress: String, bridgeInterface: String? = nil) { self.mode = mode self.macAddress = macAddress + self.bridgeInterface = bridgeInterface } } @@ -220,7 +225,8 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable { cpuCount: UInt? = nil, memorySize: UInt64? = nil, screenConfig: ScreenConfig? = nil, - machineIdentifier: Data? = nil + machineIdentifier: Data? = nil, + networkConfig: NetworkConfig? = nil ) -> VPhoneVirtualMachineManifest { VPhoneVirtualMachineManifest( platformType: platformType, @@ -229,7 +235,7 @@ public struct VPhoneVirtualMachineManifest: Codable, Sendable { cpuCount: cpuCount ?? self.cpuCount, memorySize: memorySize ?? self.memorySize, screenConfig: screenConfig ?? self.screenConfig, - networkConfig: networkConfig, + networkConfig: networkConfig ?? self.networkConfig, diskImage: diskImage, nvramStorage: nvramStorage, romImages: romImages, diff --git a/sources/vphone-cli/VPhoneVMCLI.swift b/sources/vphone-cli/VPhoneVMCLI.swift index 6b6d4cd..0f1421b 100644 --- a/sources/vphone-cli/VPhoneVMCLI.swift +++ b/sources/vphone-cli/VPhoneVMCLI.swift @@ -81,7 +81,8 @@ struct VPhoneVMInfoCommand: ParsableCommand { func run() throws { let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library) - let report = VPhoneBundleReport(bundle: try lib.library.bundle(named: name)) + let bundle = try lib.library.bundle(named: name) + let report = VPhoneBundleReport(bundle: bundle) if json { print(String(decoding: try JSONEncoder().encode(report), as: UTF8.self)) } else { @@ -89,6 +90,7 @@ struct VPhoneVMInfoCommand: ParsableCommand { print("cpu: \(report.cpuCount)") print("mem: \(report.memoryMB) MB") print("disk: \(report.diskSizeBytes) bytes") + print("net: \(describeNetwork(bundle.manifest.networkConfig))") if let info = report.restoreInfo { print("iOS: \(info.ios.version) (\(info.ios.build))") print("cloudOS: \(info.cloudOS.version) (\(info.cloudOS.build))") @@ -124,19 +126,46 @@ struct VPhoneVMNewCommand: ParsableCommand { struct VPhoneVMConfigCommand: ParsableCommand { static let configuration = CommandConfiguration( - commandName: "config", abstract: "Edit VM manifest fields (cpu/memory)") + commandName: "config", abstract: "Edit VM manifest fields (cpu/memory/network)") @OptionGroup var lib: VPhoneLibraryOption @Argument(help: "VM name") var name: String? @Option(name: .shortAndLong, help: "CPU cores") var cpu: UInt? @Option(name: .shortAndLong, help: "Memory (MB)") var memory: UInt64? + @Option(name: [.customShort("n"), .long], help: "Network mode: nat | bridged | none") var network: String? + @Option(name: .long, help: "Host interface to bridge (bridged mode; auto-picks first if omitted)") + var bridgeInterface: String? func run() throws { + let mode = try network.map(Self.parseMode) let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library) let updated = try VPhoneBundleOps.updateConfig( - bundleNamed: name, in: lib.library, cpuCount: cpu, memoryMB: memory) - print("updated \(updated.name): \(updated.manifest.cpuCount) CPU, \(updated.manifest.memorySize / (1024*1024)) MB") + bundleNamed: name, in: lib.library, cpuCount: cpu, memoryMB: memory, + networkMode: mode, bridgeInterface: bridgeInterface) + let m = updated.manifest + print("updated \(updated.name): \(m.cpuCount) CPU, \(m.memorySize / (1024*1024)) MB, " + + "net=\(describeNetwork(m.networkConfig))") } + + private static func parseMode(_ s: String) + throws -> VPhoneVirtualMachineManifest.NetworkConfig.NetworkMode + { + switch s.lowercased() { + case "nat": return .nat + case "bridged": return .bridged + case "none", "off": return .off + case "hostonly", "host-only": + throw ValidationError("network mode 'hostOnly' is not supported; use nat, bridged, or none") + default: + throw ValidationError("unknown network mode '\(s)'; expected nat, bridged, or none") + } + } +} + +private func describeNetwork(_ net: VPhoneVirtualMachineManifest.NetworkConfig) -> String { + var s = net.mode.rawValue + if net.mode == .bridged, let iface = net.bridgeInterface { s += "(\(iface))" } + return s } // MARK: - rename diff --git a/sources/vphone-cli/VPhoneVirtualMachine.swift b/sources/vphone-cli/VPhoneVirtualMachine.swift index 6f477fd..14c80ae 100644 --- a/sources/vphone-cli/VPhoneVirtualMachine.swift +++ b/sources/vphone-cli/VPhoneVirtualMachine.swift @@ -184,10 +184,12 @@ class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { let attachment = try VZDiskImageStorageDeviceAttachment(url: options.diskURL, readOnly: false) config.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: attachment)] - // Network (shared NAT) - let net = VZVirtioNetworkDeviceConfiguration() - net.attachment = VZNATNetworkDeviceAttachment() - config.networkDevices = [net] + // Network (mode + MAC from the bundle manifest; nat/bridged/none) + if let net = try VPhoneNetworking.makeNetworkDevice(manifest.networkConfig) { + config.networkDevices = [net] + } else { + config.networkDevices = [] + } // Serial port (PL011 UART - pipes for input/output with boot detection) if let serialPort = Dynamic._VZPL011SerialPortConfiguration().asObject diff --git a/tests/VPhoneCoreTests/BundleOpsTests.swift b/tests/VPhoneCoreTests/BundleOpsTests.swift index 74d1e3f..24f7528 100644 --- a/tests/VPhoneCoreTests/BundleOpsTests.swift +++ b/tests/VPhoneCoreTests/BundleOpsTests.swift @@ -97,6 +97,45 @@ struct BundleOpsTests { // Persisted: a fresh load sees the change. #expect(try lib.bundle(named: "cfg").manifest.cpuCount == 4) + // Untouched network stays at the default. + #expect(updated.manifest.networkConfig.mode == .nat) + } + + @Test func updateConfigPersistsNetwork() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let rom = try fakeROM(); let seprom = try fakeROM() + defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) } + let lib = VPhoneLibrary(root: root) + _ = try VPhoneBundleOps.create( + .init(name: "net", cpuCount: 8, memoryMB: 8192, diskSizeGB: 1, + romSource: rom, sepromSource: seprom), in: lib) + + let updated = try VPhoneBundleOps.updateConfig( + bundleNamed: "net", in: lib, cpuCount: nil, memoryMB: nil, networkMode: .off) + #expect(updated.manifest.networkConfig.mode == .off) + // Persisted across a fresh load, and cpu/memory untouched. + let reloaded = try lib.bundle(named: "net").manifest + #expect(reloaded.networkConfig.mode == .off) + #expect(reloaded.cpuCount == 8) + } + + @Test func updateConfigRejectsBadNetwork() throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let rom = try fakeROM(); let seprom = try fakeROM() + defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) } + let lib = VPhoneLibrary(root: root) + _ = try VPhoneBundleOps.create( + .init(name: "bad", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1, + romSource: rom, sepromSource: seprom), in: lib) + + #expect(throws: VPhoneNetworkingError.hostOnlyUnsupported) { + _ = try VPhoneBundleOps.updateConfig( + bundleNamed: "bad", in: lib, cpuCount: nil, memoryMB: nil, networkMode: .hostOnly) + } + // A rejected edit must not have mutated the on-disk manifest. + #expect(try lib.bundle(named: "bad").manifest.networkConfig.mode == .nat) } @Test func renameThenDelete() throws { diff --git a/tests/VPhoneCoreTests/ManifestTests.swift b/tests/VPhoneCoreTests/ManifestTests.swift index d2a7d2b..69bb0c0 100644 --- a/tests/VPhoneCoreTests/ManifestTests.swift +++ b/tests/VPhoneCoreTests/ManifestTests.swift @@ -31,5 +31,40 @@ struct ManifestTests { let updated = sampleManifest().updating(cpuCount: 4, memorySize: nil, screenConfig: nil) #expect(updated.cpuCount == 4) #expect(updated.memorySize == 8 * 1024 * 1024 * 1024) + // networkConfig is preserved when not passed. + #expect(updated.networkConfig.mode == .nat) + } + + @Test func networkConfigRoundTripsThroughPlist() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let net = VPhoneVirtualMachineManifest.NetworkConfig( + mode: .bridged, macAddress: "", bridgeInterface: "en0") + let url = dir.appendingPathComponent("config.plist") + try sampleManifest().updating(networkConfig: net).write(to: url) + let loaded = try VPhoneVirtualMachineManifest.load(from: url) + + #expect(loaded.networkConfig.mode == .bridged) + #expect(loaded.networkConfig.bridgeInterface == "en0") + } + + // Manifests written before bridgeInterface existed omit that key; they must + // still decode, with bridgeInterface defaulting to nil. + @Test func decodesManifestWithoutBridgeInterfaceKey() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let url = dir.appendingPathComponent("config.plist") + try sampleManifest().write(to: url) // default network → bridgeInterface nil + let text = try String(contentsOf: url, encoding: .utf8) + #expect(!text.contains("bridgeInterface")) // nil optional is omitted from the plist + + let loaded = try VPhoneVirtualMachineManifest.load(from: url) + #expect(loaded.networkConfig.bridgeInterface == nil) } } diff --git a/tests/VPhoneCoreTests/NetworkingTests.swift b/tests/VPhoneCoreTests/NetworkingTests.swift new file mode 100644 index 0000000..d822327 --- /dev/null +++ b/tests/VPhoneCoreTests/NetworkingTests.swift @@ -0,0 +1,55 @@ +@testable import VPhoneCore +import Foundation +import Testing +import Virtualization + +struct NetworkingTests { + typealias NetworkConfig = VPhoneVirtualMachineManifest.NetworkConfig + + @Test func mergeSetsMode() throws { + let out = try VPhoneNetworking.merge(into: .default, mode: .off, bridgeInterface: nil) + #expect(out.mode == .off) + #expect(out.bridgeInterface == nil) + } + + @Test func mergeWithoutModePreservesCurrent() throws { + let current = NetworkConfig(mode: .off, macAddress: "") + let out = try VPhoneNetworking.merge(into: current, mode: nil, bridgeInterface: nil) + #expect(out.mode == .off) + } + + @Test func mergeRejectsHostOnly() { + #expect(throws: VPhoneNetworkingError.hostOnlyUnsupported) { + _ = try VPhoneNetworking.merge(into: .default, mode: .hostOnly, bridgeInterface: nil) + } + } + + @Test func mergeRejectsBridgeInterfaceWithoutBridgedMode() { + #expect(throws: VPhoneNetworkingError.bridgeInterfaceWithoutBridgedMode) { + _ = try VPhoneNetworking.merge(into: .default, mode: .nat, bridgeInterface: "en0") + } + } + + // The test binary is unsigned, so no interfaces are available for bridging: + // selecting bridged must fail loudly rather than silently produce a dead NIC. + @Test func bridgedWithoutAvailableInterfacesThrows() { + guard VPhoneNetworking.availableBridgeInterfaces().isEmpty else { return } + #expect(throws: VPhoneNetworkingError.self) { + _ = try VPhoneNetworking.merge(into: .default, mode: .bridged, bridgeInterface: nil) + } + #expect(throws: VPhoneNetworkingError.self) { + _ = try VPhoneNetworking.merge(into: .default, mode: .bridged, bridgeInterface: "en0") + } + } + + @Test func makeNetworkDeviceOffIsNil() throws { + let dev = try VPhoneNetworking.makeNetworkDevice(NetworkConfig(mode: .off, macAddress: "")) + #expect(dev == nil) + } + + @Test func makeNetworkDeviceNATHasAttachment() throws { + let dev = try VPhoneNetworking.makeNetworkDevice(NetworkConfig(mode: .nat, macAddress: "")) + #expect(dev != nil) + #expect(dev?.attachment is VZNATNetworkDeviceAttachment) + } +}