Przeglądaj źródła

remote: add remote server

osy 1 rok temu
rodzic
commit
1bd1b5fda2

+ 9 - 1
Platform/UTMData.swift

@@ -88,7 +88,12 @@ struct AlertMessage: Identifiable {
     nonisolated private var documentsURL: URL {
         UTMData.defaultStorageUrl
     }
-    
+
+    #if os(macOS)
+    /// Remote access server
+    private(set) var remoteServer: UTMRemoteServer!
+    #endif
+
     /// Queue to run `busyWork` tasks
     private var busyQueue: DispatchQueue
     
@@ -100,6 +105,9 @@ struct AlertMessage: Identifiable {
         self.virtualMachines = []
         self.pendingVMs = []
         self.selectedVM = nil
+        #if os(macOS)
+        self.remoteServer = UTMRemoteServer(data: self)
+        #endif
         listLoadFromDefaults()
     }
     

+ 445 - 0
Remote/UTMRemoteServer.swift

@@ -0,0 +1,445 @@
+//
+// 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 SwiftConnect
+import UserNotifications
+
+let service = "_utm_server._tcp"
+
+actor UTMRemoteServer {
+    private let data: UTMData
+    private let keyManager = UTMRemoteKeyManager(forClient: false)
+    private let center = UNUserNotificationCenter.current()
+    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: Connection] = [:]
+
+    private func _replaceCancellables(with set: Set<AnyCancellable>) {
+        cancellables = set
+    }
+
+    @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 {
+            await notifyError(error)
+        }
+    }
+
+    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()
+            registerNotifications()
+            listener = Task {
+                await withErrorNotification {
+                    for try await connection in Connection.advertise(forServiceType: service, identity: keyManager.identity) {
+                        await withErrorNotification {
+                            let connection = try await Connection(connection: connection)
+                            await newRemoteConnection(connection)
+                        }
+                    }
+                }
+                await state.setServerActive(false)
+            }
+            await state.setServerActive(true)
+        }
+    }
+
+    func stop() async {
+        unregisterNotifications()
+        listener?.cancel()
+        listener = nil
+        await state.setServerActive(false)
+    }
+
+    private func newRemoteConnection(_ connection: Connection) async {
+        let remoteAddress = connection.connection.endpoint.debugDescription
+        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
+            connection.close()
+            return
+        }
+        guard await !state.isBlocked(fingerprint) else {
+            connection.close()
+            return
+        }
+        await state.seen(fingerprint, name: remoteAddress)
+        pendingConnections[fingerprint] = connection
+        if await state.isApproved(fingerprint) {
+            await notifyNewConnection(remoteAddress: remoteAddress, fingerprint: fingerprint)
+            await establishConnection(connection)
+        } else {
+            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 connectedClient in connectedClients {
+            if let connection = establishedConnections.removeValue(forKey: connectedClient) {
+                connection.close()
+            }
+        }
+    }
+
+    private func establishConnection(_ connection: Connection) async {
+        guard let fingerprint = connection.peerCertificateChain.first?.fingerprint().hexString() else {
+            connection.close()
+            return
+        }
+        await withErrorNotification {
+            await state.connect(fingerprint)
+        }
+    }
+
+    private func resetServer() async {
+        await withErrorNotification {
+            try await keyManager.reset()
+        }
+    }
+}
+
+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 fingerprint = userInfo["FINGERPRINT"] as? String 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: String, isUnknown: Bool = false) async {
+        let settings = await center.notificationSettings()
+        guard settings.authorizationStatus == .authorized else {
+            logger.info("Notifications disabled, ignoring connection request from '\(remoteAddress)' with fingerprint '\(fingerprint)'")
+            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: [fingerprint])
+            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"
+        }
+        content.userInfo = ["FINGERPRINT": fingerprint]
+        let request = UNNotificationRequest(identifier: fingerprint,
+                                            content: content,
+                                            trigger: nil)
+        do {
+            try await center.add(request)
+        } catch {
+            logger.error("Error sending remote connection request: \(error.localizedDescription)")
+        }
+    }
+
+    private 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 = String
+        struct Client: Codable, Identifiable, Hashable {
+            let fingerprint: ClientFingerprint
+            var name: String
+            var lastSeen: Date
+
+            var id: String {
+                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
+
+        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)
+        }
+
+        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 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)
+        }
+    }
+}

+ 17 - 0
Services/UTMExtensions.swift

@@ -384,3 +384,20 @@ extension String {
         return Int(numeric)
     }
 }
+
+extension Encodable {
+    func propertyList() throws -> Any {
+        let encoder = PropertyListEncoder()
+        encoder.outputFormat = .xml
+        let xml = try encoder.encode(self)
+        return try PropertyListSerialization.propertyList(from: xml, format: nil)
+    }
+}
+
+extension Decodable {
+    init(fromPropertyList propertyList: Any) throws {
+        let data = try PropertyListSerialization.data(fromPropertyList: propertyList, format: .xml, options: 0)
+        let decoder = PropertyListDecoder()
+        self = try decoder.decode(Self.self, from: data)
+    }
+}

