UTM/Remote/UTMRemoteServer.swift

982 lines
42 KiB
Swift

//
// Copyright © 2023 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 Foundation
import Combine
import Network
import SwiftConnect
import SwiftPortmap
import UserNotifications
let service = "_utm_server._tcp"
actor UTMRemoteServer {
fileprivate let data: UTMData
private let keyManager = UTMRemoteKeyManager(forClient: false)
private let center = UNUserNotificationCenter.current()
private let connectionQueue = DispatchQueue(label: "UTM Remote Server Connection")
let state: State
private var cancellables = Set<AnyCancellable>()
private var notificationDelegate: NotificationDelegate?
private var listener: Task<Void, Error>?
private var pendingConnections: [State.ClientFingerprint: Connection] = [:]
private var establishedConnections: [State.ClientFingerprint: Remote] = [:]
private var natPort: SwiftPortmap.Port?
private func _replaceCancellables(with set: Set<AnyCancellable>) {
cancellables = set
}
@Setting("ServerAutostart") private var isServerAutostart: Bool = false
@Setting("ServerExternal") private var isServerExternal: Bool = false
@Setting("ServerAutoblock") private var isServerAutoblock: Bool = false
@Setting("ServerPort") private var serverPort: Int = 0
@Setting("ServerPasswordRequired") private var isServerPasswordRequired: Bool = false
@Setting("ServerPassword") private var serverPassword: String = ""
@MainActor
init(data: UTMData) {
let _state = State()
var _cancellables = Set<AnyCancellable>()
self.data = data
self.state = _state
_cancellables.insert(_state.$approvedClients.sink { approved in
Task {
await self.approvedClientsHasChanged(approved)
}
})
_cancellables.insert(_state.$blockedClients.sink { blocked in
Task {
await self.blockedClientsHasChanged(blocked)
}
})
_cancellables.insert(_state.$connectedClients.sink { connected in
Task {
await self.connectedClientsHasChanged(connected)
}
})
_cancellables.insert(_state.$serverAction.sink { action in
guard action != .none else {
return
}
Task {
switch action {
case .stop:
await self.stop()
break
case .start:
await self.start()
break
case .reset:
await self.resetServer()
break
default:
break
}
self.state.requestServerAction(.none)
}
})
// this is a really ugly way to make sure that we keep a reference to the AnyCancellables even though
// we cannot access self._cancellables from init() due to it being associated with @MainActor.
// it should be fine because we only need to make sure the references are not dropped, we will never
// actually read from _cancellables
Task {
await self._replaceCancellables(with: _cancellables)
}
}
private func withErrorNotification(_ body: () async throws -> Void) async {
do {
try await body()
} catch {
if case .silentError(let error) = error as? ServerError {
logger.error("Error message inhibited: \(error)")
} else {
await notifyError(error)
}
}
}
private var metadata: NWTXTRecord {
NWTXTRecord(["Model": MacDevice.current.model])
}
func start() async {
do {
try await center.requestAuthorization(options: .alert)
} catch {
logger.error("Failed to authorize notifications.")
}
await withErrorNotification {
guard await !state.isServerActive else {
return
}
try await keyManager.load()
await state.setServerFingerprint(keyManager.fingerprint!)
registerNotifications()
listener = Task {
await withErrorNotification {
if isServerExternal && serverPort > 0 {
natPort = Port.TCP(internalPort: UInt16(serverPort))
natPort!.mappingChangedHandler = { port in
Task {
let address = try? await port.externalIpv4Address
let port = try? await port.externalPort
await self.state.setExternalAddress(address, port: port)
}
}
await withErrorNotification {
guard try await natPort!.externalPort == serverPort else {
throw ServerError.natReservationMismatch(serverPort)
}
}
}
let port = serverPort > 0 ? NWEndpoint.Port(integerLiteral: UInt16(serverPort)) : .any
for try await connection in Connection.advertise(on: port, forServiceType: service, txtRecord: metadata, connectionQueue: connectionQueue, identity: keyManager.identity) {
let connection = try? await Connection(connection: connection, connectionQueue: connectionQueue) { connection, error in
Task {
guard let fingerprint = connection.fingerprint else {
return
}
if !(error is NWError) {
// connection errors are too noisy
await self.notifyError(error)
}
await self.state.disconnect(fingerprint)
}
}
if let connection = connection {
await newRemoteConnection(connection)
}
}
}
natPort = nil
await stop()
}
await state.setServerActive(true)
}
}
func stop() async {
await state.disconnectAll()
unregisterNotifications()
if let listener = listener {
self.listener = nil
listener.cancel()
_ = await listener.result
}
await state.setExternalAddress()
await state.setServerActive(false)
}
private func newRemoteConnection(_ connection: Connection) async {
let remoteAddress = connection.connection.endpoint.hostname ?? "\(connection.connection.endpoint)"
guard let fingerprint = connection.fingerprint else {
connection.close()
return
}
guard await !state.isBlocked(fingerprint) else {
connection.close()
return
}
await state.seen(fingerprint, name: remoteAddress)
if await state.isApproved(fingerprint) {
await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint)
await establishConnection(connection)
} else if isServerAutoblock {
await state.block(fingerprint)
connection.close()
} else {
pendingConnections[fingerprint] = connection
await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint, isUnknown: true)
}
}
private func approvedClientsHasChanged(_ approvedClients: Set<State.Client>) async {
for approvedClient in approvedClients {
if let connection = pendingConnections.removeValue(forKey: approvedClient.fingerprint) {
await establishConnection(connection)
}
}
}
private func blockedClientsHasChanged(_ blockedClients: Set<State.Client>) {
for blockedClient in blockedClients {
if let connection = pendingConnections.removeValue(forKey: blockedClient.fingerprint) {
connection.close()
}
}
}
private func connectedClientsHasChanged(_ connectedClients: Set<State.ClientFingerprint>) {
for client in establishedConnections.keys {
if !connectedClients.contains(client) {
if let remote = establishedConnections.removeValue(forKey: client) {
remote.close()
Task { @MainActor in
await suspendSessions(for: remote)
}
}
}
}
}
@MainActor
private func suspendSessions(for remote: Remote) async {
let sessions = data.vmWindows.compactMap {
if let session = $0.value as? VMRemoteSessionState {
return ($0.key, session)
} else {
return nil
}
}
await withTaskGroup(of: Void.self) { group in
for (vm, session) in sessions {
if session.client?.id == remote.id {
session.client = nil
}
group.addTask {
try? await vm.wrapped?.pause()
}
}
await group.waitForAll()
}
}
private func establishConnection(_ connection: Connection) async {
guard let fingerprint = connection.fingerprint else {
connection.close()
return
}
await withErrorNotification {
let remote = Remote()
let local = Local(server: self, client: remote)
let peer = Peer(connection: connection, localInterface: local)
remote.peer = peer
do {
try await remote.handshake()
} catch {
if let error = error as? NWError, case .posix(let code) = error, code == .ECONNRESET {
// if the user canceled the connection, we don't do anything
throw ServerError.silentError(error)
}
peer.close()
throw error
}
establishedConnections.updateValue(remote, forKey: fingerprint)
await state.connect(fingerprint)
}
}
private func resetServer() async {
await withErrorNotification {
try await keyManager.reset()
await state.setServerFingerprint(keyManager.fingerprint!)
}
}
/// Send message to every connected remote client.
///
/// If any are disconnected, we will gracefully handle the disconnect.
/// If `body` throws an error for any remote client (excluding NWError), then we ignore it.
/// - Parameter body: What to broadcast
func broadcast(_ body: @escaping (Remote) async throws -> Void) async {
enum BroadcastError: Error {
case connectionError(NWError, State.ClientFingerprint)
}
await withThrowingTaskGroup(of: Void.self) { group in
for (fingerprint, remote) in establishedConnections {
if Task.isCancelled {
break
}
group.addTask {
do {
try await body(remote)
} catch {
if let error = error as? NWError {
throw BroadcastError.connectionError(error, fingerprint)
} else {
throw error
}
}
}
}
while !group.isEmpty {
switch await group.nextResult() {
case .failure(let error):
if case BroadcastError.connectionError(_, let fingerprint) = error {
// disconnect any clients who failed to respond
await state.disconnect(fingerprint)
} else {
logger.error("client returned error on broadcast: \(error)")
}
default:
break
}
}
}
}
}
extension UTMRemoteServer {
private class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
private let state: UTMRemoteServer.State
init(state: UTMRemoteServer.State) {
self.state = state
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
.banner
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
Task {
let userInfo = response.notification.request.content.userInfo
guard let hexString = userInfo["FINGERPRINT"] as? String, let fingerprint = State.ClientFingerprint(hexString: hexString) else {
return
}
switch response.actionIdentifier {
case "ALLOW_ACTION":
await state.approve(fingerprint)
case "DENY_ACTION":
await state.block(fingerprint)
case "DISCONNECT_ACTION":
await state.disconnect(fingerprint)
default:
break
}
completionHandler()
}
}
}
private func registerNotifications() {
let allowAction = UNNotificationAction(identifier: "ALLOW_ACTION",
title: NSString.localizedUserNotificationString(forKey: "Allow", arguments: nil),
options: [])
let denyAction = UNNotificationAction(identifier: "DENY_ACTION",
title: NSString.localizedUserNotificationString(forKey: "Deny", arguments: nil),
options: [])
let disconnectAction = UNNotificationAction(identifier: "DISCONNECT_ACTION",
title: NSString.localizedUserNotificationString(forKey: "Disconnect", arguments: nil),
options: [])
let unknownRemoteCategory = UNNotificationCategory(identifier: "UNKNOWN_REMOTE_CLIENT",
actions: [denyAction, allowAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New unknown remote client connection.", arguments: nil),
options: .customDismissAction)
let trustedRemoteCategory = UNNotificationCategory(identifier: "TRUSTED_REMOTE_CLIENT",
actions: [disconnectAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSString.localizedUserNotificationString(forKey: "New trusted remote client connection.", arguments: nil),
options: [])
center.setNotificationCategories([unknownRemoteCategory, trustedRemoteCategory])
notificationDelegate = NotificationDelegate(state: state)
center.delegate = notificationDelegate
}
private func unregisterNotifications() {
center.setNotificationCategories([])
notificationDelegate = nil
center.delegate = nil
}
private func notifyNewConnection(remoteAddress: String, fingerprint: State.ClientFingerprint, isUnknown: Bool = false) async {
let settings = await center.notificationSettings()
let combinedFingerprint = (fingerprint ^ keyManager.fingerprint!).hexString()
guard settings.authorizationStatus == .authorized else {
logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(combinedFingerprint)'")
return
}
let content = UNMutableNotificationContent()
if isUnknown {
content.title = NSString.localizedUserNotificationString(forKey: "Unknown Remote Client", arguments: nil)
content.body = NSString.localizedUserNotificationString(forKey: "A client with fingerprint '%@' is attempting to connect.", arguments: [combinedFingerprint])
content.categoryIdentifier = "UNKNOWN_REMOTE_CLIENT"
} else {
content.title = NSString.localizedUserNotificationString(forKey: "Remote Client Connected", arguments: nil)
content.body = NSString.localizedUserNotificationString(forKey: "Established connection from %@.", arguments: [remoteAddress])
content.categoryIdentifier = "TRUSTED_REMOTE_CLIENT"
}
let clientFingerprint = fingerprint.hexString()
content.userInfo = ["FINGERPRINT": clientFingerprint]
let request = UNNotificationRequest(identifier: clientFingerprint,
content: content,
trigger: nil)
do {
try await center.add(request)
if !isUnknown {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) {
self.center.removeDeliveredNotifications(withIdentifiers: [clientFingerprint])
}
}
} catch {
logger.error("Error sending remote connection request: \(error.localizedDescription)")
}
}
fileprivate func notifyError(_ error: Error) async {
logger.error("UTM Remote Server error: '\(error)'")
let settings = await center.notificationSettings()
guard settings.authorizationStatus == .authorized else {
return
}
let content = UNMutableNotificationContent()
content.title = NSString.localizedUserNotificationString(forKey: "UTM Remote Server Error", arguments: nil)
content.body = error.localizedDescription
let request = UNNotificationRequest(identifier: UUID().uuidString,
content: content,
trigger: nil)
do {
try await center.add(request)
} catch {
logger.error("Error sending error notification: \(error.localizedDescription)")
}
}
}
extension UTMRemoteServer {
@MainActor
class State: ObservableObject {
typealias ClientFingerprint = [UInt8]
typealias ServerFingerprint = [UInt8]
struct Client: Codable, Identifiable, Hashable {
let fingerprint: ClientFingerprint
var name: String
var lastSeen: Date
var id: ClientFingerprint {
fingerprint
}
func hash(into hasher: inout Hasher) {
hasher.combine(fingerprint)
}
static func == (lhs: Client, rhs: Client) -> Bool {
lhs.hashValue == rhs.hashValue
}
}
enum ServerAction {
case none
case stop
case start
case reset
}
@Published var allClients: [Client] {
didSet {
let all = Set(allClients)
approvedClients.subtract(approvedClients.subtracting(all))
blockedClients.subtract(blockedClients.subtracting(all))
connectedClients.subtract(connectedClients.subtracting(all.map({ $0.fingerprint })))
}
}
@Published var approvedClients: Set<Client> {
didSet {
UserDefaults.standard.setValue(try! approvedClients.propertyList(), forKey: "TrustedClients")
}
}
@Published var blockedClients: Set<Client> {
didSet {
UserDefaults.standard.setValue(try! blockedClients.propertyList(), forKey: "BlockedClients")
}
}
@Published var connectedClients = Set<ClientFingerprint>()
@Published var serverAction: ServerAction = .none
var isBusy: Bool {
serverAction != .none
}
@Published private(set) var isServerActive = false
@Published private(set) var serverFingerprint: ServerFingerprint = [] {
didSet {
UserDefaults.standard.setValue(serverFingerprint.hexString(), forKey: "ServerFingerprint")
}
}
@Published private(set) var externalIPAddress: String?
@Published private(set) var externalPort: UInt16?
init() {
var _approvedClients = Set<Client>()
if let array = UserDefaults.standard.array(forKey: "TrustedClients") {
if let clients = try? Set<Client>(fromPropertyList: array) {
_approvedClients = clients
}
}
self.approvedClients = _approvedClients
var _blockedClients = Set<Client>()
if let array = UserDefaults.standard.array(forKey: "BlockedClients") {
if let clients = try? Set<Client>(fromPropertyList: array) {
_blockedClients = clients
}
}
self.blockedClients = _blockedClients
self.allClients = Array(_approvedClients) + Array(_blockedClients)
if let value = UserDefaults.standard.string(forKey: "ServerFingerprint"), let serverFingerprint = ServerFingerprint(hexString: value) {
self.serverFingerprint = serverFingerprint
}
}
func isConnected(_ fingerprint: ClientFingerprint) -> Bool {
connectedClients.contains(fingerprint)
}
func isApproved(_ fingerprint: ClientFingerprint) -> Bool {
approvedClients.contains(where: { $0.fingerprint == fingerprint }) && !isBlocked(fingerprint)
}
func isBlocked(_ fingerprint: ClientFingerprint) -> Bool {
blockedClients.contains(where: { $0.fingerprint == fingerprint })
}
fileprivate func setServerActive(_ isActive: Bool) {
isServerActive = isActive
}
func requestServerAction(_ action: ServerAction) {
serverAction = action
}
private func client(forFingerprint fingerprint: ClientFingerprint, name: String? = nil) -> (Int?, Client) {
if let idx = allClients.firstIndex(where: { $0.fingerprint == fingerprint }) {
if let name = name {
allClients[idx].name = name
}
return (idx, allClients[idx])
} else {
return (nil, Client(fingerprint: fingerprint, name: name ?? "", lastSeen: Date()))
}
}
func seen(_ fingerprint: ClientFingerprint, name: String? = nil) {
var (idx, client) = client(forFingerprint: fingerprint, name: name)
client.lastSeen = Date()
if let idx = idx {
allClients[idx] = client
} else {
allClients.append(client)
}
}
fileprivate func connect(_ fingerprint: ClientFingerprint, name: String? = nil) {
connectedClients.insert(fingerprint)
}
func disconnect(_ fingerprint: ClientFingerprint) {
connectedClients.remove(fingerprint)
}
func disconnectAll() {
connectedClients.removeAll()
}
func approve(_ fingerprint: ClientFingerprint) {
let (_, client) = client(forFingerprint: fingerprint)
approvedClients.insert(client)
blockedClients.remove(client)
}
func block(_ fingerprint: ClientFingerprint) {
let (_, client) = client(forFingerprint: fingerprint)
approvedClients.remove(client)
blockedClients.insert(client)
}
fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) {
serverFingerprint = fingerprint
}
fileprivate func setExternalAddress(_ address: String? = nil, port: UInt16? = nil) {
externalIPAddress = address
externalPort = port
}
}
}
extension UTMRemoteServer {
class Local: LocalInterface {
typealias M = UTMRemoteMessageServer
private let server: UTMRemoteServer
private let client: UTMRemoteServer.Remote
private var isAuthenticated: Bool = false
private var data: UTMData {
server.data
}
init(server: UTMRemoteServer, client: UTMRemoteServer.Remote) {
self.server = server
self.client = client
}
func handle(message: M, data: Data) async throws -> Data {
guard isAuthenticated || message == .serverHandshake else {
throw ServerError.notAuthenticated
}
switch message {
case .serverHandshake:
return try await _handshake(parameters: .decode(data)).encode()
case .listVirtualMachines:
return try await _listVirtualMachines(parameters: .decode(data)).encode()
case .reorderVirtualMachines:
return try await _reorderVirtualMachines(parameters: .decode(data)).encode()
case .getVirtualMachineInformation:
return try await _getVirtualMachineInformation(parameters: .decode(data)).encode()
case .getQEMUConfiguration:
return try await _getQEMUConfiguration(parameters: .decode(data)).encode()
case .getPackageSize:
return try await _getPackageSize(parameters: .decode(data)).encode()
case .getPackageFile:
return try await _getPackageFile(parameters: .decode(data)).encode()
case .sendPackageFile:
return try await _sendPackageFile(parameters: .decode(data)).encode()
case .deletePackageFile:
return try await _deletePackageFile(parameters: .decode(data)).encode()
case .mountGuestToolsOnVirtualMachine:
return try await _mountGuestToolsOnVirtualMachine(parameters: .decode(data)).encode()
case .startVirtualMachine:
return try await _startVirtualMachine(parameters: .decode(data)).encode()
case .stopVirtualMachine:
return try await _stopVirtualMachine(parameters: .decode(data)).encode()
case .restartVirtualMachine:
return try await _restartVirtualMachine(parameters: .decode(data)).encode()
case .pauseVirtualMachine:
return try await _pauseVirtualMachine(parameters: .decode(data)).encode()
case .resumeVirtualMachine:
return try await _resumeVirtualMachine(parameters: .decode(data)).encode()
case .saveSnapshotVirtualMachine:
return try await _saveSnapshotVirtualMachine(parameters: .decode(data)).encode()
case .deleteSnapshotVirtualMachine:
return try await _deleteSnapshotVirtualMachine(parameters: .decode(data)).encode()
case .restoreSnapshotVirtualMachine:
return try await _restoreSnapshotVirtualMachine(parameters: .decode(data)).encode()
case .changePointerTypeVirtualMachine:
return try await _changePointerTypeVirtualMachine(parameters: .decode(data)).encode()
}
}
@MainActor
private func findVM(withId id: UUID) throws -> VMData {
let vm = data.virtualMachines.first(where: { $0.id == id })
if let vm = vm, let _ = vm.wrapped {
return vm
} else {
throw UTMRemoteServer.ServerError.notFound(id)
}
}
@MainActor
private func packageFileHasChanged(for vm: VMData, relativePathComponents: [String]) throws {
if relativePathComponents.count == 1 && relativePathComponents[0] == kUTMBundleScreenshotFilename {
try vm.wrapped?.reloadScreenshotFromFile()
}
}
private func _handshake(parameters: M.ServerHandshake.Request) async throws -> M.ServerHandshake.Reply {
let serverPassword = await server.serverPassword
if await server.isServerPasswordRequired && !serverPassword.isEmpty {
if serverPassword == parameters.password {
isAuthenticated = true
}
} else {
isAuthenticated = true
}
return .init(version: UTMRemoteMessageServer.version, isAuthenticated: isAuthenticated, capabilities: .current, model: MacDevice.current.model)
}
private func _listVirtualMachines(parameters: M.ListVirtualMachines.Request) async throws -> M.ListVirtualMachines.Reply {
let ids = await Task { @MainActor in
data.virtualMachines.map({ $0.id })
}.value
return .init(ids: ids)
}
private func _reorderVirtualMachines(parameters: M.ReorderVirtualMachines.Request) async throws -> M.ReorderVirtualMachines.Reply {
await Task { @MainActor in
let vms = data.virtualMachines
let source = parameters.ids.reduce(into: IndexSet(), { indexSet, id in
if let index = vms.firstIndex(where: { $0.id == id }) {
indexSet.insert(index)
}
})
let destination = min(max(0, parameters.offset), vms.count)
data.listMove(fromOffsets: source, toOffset: destination)
return .init()
}.value
}
private func _getVirtualMachineInformation(parameters: M.GetVirtualMachineInformation.Request) async throws -> M.GetVirtualMachineInformation.Reply {
let informations = try await Task { @MainActor in
try parameters.ids.map { id in
let vm = try findVM(withId: id)
let mountedDrives = vm.registryEntry?.externalDrives.mapValues({ $0.path }) ?? [:]
let isTakeoverAllowed = data.vmWindows[vm] is VMRemoteSessionState && (vm.state == .started || vm.state == .paused)
return M.VirtualMachineInformation(id: vm.id,
name: vm.detailsTitleLabel,
path: vm.pathUrl.path,
isShortcut: vm.isShortcut,
isSuspended: vm.registryEntry?.isSuspended ?? false,
isTakeoverAllowed: isTakeoverAllowed,
backend: vm.wrapped is UTMQemuVirtualMachine ? .qemu : .unknown,
state: vm.wrapped?.state ?? .stopped,
mountedDrives: mountedDrives)
}
}.value
return .init(informations: informations)
}
private func _getQEMUConfiguration(parameters: M.GetQEMUConfiguration.Request) async throws -> M.GetQEMUConfiguration.Reply {
let vm = try await findVM(withId: parameters.id)
if let config = await vm.config as? UTMQemuConfiguration {
return .init(configuration: config)
} else {
throw ServerError.invalidBackend
}
}
private func _getPackageSize(parameters: M.GetPackageSize.Request) async throws -> M.GetPackageSize.Reply {
let vm = try await findVM(withId: parameters.id)
let size = await data.computeSize(for: vm)
return .init(size: size)
}
private func _getPackageFile(parameters: M.GetPackageFile.Request) async throws -> M.GetPackageFile.Reply {
let vm = try await findVM(withId: parameters.id)
let fm = FileManager.default
let pathUrl = await vm.pathUrl
let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
guard let lastModified = try fm.attributesOfItem(atPath: fileUrl.path)[.modificationDate] as? Date else {
throw ServerError.failedToAccessFile
}
if let requestLastModified = parameters.lastModified {
if lastModified.distance(to: requestLastModified).rounded(.towardZero) == 0 {
return .init(data: nil, lastModified: lastModified)
}
}
guard let data = fm.contents(atPath: fileUrl.path) else {
throw ServerError.failedToAccessFile
}
return .init(data: data, lastModified: lastModified)
}
private func _sendPackageFile(parameters: M.SendPackageFile.Request) async throws -> M.SendPackageFile.Reply {
let vm = try await findVM(withId: parameters.id)
let fm = FileManager.default
let pathUrl = await vm.pathUrl
let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
try? fm.removeItem(at: fileUrl)
guard fm.createFile(atPath: fileUrl.path, contents: parameters.data, attributes: [.modificationDate: parameters.lastModified]) else {
throw ServerError.failedToAccessFile
}
try await packageFileHasChanged(for: vm, relativePathComponents: parameters.relativePathComponents)
return .init()
}
private func _deletePackageFile(parameters: M.DeletePackageFile.Request) async throws -> M.DeletePackageFile.Reply {
let vm = try await findVM(withId: parameters.id)
let fm = FileManager.default
let pathUrl = await vm.pathUrl
let fileUrl = parameters.relativePathComponents.reduce(pathUrl, { $0.appendingPathComponent($1) })
try fm.removeItem(at: fileUrl)
try await packageFileHasChanged(for: vm, relativePathComponents: parameters.relativePathComponents)
return .init()
}
private func _mountGuestToolsOnVirtualMachine(parameters: M.MountGuestToolsOnVirtualMachine.Request) async throws -> M.MountGuestToolsOnVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
if let wrapped = await vm.wrapped {
try await data.mountSupportTools(for: wrapped)
}
return .init()
}
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
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 {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.stop(usingMethod: parameters.method)
return .init()
}
private func _restartVirtualMachine(parameters: M.RestartVirtualMachine.Request) async throws -> M.RestartVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.restart()
return .init()
}
private func _pauseVirtualMachine(parameters: M.PauseVirtualMachine.Request) async throws -> M.PauseVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.pause()
return .init()
}
private func _resumeVirtualMachine(parameters: M.ResumeVirtualMachine.Request) async throws -> M.ResumeVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.resume()
return .init()
}
private func _saveSnapshotVirtualMachine(parameters: M.SaveSnapshotVirtualMachine.Request) async throws -> M.SaveSnapshotVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.saveSnapshot(name: parameters.name)
return .init()
}
private func _deleteSnapshotVirtualMachine(parameters: M.DeleteSnapshotVirtualMachine.Request) async throws -> M.DeleteSnapshotVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.deleteSnapshot(name: parameters.name)
return .init()
}
private func _restoreSnapshotVirtualMachine(parameters: M.RestoreSnapshotVirtualMachine.Request) async throws -> M.RestoreSnapshotVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
try await vm.wrapped!.restoreSnapshot(name: parameters.name)
return .init()
}
private func _changePointerTypeVirtualMachine(parameters: M.ChangePointerTypeVirtualMachine.Request) async throws -> M.ChangePointerTypeVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
guard let wrapped = await vm.wrapped as? UTMQemuVirtualMachine else {
throw ServerError.invalidBackend
}
try await wrapped.changeInputTablet(parameters.isTabletMode)
return .init()
}
}
}
extension UTMRemoteServer {
class Remote: Identifiable {
typealias M = UTMRemoteMessageClient
fileprivate(set) var peer: Peer<UTMRemoteMessageServer>!
let id = UUID()
func close() {
peer.close()
}
func handshake() async throws {
guard try await _handshake(parameters: .init(version: UTMRemoteMessageClient.version)).version == UTMRemoteMessageClient.version else {
throw ServerError.versionMismatch
}
}
func listHasChanged(ids: [UUID]) async throws {
try await _listHasChanged(parameters: .init(ids: ids))
}
func qemuConfigurationHasChanged(id: UUID, configuration: UTMQemuConfiguration) async throws {
try await _qemuConfigurationHasChanged(parameters: .init(id: id, configuration: configuration))
}
func mountedDrivesHasChanged(id: UUID, mountedDrives: [String: String]) async throws {
try await _mountedDrivesHasChanged(parameters: .init(id: id, mountedDrives: mountedDrives))
}
func virtualMachine(id: UUID, didTransitionToState state: UTMVirtualMachineState, isTakeoverAllowed: Bool) async throws {
try await _virtualMachineDidTransition(parameters: .init(id: id, state: state, isTakeoverAllowed: isTakeoverAllowed))
}
func virtualMachine(id: UUID, didErrorWithMessage message: String) async throws {
try await _virtualMachineDidError(parameters: .init(id: id, errorMessage: message))
}
private func _handshake(parameters: M.ClientHandshake.Request) async throws -> M.ClientHandshake.Reply {
try await M.ClientHandshake.send(parameters, to: peer)
}
@discardableResult
private func _listHasChanged(parameters: M.ListHasChanged.Request) async throws -> M.ListHasChanged.Reply {
try await M.ListHasChanged.send(parameters, to: peer)
}
@discardableResult
private func _qemuConfigurationHasChanged(parameters: M.QEMUConfigurationHasChanged.Request) async throws -> M.QEMUConfigurationHasChanged.Reply {
try await M.QEMUConfigurationHasChanged.send(parameters, to: peer)
}
@discardableResult
private func _mountedDrivesHasChanged(parameters: M.MountedDrivesHasChanged.Request) async throws -> M.MountedDrivesHasChanged.Reply {
try await M.MountedDrivesHasChanged.send(parameters, to: peer)
}
@discardableResult
private func _virtualMachineDidTransition(parameters: M.VirtualMachineDidTransition.Request) async throws -> M.VirtualMachineDidTransition.Reply {
try await M.VirtualMachineDidTransition.send(parameters, to: peer)
}
@discardableResult
private func _virtualMachineDidError(parameters: M.VirtualMachineDidError.Request) async throws -> M.VirtualMachineDidError.Reply {
try await M.VirtualMachineDidError.send(parameters, to: peer)
}
}
}
extension UTMRemoteServer {
enum ServerError: LocalizedError {
case silentError(Error)
case natReservationMismatch(Int)
case notAuthenticated
case versionMismatch
case notFound(UUID)
case invalidBackend
case failedToAccessFile
var errorDescription: String? {
switch self {
case .silentError(let error):
return error.localizedDescription
case .natReservationMismatch(let port):
return String.localizedStringWithFormat(NSLocalizedString("Cannot reserve port '%@' for external access from NAT. Make sure no other device on the network has reserved it.", comment: "UTMRemoteServer"), port)
case .notAuthenticated:
return NSLocalizedString("Not authenticated.", comment: "UTMRemoteServer")
case .versionMismatch:
return NSLocalizedString("The client interface version does not match the server.", comment: "UTMRemoteServer")
case .notFound(let id):
return String.localizedStringWithFormat(NSLocalizedString("Cannot find VM with ID: %@", comment: "UTMRemoteServer"), id.uuidString)
case .invalidBackend:
return NSLocalizedString("Invalid backend.", comment: "UTMRemoteServer")
case .failedToAccessFile:
return NSLocalizedString("Failed to access file.", comment: "UTMRemoteServer")
}
}
}
}
extension Connection {
var fingerprint: [UInt8]? {
return peerCertificateChain.first?.fingerprint()
}
}