From 4597d2ee72e18b4c22b7eae0076be67485306809 Mon Sep 17 00:00:00 2001 From: Giova Date: Mon, 31 Aug 2026 15:32:41 +0200 Subject: [PATCH] USB: claim mass-storage devices from a privileged helper (proposal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USB redirection currently cannot take over a mass-storage device on macOS: `libusb_claim_interface()` returns LIBUSB_ERROR_ACCESS for any device already bound to a kernel driver, so the disk shows up in the guest without its data path, or not at all. This adds a small privileged LaunchDaemon (`UTMUSBHelper`) that does the claim, talks to the app over XPC, and hands the traffic to libusbredirhost. QEMU keeps running as a normal user process and only sees a socket, so nothing about TCC, storage locations or the group container changes for it. Measured with a controlled A/B comparison (same device, same unmount, same binary, only the uid changes): as a normal user libusb refuses with "USB device capture requires either an entitlement (com.apple.vm.device-access) or root privilege"; as root it succeeds. Two invariants the code enforces, both found the hard way: * the volumes of the device are unmounted BEFORE the claim, never in parallel and never after — a device claimed while macOS still has it mounted means two owners for the same disk; * on release the device is reset to force re-enumeration, even when the claim failed — without it the device stays on the bus with no block device, `diskutil` hangs, and the only way out for the user is pulling the cable. Not merge-ready as it stands: see the pull request description. --- Services/UTMUSBHelperController.swift | 174 ++++++++ Services/UTMUSBHelperProtocol.swift | 68 +++ UTMUSBHelper/Info.plist | 38 ++ UTMUSBHelper/USBDiskLocator.swift | 78 ++++ UTMUSBHelper/USBDiskUnmounter.swift | 118 ++++++ UTMUSBHelper/USBRedirService.swift | 433 ++++++++++++++++++++ UTMUSBHelper/USBRedirWorker.swift | 152 +++++++ UTMUSBHelper/UTMUSBHelper-Bridging-Header.h | 7 + UTMUSBHelper/com.utmapp.UTMUSBHelper.plist | 23 ++ UTMUSBHelper/main.swift | 71 ++++ UTMUSBHelper/usbredir_bridge.c | 274 +++++++++++++ UTMUSBHelper/usbredir_bridge.h | 77 ++++ 12 files changed, 1513 insertions(+) create mode 100644 Services/UTMUSBHelperController.swift create mode 100644 Services/UTMUSBHelperProtocol.swift create mode 100644 UTMUSBHelper/Info.plist create mode 100644 UTMUSBHelper/USBDiskLocator.swift create mode 100644 UTMUSBHelper/USBDiskUnmounter.swift create mode 100644 UTMUSBHelper/USBRedirService.swift create mode 100644 UTMUSBHelper/USBRedirWorker.swift create mode 100644 UTMUSBHelper/UTMUSBHelper-Bridging-Header.h create mode 100644 UTMUSBHelper/com.utmapp.UTMUSBHelper.plist create mode 100644 UTMUSBHelper/main.swift create mode 100644 UTMUSBHelper/usbredir_bridge.c create mode 100644 UTMUSBHelper/usbredir_bridge.h diff --git a/Services/UTMUSBHelperController.swift b/Services/UTMUSBHelperController.swift new file mode 100644 index 0000000000..ae67817eaf --- /dev/null +++ b/Services/UTMUSBHelperController.swift @@ -0,0 +1,174 @@ +// +// App side of the XPC protocol to UTMUSBHelper (§ Services/ +// UTMUSBHelperProtocol.swift). Handles registering the daemon through +// SMAppService (macOS 13+, the replacement for the deprecated SMJobBless): +// no Apple-approved entitlement, just the ordinary administrator +// authorization prompt, shown ONCE at install time. +// + +import Foundation +#if os(macOS) +import ServiceManagement +#endif + +enum UTMUSBHelperError: LocalizedError { + case unavailable + case requiresApproval + case registrationFailed(String) + case xpcFailed(String) + case operationFailed(String) + + var errorDescription: String? { + switch self { + case .unavailable: + return NSLocalizedString("Richiede macOS 13 o successivo.", comment: "UTMUSBHelperError") + case .requiresApproval: + return NSLocalizedString("Approva UTMUSBHelper in Impostazioni di Sistema → Generale → Elementi login ed estensioni, poi riprova.", comment: "UTMUSBHelperError") + case .registrationFailed(let m): + return String.localizedStringWithFormat(NSLocalizedString("Impossibile installare l'helper USB: %@", comment: "UTMUSBHelperError"), m) + case .xpcFailed(let m): + return String.localizedStringWithFormat(NSLocalizedString("Comunicazione con l'helper USB fallita: %@", comment: "UTMUSBHelperError"), m) + case .operationFailed(let m): + return m + } + } +} + +#if os(macOS) +@available(macOS 13.0, *) +final class UTMUSBHelperController { + static let shared = UTMUSBHelperController() + + private let plistName = "com.utmapp.UTMUSBHelper.plist" + private var service: SMAppService { SMAppService.daemon(plistName: plistName) } + + var isRegistered: Bool { service.status == .enabled } + + /// Registra il demone se serve — il prompt di autorizzazione di sistema + /// compare SOLO la prima volta in assoluto. + func registerIfNeeded() throws { + switch service.status { + case .enabled: + return + + case .requiresApproval: + SMAppService.openSystemSettingsLoginItems() + throw UTMUSBHelperError.requiresApproval + + // `.notFound` NON va trattato come errore fatale, malgrado il nome + // e la documentazione ("il servizio non è nel bundle"). + // + // Verificato dal log di sistema su macOS 26: prima della prima + // registrazione lo stato è .notFound (3), non .notRegistered (0). + // `smd` il plist lo legge correttamente — stampa Label e + // BundleProgram giusti — e fallisce solo nel recuperare la + // "disposition" da backgroundtaskmanagementd, che di quel servizio + // non ha ancora alcun record: ovvio per un demone mai registrato. + // Nello stesso log altre app di sistema mostrano lo stesso stato 3 + // nelle stesse condizioni. + // + // Trattarlo come fatale impediva la registrazione per sempre, con + // un messaggio fuorviante su un file che nel bundle c'era eccome. + // Si tenta quindi comunque, e si segnala errore solo se a fallire + // è register(). + case .notRegistered, .notFound: + fallthrough + + @unknown default: + do { + try service.register() + } catch { + throw UTMUSBHelperError.registrationFailed(error.localizedDescription) + } + if service.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + throw UTMUSBHelperError.requiresApproval + } + } + } + + private func makeConnection() -> NSXPCConnection { + let c = NSXPCConnection(machServiceName: kUTMUSBHelperMachServiceName, options: .privileged) + c.remoteObjectInterface = NSXPCInterface(with: UTMUSBHelperProtocol.self) + c.resume() + return c + } + + /// Wrapper comune per una chiamata XPC one-shot: risolve UNA sola volta + /// fra errorHandler, interruption e reply (qualunque arrivi prima), e + /// invalida sempre la connessione. + private func callHelper(_ body: @escaping (UTMUSBHelperProtocol, @escaping (Result) -> Void) -> Void) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + let connection = self.makeConnection() + var didResume = false + let resumeOnce: (Result) -> Void = { result in + guard !didResume else { return } + didResume = true + connection.invalidate() + continuation.resume(with: result) + } + connection.interruptionHandler = { + resumeOnce(.failure(UTMUSBHelperError.xpcFailed( + NSLocalizedString("Connessione interrotta.", comment: "UTMUSBHelperError")))) + } + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + resumeOnce(.failure(UTMUSBHelperError.xpcFailed(error.localizedDescription))) + }) as? UTMUSBHelperProtocol else { + resumeOnce(.failure(UTMUSBHelperError.xpcFailed( + NSLocalizedString("Proxy XPC non valido.", comment: "UTMUSBHelperError")))) + return + } + body(proxy) { resumeOnce($0) } + } + } + + func ping() async throws -> String { + try await callHelper { proxy, done in + proxy.ping { done(.success($0)) } + } + } + + /// Collega il device alla VM in ascolto su `socketPath`. Il demone + /// smonta da sé gli eventuali volumi PRIMA della claim. + func attachDevice(vendorId: Int, productId: Int, socketPath: String) async throws { + try await callHelper { proxy, done in + proxy.attachDevice(vendorId: vendorId, productId: productId, socketPath: socketPath) { ok, err in + if ok { done(.success(())) } + else { done(.failure(UTMUSBHelperError.operationFailed( + err ?? NSLocalizedString("Errore sconosciuto.", comment: "UTMUSBHelperError")))) } + } + } + } + + func detachDevice(vendorId: Int, productId: Int) async throws { + try await callHelper { proxy, done in + proxy.detachDevice(vendorId: vendorId, productId: productId) { ok, err in + if ok { done(.success(())) } + else { done(.failure(UTMUSBHelperError.operationFailed( + err ?? NSLocalizedString("Errore sconosciuto.", comment: "UTMUSBHelperError")))) } + } + } + } + + func listAttached() async -> [String] { + (try? await callHelper { (proxy: UTMUSBHelperProtocol, done: @escaping (Result<[String], Error>) -> Void) in + proxy.listAttached { done(.success($0)) } + }) ?? [] + } + + /// Rete di sicurezza per la chiusura dell'app. No-op se il demone non è + /// mai stato installato: non ha senso mostrare il prompt di + /// installazione mentre si sta uscendo. + /// + /// Nota: il demone stacca tutto anche da sé quando l'ultima connessione + /// XPC cade (§ main.swift), quindi questa è una cintura in più oltre + /// alle bretelle — copre il caso di uscita pulita senza attendere il + /// timeout di invalidazione. + func detachAllIfRegistered() async { + guard isRegistered else { return } + _ = try? await callHelper { (proxy: UTMUSBHelperProtocol, done: @escaping (Result) -> Void) in + proxy.detachAll { _ in done(.success(())) } + } + } +} +#endif diff --git a/Services/UTMUSBHelperProtocol.swift b/Services/UTMUSBHelperProtocol.swift new file mode 100644 index 0000000000..7a81c01243 --- /dev/null +++ b/Services/UTMUSBHelperProtocol.swift @@ -0,0 +1,68 @@ +// +// XPC protocol shared between the app and UTMUSBHelper (§ UTMUSBHelper/, a +// privileged LaunchDaemon registered through SMAppService — macOS 13+). +// Compiled into BOTH targets: only the shape of the contract lives here, +// never the logic (that is in USBRedirService.swift, on the daemon side). +// +// ── Why a daemon at all ───────────────────────────────────────────────── +// On macOS `libusb_claim_interface()` fails with LIBUSB_ERROR_ACCESS for any +// device already bound to a kernel driver. Measured with a controlled A/B +// comparison (same device, same unmount, same binary — only the uid +// changes): as a normal user libusb refuses with "USB device capture +// requires either an entitlement (com.apple.vm.device-access) or root +// privilege"; as root it succeeds. That entitlement is restricted, so root +// it is — but only for this very small daemon. +// +// ── Why running QEMU as root is not the answer ────────────────────────── +// Because root does not bypass TCC: TCC protects by process identity, not by +// privilege. A QEMU started from a LaunchDaemon can no longer open the VM +// files. Here QEMU stays an ordinary user process and only talks to us over +// a socket: no TCC problem, no storage migration, the group container stays +// where it is. +// + +import Foundation + +/// Name of the Mach service the daemon listens on — the same value appears +/// in the launchd plist Label, in its MachServices entry, and here for the +/// app-side NSXPCConnection. +let kUTMUSBHelperMachServiceName = "com.utmapp.UTMUSBHelper" + +@objc protocol UTMUSBHelperProtocol { + /// Diagnostic ping — checks that the daemon is actually reachable after + /// registration, before trusting any other call. + func ping(reply: @escaping (String) -> Void) + + /// Redirects `vendorId:productId` to the VM listening on `socketPath` + /// (the `-chardev socket,server=on,wait=off` QEMU created at startup). + /// + /// SAFETY INVARIANT: if the device is a storage unit, the daemon + /// unmounts its volumes with `diskutil unmountDisk` and proceeds ONLY if + /// the unmount succeeded. Never in parallel, never afterwards: claiming + /// a device while a volume is still mounted risks corruption. + func attachDevice(vendorId: Int, + productId: Int, + socketPath: String, + reply: @escaping (Bool, String?) -> Void) + + /// Gives the device back to macOS: releases the interfaces, **resets it + /// to force re-enumeration** and remounts the volumes. + /// + /// The reset is not a detail: verified live that without it macOS does + /// not probe the device again, and it stays enumerated on the USB bus + /// with no block device — `diskutil` hangs and the only remedy left to + /// the user is unplugging the cable. + func detachDevice(vendorId: Int, + productId: Int, + reply: @escaping (Bool, String?) -> Void) + + /// Detach everything. A safety net called when the app quits; the daemon + /// also runs it on its own when the client's XPC connection is + /// invalidated (app quit or crashed), because it owns the devices and + /// outlives the client. + func detachAll(reply: @escaping (Bool) -> Void) + + /// Currently redirected devices, as "vvvv:pppp" hex strings — the UI + /// uses it to rebuild its state after an app restart instead of guessing. + func listAttached(reply: @escaping ([String]) -> Void) +} diff --git a/UTMUSBHelper/Info.plist b/UTMUSBHelper/Info.plist new file mode 100644 index 0000000000..f822b553db --- /dev/null +++ b/UTMUSBHelper/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleIdentifier + com.utmapp.UTMUSBHelper + CFBundleName + UTMUSBHelper + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundleDisplayName + UTM — Redirezione USB + + NSRemovableVolumesUsageDescription + UTM deve prendere possesso del dispositivo USB per collegarlo direttamente alla macchina virtuale. Il dispositivo viene smontato da macOS per la durata del collegamento e restituito al distacco. + + SMAuthorizedClients + + identifier "com.utmapp.UTM" + + + diff --git a/UTMUSBHelper/USBDiskLocator.swift b/UTMUSBHelper/USBDiskLocator.swift new file mode 100644 index 0000000000..fb7ca570cc --- /dev/null +++ b/UTMUSBHelper/USBDiskLocator.swift @@ -0,0 +1,78 @@ +// +// From `vid:pid` to the BSD disks that device exposes. +// +// Used to unmount EXACTLY the volumes of the device we are about to claim +// — never by guessing from a name or from a position in `diskutil list`, +// which changes on every re-enumeration (after a reset the same SSD can +// come back with a different identifier). +// +// If the device is not a storage unit (keyboard, dongle, webcam…) the list +// is simply empty and there is nothing to unmount: that is the normal case, +// not an error. +// + +import Foundation +import IOKit + +enum USBDiskLocator { + /// Nomi BSD dei dischi INTERI (es. "disk4", mai "disk4s1") esposti dal + /// device USB indicato. Vuoto se non è un dispositivo di archiviazione. + static func wholeDisks(forVendorId vid: Int, productId pid: Int) -> [String] { + guard let matching = IOServiceMatching("IOUSBHostDevice") as NSMutableDictionary? else { + return [] + } + matching["idVendor"] = NSNumber(value: vid) + matching["idProduct"] = NSNumber(value: pid) + + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, + matching as CFDictionary, + &iterator) == KERN_SUCCESS else { + return [] + } + defer { IOObjectRelease(iterator) } + + var disks: [String] = [] + while case let device = IOIteratorNext(iterator), device != 0 { + defer { IOObjectRelease(device) } + disks.append(contentsOf: wholeDisksUnder(device)) + } + // Lo stesso vid:pid potrebbe teoricamente comparire su più unità; + // deduplica preservando l'ordine. + var seen = Set() + return disks.filter { seen.insert($0).inserted } + } + + /// Scende ricorsivamente nell'albero IORegistry sotto un device USB in + /// cerca di nodi IOMedia che rappresentino un disco intero. + private static func wholeDisksUnder(_ device: io_object_t) -> [String] { + var iterator: io_iterator_t = 0 + guard IORegistryEntryCreateIterator(device, + kIOServicePlane, + IOOptionBits(kIORegistryIterateRecursively), + &iterator) == KERN_SUCCESS else { + return [] + } + defer { IOObjectRelease(iterator) } + + var result: [String] = [] + while case let child = IOIteratorNext(iterator), child != 0 { + defer { IOObjectRelease(child) } + guard IOObjectConformsTo(child, "IOMedia") != 0 else { continue } + + // Solo il disco intero: smontare quello smonta tutte le sue + // partizioni in un colpo solo (`diskutil unmountDisk`). + let isWhole = (IORegistryEntryCreateCFProperty(child, "Whole" as CFString, + kCFAllocatorDefault, 0)? + .takeRetainedValue() as? NSNumber)?.boolValue ?? false + guard isWhole else { continue } + + if let bsd = (IORegistryEntryCreateCFProperty(child, "BSD Name" as CFString, + kCFAllocatorDefault, 0)? + .takeRetainedValue() as? String) { + result.append(bsd) + } + } + return result + } +} diff --git a/UTMUSBHelper/USBDiskUnmounter.swift b/UTMUSBHelper/USBDiskUnmounter.swift new file mode 100644 index 0000000000..6af7a25a12 --- /dev/null +++ b/UTMUSBHelper/USBDiskUnmounter.swift @@ -0,0 +1,118 @@ +// +// Unmounting and remounting the volumes of a USB device around the claim. +// It lives in the privileged process because a forced unmount needs root, +// and because the order is an invariant of the protocol: +// +// attach: locate disks -> UNMOUNT (must succeed) -> claim +// detach: release + reset -> remount +// +// Never in parallel, never the other way round: a device claimed while +// macOS still has it mounted means two owners for the same disk. +// + +import Foundation + +enum USBDiskUnmounter { + + /// Smonta un disco intero preparandolo alla claim. Ritorna `nil` se ci è + /// riuscito, altrimenti il messaggio da mostrare all'utente. + /// + /// `diskutil unmountDisk` chiede il permesso ai processi che tengono + /// aperto il volume e accetta il loro veto. Sui dischi grandi il vetante + /// abituale è l'indicizzazione Spotlight (`mdsync`/`mds_stores`), che da + /// sé non molla in tempi utili: senza questa gestione il collegamento + /// resta bloccato per sempre su "Unmount was dissented by…". + /// + /// La distinzione che conta, ed è il motivo per cui non si forza e + /// basta: un vetante di SISTEMA (indicizzatori, fseventsd) non ha dati + /// dell'utente in bilico, quindi dopo qualche tentativo educato lo si + /// scavalca; un vetante che è un'APPLICAZIONE dell'utente può avere + /// scritture non ancora sul disco — lì forzare significherebbe perdere + /// dati, quindi ci si ferma e si dice quale programma chiudere. + static func unmountForClaim(_ disk: String) -> String? { + let device = "/dev/\(disk)" + var lastOutput = "" + + for attempt in 0..<3 { + let result = run("/usr/sbin/diskutil", ["unmountDisk", device]) + if result.status == 0 { return nil } + lastOutput = result.output + + let dissenters = parseDissenters(result.output) + if let userApp = dissenters.first(where: { !isSystemProcess($0.path) }) { + let name = (userApp.path as NSString).lastPathComponent + return "Smontaggio di \(device) rifiutato da \(name) (PID \(userApp.pid)), " + + "che sta usando il disco. Non forzo lo smontaggio per non rischiare " + + "la perdita di dati non ancora scritti: chiudi quel programma e riprova." + } + if dissenters.isEmpty && attempt == 0 { + // Fallimento non dovuto a un veto: insistere non serve. + break + } + Thread.sleep(forTimeInterval: 2.0) + } + + // Restano solo vetanti di sistema (o un fallimento senza veto): + // ultima risorsa, tracciata perché non sia una decisione invisibile. + FileHandle.standardError.write(Data( + "smontaggio educato di \(device) non riuscito (\(lastOutput.trimmingCharacters(in: .whitespacesAndNewlines))), forzo\n".utf8)) + let forced = run("/usr/sbin/diskutil", ["unmountDisk", "force", device]) + if forced.status == 0 { return nil } + + return "Smontaggio di \(device) fallito, non procedo: \(forced.output)" + } + + /// Rimontaggio best-effort: dopo il reset macOS ri-sonda il device e + /// spesso monta da sé, ma l'identificatore BSD può essere cambiato. + /// Si concede qualche secondo perché la re-enumerazione non è istantanea. + static func remount(_ disks: [String]) { + guard !disks.isEmpty else { return } + for disk in disks { + var mounted = false + for _ in 0..<10 { + if run("/usr/sbin/diskutil", ["mountDisk", "/dev/\(disk)"]).status == 0 { + mounted = true + break + } + Thread.sleep(forTimeInterval: 1.0) + } + if !mounted { + FileHandle.standardError.write(Data( + "/dev/\(disk) non rimontato; macOS potrebbe farlo da sé\n".utf8)) + } + } + } + + /// Estrae i vetanti dall'output di `diskutil`, che li elenca come + /// "Unmount was dissented by PID 123 (/percorso/del/binario)". + private static func parseDissenters(_ output: String) -> [(pid: Int, path: String)] { + let pattern = #"dissented by PID (\d+) \(([^)]*)\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(output.startIndex.. Bool { + ["/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/usr/bin/"] + .contains { path.hasPrefix($0) } + } + + @discardableResult + private static func run(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = arguments + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + do { try task.run() } catch { return (-1, error.localizedDescription) } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return (task.terminationStatus, String(data: data, encoding: .utf8) ?? "") + } +} diff --git a/UTMUSBHelper/USBRedirService.swift b/UTMUSBHelper/USBRedirService.swift new file mode 100644 index 0000000000..9013f02dff --- /dev/null +++ b/UTMUSBHelper/USBRedirService.swift @@ -0,0 +1,433 @@ +// +// The real implementation of the XPC protocol (§ Services/ +// UTMUSBHelperProtocol.swift). +// +// The daemon no longer claims the device itself. It delegates that to a +// worker (§ USBRedirWorker.swift) started with `launchctl asuser` inside +// the user's graphical session: still root, but with a real `auid`, which +// is the only way for TCC to show the removable-volume access prompt +// instead of denying in silence. What stays here is XPC ownership, the +// unmount/remount, and the lifetime of the workers. +// +// Order of operations (not negotiable, § the invariant in the protocol): +// attach: locate disks -> UNMOUNT (must succeed) -> start worker +// detach: stop the worker (release + reset) -> remount +// +// The reset on release is mandatory even when the claim FAILED: verified +// live that a failed kernel-driver detach still leaves the device crippled +// (enumerated on the USB bus but with no block device, `diskutil` hanging, +// cable to be pulled by hand). That reset lives inside usbredir_bridge.c, +// on every exit path. +// + +import Foundation +import MachO + +/// Una redirezione attiva. `disks` sono i dischi INTERI che abbiamo smontato +/// noi e che dobbiamo rimontare al rilascio — memorizzati all'attach perché +/// dopo il reset gli identificatori BSD possono cambiare e non sarebbero più +/// ricavabili. +private final class RedirectSession { + let vendorId: Int + let productId: Int + let disks: [String] + /// Il processo `launchctl asuser` che fa da tramite. + let task: Process + /// Pid del worker VERO, comunicato da lui stesso all'avvio: è questo che + /// va segnalato, non `task.processIdentifier`. + let workerPid: pid_t + /// Alzato quando siamo NOI a smontare la sessione, così il + /// terminationHandler non rimonta una seconda volta. + var didTeardown = false + + init(vendorId: Int, productId: Int, disks: [String], task: Process, workerPid: pid_t) { + self.vendorId = vendorId + self.productId = productId + self.disks = disks + self.task = task + self.workerPid = workerPid + } + + var key: String { USBRedirService.key(vendorId, productId) } +} + +/// Esito della stretta di mano iniziale col worker. +private final class HandshakeBox { + var workerPid: pid_t = 0 + var ok = false + var message: String? + var timedOut = false + let lock = NSLock() +} + +final class USBRedirService: NSObject, UTMUSBHelperProtocol { + /// Chiave = "vvvv:pppp". Protetto da `lock`: le chiamate XPC arrivano su + /// code diverse e i terminationHandler dei worker su code di sistema. + private var sessions: [String: RedirectSession] = [:] + private let lock = NSLock() + + /// Coda per il lavoro di chiusura asincrono (il rimontaggio può + /// richiedere secondi e non deve trattenere chi ci ha notificati). + private let teardownQueue = DispatchQueue(label: "com.utmapp.UTMUSBHelper.teardown") + + /// Generoso di proposito: se è la PRIMA volta, dentro questa finestra + /// c'è l'utente che legge la richiesta di accesso di macOS e decide. + private static let handshakeTimeout: TimeInterval = 120 + + static func key(_ vid: Int, _ pid: Int) -> String { + String(format: "%04x:%04x", vid, pid) + } + + // MARK: - Protocollo + + func ping(reply: @escaping (String) -> Void) { + lock.lock(); let n = sessions.count; lock.unlock() + reply("UTMUSBHelper v1, pid \(ProcessInfo.processInfo.processIdentifier), \(n) device rediretti") + } + + func attachDevice(vendorId: Int, + productId: Int, + socketPath: String, + reply: @escaping (Bool, String?) -> Void) { + let k = Self.key(vendorId, productId) + + lock.lock() + if sessions[k] != nil { + lock.unlock() + reply(false, "Il dispositivo \(k) è già collegato a una VM.") + return + } + lock.unlock() + + guard let consoleUid = Self.consoleUserID() else { + reply(false, "Nessun utente collegato alla sessione grafica: " + + "la redirezione USB non può chiedere l'autorizzazione necessaria.") + return + } + + // 1. Individua i dischi ESPOSTI DA QUESTO device (vuoto se non è + // un'unità di archiviazione: tastiere, dongle, webcam…). + let disks = USBDiskLocator.wholeDisks(forVendorId: vendorId, productId: productId) + + // 2. Smonta. DEVE riuscire prima di qualunque claim. + for disk in disks { + if let failure = Self.unmountForClaim(disk) { + reply(false, failure) + return + } + } + + // 3. Avvia il worker nella sessione dell'utente e attendi il suo + // verdetto (qui dentro può comparire il prompt di sistema). + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["asuser", "\(consoleUid)", + Self.selfExecutablePath, + "--worker", "\(vendorId)", "\(productId)", socketPath] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + } catch { + Self.remount(disks) + reply(false, "Avvio del processo di redirezione fallito: \(error.localizedDescription)") + return + } + + let shake = Self.waitForHandshake(pipe.fileHandleForReading, timeout: Self.handshakeTimeout) + + guard shake.ok else { + // Fallita: fermiamo il worker e rimettiamo il disco com'era, + // altrimenti l'utente si ritrova un volume sparito per + // un'operazione che non è nemmeno riuscita. + if shake.workerPid > 0 { kill(shake.workerPid, SIGTERM) } + if task.isRunning { task.terminate() } + Self.remount(disks) + let msg: String + if shake.timedOut { + msg = "Nessuna risposta dal processo di redirezione entro " + + "\(Int(Self.handshakeTimeout)) secondi. Se è comparsa una richiesta " + + "di autorizzazione di macOS e non è stata accettata, riprova." + } else { + msg = shake.message ?? "Avvio della redirezione fallito." + } + reply(false, msg) + return + } + + let session = RedirectSession(vendorId: vendorId, productId: productId, + disks: disks, task: task, workerPid: shake.workerPid) + lock.lock() + sessions[k] = session + lock.unlock() + + // Da qui in poi il pipe va tenuto drenato, altrimenti il worker si + // bloccherebbe a buffer pieno; ne approfittiamo per far confluire il + // suo output nel log del demone. + pipe.fileHandleForReading.readabilityHandler = { fh in + let data = fh.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + for line in text.split(separator: "\n") where !line.isEmpty { + NSLog("UTMUSBHelper[worker %d]: %@", shake.workerPid, String(line)) + } + } + + // Uscita spontanea del worker = il guest ha chiuso il socket (VM + // fermata, VM crashata, QEMU ucciso). È il percorso che garantisce il + // rimontaggio anche quando nessuno ce lo chiede. + task.terminationHandler = { [weak self] _ in + pipe.fileHandleForReading.readabilityHandler = nil + guard let self else { return } + self.lock.lock() + let known = self.sessions[k] + let wasOurs = known?.didTeardown ?? true + if !wasOurs { self.sessions.removeValue(forKey: k) } + self.lock.unlock() + guard !wasOurs else { return } + NSLog("UTMUSBHelper: worker di %@ terminato da sé, rimonto", k) + self.teardownQueue.async { Self.remount(disks) } + } + + reply(true, nil) + } + + func detachDevice(vendorId: Int, productId: Int, reply: @escaping (Bool, String?) -> Void) { + let k = Self.key(vendorId, productId) + lock.lock() + let session = sessions.removeValue(forKey: k) + session?.didTeardown = true + lock.unlock() + + guard let session else { + reply(false, "Il dispositivo \(k) non risulta collegato.") + return + } + Self.teardown(session) + reply(true, nil) + } + + func detachAll(reply: @escaping (Bool) -> Void) { + lock.lock() + let all = Array(sessions.values) + all.forEach { $0.didTeardown = true } + sessions.removeAll() + lock.unlock() + + for session in all { Self.teardown(session) } + reply(true) + } + + func listAttached(reply: @escaping ([String]) -> Void) { + lock.lock(); let keys = Array(sessions.keys); lock.unlock() + reply(keys.sorted()) + } + + // MARK: - Chiusura + + /// Rilascio effettivo. SIGTERM fa uscire il worker per la sua via pulita + /// (rilascio interfacce + re-enumerazione); solo dopo ha senso rimontare, + /// perché prima del reset il block device non esiste ancora. + private static func teardown(_ session: RedirectSession) { + if session.workerPid > 0 { kill(session.workerPid, SIGTERM) } + + var waited = 0.0 + while session.task.isRunning && waited < 15.0 { + Thread.sleep(forTimeInterval: 0.1) + waited += 0.1 + } + if session.task.isRunning { + // Ultima risorsa: un worker ucciso a freddo NON ha fatto il + // reset, quindi il device può restare monco fino a che l'utente + // non stacca il cavo. Va detto nel log, non nascosto. + NSLog("UTMUSBHelper: worker di %@ non uscito in 15s, lo uccido: " + + "il dispositivo potrebbe richiedere il ricollegamento manuale", session.key) + if session.workerPid > 0 { kill(session.workerPid, SIGKILL) } + session.task.terminate() + } + remount(session.disks) + } + + // MARK: - Smontaggio + + /// Smonta un disco intero preparandolo alla claim. Ritorna `nil` se ci è + /// riuscito, altrimenti il messaggio da mostrare all'utente. + /// + /// `diskutil unmountDisk` chiede il permesso ai processi che tengono + /// aperto il volume e accetta il loro veto. Sui dischi grandi il vetante + /// abituale è l'indicizzazione Spotlight (`mdsync`/`mds_stores`), che da + /// sé non molla in tempi utili: senza questa gestione il collegamento + /// resta bloccato per sempre con "Unmount was dissented by…". + /// + /// La distinzione che conta, ed è il motivo per cui non si forza e + /// basta: un vetante di SISTEMA (indicizzatori, fseventsd) non ha dati + /// dell'utente in bilico, quindi dopo qualche tentativo educato lo si + /// scavalca; un vetante che è un'APPLICAZIONE dell'utente può avere + /// scritture non ancora sul disco — lì forzare significherebbe perdere + /// dati, quindi ci si ferma e si dice quale programma chiudere. + private static func unmountForClaim(_ disk: String) -> String? { + let device = "/dev/\(disk)" + var lastOutput = "" + + for attempt in 0..<3 { + let result = runProcess("/usr/sbin/diskutil", ["unmountDisk", device]) + if result.status == 0 { return nil } + lastOutput = result.output + + let dissenters = parseDissenters(result.output) + if let userApp = dissenters.first(where: { !isSystemProcess($0.path) }) { + let name = (userApp.path as NSString).lastPathComponent + return "Smontaggio di \(device) rifiutato da \(name) (PID \(userApp.pid)), " + + "che sta usando il disco. Non forzo lo smontaggio per non rischiare " + + "la perdita di dati non ancora scritti: chiudi quel programma e riprova." + } + if dissenters.isEmpty && attempt == 0 { + // Fallimento non dovuto a un veto: insistere non serve. + break + } + Thread.sleep(forTimeInterval: 2.0) + } + + // Restano solo vetanti di sistema (o un fallimento senza veto): + // ultima risorsa, tracciata nel log perché non sia una decisione + // invisibile. + NSLog("UTMUSBHelper: smontaggio educato di %@ non riuscito (%@), forzo", + device, lastOutput.trimmingCharacters(in: .whitespacesAndNewlines)) + let forced = runProcess("/usr/sbin/diskutil", ["unmountDisk", "force", device]) + if forced.status == 0 { return nil } + + return "Smontaggio di \(device) fallito, non procedo: \(forced.output)" + } + + /// Estrae i vetanti dall'output di `diskutil`, che li elenca come + /// "Unmount was dissented by PID 123 (/percorso/del/binario)". + private static func parseDissenters(_ output: String) -> [(pid: Int, path: String)] { + let pattern = #"dissented by PID (\d+) \(([^)]*)\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(output.startIndex.. Bool { + ["/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/usr/bin/"] + .contains { path.hasPrefix($0) } + } + + /// Rimontaggio best-effort: dopo il reset macOS ri-sonda il device e + /// spesso monta da sé, ma l'identificatore BSD può essere cambiato. + /// Si concede qualche secondo perché la re-enumerazione non è istantanea. + private static func remount(_ disks: [String]) { + guard !disks.isEmpty else { return } + for disk in disks { + var mounted = false + for _ in 0..<10 { + if runProcess("/usr/sbin/diskutil", ["mountDisk", "/dev/\(disk)"]).status == 0 { + mounted = true + break + } + Thread.sleep(forTimeInterval: 1.0) + } + if !mounted { + NSLog("UTMUSBHelper: /dev/%@ non rimontato; macOS potrebbe farlo da sé", disk) + } + } + } + + // MARK: - Avvio del worker + + /// Utente della sessione grafica corrente. `/dev/console` appartiene a + /// chi ha la console; se è ancora di root non c'è nessuno collegato, e + /// senza una sessione utente il worker non avrebbe alcun vantaggio + /// rispetto al demone (§ USBRedirWorker.swift). + private static func consoleUserID() -> uid_t? { + var info = stat() + guard stat("/dev/console", &info) == 0, info.st_uid != 0 else { return nil } + return info.st_uid + } + + /// Percorso assoluto di QUESTO binario, da rilanciare in modalità + /// worker. `CommandLine.arguments[0]` non basta: launchd ci avvia con un + /// BundleProgram relativo al bundle dell'app. + private static var selfExecutablePath: String = { + var size = UInt32(PATH_MAX) + var buffer = [CChar](repeating: 0, count: Int(size)) + if _NSGetExecutablePath(&buffer, &size) == 0 { + let path = String(cString: buffer) + if let resolved = try? FileManager.default.destinationOfSymbolicLink(atPath: path) { + return resolved + } + return (path as NSString).resolvingSymlinksInPath + } + return Bundle.main.executablePath ?? CommandLine.arguments[0] + }() + + /// Legge le righe di protocollo del worker fino a "OK"/"ERR" o alla + /// scadenza. Tutto ciò che non è protocollo finisce nel log. + private static func waitForHandshake(_ fh: FileHandle, timeout: TimeInterval) -> HandshakeBox { + let box = HandshakeBox() + let semaphore = DispatchSemaphore(value: 0) + + DispatchQueue.global().async { + var buffer = Data() + var finished = false + while !finished { + let chunk = fh.availableData + if chunk.isEmpty { break } // EOF: il worker è morto + buffer.append(chunk) + + while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = buffer[buffer.startIndex.. (status: Int32, output: String) { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = arguments + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + do { try task.run() } catch { return (-1, error.localizedDescription) } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return (task.terminationStatus, String(data: data, encoding: .utf8) ?? "") + } +} diff --git a/UTMUSBHelper/USBRedirWorker.swift b/UTMUSBHelper/USBRedirWorker.swift new file mode 100644 index 0000000000..0a94c4fd68 --- /dev/null +++ b/UTMUSBHelper/USBRedirWorker.swift @@ -0,0 +1,152 @@ +// +// Privileged worker: this is where all the work on the USB device happens — +// unmount, interface claim, traffic pump, release. +// +// It is started by the APP through Authorization Services, not by a daemon. +// The difference is not stylistic, and it cost a full session of diagnosis: +// +// claiming a USB interface on a storage device goes through the sandbox +// gate `iokit-open-service IOUSBHostInterface`, which consults TCC. For a +// root process TCC also asks the SYSTEM domain, and that domain can only +// answer if it can attribute the request to a RESPONSIBLE process that is a +// user-session app holding Full Disk Access: then the question becomes +// `AllFiles`, which it can answer by itself. Otherwise it becomes +// `RemovableVolumes`, which it must forward to a user agent it cannot reach +// from there — `bootstrap look-up: No such process` — and it denies. +// +// A LaunchDaemon has no such responsible process, whatever its uid. An app +// that spawns the worker itself does, and the prompt appears. +// + +import Foundation + +enum USBRedirWorker { + static let pidPrefix = "PID " + static let okLine = "OK" + static let errPrefix = "ERR " + + /// Handle vivo della redirezione. Serve a `finish()`, raggiungibile da + /// SIGTERM, dalla chiusura del peer o dalla morte dell'app. + private static var handle: OpaquePointer? + private static var mountedDisks: [String] = [] + private static let stateLock = NSLock() + + /// Tenuti vivi qui: un DispatchSource deallocato smette di scattare. + private static var sources: [any DispatchSourceProtocol] = [] + + static func run(vendorId: UInt16, + productId: UInt16, + socketPath: String, + watchPid: pid_t) -> Never { + emit("\(pidPrefix)\(ProcessInfo.processInfo.processIdentifier)") + + // Installati PRIMA di toccare il device: qualunque cosa vada storta + // da qui in poi, l'uscita deve passare per la via pulita. + installSignalHandling() + if watchPid > 0 { installAppWatch(watchPid) } + + // 1. Dischi ESPOSTI DA QUESTO device (vuoto se non è un'unità di + // archiviazione: dongle, seriali, schede…). + let disks = USBDiskLocator.wholeDisks(forVendorId: Int(vendorId), productId: Int(productId)) + + // 2. Smontaggio. DEVE riuscire prima di qualunque claim. Se fallisce + // a metà, si rimette a posto ciò che si era già smontato. + var unmounted: [String] = [] + for disk in disks { + if let failure = USBDiskUnmounter.unmountForClaim(disk) { + USBDiskUnmounter.remount(unmounted) + emit("\(errPrefix)\(failure)") + exit(1) + } + unmounted.append(disk) + } + stateLock.lock(); mountedDisks = unmounted; stateLock.unlock() + + // 3. Claim e avvio della redirezione. + var errBuf = [CChar](repeating: 0, count: 512) + let started = usbredir_start(vendorId, + productId, + socketPath, + { _ in USBRedirWorker.peerClosedFromPumpThread() }, + nil, + &errBuf, + errBuf.count) + + guard let started else { + let message = String(cString: errBuf) + // Avevamo smontato noi: rimettiamo il disco com'era, altrimenti + // l'utente si ritrova un volume sparito per un'operazione che + // non è nemmeno riuscita. + USBDiskUnmounter.remount(unmounted) + emit("\(errPrefix)\(message.isEmpty ? "avvio della redirezione fallito" : message)") + exit(1) + } + + stateLock.lock(); handle = started; stateLock.unlock() + emit(okLine) + + while true { RunLoop.main.run(until: .distantFuture) } + } + + // MARK: - Percorsi di uscita + + private static func installSignalHandling() { + signal(SIGTERM, SIG_IGN) + signal(SIGINT, SIG_IGN) + for signalNumber in [SIGTERM, SIGINT] { + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { finish(0) } + source.resume() + sources.append(source) + } + } + + /// Sorveglia il processo dell'app. È la rete che sostituisce + /// l'invalidazione XPC del vecchio demone: se UTM **crasha** — + /// non si limita a chiudersi — nessuno ci direbbe di mollare il device, + /// e l'utente resterebbe con un disco reclamato da un processo orfano, + /// invisibile a macOS finché non stacca il cavo. + /// + /// kqueue e non il controllo del genitore: il nostro genitore è il + /// trampolino di Authorization Services, che esce subito, quindi + /// `getppid()` non dice nulla di utile sull'app. + private static func installAppWatch(_ pid: pid_t) { + let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main) + source.setEventHandler { + FileHandle.standardError.write(Data("l'app è terminata, rilascio il device\n".utf8)) + finish(0) + } + source.resume() + sources.append(source) + } + + /// Il guest ha chiuso il socket: VM fermata, VM crashata o QEMU ucciso. + /// Arriva DAL THREAD DI PUMP, dove `usbredir_stop()` si auto-attenderebbe + /// (§ usbredir_bridge.h), quindi si rimbalza altrove prima di chiudere. + private static func peerClosedFromPumpThread() { + DispatchQueue.global().async { finish(0) } + } + + /// Unica uscita. `usbredir_stop` rilascia le interfacce E forza la + /// re-enumerazione, che è ciò che restituisce davvero il disco a macOS; + /// solo dopo ha senso rimontare, perché prima del reset il block device + /// non esiste ancora. + private static func finish(_ code: Int32) -> Never { + stateLock.lock() + let liveHandle = handle + let disks = mountedDisks + handle = nil + mountedDisks = [] + stateLock.unlock() + + if let liveHandle { usbredir_stop(liveHandle) } + USBDiskUnmounter.remount(disks) + exit(code) + } + + /// Scrittura non bufferizzata: l'app legge queste righe in tempo reale + /// per decidere se l'attach è riuscito. + private static func emit(_ line: String) { + FileHandle.standardOutput.write(Data((line + "\n").utf8)) + } +} diff --git a/UTMUSBHelper/UTMUSBHelper-Bridging-Header.h b/UTMUSBHelper/UTMUSBHelper-Bridging-Header.h new file mode 100644 index 0000000000..407ffd1e36 --- /dev/null +++ b/UTMUSBHelper/UTMUSBHelper-Bridging-Header.h @@ -0,0 +1,7 @@ +// +// Bridging header del target UTMUSBHelper: espone a Swift l'API C +// della redirezione (§ usbredir_bridge.h), che resta in C perché è la +// forma già verificata dal vivo contro libusbredirhost. +// + +#import "usbredir_bridge.h" diff --git a/UTMUSBHelper/com.utmapp.UTMUSBHelper.plist b/UTMUSBHelper/com.utmapp.UTMUSBHelper.plist new file mode 100644 index 0000000000..26ae0513a9 --- /dev/null +++ b/UTMUSBHelper/com.utmapp.UTMUSBHelper.plist @@ -0,0 +1,23 @@ + + + + + Label + com.utmapp.UTMUSBHelper + + BundleProgram + Contents/Library/LaunchDaemons/UTMUSBHelper + MachServices + + com.utmapp.UTMUSBHelper + + + + ProcessType + Standard + + diff --git a/UTMUSBHelper/main.swift b/UTMUSBHelper/main.swift new file mode 100644 index 0000000000..fef97e52d5 --- /dev/null +++ b/UTMUSBHelper/main.swift @@ -0,0 +1,71 @@ +// +// Entry point of the privileged LaunchDaemon. +// +// Central safety net: when the LAST client disconnects — whether the app +// quit politely or crashed, XPC reports the same event — we detach +// everything ourselves. The daemon owns the devices and outlives the +// client: without this, an app crash would leave the user with a disk +// claimed by nobody and invisible to macOS until they unplug the cable. +// + +import Foundation + +final class ListenerDelegate: NSObject, NSXPCListenerDelegate { + private let service = USBRedirService() + private var activeConnections = 0 + private let lock = NSLock() + + func listener(_ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + connection.exportedInterface = NSXPCInterface(with: UTMUSBHelperProtocol.self) + connection.exportedObject = service + + let onGone: () -> Void = { [weak self] in + guard let self else { return } + self.lock.lock() + self.activeConnections -= 1 + let none = self.activeConnections <= 0 + self.lock.unlock() + guard none else { return } + NSLog("UTMUSBHelper: nessun client collegato, rilascio tutti i device") + self.service.detachAll { _ in } + } + // invalidation E interruption: la prima per una chiusura pulita, + // la seconda per un crash del client. + connection.invalidationHandler = onGone + connection.interruptionHandler = onGone + + lock.lock(); activeConnections += 1; lock.unlock() + connection.resume() + return true + } +} + +// Lo stesso binario ha due vite. Senza argomenti è il LaunchDaemon; con +// `--worker` è il processo che fa la claim, lanciato dal demone dentro la +// sessione grafica dell'utente perché TCC possa mostrargli il prompt +// (§ USBRedirWorker.swift per il perché disteso). +// +// Un solo binario e non due target distinti di proposito: così l'identità +// di firma è la stessa, e quindi la concessione TCC che l'utente dà al +// worker vale anche per il demone, senza doverla dare due volte. +// Modalità worker: `--worker [pid-app]`. +// +// L'ultimo argomento è il processo dell'app da sorvegliare, così un crash di +// UTM non lascia il device reclamato da un orfano (§ installAppWatch). +let arguments = CommandLine.arguments +if arguments.count >= 5, arguments[1] == "--worker" { + guard let vid = UInt16(arguments[2]), let pid = UInt16(arguments[3]) else { + FileHandle.standardError.write(Data("vendor/product id non validi\n".utf8)) + exit(2) + } + let watchPid: pid_t = arguments.count >= 6 ? (pid_t(arguments[5]) ?? 0) : 0 + USBRedirWorker.run(vendorId: vid, productId: pid, socketPath: arguments[4], watchPid: watchPid) +} + +let delegate = ListenerDelegate() +let listener = NSXPCListener(machServiceName: kUTMUSBHelperMachServiceName) +listener.delegate = delegate +listener.resume() +NSLog("UTMUSBHelper avviato (pid %d)", ProcessInfo.processInfo.processIdentifier) +RunLoop.main.run() diff --git a/UTMUSBHelper/usbredir_bridge.c b/UTMUSBHelper/usbredir_bridge.c new file mode 100644 index 0000000000..c737e7c6af --- /dev/null +++ b/UTMUSBHelper/usbredir_bridge.c @@ -0,0 +1,274 @@ +// +// Implementazione del bridge usbredir (§ usbredir_bridge.h). +// +// Struttura derivata direttamente dal prototipo verificato dal vivo: la +// sequenza open -> connect -> usbredirhost_open -> pump -> release+reset è +// quella che ha superato la prova sul campo, qui riorganizzata attorno a un +// thread dedicato per non bloccare il demone. +// +// Disciplina di threading: TUTTE le chiamate a usbredirhost avvengono sul +// thread di pump, inclusa la chiusura. `usbredir_stop()` si limita ad +// alzare un flag e ad attendere il thread. Così non serve alcun lock su +// usbredirhost (che è solo parzialmente thread-safe) e non esistono +// chiamate concorrenti su di esso. +// + +#include "usbredir_bridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// Causa REALE del fallimento più comune, verificata dal log di sistema — e +// NON è "manca root", come diceva questo messaggio in origine. +// +// Prendere possesso di un'interfaccia USB di un dispositivo di archiviazione +// passa dal gate sandbox `iokit-open-service IOUSBHostInterface`, che a sua +// volta consulta TCC sul servizio kTCCServiceSystemPolicyRemovableVolumes. +// Con il demone (root, non sandboxato) il kernel logga: +// +// System Policy: UTMUSBHelper(N) deny(1) +// iokit-open-service IOUSBHostInterface +// +// e tccd, nella stessa finestra temporale: +// +// CREDENTIAL_AUDIT_TOKEN={pid:N, auid:-1, euid:0} +// REPLY: XPCErrorDescription="Connection invalid" +// +// `auid:-1` è la chiave: un LaunchDaemon non appartiene ad alcuna sessione +// utente, quindi tccd non ha un agente a cui inoltrare la richiesta, la +// valutazione fallisce in partenza e la sandbox nega. Il prototipo CLI +// riusciva perché lanciato con sudo DA UN TERMINALE: lì il processo +// responsabile era Terminal.app (auid=501, già autorizzato e con +// com.apple.private.tcc.allow-prompting), e TCC rispondeva authValue=2. +// +// Conseguenza pratica: root è necessario ma NON sufficiente. Serve una +// concessione TCC registrata per QUESTO binario, che essendo senza sessione +// non può essere richiesta con un prompt — va concessa a mano una volta. +// +// Nota sul sintomo: negato il gate, libusb non riesce nemmeno a costruire il +// plugin IOKit del device, che quindi sparisce dalla sua enumerazione. Il +// fallimento si manifesta perciò come LIBUSB_ERROR_NO_DEVICE ("No such +// device") su un dispositivo perfettamente collegato e visibile in Finder — +// sintomo fuorviante che ha senso solo conoscendo la catena qui sopra. +// Il worker gira nella sessione dell'utente proprio perché questa +// autorizzazione possa essere chiesta con la normale finestra di sistema +// (§ USBRedirWorker.swift); il ripiego manuale resta indicato per il caso +// in cui il prompt non compaia o sia stato rifiutato in passato. +static const char *const kTccHint = + "autorizzazione ai volumi rimovibili negata. Consenti l'accesso quando " + "macOS lo chiede; se la richiesta non compare o l'hai già rifiutata, " + "aggiungi UTM.app/Contents/Library/LaunchDaemons/UTMUSBHelper " + "in Impostazioni di Sistema > Privacy e sicurezza > Accesso completo al " + "disco (root da solo non basta)"; + +struct usbredir_session { + libusb_context *ctx; + struct usbredirhost *host; + int sock_fd; + uint16_t vid; + uint16_t pid; + + pthread_t thread; + volatile int stop_requested; // alzato da usbredir_stop() + volatile int running; // 0 quando il thread è uscito + int peer_closed; // il guest ha chiuso -> notifica + + usbredir_closed_cb on_closed; + void *user_ctx; +}; + +// ---------------------------------------------------------------- callbacks + +static void log_cb(void *priv, int level, const char *msg) { + (void)priv; + // Solo errori e warning: il resto è troppo verboso per il log di sistema. + if (level <= usbredirparser_warning) { + fprintf(stderr, "usbredir: %s\n", msg); + } +} + +static int read_cb(void *priv, uint8_t *data, int count) { + usbredir_session *s = (usbredir_session *)priv; + ssize_t r = read(s->sock_fd, data, (size_t)count); + if (r < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0; + return -1; + } + if (r == 0) { // EOF: QEMU ha chiuso (VM fermata o crashata) + s->peer_closed = 1; + return -1; + } + return (int)r; +} + +static int write_cb(void *priv, uint8_t *data, int count) { + usbredir_session *s = (usbredir_session *)priv; + ssize_t r = write(s->sock_fd, data, (size_t)count); + if (r < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 0; + return -1; + } + return (int)r; +} + +// ------------------------------------------------------------- pump thread + +/// Forza una re-enumerazione USB del device, così macOS lo ri-sonda e +/// riattacca il proprio driver. +/// +/// Va invocata SEMPRE che si sia toccato il device, **anche quando la claim +/// è FALLITA**: verificato dal vivo che un tentativo di detach del driver +/// kernel andato male lascia comunque il device in uno stato monco — +/// enumerato via USB ma senza block device, con `diskutil` che si pianta e +/// l'utente costretto a staccare il cavo. Il costo di un reset di troppo è +/// nullo; il costo di uno mancante è un disco appeso. +static void reenumerate_device(libusb_context *ctx, uint16_t vid, uint16_t pid) { + if (!ctx) return; + libusb_device_handle *h = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (h) { + libusb_reset_device(h); // LIBUSB_ERROR_NOT_FOUND qui è normale + libusb_close(h); + } +} + +/// Restituisce il device al sistema: rilascio interfacce + re-enumerazione. +/// Eseguito SEMPRE sul thread di pump, sia in uscita normale sia su stop. +static void release_device(usbredir_session *s) { + if (s->host) { + usbredirhost_close(s->host); // rilascia le interfacce e chiude l'handle + s->host = NULL; + } + reenumerate_device(s->ctx, s->vid, s->pid); +} + +static void *pump_thread(void *arg) { + usbredir_session *s = (usbredir_session *)arg; + + while (!s->stop_requested) { + struct pollfd pfd; + pfd.fd = s->sock_fd; + pfd.events = POLLIN; + if (usbredirhost_has_data_to_write(s->host) > 0) pfd.events |= POLLOUT; + pfd.revents = 0; + + int pr = poll(&pfd, 1, 50); + if (pr < 0 && errno != EINTR) break; + + if (pfd.revents & (POLLERR | POLLHUP)) { s->peer_closed = 1; break; } + if (pfd.revents & POLLIN) { + if (usbredirhost_read_guest_data(s->host) != 0) break; + } + if (pfd.revents & POLLOUT) { + if (usbredirhost_write_guest_data(s->host) < 0) break; + } + + // usbredirhost richiede che qualcuno pompi gli eventi libusb + // (§ nota 2 in usbredirhost.h). + struct timeval tv = {0, 0}; + libusb_handle_events_timeout(s->ctx, &tv); + } + + release_device(s); + if (s->sock_fd >= 0) { close(s->sock_fd); s->sock_fd = -1; } + + int notify = (!s->stop_requested && s->peer_closed && s->on_closed); + s->running = 0; + // La notifica va emessa per ULTIMA: da qui in poi la sessione può + // essere considerata conclusa dal chiamante. + if (notify) s->on_closed(s->user_ctx); + return NULL; +} + +// -------------------------------------------------------------- public API + +usbredir_session *usbredir_start(uint16_t vid, + uint16_t pid, + const char *socket_path, + usbredir_closed_cb on_closed, + void *user_ctx, + char *err_buf, + size_t err_len) { +#define FAIL(...) do { if (err_buf && err_len) snprintf(err_buf, err_len, __VA_ARGS__); goto fail; } while (0) + + usbredir_session *s = calloc(1, sizeof(*s)); + if (!s) { if (err_buf && err_len) snprintf(err_buf, err_len, "memoria esaurita"); return NULL; } + s->sock_fd = -1; + s->vid = vid; + s->pid = pid; + s->on_closed = on_closed; + s->user_ctx = user_ctx; + + int rc = libusb_init(&s->ctx); + if (rc < 0) FAIL("libusb_init: %s", libusb_error_name(rc)); + + libusb_device_handle *handle = libusb_open_device_with_vid_pid(s->ctx, vid, pid); + if (!handle) FAIL("device %04x:%04x non apribile: %s", vid, pid, kTccHint); + + s->sock_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (s->sock_fd < 0) { libusb_close(handle); FAIL("socket(): %s", strerror(errno)); } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + if (strlen(socket_path) >= sizeof(addr.sun_path)) { + libusb_close(handle); + FAIL("percorso socket troppo lungo (max %zu byte)", sizeof(addr.sun_path) - 1); + } + strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); + if (connect(s->sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + libusb_close(handle); + FAIL("connessione a %s fallita: %s", socket_path, strerror(errno)); + } + fcntl(s->sock_fd, F_SETFL, O_NONBLOCK); + + // Qui avviene la claim delle interfacce. usbredirhost_open prende + // possesso di `handle` e lo chiude da sé in caso di errore. + s->host = usbredirhost_open(s->ctx, handle, log_cb, read_cb, write_cb, + s, "UTM", usbredirparser_warning, 0); + if (!s->host) FAIL("claim delle interfacce negata: %s", kTccHint); + + s->running = 1; + if (pthread_create(&s->thread, NULL, pump_thread, s) != 0) { + s->running = 0; + FAIL("creazione del thread di pump fallita"); + } + return s; + +fail: + if (s) { + if (s->host) usbredirhost_close(s->host); + if (s->sock_fd >= 0) close(s->sock_fd); + // ANCHE sul fallimento: se siamo arrivati fin qui abbiamo aperto il + // device e, molto probabilmente, tentato (invano) il detach del + // driver kernel — che basta a lasciarlo monco. Senza questo reset + // l'utente si ritrova un disco appeso dopo un attach *fallito*. + reenumerate_device(s->ctx, vid, pid); + if (s->ctx) libusb_exit(s->ctx); + free(s); + } + return NULL; +#undef FAIL +} + +void usbredir_stop(usbredir_session *s) { + if (!s) return; + s->stop_requested = 1; + pthread_join(s->thread, NULL); // il rilascio avviene sul thread di pump + if (s->ctx) { libusb_exit(s->ctx); s->ctx = NULL; } + free(s); +} + +int usbredir_is_active(usbredir_session *s) { + return s && s->running; +} diff --git a/UTMUSBHelper/usbredir_bridge.h b/UTMUSBHelper/usbredir_bridge.h new file mode 100644 index 0000000000..828d252b93 --- /dev/null +++ b/UTMUSBHelper/usbredir_bridge.h @@ -0,0 +1,77 @@ +// +// C bridge between the privileged daemon (Swift) and libusbredirhost. +// +// Why C and not Swift: usbredirhost is a callback API over a +// `libusb_device_handle`, and the whole pump loop (poll on the socket + +// libusb_handle_events) has already been verified live in this shape — +// rewriting it in Swift would put risk back into the one part we know +// works. +// +// Vincolo verificato sul campo (§ memoria macos-usb-passthrough-constraints): +// serve root. Come utente normale libusb rifiuta la claim con +// "USB device capture requires either an entitlement +// (com.apple.vm.device-access) or root privilege". Questo bridge quindi +// gira SOLO dentro il LaunchDaemon. +// + +#ifndef USBREDIR_BRIDGE_H +#define USBREDIR_BRIDGE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct usbredir_session usbredir_session; + +/// Callback invocata quando la redirezione termina da sé — cioè quando il +/// lato guest (QEMU) chiude il socket: VM fermata, VM crashata, o QEMU +/// ucciso. NON viene invocata per una chiusura richiesta via +/// `usbredir_stop()`. +/// +/// ATTENZIONE: viene chiamata DAL THREAD DI PUMP. Il chiamante non deve +/// invocare `usbredir_stop()` in modo sincrono da qui (si auto-attenderebbe): +/// va sempre rimandata su un'altra coda. +typedef void (*usbredir_closed_cb)(void *user_ctx); + +/// Apre il device `vid:pid`, si connette al socket Unix dove QEMU è in +/// ascolto (`-chardev socket,server=on,wait=off`), fa la claim delle +/// interfacce ed esporta il device verso il guest. Ritorna immediatamente: +/// il traffico viene pompato su un thread dedicato. +/// +/// IMPORTANTE: per i dispositivi di archiviazione il volume deve essere +/// GIÀ stato smontato con successo dal chiamante prima di invocare questa +/// funzione — mai in parallelo, mai dopo. +/// +/// Ritorna NULL in caso di errore, scrivendo il messaggio in `err_buf`. +usbredir_session *usbredir_start(uint16_t vid, + uint16_t pid, + const char *socket_path, + usbredir_closed_cb on_closed, + void *user_ctx, + char *err_buf, + size_t err_len); + +/// Ferma la redirezione e restituisce il device al sistema. +/// +/// Non si limita a rilasciare le interfacce: forza anche una +/// re-enumerazione USB (`libusb_reset_device`). Senza quel reset macOS NON +/// ri-sonda il device e non riattacca il proprio driver — verificato dal +/// vivo: il disco resta enumerato via USB ma senza block device, con +/// `diskutil list` che si pianta, e l'unico rimedio sarebbe staccare il +/// cavo. È la differenza fra un distacco pulito e lasciare all'utente un +/// disco appeso. +/// +/// Idempotente e sicura anche se la sessione è già terminata da sé. +void usbredir_stop(usbredir_session *session); + +/// True (non-zero) se il thread di pump è ancora attivo. +int usbredir_is_active(usbredir_session *session); + +#ifdef __cplusplus +} +#endif + +#endif /* USBREDIR_BRIDGE_H */