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:
zqxwce
2026-08-02 13:13:50 +03:00
committed by zqxwce
co-authored by Claude Fable 5
parent 6aef60bd9a
commit c6fa19efcb
8 changed files with 307 additions and 14 deletions
+11 -2
View File
@@ -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)
}
+118
View File
@@ -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,
+33 -4
View File
@@ -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 {
+35
View File
@@ -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)
}
}