From 7d9b54fa69a4074aa456f2cf182f220ef8620b9c Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Mon, 14 Sep 2026 00:00:05 -0400 Subject: [PATCH] Preserve bridged MAC identity across VM upgrades --- Makefile | 1 + README.md | 18 ++ .../OmarchyVMHelper/NetworkEditor.swift | 68 +++++++- .../OmarchyVMHelper/NetworkIdentity.swift | 37 +++++ .../OmarchyVMHelper/StartMenuWindow.swift | 5 +- .../VMApplicationController.swift | 21 +++ macos/Tests/qemu-networking.test.sh | 2 +- macos/Tests/test-network-identity.py | 133 +++++++++++++++ macos/build-app.sh | 1 + macos/network-identity.py | 155 ++++++++++++++++++ macos/qemu-networking.sh | 63 +------ 11 files changed, 445 insertions(+), 59 deletions(-) create mode 100644 macos/Sources/OmarchyVMHelper/NetworkIdentity.swift create mode 100644 macos/Tests/test-network-identity.py create mode 100644 macos/network-identity.py diff --git a/Makefile b/Makefile index 223f993..3348dfe 100644 --- a/Makefile +++ b/Makefile @@ -53,6 +53,7 @@ doctor: @printf 'Toolchain ready: %s (%s)\n' "$$(sw_vers -productVersion)" "$$(uname -m)" test: + @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-network-identity.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-libslirp-icmp.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-cocoa-pinch.py" @PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/test-virtio-pinch.py" diff --git a/README.md b/README.md index 9ff86f9..58655f1 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,24 @@ bridged launches do not request your password. QEMU continues to run as your user. **Remove Networking Helper** unregisters the service when it is no longer needed. Shut down any bridged VM before repairing or removing the helper. +Persistent VMs keep a stable, randomly generated bridged MAC address across app +updates, disk replacement, resizing, resets, and moves of the complete VM data +folder. Existing saved addresses are retained when upgrading from older builds. +The Networking sheet displays the address after the first bridged launch; +**Copy MAC** makes it available for a DHCP reservation. **Generate new MAC…** +shows a proposed address and requires confirmation while the VM is stopped. +This action saves immediately; DHCP reservations may need updating. Cancelling +the confirmation leaves the existing identity unchanged. + +A copy of the complete VM data folder includes its network identity. To run a +copy as a separate VM, generate a new MAC before running both copies. Move or +restore the complete data folder to retain the identity; importing only a disk +into a new workspace does not transfer its network identity. Ephemeral bridged +VMs receive a fresh address on each launch. Damaged identity records produce an +error instead of silently changing the MAC. Migration and regeneration retain +the preceding record as `network-identities/current.previous.json` in the VM +data folder; restore a known-good record only with the VM stopped. + For repeated local development builds, use a consistent Apple Development signing identity (the `DEVELOPMENT_SIGN_IDENTITY` option above). Ad-hoc-signed helper registrations are not reliable across rebuilds on the tested macOS diff --git a/macos/Sources/OmarchyVMHelper/NetworkEditor.swift b/macos/Sources/OmarchyVMHelper/NetworkEditor.swift index a47888f..6af4bfe 100644 --- a/macos/Sources/OmarchyVMHelper/NetworkEditor.swift +++ b/macos/Sources/OmarchyVMHelper/NetworkEditor.swift @@ -7,6 +7,14 @@ final class NetworkEditor: NSObject { private let interface = NSPopUpButton() private let explanation = NSTextField(wrappingLabelWithString: "") private let ssh = NSButton(checkboxWithTitle: "Allow SSH connections from the LAN", target: nil, action: nil) + private let macAddress = NSTextField(wrappingLabelWithString: "") + private let macHelp = NSTextField(wrappingLabelWithString: "This address stays the same when you update or move this VM. For a copied VM, generate a new address before running both copies.") + private let copyMAC = NSButton(title: "Copy MAC", target: nil, action: nil) + private let regenerateMAC = NSButton(title: "Generate new MAC…", target: nil, action: nil) + private let macControls = NSStackView() + private let identity: VMNetworkIdentityAccess + private var currentMAC = "" + private var canReplaceMAC = false private let detail = NSTextField(wrappingLabelWithString: "") private let serviceStatus = NSTextField(wrappingLabelWithString: "") private let setup = NSButton() @@ -21,7 +29,9 @@ final class NetworkEditor: NSObject { private let didClose: () -> Void init(preferences: VMNetworkPreferences, interfaces: [VMBridgeInterface], + identity: VMNetworkIdentityAccess = .unavailable, save: @escaping (VMNetworkPreferences) -> String?, didClose: @escaping () -> Void) { + self.identity = identity self.savedPreferences = preferences self.interfaces = interfaces self.save = save @@ -54,7 +64,17 @@ final class NetworkEditor: NSObject { controls.addArrangedSubview(setup) controls.addArrangedSubview(remove) controls.spacing = 8 - let rows: [NSView] = [mode, interface, explanation, ssh, detail, serviceStatus, controls] + macAddress.setAccessibilityLabel("Bridged MAC address") + macAddress.isSelectable = true + macHelp.font = .systemFont(ofSize: 12) + macHelp.textColor = .secondaryLabelColor + copyMAC.target = self; copyMAC.action = #selector(copyAddress) + regenerateMAC.target = self; regenerateMAC.action = #selector(regenerateAddress) + macControls.addArrangedSubview(copyMAC) + macControls.addArrangedSubview(regenerateMAC) + macControls.spacing = 8 + loadIdentity() + let rows: [NSView] = [mode, interface, explanation, ssh, macAddress, macHelp, macControls, detail, serviceStatus, controls] for row in rows { stack.addArrangedSubview(row) } stack.orientation = .vertical stack.alignment = .leading @@ -72,7 +92,7 @@ final class NetworkEditor: NSObject { mode.isEnabled = !serviceBusy alert.buttons.last?.isEnabled = !serviceBusy let bridged = mode.indexOfSelectedItem == 1 - for view in [interface, explanation, ssh, serviceStatus, controls] as [NSView] { + for view in [interface, explanation, ssh, macAddress, macHelp, macControls, serviceStatus, controls] as [NSView] { view.isHidden = !bridged } let valid = interfaces.indices.contains(interface.indexOfSelectedItem) @@ -83,6 +103,8 @@ final class NetworkEditor: NSObject { explanation.stringValue = compatibilityRequired ? "Wi-Fi bridging on this Mac temporarily adjusts DHCP handling for all bridged VMs, including other virtualization apps. The previous setting is restored when Omarchy stops. Saving this choice enables that handling automatically." : "The networking helper is approved once through macOS. Subsequent bridged launches do not ask for your password." + copyMAC.isEnabled = !serviceBusy && !currentMAC.isEmpty + regenerateMAC.isEnabled = !serviceBusy && !currentMAC.isEmpty && canReplaceMAC ssh.isEnabled = bridged && !serviceBusy alert.buttons.first?.isEnabled = !serviceBusy && (!bridged || valid || !savedPreferences.interface.isEmpty) detail.stringValue = bridged @@ -94,6 +116,48 @@ final class NetworkEditor: NSObject { alert.layout() } + private func loadIdentity() { + do { + currentMAC = try identity.read() + canReplaceMAC = !currentMAC.isEmpty && identity.canReplace() + regenerateMAC.toolTip = canReplaceMAC ? nil : "Shut down the VM before changing its MAC address." + macAddress.stringValue = currentMAC.isEmpty + ? "MAC address: assigned on the first bridged launch." + : "MAC address: \(currentMAC)" + } catch { + currentMAC = "" + macAddress.stringValue = error.localizedDescription + } + } + + @objc private func copyAddress() { + guard !currentMAC.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(currentMAC, forType: .string) + } + + @objc private func regenerateAddress() { + guard !serviceBusy, !currentMAC.isEmpty else { return } + var proposed = VMNetworkIdentityAccess.proposedMAC() + while proposed == currentMAC { proposed = VMNetworkIdentityAccess.proposedMAC() } + let confirmation = NSAlert() + confirmation.messageText = "Generate a new MAC address?" + confirmation.informativeText = "Current: \(currentMAC)\nNew: \(proposed)\n\nThis VM must be shut down. DHCP reservations may need updating. This change is saved immediately, independently of the Networking Save button." + confirmation.addButton(withTitle: "Change MAC Address") + confirmation.addButton(withTitle: "Cancel") + guard confirmation.runModal() == .alertFirstButtonReturn else { return } + do { + _ = try identity.replace(currentMAC, proposed) + } catch { + let problem = NSAlert() + problem.messageText = "MAC address could not be changed" + problem.informativeText = error.localizedDescription + problem.runModal() + } + loadIdentity() + update() + } + private func serviceAction(success: String? = nil, _ action: @escaping @MainActor () async throws -> Void) { guard !serviceBusy else { return } serviceBusy = true diff --git a/macos/Sources/OmarchyVMHelper/NetworkIdentity.swift b/macos/Sources/OmarchyVMHelper/NetworkIdentity.swift new file mode 100644 index 0000000..0e742d2 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NetworkIdentity.swift @@ -0,0 +1,37 @@ +import Foundation + +struct VMNetworkIdentityAccess { + var read: () throws -> String + var canReplace: () -> Bool = { false } + var replace: (_ expected: String, _ proposed: String) throws -> String + + static let unavailable = Self( + read: { "" }, + replace: { _, _ in throw HelperError.io("Start this VM once before changing its MAC address.") } + ) + + static func proposedMAC() -> String { + "02:" + (0..<5).map { _ in String(format: "%02x", UInt8.random(in: .min ... .max)) }.joined(separator: ":") + } + + static func operation(_ arguments: [String], root: URL, resources: URL) throws -> String { + let process = Process() + let output = Pipe() + let errors = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", resources.appendingPathComponent("scripts/network-identity.py").path, + arguments[0], root.path, "current"] + arguments.dropFirst() + process.standardOutput = output + process.standardError = errors + try process.run() + process.waitUntilExit() + let result = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + let error = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard process.terminationStatus == 0 else { + throw HelperError.io(error.isEmpty ? "The VM network identity could not be read." : error) + } + return result + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 07501fa..fa8c0d3 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -167,6 +167,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let saveResources: (VMResources) -> Void private let networkPreferences: () -> VMNetworkPreferences private let saveNetworkPreferences: (VMNetworkPreferences) -> String? + private let networkIdentity: VMNetworkIdentityAccess private var networkEditor: NetworkEditor? private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void @@ -255,6 +256,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { saveResources: @escaping (VMResources) -> Void = { _ in }, networkPreferences: @escaping () -> VMNetworkPreferences = { VMNetworkPreferences() }, saveNetworkPreferences: @escaping (VMNetworkPreferences) -> String? = { _ in nil }, + networkIdentity: VMNetworkIdentityAccess = .unavailable, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, launch: @escaping () -> Void @@ -284,6 +286,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.saveResources = saveResources self.networkPreferences = networkPreferences self.saveNetworkPreferences = saveNetworkPreferences + self.networkIdentity = networkIdentity self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode self.launch = launch @@ -1353,7 +1356,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { guard !launchInProgress, !resetInProgress, networkEditor == nil else { return } permissionWindowRestorer.cancel() let editor = NetworkEditor(preferences: networkPreferences(), interfaces: VMBridgeInterfaces.available(), - save: saveNetworkPreferences, didClose: { [weak self] in + identity: networkIdentity, save: saveNetworkPreferences, didClose: { [weak self] in self?.networkEditor = nil self?.render() }) diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 50a44e7..bd122d1 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -218,6 +218,16 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try self?.networkStore.save(preferences); return nil } catch { return error.localizedDescription } }, + networkIdentity: VMNetworkIdentityAccess( + read: { [weak self] in try self?.networkIdentityOperation(["show"]) ?? "" }, + canReplace: { [weak self] in + guard let self else { return false } + return (try? self.networkIdentityOperation(["check"])) != nil + }, + replace: { [weak self] expected, proposed in + guard let self else { throw HelperError.io("The VM controller is unavailable.") } + return try self.networkIdentityOperation(["replace", expected, proposed]) + }), immersiveMode: { [weak self] in self?.fullscreenPreferenceStore.load().isImmersive ?? true }, @@ -406,6 +416,17 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { let storageUnavailableReason: String? } + private func networkIdentityOperation(_ arguments: [String]) throws -> String { + let context = childLaunchContext() + if let error = context.storageUnavailableReason { throw HelperError.io(error) } + guard let root = QEMUGPUStorageSpaceEstimate.storageRootURL( + environment: context.environment, preference: storageLocationStore.load()), + let resources = Bundle.main.resourceURL else { + throw HelperError.io("The VM data folder is unavailable.") + } + return try VMNetworkIdentityAccess.operation(arguments, root: root, resources: resources) + } + private func resolvedNetworkPreferences() -> VMNetworkPreferences { var preferences = networkStore.load() if preferences.mode == .bridged, diff --git a/macos/Tests/qemu-networking.test.sh b/macos/Tests/qemu-networking.test.sh index fcefd70..149da89 100755 --- a/macos/Tests/qemu-networking.test.sh +++ b/macos/Tests/qemu-networking.test.sh @@ -28,7 +28,7 @@ first=$(qemu_network_mac) cp "$QEMU_SELECTED_DISK" "$QEMU_SELECTED_DISK.new" mv "$QEMU_SELECTED_DISK.new" "$QEMU_SELECTED_DISK" second=$(qemu_network_mac) -[[ $first != "$second" ]] || fail 'replacement disk reused identity' +[[ $first == "$second" ]] || fail 'replacement disk changed identity' [[ $second == "$(qemu_network_mac)" ]] || fail 'replacement identity not retained' chmod 644 "$test_root/network-identities/current.json" if qemu_network_mac >/dev/null 2>&1; then fail 'unsafe record permissions accepted'; fi diff --git a/macos/Tests/test-network-identity.py b/macos/Tests/test-network-identity.py new file mode 100644 index 0000000..9faabe4 --- /dev/null +++ b/macos/Tests/test-network-identity.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "network-identity.py" +spec = importlib.util.spec_from_file_location("network_identity", SCRIPT) +identity = importlib.util.module_from_spec(spec) +spec.loader.exec_module(identity) + + +class NetworkIdentityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / "locks").mkdir(mode=0o700) + self.lock = self.root / "locks/current.lock" + self.lock.touch(mode=0o600) + self.records = self.root / "network-identities" + self.record = self.records / "current.json" + + def run_identity(self, action="ensure", *args): + return identity.identity(action, str(self.root), "current", *args) + + def legacy(self): + self.records.mkdir(mode=0o700) + value = {"disk": [1, 2, 3.0], "mac": "02:11:22:33:44:55"} + self.record.write_text(json.dumps(value)) + self.record.chmod(0o600) + return value + + def test_migrate_legacy_preserves_address_and_backup(self): + old = self.legacy() + self.assertEqual(self.run_identity("show"), old["mac"]) + self.assertEqual(json.loads(self.record.read_text()), old) + self.assertEqual(self.run_identity(), old["mac"]) + value = json.loads(self.record.read_text()) + self.assertEqual(value["version"], 2) + self.assertEqual(json.loads((self.records / "current.previous.json").read_text()), old) + self.assertEqual(self.run_identity(), old["mac"]) + + def test_disk_replacement_and_move_preserve_identity(self): + disk = self.root / "rootfs.ext4" + disk.write_bytes(b"original") + mac = self.run_identity() + saved = self.record.read_bytes() + replacement = self.root / "replacement.ext4" + replacement.write_bytes(b"upgraded") + replacement.replace(disk) + self.assertEqual(self.run_identity(), mac) + moved = self.root / "moved" + moved.mkdir(mode=0o700) + self.records.rename(moved / "network-identities") + self.assertEqual(identity.identity("ensure", str(moved)), mac) + self.assertEqual((moved / "network-identities/current.json").read_bytes(), saved) + + def test_regenerate_preserves_vm_id_and_requires_current_mac(self): + mac = self.run_identity() + old = json.loads(self.record.read_text()) + new = identity.new_mac() + with self.assertRaises(identity.IdentityError): + self.run_identity("replace", identity.new_mac(), new) + self.assertEqual(self.run_identity("replace", mac, new), new) + self.assertEqual(json.loads(self.record.read_text())["vmID"], old["vmID"]) + self.assertEqual(json.loads((self.records / "current.previous.json").read_text()), old) + self.assertEqual(self.record.stat().st_mode & 0o777, 0o600) + + def test_running_vm_lock_prevents_regeneration(self): + mac = self.run_identity() + # Exactly the lock acquisition used by qemu-persistent-storage.sh. + process = subprocess.Popen(["/bin/bash", "-c", + 'exec 9>>"$1"; /usr/bin/lockf -s -t 0 9 || exit; echo ready; read answer', + "lock-test", str(self.lock)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + try: + self.assertEqual(process.stdout.readline().strip(), "ready") + with self.assertRaisesRegex(identity.IdentityError, "Shut down"): + self.run_identity("check") + with self.assertRaisesRegex(identity.IdentityError, "Shut down"): + self.run_identity("replace", mac, identity.new_mac()) + self.assertEqual(self.run_identity("show"), mac) + finally: + process.communicate("done\n", timeout=5) + + def test_invalid_records_fail_without_replacement(self): + self.legacy() + for value in [b"broken", b"[]", b'{"mac":"02:11:22:33:44:55"}', + b'{"version":3,"mac":"02:11:22:33:44:55","vmID":"unknown"}', + b'{"version":2,"mac":"02:11:22:33:44:55","vmID":123}', + b'{"disk":[1,2,3],"mac":"ff:ff:ff:ff:ff:ff"}']: + self.record.write_bytes(value) + with self.assertRaises(identity.IdentityError): + self.run_identity() + self.assertEqual(self.record.read_bytes(), value) + + def test_unsafe_record_is_not_followed_or_replaced(self): + self.run_identity() + self.record.chmod(0o644) + with self.assertRaises(identity.IdentityError): + self.run_identity() + self.record.chmod(0o600) + target = self.records / "target" + self.record.rename(target) + self.record.symlink_to(target) + with self.assertRaises(OSError): + self.run_identity() + self.assertTrue(target.exists()) + + def test_missing_record_is_read_without_creating_state(self): + self.assertEqual(self.run_identity("show"), "") + self.assertFalse(self.records.exists()) + + def test_separate_vm_generates_separate_identity(self): + mac = self.run_identity() + separate = self.root / "separate" + separate.mkdir(mode=0o700) + self.assertNotEqual(identity.identity("ensure", str(separate)), mac) + + def test_concurrent_first_launch_has_one_identity(self): + processes = [subprocess.Popen(["python3", str(SCRIPT), "ensure", str(self.root)], + stdout=subprocess.PIPE, text=True) for _ in range(8)] + values = [process.communicate(timeout=10)[0].strip() for process in processes] + self.assertTrue(all(process.returncode == 0 for process in processes)) + self.assertEqual(len(set(values)), 1) + self.assertTrue(identity.valid_mac(values[0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/macos/build-app.sh b/macos/build-app.sh index 9b79531..cb31504 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -189,6 +189,7 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \ install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \ "$contents/Resources/scripts/qemu-port-forwarding.sh" install -m 0644 "$macos_dir/qemu-networking.sh" "$contents/Resources/scripts/qemu-networking.sh" +install -m 0644 "$macos_dir/network-identity.py" "$contents/Resources/scripts/network-identity.py" for guest_resource in \ LICENSE.omarchy \ SHA256SUMS \ diff --git a/macos/network-identity.py b/macos/network-identity.py new file mode 100644 index 0000000..58f9088 --- /dev/null +++ b/macos/network-identity.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Persistent bridged identity shared by the launcher and networking editor.""" +import fcntl +import json +import os +import re +import secrets +import stat +import sys +import uuid +from contextlib import ExitStack + + +class IdentityError(Exception): + pass + + +def valid_mac(value): + return isinstance(value, str) and re.fullmatch(r"02(?::[0-9a-f]{2}){5}", value) is not None + + +def new_mac(): + return "02:" + ":".join(f"{byte:02x}" for byte in secrets.token_bytes(5)) + + +def private_fd(stack, name, parent=None, directory=False, create=False): + flags = os.O_RDONLY if directory else os.O_RDWR + flags |= os.O_NOFOLLOW | os.O_NONBLOCK + if directory: + flags |= os.O_DIRECTORY + if create: + try: + os.mkdir(name, 0o700, dir_fd=parent) + except FileExistsError: + pass + if create and not directory: + try: + fd = os.open(name, flags | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=parent) + except FileExistsError: + fd = os.open(name, flags, dir_fd=parent) + else: + fd = os.open(name, flags, dir_fd=parent) + stack.callback(os.close, fd) + info = os.fstat(fd) + kind = stat.S_ISDIR if directory else stat.S_ISREG + if (not kind(info.st_mode) or info.st_uid != os.getuid() + or stat.S_IMODE(info.st_mode) != (0o700 if directory else 0o600) + or (not directory and info.st_nlink != 1)): + raise IdentityError("Unsafe network identity permissions or file type.") + return fd + + +def read_record(stack, directory, name): + try: + fd = private_fd(stack, name, directory) + except FileNotFoundError: + return None + if os.fstat(fd).st_size > 4096: + raise IdentityError("Network identity record is too large.") + try: + record = json.loads(os.read(fd, 4097)) + if not isinstance(record, dict) or not valid_mac(record.get("mac")): + raise ValueError() + if "version" in record: + if (type(record["version"]) is not int or record["version"] != 2 + or not isinstance(record.get("vmID"), str) + or str(uuid.UUID(record["vmID"])) != record["vmID"]): + raise ValueError() + elif (set(record) != {"disk", "mac"} or not isinstance(record["disk"], list) + or len(record["disk"]) != 3 + or any(type(value) not in (int, float) for value in record["disk"])): + raise ValueError() + except (ValueError, KeyError, TypeError): + raise IdentityError("The saved network identity is damaged or unsupported. Restore its backup; no new MAC was generated.") from None + return record + + +def write_record(directory, name, record): + temporary = name + "." + secrets.token_hex(8) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory) + try: + with os.fdopen(fd, "w") as stream: + json.dump(record, stream) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.rename(temporary, name, src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + finally: + try: + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + + +def identity(action, root, key="current", expected=None, proposed=None): + if not re.fullmatch(r"current|[0-9a-f]{64}", key): + raise IdentityError("Invalid VM identity key.") + if action not in ("show", "check", "ensure", "replace"): + raise IdentityError("Invalid identity operation.") + with ExitStack() as stack: + try: + root_fd = private_fd(stack, root, directory=True) + except FileNotFoundError: + if action == "show": + return "" + raise + # Regeneration shares the launcher's flock, excluding running VMs and + # concurrent launches. ensure is called with the launcher lock held. + if action in ("check", "replace"): + locks = private_fd(stack, "locks", root_fd, directory=True) + workspace = private_fd(stack, key + ".lock", locks) + try: + fcntl.flock(workspace, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise IdentityError("Shut down this VM before changing its MAC address.") from None + try: + directory = private_fd(stack, "network-identities", root_fd, directory=True, create=action == "ensure") + except FileNotFoundError: + if action == "show": + return "" + raise + if action in ("ensure", "replace"): + lock = private_fd(stack, key + ".lock", directory, create=True) + fcntl.flock(lock, fcntl.LOCK_EX) + name = key + ".json" + record = read_record(stack, directory, name) + if action in ("show", "check"): + return record["mac"] if record else "" + original = record + if action == "replace": + if record is None or record["mac"] != expected: + raise IdentityError("The MAC address changed. Reopen Networking and try again.") + if not valid_mac(proposed) or proposed == expected: + raise IdentityError("Invalid replacement MAC address.") + if record is None: + record = {"version": 2, "vmID": str(uuid.uuid4()), "mac": new_mac()} + elif "version" not in record: + # Preserve legacy addresses even when an upgrade replaced the disk. + record = {"version": 2, "vmID": str(uuid.uuid4()), "mac": record["mac"]} + if action == "replace": + record = dict(record, mac=proposed) + if record != original: + if original is not None: + write_record(directory, key + ".previous.json", original) + write_record(directory, name, record) + return record["mac"] + + +if __name__ == "__main__": + try: + print(identity(*sys.argv[1:])) + except (IdentityError, OSError) as error: + print(f"Network identity: {error}", file=sys.stderr) + sys.exit(1) diff --git a/macos/qemu-networking.sh b/macos/qemu-networking.sh index 5b5c51f..774cd4d 100644 --- a/macos/qemu-networking.sh +++ b/macos/qemu-networking.sh @@ -16,65 +16,18 @@ qemu_network_validate() { } qemu_network_mac() { - # Keep the established NAT adapter identity. A bridge gets a private identity - # tied to this disk instance, so copied/factory-reset disks get a fresh MAC. if [[ $QEMU_NETWORK_MODE == nat ]]; then printf '%s\n' '52:54:00:12:34:56' return fi - python3 - "$QEMU_SELECTED_STORAGE_MODE" "$QEMU_SELECTED_DISK" "${QEMU_PERSISTENT_STORAGE_DISKS_ROOT:-}" <<'PY' -import json, os, secrets, stat, sys -mode, disk, disks_root = sys.argv[1:] -def new_mac(): - return '02:' + ':'.join(f'{byte:02x}' for byte in secrets.token_bytes(5)) -if mode != 'persistent': - print(new_mac()) - raise SystemExit -root = os.path.join(os.path.dirname(disks_root), 'network-identities') -try: - os.mkdir(root, 0o700) -except FileExistsError: - pass -info = os.lstat(root) -if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: - raise SystemExit('Unsafe network identity directory') -fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) -opened = os.fstat(fd) -if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): - raise SystemExit("Network identity directory changed") -info = os.stat(disk, follow_symlinks=False) -identity = [info.st_dev, info.st_ino, info.st_birthtime] -name = os.path.basename(os.path.dirname(disk)) + '.json' -record = None -try: - item = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd) -except FileNotFoundError: - pass -else: - info = os.fstat(item) - if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > 512: - os.close(item) - raise SystemExit('Unsafe network identity record') - with os.fdopen(item) as stream: - record = json.load(stream) -if record is not None and record.get('disk') == identity: - import re - mac = record.get('mac', '') - if not re.fullmatch(r'02(?::[0-9a-f]{2}){5}', mac): - raise SystemExit('Invalid saved network identity') -else: - mac = new_mac() - temporary = name + '.' + secrets.token_hex(8) - item = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd) - with os.fdopen(item, 'w') as stream: - json.dump({'disk': identity, 'mac': mac}, stream) - stream.flush() - os.fsync(stream.fileno()) - os.rename(temporary, name, src_dir_fd=fd, dst_dir_fd=fd) - os.fsync(fd) -os.close(fd) -print(mac) -PY + if [[ $QEMU_SELECTED_STORAGE_MODE != persistent ]]; then + python3 -c 'import secrets; print("02:" + ":".join(f"{b:02x}" for b in secrets.token_bytes(5)))' + return + fi + local identity_script + identity_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/network-identity.py" + python3 "$identity_script" ensure "$(dirname "$QEMU_PERSISTENT_STORAGE_DISKS_ROOT")" \ + "$(basename "$(dirname "$QEMU_SELECTED_DISK")")" } qemu_network_start() {