+ 1 - 1
Services/UTMRegistry.swift

@@ -59,7 +59,7 @@ class UTMRegistry: NSObject {
         super.init()
         if let newEntries = try? serializedEntries.mapValues({ value in
             let dict = value as! [String: Any]
-            return try UTMRegistryEntry(from: dict)
+            return try UTMRegistryEntry(fromPropertyList: dict)
         }) {
             entries = newEntries
         }

+ 1 - 12
Services/UTMRegistryEntry.swift

@@ -109,11 +109,7 @@ import Foundation
     }
     
     func asDictionary() throws -> [String: Any] {
-        let encoder = PropertyListEncoder()
-        encoder.outputFormat = .xml
-        let xml = try encoder.encode(self)
-        let dict = try PropertyListSerialization.propertyList(from: xml, format: nil)
-        return dict as! [String: Any]
+        return try propertyList() as! [String: Any]
     }
     
     /// Update the UUID
@@ -128,13 +124,6 @@ import Foundation
 
 protocol UTMRegistryEntryDecodable: Decodable {}
 extension UTMRegistryEntry: UTMRegistryEntryDecodable {}
-extension UTMRegistryEntryDecodable {
-    init(from dictionary: [String: Any]) throws {
-        let data = try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0)
-        let decoder = PropertyListDecoder()
-        self = try decoder.decode(Self.self, from: data)
-    }
-}
 
 // MARK: - Accessors
 @MainActor extension UTMRegistryEntry {

+ 4 - 0
UTM.xcodeproj/project.pbxproj

@@ -652,6 +652,7 @@
 		CE9B15382B11A4A7003A32DD /* SwiftConnect in Frameworks */ = {isa = PBXBuildFile; productRef = CE9B15372B11A4A7003A32DD /* SwiftConnect */; };
 		CE9B153A2B11A4AE003A32DD /* SwiftConnect in Frameworks */ = {isa = PBXBuildFile; productRef = CE9B15392B11A4AE003A32DD /* SwiftConnect */; };
 		CE9B153C2B11A4B4003A32DD /* SwiftConnect in Frameworks */ = {isa = PBXBuildFile; productRef = CE9B153B2B11A4B4003A32DD /* SwiftConnect */; };
+		CE9B153F2B11A63E003A32DD /* UTMRemoteServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9B153E2B11A63E003A32DD /* UTMRemoteServer.swift */; };
 		CE9B15412B11A74E003A32DD /* UTMRemoteKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9B15402B11A74E003A32DD /* UTMRemoteKeyManager.swift */; };
 		CE9B15422B11A74E003A32DD /* UTMRemoteKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9B15402B11A74E003A32DD /* UTMRemoteKeyManager.swift */; };
 		CE9B15432B11A74E003A32DD /* UTMRemoteKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9B15402B11A74E003A32DD /* UTMRemoteKeyManager.swift */; };
@@ -1912,6 +1913,7 @@
 		CE9A352D26533A51005077CF /* JailbreakInterposer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = JailbreakInterposer.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE9A353026533A52005077CF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		CE9A353F26533AE6005077CF /* JailbreakInterposer.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = JailbreakInterposer.c; sourceTree = "<group>"; };
+		CE9B153E2B11A63E003A32DD /* UTMRemoteServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMRemoteServer.swift; sourceTree = "<group>"; };
 		CE9B15402B11A74E003A32DD /* UTMRemoteKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMRemoteKeyManager.swift; sourceTree = "<group>"; };
 		CE9B15452B12A87E003A32DD /* GenerateKey.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GenerateKey.h; sourceTree = "<group>"; };
 		CE9B15462B12A87E003A32DD /* GenerateKey.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = GenerateKey.c; sourceTree = "<group>"; };
@@ -2826,6 +2828,7 @@
 			isa = PBXGroup;
 			children = (
 				CE9B15402B11A74E003A32DD /* UTMRemoteKeyManager.swift */,
+				CE9B153E2B11A63E003A32DD /* UTMRemoteServer.swift */,
 				CE9B15452B12A87E003A32DD /* GenerateKey.h */,
 				CE9B15462B12A87E003A32DD /* GenerateKey.c */,
 			);
@@ -3732,6 +3735,7 @@
 				CE020BA424AEDC7C00B44AB6 /* UTMData.swift in Sources */,
 				CE25124929BFDBA6000790AB /* UTMScriptingGuestFileImpl.swift in Sources */,
 				848A98C8287206AE006F0550 /* VMConfigAppleVirtualizationView.swift in Sources */,
+				CE9B153F2B11A63E003A32DD /* UTMRemoteServer.swift in Sources */,
 				847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */,
 				CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */,
 				CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */,