diff --git a/Configuration/UTMQemuConfigurationQEMU.swift b/Configuration/UTMQemuConfigurationQEMU.swift index 3752ac624..14c6d2619 100644 --- a/Configuration/UTMQemuConfigurationQEMU.swift +++ b/Configuration/UTMQemuConfigurationQEMU.swift @@ -74,9 +74,6 @@ struct UTMQemuConfigurationQEMU: Codable { /// If true, all SPICE channels will be over TLS. Not saved. var isSpiceServerTlsEnabled: Bool = false - - /// Set to TLS public key for SPICE server in SubjectPublicKey. Not saved. - var spiceServerPublicKey: Data? /// Set to a password shared with the client. Not saved. var spiceServerPassword: String? diff --git a/Platform/macOS/UTMDataExtension.swift b/Platform/macOS/UTMDataExtension.swift index 3c6ad4b68..631d198ca 100644 --- a/Platform/macOS/UTMDataExtension.swift +++ b/Platform/macOS/UTMDataExtension.swift @@ -82,7 +82,7 @@ extension UTMData { /// - options: Start options /// - server: Remote server /// - Returns: Port number to SPICE server - func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> (port: UInt16, publicKey: Data, password: String) { + func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation { guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else { throw UTMDataError.unsupportedBackend } @@ -94,7 +94,10 @@ extension UTMData { } try await wrapped.start(options: options.union(.remoteSession)) vmWindows[vm] = session - return (wrapped.config.qemu.spiceServerPort!, wrapped.config.qemu.spiceServerPublicKey!, wrapped.config.qemu.spiceServerPassword!) + guard let spiceServerInfo = wrapped.spiceServerInfo else { + throw UTMDataError.unsupportedBackend + } + return spiceServerInfo } func stop(vm: VMData) { diff --git a/Remote/UTMRemoteClient.swift b/Remote/UTMRemoteClient.swift index e7b017d0f..581047143 100644 --- a/Remote/UTMRemoteClient.swift +++ b/Remote/UTMRemoteClient.swift @@ -356,9 +356,8 @@ extension UTMRemoteClient { return fileUrl } - func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> (port: UInt16, publicKey: Data, password: String) { - let reply = try await _startVirtualMachine(parameters: .init(id: id, options: options)) - return (reply.spiceServerPort, reply.spiceServerPublicKey, reply.spiceServerPassword) + func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UTMRemoteMessageServer.StartVirtualMachine.ServerInformation { + return try await _startVirtualMachine(parameters: .init(id: id, options: options)).serverInfo } func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws { diff --git a/Remote/UTMRemoteMessage.swift b/Remote/UTMRemoteMessage.swift index 6465c63df..38534ae8b 100644 --- a/Remote/UTMRemoteMessage.swift +++ b/Remote/UTMRemoteMessage.swift @@ -130,10 +130,16 @@ extension UTMRemoteMessageServer { let options: UTMVirtualMachineStartOptions } + struct ServerInformation: Serializable, Codable { + let spicePortInternal: UInt16 + let spicePortExternal: UInt16? + let spiceHostExternal: String? + let spicePublicKey: Data + let spicePassword: String + } + struct Reply: Serializable, Codable { - let spiceServerPort: UInt16 - let spiceServerPublicKey: Data - let spiceServerPassword: String + let serverInfo: ServerInformation } } diff --git a/Remote/UTMRemoteServer.swift b/Remote/UTMRemoteServer.swift index 25cab9e62..26cfe6ce5 100644 --- a/Remote/UTMRemoteServer.swift +++ b/Remote/UTMRemoteServer.swift @@ -712,8 +712,8 @@ extension UTMRemoteServer { private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply { let vm = try await findVM(withId: parameters.id) - let (port, publicKey, password) = try await data.startRemote(vm: vm, options: parameters.options, forClient: client) - return .init(spiceServerPort: port, spiceServerPublicKey: publicKey, spiceServerPassword: password) + let serverInfo = try await data.startRemote(vm: vm, options: parameters.options, forClient: client) + return .init(serverInfo: serverInfo) } private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply { diff --git a/Remote/UTMRemoteSpiceVirtualMachine.swift b/Remote/UTMRemoteSpiceVirtualMachine.swift index 2a99ed4e4..bfbd23fdf 100644 --- a/Remote/UTMRemoteSpiceVirtualMachine.swift +++ b/Remote/UTMRemoteSpiceVirtualMachine.swift @@ -152,6 +152,33 @@ extension UTMRemoteSpiceVirtualMachine { } extension UTMRemoteSpiceVirtualMachine { + private func connect(_ serverInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation, options: UTMSpiceIOOptions, remoteConnection: Bool) async throws -> UTMSpiceIO { + let ioService = UTMSpiceIO(host: remoteConnection ? serverInfo.spiceHostExternal! : server.host, + tlsPort: Int(remoteConnection ? serverInfo.spicePortExternal! : serverInfo.spicePortInternal), + serverPublicKey: serverInfo.spicePublicKey, + password: serverInfo.spicePassword, + options: options) + ioService.logHandler = { (line: String) -> Void in + guard !line.contains("spice_make_scancode") else { + return // do not log key presses for privacy reasons + } + NSLog("%@", line) // FIXME: log to file + } + try ioService.start() + let coordinator = ConnectCoordinator() + try await withCheckedThrowingContinuation { continuation in + coordinator.continuation = continuation + ioService.connectDelegate = coordinator + do { + try ioService.connect() + } catch { + ioService.connectDelegate = nil + continuation.resume(throwing: error) + } + } + return ioService + } + func start(options: UTMVirtualMachineStartOptions) async throws { try await _state.operation(before: .stopped, during: .starting, after: .started) { let spiceServer = try await server.startVirtualMachine(id: id, options: options) @@ -170,30 +197,16 @@ extension UTMRemoteSpiceVirtualMachine { options.insert(.hasDebugLog) } #endif - let ioService = UTMSpiceIO(host: server.host, - tlsPort: Int(spiceServer.port), - serverPublicKey: spiceServer.publicKey, - password: spiceServer.password, - options: options) - ioService.logHandler = { (line: String) -> Void in - guard !line.contains("spice_make_scancode") else { - return // do not log key presses for privacy reasons - } - NSLog("%@", line) // FIXME: log to file - } - try ioService.start() - let coordinator = ConnectCoordinator() - try await withCheckedThrowingContinuation { continuation in - coordinator.continuation = continuation - ioService.connectDelegate = coordinator - do { - try ioService.connect() - } catch { - ioService.connectDelegate = nil - continuation.resume(throwing: error) + do { + self.ioService = try await connect(spiceServer, options: options, remoteConnection: false) + } catch { + if spiceServer.spiceHostExternal != nil && spiceServer.spicePortExternal != nil { + // retry with external port + self.ioService = try await connect(spiceServer, options: options, remoteConnection: true) + } else { + throw error } } - self.ioService = ioService } } diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift index 087815b77..46c73e17a 100644 --- a/Services/UTMQemuVirtualMachine.swift +++ b/Services/UTMQemuVirtualMachine.swift @@ -16,6 +16,9 @@ import Foundation import QEMUKit +#if os(macOS) +import SwiftPortmap +#endif private var SpiceIoServiceGuestAgentContext = 0 private let kSuspendSnapshotName = "suspend" @@ -152,6 +155,11 @@ final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine { private var changeCursorRequestInProgress: Bool = false + #if WITH_SERVER + private var spicePort: SwiftPortmap.Port? + private(set) var spiceServerInfo: UTMRemoteMessageServer.StartVirtualMachine.ServerInformation? + #endif + @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration, isShortcut: Bool = false) throws { self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource() // load configuration @@ -275,13 +283,21 @@ extension UTMQemuVirtualMachine { } let isRunningAsDisposible = options.contains(.bootDisposibleMode) let isRemoteSession = options.contains(.remoteSession) - let spicePort = isRemoteSession ? try UTMSocketUtils.reservePort() : nil + #if WITH_SERVER let spicePassword = isRemoteSession ? String.random(length: 32) : nil + let spicePort = isRemoteSession ? try SwiftPortmap.Port.TCP() : nil + #else + if isRemoteSession { + throw UTMVirtualMachineError.notImplemented + } + #endif await MainActor.run { config.qemu.isDisposable = isRunningAsDisposible - config.qemu.spiceServerPort = spicePort - config.qemu.isSpiceServerTlsEnabled = true + #if WITH_SERVER + config.qemu.spiceServerPort = spicePort?.internalPort config.qemu.spiceServerPassword = spicePassword + config.qemu.isSpiceServerTlsEnabled = true + #endif } // start TPM @@ -331,6 +347,7 @@ extension UTMQemuVirtualMachine { #endif let spiceSocketUrl = await config.spiceSocketURL let interface: any QEMUInterface + let spicePublicKey: Data? if isRemoteSession { let pipeInterface = UTMPipeInterface() await MainActor.run { @@ -351,9 +368,7 @@ extension UTMQemuVirtualMachine { } try await key[1].write(to: config.spiceTlsKeyUrl) try await key[2].write(to: config.spiceTlsCertUrl) - await MainActor.run { - config.qemu.spiceServerPublicKey = key[3] - } + spicePublicKey = key[3] } else { let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options) ioService.logHandler = { [weak system] (line: String) -> Void in @@ -364,6 +379,7 @@ extension UTMQemuVirtualMachine { } try ioService.start() interface = ioService + spicePublicKey = nil } try Task.checkCancellation() @@ -408,6 +424,18 @@ extension UTMQemuVirtualMachine { // test out snapshots self.snapshotUnsupportedError = await determineSnapshotSupport() + + #if WITH_SERVER + // save server details + if let spicePort = spicePort, let spicePublicKey = spicePublicKey, let spicePassword = spicePassword { + self.spiceServerInfo = .init(spicePortInternal: spicePort.internalPort, + spicePortExternal: try? await spicePort.externalPort, + spiceHostExternal: try? await spicePort.externalIpv4Address, + spicePublicKey: spicePublicKey, + spicePassword: spicePassword) + self.spicePort = spicePort + } + #endif } func start(options: UTMVirtualMachineStartOptions = []) async throws { @@ -629,6 +657,10 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate { } func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) { + #if WITH_SERVER + spicePort = nil + spiceServerInfo = nil + #endif swtpm?.stop() swtpm = nil ioService = nil diff --git a/Services/UTMSocketUtils.swift b/Services/UTMSocketUtils.swift deleted file mode 100644 index 269adaba8..000000000 --- a/Services/UTMSocketUtils.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// Copyright © 2024 osy. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Darwin - -struct UTMSocketUtils { - /// Reserve an ephemeral port from the system - /// - /// First we `bind` to port 0 in order to allocate an ephemeral port. - /// Next, we `connect` to that port to establish a connection. - /// Finally, we close the port and put it into the `TIME_WAIT` state. - /// - /// This allows another process to `bind` the port with `SO_REUSEADDR` specified. - /// However, for the next ~120 seconds, the system will not re-use this port. - /// - Returns: A port number that is valid for ~120 seconds. - static func reservePort() throws -> UInt16 { - let serverSock = socket(AF_INET, SOCK_STREAM, 0) - guard serverSock >= 0 else { - throw SocketError.cannotReservePort(errno) - } - defer { - close(serverSock) - } - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_addr.s_addr = INADDR_ANY - addr.sin_port = 0 // request an ephemeral port - - var len = socklen_t(MemoryLayout.stride) - let res = withUnsafeMutablePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - let res1 = bind(serverSock, $0, len) - let res2 = getsockname(serverSock, $0, &len) - return (res1, res2) - } - } - guard res.0 == 0 && res.1 == 0 else { - throw SocketError.cannotReservePort(errno) - } - - guard listen(serverSock, 1) == 0 else { - throw SocketError.cannotReservePort(errno) - } - - let clientSock = socket(AF_INET, SOCK_STREAM, 0) - guard clientSock >= 0 else { - throw SocketError.cannotReservePort(errno) - } - defer { - close(clientSock) - } - let res3 = withUnsafeMutablePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(clientSock, $0, len) - } - } - guard res3 == 0 else { - throw SocketError.cannotReservePort(errno) - } - - let acceptSock = accept(serverSock, nil, nil) - guard acceptSock >= 0 else { - throw SocketError.cannotReservePort(errno) - } - defer { - close(acceptSock) - } - return addr.sin_port.byteSwapped - } -} - -extension UTMSocketUtils { - enum SocketError: LocalizedError { - case cannotReservePort(Int32) - - var errorDescription: String? { - switch self { - case .cannotReservePort(_): - return NSLocalizedString("Cannot reserve an unused port on this system.", comment: "UTMSocketUtils") - } - } - } -} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index af3f6f5f6..3df4faa64 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -1199,9 +1199,6 @@ CEF83F8D250094E700557D15 /* gthread-2.0.0.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE2D63DC22653C7300FC7E63 /* gthread-2.0.0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CEF83F8E250094EC00557D15 /* gpg-error.0.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE2D63F122653C7400FC7E63 /* gpg-error.0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CEF83F8F250094EE00557D15 /* gcrypt.20.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = CE2D63F322653C7400FC7E63 /* gcrypt.20.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - CEFE96722B699954000F00C9 /* UTMSocketUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFE96712B699954000F00C9 /* UTMSocketUtils.swift */; }; - CEFE96732B699954000F00C9 /* UTMSocketUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFE96712B699954000F00C9 /* UTMSocketUtils.swift */; }; - CEFE96752B699954000F00C9 /* UTMSocketUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFE96712B699954000F00C9 /* UTMSocketUtils.swift */; }; CEFE96772B69A7CC000F00C9 /* VMRemoteSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFE96762B69A7CC000F00C9 /* VMRemoteSessionState.swift */; }; CEFE98DF29485237007CB7A8 /* UTM.sdef in Resources */ = {isa = PBXBuildFile; fileRef = CEFE98DE29485237007CB7A8 /* UTM.sdef */; }; CEFE98E129485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */; }; @@ -2028,7 +2025,6 @@ CEF6F5EC26DDD65700BC434D /* QEMULauncher-unsigned.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "QEMULauncher-unsigned.entitlements"; sourceTree = ""; }; CEF7F6D32AEEDCC400E34952 /* UTM Remote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "UTM Remote.app"; sourceTree = BUILT_PRODUCTS_DIR; }; CEF84ADA2887D7D300578F41 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - CEFE96712B699954000F00C9 /* UTMSocketUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSocketUtils.swift; sourceTree = ""; }; CEFE96762B69A7CC000F00C9 /* VMRemoteSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMRemoteSessionState.swift; sourceTree = ""; }; CEFE98DE29485237007CB7A8 /* UTM.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = UTM.sdef; sourceTree = ""; }; CEFE98E029485776007CB7A8 /* UTMScriptingVirtualMachineImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMScriptingVirtualMachineImpl.swift; sourceTree = ""; }; @@ -2781,7 +2777,6 @@ 841E997828AA119B003C6CB6 /* UTMRegistryEntry.swift */, 848F71E7277A2A4E006A0240 /* UTMSerialPort.swift */, 848F71EB277A2F47006A0240 /* UTMSerialPortDelegate.swift */, - CEFE96712B699954000F00C9 /* UTMSocketUtils.swift */, E2D64BC7241DB24B0034E0C6 /* UTMSpiceIO.h */, E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */, E2D64BE0241EAEBE0034E0C6 /* UTMSpiceIODelegate.h */, @@ -3580,7 +3575,6 @@ 8471770627CC974F00D3A50B /* DefaultTextField.swift in Sources */, 84E6F6FD289319AE00080EEF /* VMToolbarDisplayMenuView.swift in Sources */, CE9B15472B12A87E003A32DD /* GenerateKey.c in Sources */, - CEFE96722B699954000F00C9 /* UTMSocketUtils.swift in Sources */, CE8813D324CD230300532628 /* ActivityView.swift in Sources */, CEDF83F9258AE24E0030E4AC /* UTMPasteboard.swift in Sources */, 848D99B4286300160055C215 /* QEMUArgument.swift in Sources */, @@ -3603,7 +3597,6 @@ files = ( CEE06B272B2FC89400A811AE /* UTMServerView.swift in Sources */, CEB63A7724F4654400CAF323 /* Main.swift in Sources */, - CEFE96752B699954000F00C9 /* UTMSocketUtils.swift in Sources */, 84E3A91B2946D2590024A740 /* UTMMenuBarExtraScene.swift in Sources */, CEB63A7B24F469E300CAF323 /* UTMJailbreak.m in Sources */, 83A004BB26A8CC95001AC09E /* UTMDownloadTask.swift in Sources */, @@ -3894,7 +3887,6 @@ CEF01DB32B6724A300725A0F /* UTMSpiceVirtualMachine.swift in Sources */, 843232B828C4816100CFBC97 /* UTMDownloadSupportToolsTask.swift in Sources */, CEF0306526A2AFDF00667B63 /* VMWizardOSLinuxView.swift in Sources */, - CEFE96732B699954000F00C9 /* UTMSocketUtils.swift in Sources */, CEA45EC3263519B5002FA97D /* VMCommands.swift in Sources */, 84CE3DB22904C7A100FF068B /* UTMSettingsView.swift in Sources */, 8443EFF32845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */, @@ -4483,6 +4475,7 @@ ENABLE_PREVIEWS = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "WITH_JIT=1", + "WITH_SERVER=1", "WITH_USB=1", "$(inherited)", ); @@ -4495,7 +4488,7 @@ PRODUCT_NAME = "$(PROJECT_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER_MAC:default=)"; SUPPORTED_PLATFORMS = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WITH_JIT WITH_USB $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WITH_JIT WITH_SERVER WITH_USB $(inherited)"; SWIFT_OBJC_BRIDGING_HEADER = "Services/Swift-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -4516,6 +4509,7 @@ ENABLE_PREVIEWS = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "WITH_JIT=1", + "WITH_SERVER=1", "WITH_USB=1", "$(inherited)", ); @@ -4528,7 +4522,7 @@ PRODUCT_NAME = "$(PROJECT_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER_MAC:default=)"; SUPPORTED_PLATFORMS = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WITH_JIT WITH_USB $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WITH_JIT WITH_SERVER WITH_USB $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OBJC_BRIDGING_HEADER = "Services/Swift-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-O";