mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 12:16:44 +02:00
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:
parent
5c1738fd56
commit
c0e6ffed0d
@ -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 {
|
||||
|
||||
@ -21,6 +21,9 @@ extension HostCli {
|
||||
@Option var id: String
|
||||
@Option var share: String?
|
||||
@Flag(help: "Run without GUI (for automated testing)") var headless: Bool = false
|
||||
@Flag(help: "Create NIC with no attachment (for later hot-swap)") var disconnectedNic: Bool = false
|
||||
@Option(help: "Hot-swap NIC to this dgram socket path after boot/restore") var attachNetwork: String?
|
||||
@Option(help: "Serve screenshots on this localhost port (0 = auto)") var screenshotPort: Int?
|
||||
|
||||
mutating func run() {
|
||||
config = Config(id)
|
||||
@ -28,20 +31,84 @@ extension HostCli {
|
||||
print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")")
|
||||
|
||||
if headless {
|
||||
let attachSocket = attachNetwork
|
||||
let disconnected = disconnectedNic || attachSocket != nil
|
||||
let wantScreenshots = screenshotPort != nil
|
||||
let requestedPort = UInt16(screenshotPort ?? 0)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let controller = VMController()
|
||||
controller.createVirtualMachine(headless: true)
|
||||
controller.createVirtualMachine(headless: true, disconnectedNIC: disconnected)
|
||||
|
||||
// Handle SIGINT (from test cleanup) by saving VM state before exit.
|
||||
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||
signal(SIGINT, SIG_IGN) // Let DispatchSource handle it
|
||||
sigintSource.setEventHandler {
|
||||
print("SIGINT received, saving VM state...")
|
||||
controller.pauseAndSaveVirtualMachine {
|
||||
print("VM state saved, exiting.")
|
||||
Foundation.exit(0)
|
||||
}
|
||||
}
|
||||
sigintSource.resume()
|
||||
|
||||
// Set up screenshot HTTP server if requested.
|
||||
// The window must be ordered on-screen for the window server
|
||||
// to composite VZVirtualMachineView's content. We place it
|
||||
// behind all other windows and make it tiny (1x1) so it's
|
||||
// effectively invisible.
|
||||
if wantScreenshots {
|
||||
let vmView = VZVirtualMachineView()
|
||||
vmView.virtualMachine = controller.virtualMachine
|
||||
vmView.frame = NSRect(x: 0, y: 0, width: 1920, height: 1200)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1920, height: 1200),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = vmView
|
||||
// Place behind all other windows so it's not visible to the user.
|
||||
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.minimumWindow)) - 1)
|
||||
window.orderFront(nil)
|
||||
|
||||
startScreenshotServer(view: vmView, port: requestedPort)
|
||||
}
|
||||
|
||||
let doAttach = {
|
||||
if let sock = attachSocket {
|
||||
// Give macOS a moment to settle after boot/restore,
|
||||
// then hot-swap the NIC attachment.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
controller.attachNetwork(serverSocket: sock, clientID: config.vmID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
if fileManager.fileExists(atPath: config.saveFileURL.path) {
|
||||
print("Restoring virtual machine state from \(config.saveFileURL)")
|
||||
controller.restoreVirtualMachine()
|
||||
doAttach()
|
||||
} else {
|
||||
print("Starting virtual machine")
|
||||
controller.startVirtualMachine()
|
||||
doAttach()
|
||||
}
|
||||
}
|
||||
RunLoop.main.run()
|
||||
|
||||
if wantScreenshots {
|
||||
// NSApp event loop needed for VZVirtualMachineView rendering.
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
print("STARTING_NSAPP")
|
||||
fflush(stdout)
|
||||
app.run()
|
||||
} else {
|
||||
RunLoop.main.run()
|
||||
}
|
||||
} else {
|
||||
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
|
||||
}
|
||||
@ -49,3 +116,181 @@ extension HostCli {
|
||||
}
|
||||
}
|
||||
|
||||
// startScreenshotServer starts a localhost HTTP server that serves VM display
|
||||
// screenshots on GET /screenshot as JPEG. The port is printed to stdout as
|
||||
// "SCREENSHOT_PORT=<port>" so the Go test harness can discover it.
|
||||
var screenshotServer: ScreenshotHTTPServer? // prevent GC
|
||||
|
||||
func startScreenshotServer(view: NSView, port: UInt16) {
|
||||
let server = ScreenshotHTTPServer(view: view)
|
||||
screenshotServer = server
|
||||
server.start(port: port)
|
||||
}
|
||||
|
||||
/// Minimal HTTP server that serves screenshots of a VZVirtualMachineView.
|
||||
class ScreenshotHTTPServer: NSObject {
|
||||
let view: NSView
|
||||
var acceptSource: DispatchSourceRead? // prevent GC
|
||||
|
||||
init(view: NSView) {
|
||||
self.view = view
|
||||
}
|
||||
|
||||
private func log(_ msg: String) {
|
||||
let s = msg + "\n"
|
||||
FileHandle.standardError.write(Data(s.utf8))
|
||||
}
|
||||
|
||||
func start(port: UInt16) {
|
||||
let queue = DispatchQueue(label: "screenshot-server")
|
||||
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else {
|
||||
log("screenshot server: socket() failed")
|
||||
return
|
||||
}
|
||||
var yes: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = port.bigEndian
|
||||
addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian // 127.0.0.1
|
||||
|
||||
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
||||
Darwin.bind(fd, sockPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bindResult == 0 else {
|
||||
log("screenshot server: bind() failed: \(errno)")
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
guard Darwin.listen(fd, 4) == 0 else {
|
||||
log("screenshot server: listen() failed")
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
|
||||
var boundAddr = sockaddr_in()
|
||||
var boundLen = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
withUnsafeMutablePointer(to: &boundAddr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
||||
getsockname(fd, sockPtr, &boundLen)
|
||||
}
|
||||
}
|
||||
let actualPort = UInt16(bigEndian: boundAddr.sin_port)
|
||||
print("SCREENSHOT_PORT=\(actualPort)")
|
||||
fflush(stdout)
|
||||
|
||||
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
|
||||
source.setEventHandler { [self] in
|
||||
let clientFd = accept(fd, nil, nil)
|
||||
self.log("screenshot: accept fd=\(clientFd)")
|
||||
guard clientFd >= 0 else { return }
|
||||
self.handleConnection(clientFd)
|
||||
}
|
||||
source.setCancelHandler { close(fd) }
|
||||
source.resume()
|
||||
self.acceptSource = source
|
||||
}
|
||||
|
||||
private func handleConnection(_ fd: Int32) {
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
let n = read(fd, &buf, buf.count)
|
||||
let requestLine = n > 0 ? String(bytes: buf[..<n], encoding: .utf8) ?? "" : ""
|
||||
|
||||
// Route: POST /keypress?key=<keycode> — send a key event to the VM.
|
||||
if requestLine.contains("/keypress") {
|
||||
handleKeypress(fd, requestLine)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: GET /screenshot — capture the VM display.
|
||||
let wantFull = requestLine.contains("full=1")
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var jpegData: Data?
|
||||
DispatchQueue.main.async { [self] in
|
||||
jpegData = self.captureScreenshot(fullSize: wantFull)
|
||||
sem.signal()
|
||||
}
|
||||
sem.wait()
|
||||
|
||||
guard let data = jpegData else {
|
||||
let resp = Data("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n".utf8)
|
||||
resp.withUnsafeBytes { write(fd, $0.baseAddress!, resp.count) }
|
||||
close(fd)
|
||||
return
|
||||
}
|
||||
|
||||
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(data.count)\r\nConnection: close\r\n\r\n".utf8)
|
||||
response.append(data)
|
||||
response.withUnsafeBytes { ptr in
|
||||
var total = 0
|
||||
while total < response.count {
|
||||
let n = write(fd, ptr.baseAddress! + total, response.count - total)
|
||||
if n <= 0 { break }
|
||||
total += n
|
||||
}
|
||||
}
|
||||
close(fd)
|
||||
log("screenshot: served \(data.count) bytes")
|
||||
}
|
||||
|
||||
private func captureScreenshot(fullSize: Bool = false) -> Data? {
|
||||
guard let window = view.window else {
|
||||
log("screenshot: no window")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use CGWindowListCreateImage to capture the composited window content,
|
||||
// which includes GPU-rendered layers like VZVirtualMachineView's Metal surface.
|
||||
let windowID = CGWindowID(window.windowNumber)
|
||||
guard let cgImage = CGWindowListCreateImage(
|
||||
.null,
|
||||
.optionIncludingWindow,
|
||||
windowID,
|
||||
[.boundsIgnoreFraming, .bestResolution]
|
||||
) else {
|
||||
log("screenshot: CGWindowListCreateImage returned nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
if fullSize {
|
||||
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||
return bitmapRep.representation(using: .jpeg, properties: [.compressionFactor: 0.85])
|
||||
}
|
||||
|
||||
// Resize to ~800px wide for thumbnails.
|
||||
let targetWidth = 800
|
||||
let scale = Double(targetWidth) / Double(cgImage.width)
|
||||
let targetHeight = Int(Double(cgImage.height) * scale)
|
||||
|
||||
guard let ctx = CGContext(
|
||||
data: nil,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
|
||||
) else {
|
||||
log("screenshot: CGContext creation failed")
|
||||
return nil
|
||||
}
|
||||
ctx.interpolationQuality = .high
|
||||
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
|
||||
|
||||
guard let resized = ctx.makeImage() else {
|
||||
log("screenshot: makeImage failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmapRep = NSBitmapImageRep(cgImage: resized)
|
||||
return bitmapRep.representation(using: .jpeg, properties: [.compressionFactor: 0.6])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user