tstest/tailmac: add NIC hot-swap, disconnected NIC, and screenshot server

Add NIC attachment hot-swap support to Host.app: VZNetworkDevice.attachment
is writable at runtime, so --disconnected-nic creates a NIC with no
attachment, and --attach-network hot-swaps it to a vnet dgram socket
after boot/restore. macOS detects link-up and does DHCP.

Refactor TailMacConfigHelper: extract createDgramAttachment() and
createDisconnectedNetworkDeviceConfiguration() from the monolithic
createSocketNetworkDeviceConfiguration().

Add --screenshot-port flag for headless mode. Host.app serves GET
/screenshot as JPEG via a localhost HTTP server, capturing the
VZVirtualMachineView via CGWindowListCreateImage. The Go test harness
polls these to push live thumbnails to the web dashboard.

Also: SIGINT handler in headless mode for clean VM state save.

Updates #13038

Change-Id: I42fba0ecd760371b4ec5b26a0557e3dd0ba9ecae
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-27 15:02:33 -07:00
committed by Brad Fitzpatrick
parent 5c1738fd56
commit c0e6ffed0d
3 changed files with 294 additions and 20 deletions
+25 -6
View File
@@ -81,7 +81,7 @@ class VMController: NSObject, VZVirtualMachineDelegate {
return macPlatform
}
func createVirtualMachine(headless: Bool = false) {
func createVirtualMachine(headless: Bool = false, disconnectedNIC: Bool = false) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlaform()
@@ -91,11 +91,14 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
if headless {
// In headless mode, use only the socket-based NIC. This matches
// the single-NIC configuration used when creating the base VM.
// Using a different NIC count would make saved state restoration
// fail silently.
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
if disconnectedNIC {
// Create a NIC with no attachment. The NIC exists in the hardware
// config (so saved state is compatible) but appears disconnected.
// Call attachNetwork() after restore to hot-swap the attachment.
virtualMachineConfiguration.networkDevices = [helper.createDisconnectedNetworkDeviceConfiguration()]
} else {
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
}
} else {
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
}
@@ -117,6 +120,22 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachine.delegate = self
}
/// Hot-swap the NIC attachment on a running VM. The VM must have been
/// created with disconnectedNIC=true. After calling this, the guest
/// sees the link come up and does DHCP.
func attachNetwork(serverSocket: String, clientID: String) {
guard let nic = virtualMachine.networkDevices.first else {
print("attachNetwork: no network devices")
return
}
guard let attachment = helper.createDgramAttachment(serverSocket: serverSocket, clientID: clientID) else {
print("attachNetwork: failed to create attachment")
return
}
nic.attachment = attachment
print("attachNetwork: NIC attachment swapped to \(serverSocket)")
}
func startVirtualMachine() {
virtualMachine.start(completionHandler: { (result) in