remote: add remote server

This commit is contained in:
osy 2024-01-24 13:41:35 -08:00
parent 7985cdee0d
commit 1bd1b5fda2
6 changed files with 477 additions and 14 deletions

View File

@ -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()
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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 */,