mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
networking: Add vm config --network to edit VM network mode after creation
Adds `--network nat|bridged|none` and `--bridge-interface` to `vm config`, and wires the boot path to honor the stored networkConfig (it previously hardcoded NAT and ignored the manifest). `vm info` now shows the mode. - New VPhoneNetworking: validates the mode, resolves/auto-picks the bridge interface, and builds the VZ network device — shared by config-time editing and boot. - bridged uses VZBridgedNetworkDeviceAttachment (com.apple.vm.networking entitlement already present); hostOnly is rejected (no native VZ attachment); none yields no NIC. - Rename NetworkMode.none -> .off (raw value kept "none") so a NetworkMode? literal `.none` can't silently bind to Optional.none. The MAC is left framework-assigned; forcing a custom MAC breaks guest networking, so no MAC override is exposed. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
committed by
zqxwce
co-authored by
Claude Fable 5
parent
6aef60bd9a
commit
c6fa19efcb
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user