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
@@ -74,18 +74,31 @@ struct TailMacConfigHelper {
return networkDevice
}
/// Creates a NIC configuration connected to the vnet dgram socket.
func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)!
if let attachment = createDgramAttachment(serverSocket: config.serverSocket, clientID: config.vmID) {
networkDevice.attachment = attachment
}
return networkDevice
}
/// Creates a NIC configuration with no attachment (disconnected).
/// The attachment can be hot-swapped later via VZNetworkDevice.attachment.
func createDisconnectedNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)!
// No attachment NIC appears disconnected to the guest.
return networkDevice
}
/// Creates a dgram socket attachment for connecting to a vnet server.
/// Returns nil on error.
func createDgramAttachment(serverSocket: String, clientID: String) -> VZFileHandleNetworkDeviceAttachment? {
let socket = Darwin.socket(AF_UNIX, SOCK_DGRAM, 0)
// Outbound network packets
let serverSocket = config.serverSocket
// Inbound network packets bind a client socket so the server can reply.
let clientSockId = config.vmID
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
let clientSocket = "/tmp/qemu-dgram-\(clientID).sock"
unlink(clientSocket)
var clientAddr = sockaddr_un()
@@ -102,7 +115,7 @@ struct TailMacConfigHelper {
if bindRes == -1 {
print("Error binding virtual network client socket - \(String(cString: strerror(errno)))")
return networkDevice
return nil
}
var serverAddr = sockaddr_un()
@@ -119,18 +132,15 @@ struct TailMacConfigHelper {
if connectRes == -1 {
print("Error connecting to server socket \(serverSocket) - \(String(cString: strerror(errno)))")
return networkDevice
return nil
}
print("Virtual if mac address is \(config.mac)")
print("Client bound to \(clientSocket)")
print("Connected to server at \(serverSocket)")
print("Socket fd is \(socket)")
let handle = FileHandle(fileDescriptor: socket)
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
networkDevice.attachment = device
return networkDevice
return VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
}
func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration {