diff --git a/Platform/macOS/UTMServerView.swift b/Platform/macOS/UTMServerView.swift index 0a278e0de..9b6bee3cf 100644 --- a/Platform/macOS/UTMServerView.swift +++ b/Platform/macOS/UTMServerView.swift @@ -53,6 +53,10 @@ struct UTMServerView: View { ServerOverview() Divider() HStack { + if let address = remoteServer.externalIPAddress, let port = remoteServer.externalPort { + Text("Server IP: \(address), Port: \(String(port))") + .textSelection(.enabled) + } Spacer() if remoteServer.isServerActive { Image(systemName: "circle.fill") diff --git a/Remote/UTMRemoteServer.swift b/Remote/UTMRemoteServer.swift index cbfdc5af8..25cab9e62 100644 --- a/Remote/UTMRemoteServer.swift +++ b/Remote/UTMRemoteServer.swift @@ -18,6 +18,7 @@ import Foundation import Combine import Network import SwiftConnect +import SwiftPortmap import UserNotifications let service = "_utm_server._tcp" @@ -33,6 +34,7 @@ actor UTMRemoteServer { private var listener: Task? private var pendingConnections: [State.ClientFingerprint: Connection] = [:] private var establishedConnections: [State.ClientFingerprint: Remote] = [:] + private var natPort: SwiftPortmap.Port? private func _replaceCancellables(with set: Set) { cancellables = set @@ -128,6 +130,21 @@ actor UTMRemoteServer { 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, identity: keyManager.identity) { if let connection = try? await Connection(connection: connection) { @@ -135,6 +152,7 @@ actor UTMRemoteServer { } } } + natPort = nil await stop() } await state.setServerActive(true) @@ -149,6 +167,7 @@ actor UTMRemoteServer { listener.cancel() _ = await listener.result } + await state.setExternalAddress() await state.setServerActive(false) } @@ -462,6 +481,10 @@ extension UTMRemoteServer { } } + @Published private(set) var externalIPAddress: String? + + @Published private(set) var externalPort: UInt16? + init() { var _approvedClients = Set() if let array = UserDefaults.standard.array(forKey: "TrustedClients") { @@ -551,6 +574,11 @@ extension UTMRemoteServer { fileprivate func setServerFingerprint(_ fingerprint: ServerFingerprint) { serverFingerprint = fingerprint } + + fileprivate func setExternalAddress(_ address: String? = nil, port: UInt16? = nil) { + externalIPAddress = address + externalPort = port + } } } @@ -783,6 +811,7 @@ extension UTMRemoteServer { extension UTMRemoteServer { enum ServerError: LocalizedError { case silentError(Error) + case natReservationMismatch(Int) case notAuthenticated case versionMismatch case notFound(UUID) @@ -793,6 +822,8 @@ extension UTMRemoteServer { 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: diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 2738f2599..af3f6f5f6 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -886,6 +886,7 @@ CED8DF7528A120C100C34345 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CED8DF7928A120C100C34345 /* Localizable.stringsdict */; }; CED8DF7628A120C100C34345 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CED8DF7928A120C100C34345 /* Localizable.stringsdict */; }; CED8DF7728A120C100C34345 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CED8DF7928A120C100C34345 /* Localizable.stringsdict */; }; + CEDD11C12B7C74D7004DDAC6 /* SwiftPortmap in Frameworks */ = {isa = PBXBuildFile; productRef = CEDD11C02B7C74D7004DDAC6 /* SwiftPortmap */; }; CEDF83F9258AE24E0030E4AC /* UTMPasteboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDF83F8258AE24E0030E4AC /* UTMPasteboard.swift */; }; CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDF83F8258AE24E0030E4AC /* UTMPasteboard.swift */; }; CEE06B272B2FC89400A811AE /* UTMServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE06B262B2FC89400A811AE /* UTMServerView.swift */; }; @@ -2196,6 +2197,7 @@ CE03D0CE24D9A30100F76B84 /* iconv.2.framework in Frameworks */, CE0B6EF124AD677200FE012D /* libgstplayback.a in Frameworks */, CE0B6EF424AD677200FE012D /* json-glib-1.0.0.framework in Frameworks */, + CEDD11C12B7C74D7004DDAC6 /* SwiftPortmap in Frameworks */, CE0B6ED124AD677200FE012D /* phodav-2.0.0.framework in Frameworks */, CEF83F862500947D00557D15 /* gcrypt.20.framework in Frameworks */, CE0B6ECB24AD677200FE012D /* gstcheck-1.0.0.framework in Frameworks */, @@ -3075,6 +3077,7 @@ 84B36D2127B3265400C22685 /* CocoaSpice */, 84A0A8872A47D5C50038F329 /* QEMUKit */, CE9B15352B11A491003A32DD /* SwiftConnect */, + CEDD11C02B7C74D7004DDAC6 /* SwiftPortmap */, ); productName = UTM; productReference = CE2D951C24AD48BE0059923A /* UTM.app */; @@ -3241,6 +3244,7 @@ 84E3A8FE293DBC290024A740 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, 84A0A8862A47D5C50038F329 /* XCRemoteSwiftPackageReference "QEMUKit" */, CE9B15342B11A491003A32DD /* XCRemoteSwiftPackageReference "SwiftConnect" */, + CEDD11BF2B7C74D7004DDAC6 /* XCRemoteSwiftPackageReference "SwiftPortmap" */, ); productRefGroup = CE550BCA225947990063E575 /* Products */; projectDirPath = ""; @@ -5120,6 +5124,14 @@ minimumVersion = 6.5.6; }; }; + CEDD11BF2B7C74D7004DDAC6 /* XCRemoteSwiftPackageReference "SwiftPortmap" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/osy/SwiftPortmap.git"; + requirement = { + branch = main; + kind = branch; + }; + }; CEF7F5852AEEDCC400E34952 /* XCRemoteSwiftPackageReference "swift-log" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-log"; @@ -5320,6 +5332,11 @@ package = CEA45E23263519B5002FA97D /* XCRemoteSwiftPackageReference "IQKeyboardManager" */; productName = IQKeyboardManagerSwift; }; + CEDD11C02B7C74D7004DDAC6 /* SwiftPortmap */ = { + isa = XCSwiftPackageProductDependency; + package = CEDD11BF2B7C74D7004DDAC6 /* XCRemoteSwiftPackageReference "SwiftPortmap" */; + productName = SwiftPortmap; + }; CEF7F5842AEEDCC400E34952 /* Logging */ = { isa = XCSwiftPackageProductDependency; package = CEF7F5852AEEDCC400E34952 /* XCRemoteSwiftPackageReference "swift-log" */; diff --git a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 207f281e8..86af3c233 100644 --- a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -81,6 +81,15 @@ "revision" : "ac168e9e69e0dc4efbe88699e0fd712316348c55" } }, + { + "identity" : "swiftportmap", + "kind" : "remoteSourceControl", + "location" : "https://github.com/osy/SwiftPortmap.git", + "state" : { + "branch" : "main", + "revision" : "72782141ab6f6f6db58bd16bac96d4e7ce901e9a" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl",