diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index e15e9bf6e..ee53bef9b 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -49,7 +49,6 @@ 94196964280316C7008AAEB2 /* CloudCodeExecutionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77818257BC332008FE7B2 /* CloudCodeExecutionManager.swift */; }; 94196965280316C7008AAEB2 /* String+toCString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949B3CC425DEA89A00BC83B5 /* String+toCString.swift */; }; 94196967280316C7008AAEB2 /* Executor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948D12212583F2A5008F877A /* Executor.swift */; }; - 94196968280316C7008AAEB2 /* RemoteAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437155A26C3C745000376FB /* RemoteAuthView.swift */; }; 94196969280316C7008AAEB2 /* CodeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944EEBF32563C381009D77FE /* CodeApp.swift */; }; 9419696A280316C7008AAEB2 /* openFilesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A777DB257B8C99008FE7B2 /* openFilesApp.swift */; }; 9419696B280316C7008AAEB2 /* View+If.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DDF9260526D200C4F2B1 /* View+If.swift */; }; @@ -473,7 +472,6 @@ 94369B1325E3DE02008419A0 /* NodeRunner.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94369B1225E3DE02008419A0 /* NodeRunner.mm */; }; 94369B4A25EAB262008419A0 /* npm.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 94369B4925EAB175008419A0 /* npm.bundle */; }; 9437153F26BF9FC3000376FB /* RemoteContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437153E26BF9FC3000376FB /* RemoteContainer.swift */; }; - 9437155B26C3C745000376FB /* RemoteAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437155A26C3C745000376FB /* RemoteAuthView.swift */; }; 9438C9A225CBD25F00335E82 /* EditorKeyboardToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9438C9A125CBD25F00335E82 /* EditorKeyboardToolBar.swift */; }; 9441129D28217D6A00A8F1D7 /* TerminalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */; }; 9441129E2821816700A8F1D7 /* TerminalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */; }; @@ -1734,7 +1732,6 @@ 94369B1B25E3EDFC008419A0 /* extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = extension.entitlements; sourceTree = ""; }; 94369B4925EAB175008419A0 /* npm.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = npm.bundle; sourceTree = ""; }; 9437153E26BF9FC3000376FB /* RemoteContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContainer.swift; sourceTree = ""; }; - 9437155A26C3C745000376FB /* RemoteAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAuthView.swift; sourceTree = ""; }; 9438C9A125CBD25F00335E82 /* EditorKeyboardToolBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorKeyboardToolBar.swift; sourceTree = ""; }; 9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalProvider.swift; sourceTree = ""; }; 944112A0282181E500A8F1D7 /* SFTPTerminalServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPTerminalServiceProvider.swift; sourceTree = ""; }; @@ -2902,7 +2899,6 @@ 94A777F2257B91CA008FE7B2 /* RemoteImage.swift */, 94A045F92804842500182275 /* RemoteConnectedSection.swift */, 94A045F3280481A900182275 /* RemoteTypeLabel.swift */, - 9437155A26C3C745000376FB /* RemoteAuthView.swift */, 94A045ED280480E800182275 /* RemoteListSection.swift */, 94A045F62804822400182275 /* RemoteHostCell.swift */, 94A045F02804816000182275 /* RemoteCreateSection.swift */, @@ -3591,7 +3587,6 @@ 94795C4929314A0A0057C12F /* CompactSidebar.swift in Sources */, 94BEF1322B74AEBD003BBF5D /* UIFont+YYAdd.m in Sources */, 94196967280316C7008AAEB2 /* Executor.swift in Sources */, - 94196968280316C7008AAEB2 /* RemoteAuthView.swift in Sources */, 94196969280316C7008AAEB2 /* CodeApp.swift in Sources */, 9FC673852AA068EE00346FD7 /* PortForwardServiceProvider.swift in Sources */, 9419696A280316C7008AAEB2 /* openFilesApp.swift in Sources */, @@ -3774,7 +3769,6 @@ 94795C4829314A0A0057C12F /* CompactSidebar.swift in Sources */, 94BEF1312B74AEBD003BBF5D /* UIFont+YYAdd.m in Sources */, 948D12222583F2A5008F877A /* Executor.swift in Sources */, - 9437155B26C3C745000376FB /* RemoteAuthView.swift in Sources */, 944EEBF42563C381009D77FE /* CodeApp.swift in Sources */, 9FC673842AA068EE00346FD7 /* PortForwardServiceProvider.swift in Sources */, 94A777DC257B8C99008FE7B2 /* openFilesApp.swift in Sources */, diff --git a/CodeApp/Containers/MainScene.swift b/CodeApp/Containers/MainScene.swift index 697846115..c5a5db680 100644 --- a/CodeApp/Containers/MainScene.swift +++ b/CodeApp/Containers/MainScene.swift @@ -93,6 +93,7 @@ struct MainScene: View { .environmentObject(App.safariManager) .environmentObject(App.directoryPickerManager) .environmentObject(App.createFileSheetManager) + .environmentObject(App.authenticationRequestManager) .onAppear { restoreSceneState() App.extensionManager.initializeExtensions(app: App) @@ -134,6 +135,7 @@ private struct MainView: View { @EnvironmentObject var directoryPickerManager: DirectoryPickerManager @EnvironmentObject var createFileSheetManager: CreateFileSheetManager @EnvironmentObject var themeManager: ThemeManager + @EnvironmentObject var authenticationRequestManager: AuthenticationRequestManager @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.colorScheme) var colorScheme: ColorScheme @@ -243,7 +245,6 @@ private struct MainView: View { changeLogLastReadVersion = appVersion } - .alert( alertManager.title, isPresented: $alertManager.isShowingAlert, actions: { @@ -257,6 +258,32 @@ private struct MainView: View { } } ) + .alert( + authenticationRequestManager.title, + isPresented: $authenticationRequestManager.isShowingAlert, + actions: { + TextField( + authenticationRequestManager.usernameTitleKey ?? "common.username", + text: $authenticationRequestManager.username + ) + .textContentType(.username) + .disableAutocorrection(true) + .autocapitalization(.none) + + SecureField( + authenticationRequestManager.passwordTitleKey ?? "common.password", + text: $authenticationRequestManager.password + ) + .textContentType(.password) + .disableAutocorrection(true) + .autocapitalization(.none) + + Button( + "common.cancel", role: .cancel, + action: authenticationRequestManager.callbackOnCancel) + Button("common.continue", action: authenticationRequestManager.callback) + } + ) .sheet(isPresented: $safariManager.showsSafari) { if let url = safariManager.urlToVisit { SafariView(url: url) diff --git a/CodeApp/Containers/RemoteContainer.swift b/CodeApp/Containers/RemoteContainer.swift index af65822db..2ec6b832c 100644 --- a/CodeApp/Containers/RemoteContainer.swift +++ b/CodeApp/Containers/RemoteContainer.swift @@ -12,6 +12,9 @@ import SwiftUI struct RemoteContainer: View { @EnvironmentObject var App: MainApp + @EnvironmentObject var authenticationRequestManager: AuthenticationRequestManager + @EnvironmentObject var alertManager: AlertManager + @State var hosts: [RemoteHost] = [] func onSaveCredentialsForHost(for host: RemoteHost, cred: URLCredential) throws { @@ -38,15 +41,31 @@ struct RemoteContainer: View { } } - func onRemoveHost(host: RemoteHost) { - _ = KeychainAccessor.shared.removeCredentials(for: host.url) - if let keyChainId = host.privateKeyContentKeychainID { - _ = KeychainAccessor.shared.removeObjectForKey(for: keyChainId) - } + func onRemoveHost(host: RemoteHost, confirm: Bool = false) { + if !confirm + && UserDefaults.standard.remoteHosts.contains(where: { $0.jumpServerUrl == host.url }) + { + alertManager.showAlert( + title: "remote.confirm_delete_are_you_sure_to_delete", + message: "remote.one_or_more_hosts_use_this_host_as_jump_proxy", + content: AnyView( + Group { + Button("common.delete", role: .destructive) { + onRemoveHost(host: host, confirm: true) + } + Button("common.cancel", role: .cancel) {} + } + )) + } else { + _ = KeychainAccessor.shared.removeCredentials(for: host.url) + if let keyChainId = host.privateKeyContentKeychainID { + _ = KeychainAccessor.shared.removeObjectForKey(for: keyChainId) + } - DispatchQueue.main.async { - hosts.removeAll(where: { $0.url == host.url }) - UserDefaults.standard.remoteHosts = hosts + DispatchQueue.main.async { + hosts.removeAll(where: { $0.url == host.url }) + UserDefaults.standard.remoteHosts = hosts + } } } @@ -58,44 +77,79 @@ struct RemoteContainer: View { UserDefaults.standard.remoteHosts = hosts } - func onConnectToHost(host: RemoteHost, onRequestCredentials: () -> Void) async throws { + private func requestManualAuthenticationForHost(host: RemoteHost) async throws -> URLCredential + { + let hostPasswordPair = try await authenticationRequestManager.requestPasswordAuthentication( + title: "remote.credentials_for \(host.url)", + usernameTitleKey: "common.username", + passwordTitleKey: (host.useKeyAuth || host.privateKeyContentKeychainID != nil + || host.privateKeyPath != nil) + ? "remote.passphrase_for_private_key" : "common.password" + ) + return URLCredential( + user: hostPasswordPair.0, password: hostPasswordPair.1, persistence: .none) + } + + private func requestBiometricAuthenticationForHost(host: RemoteHost) async throws + -> URLCredential + { guard let hostUrl = URL(string: host.url) else { throw RemoteHostError.invalidUrl } - guard KeychainAccessor.shared.hasCredentials(for: host.url) else { - onRequestCredentials() - return - } - let context = LAContext() - context.localizedCancelTitle = "Enter Credentials" + context.localizedCancelTitle = NSLocalizedString("remote.enter_credentials", comment: "") - let biometricAuthSuccess = try? await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Authenticate to \(hostUrl.host ?? "server")") - - guard biometricAuthSuccess == true else { - onRequestCredentials() - return + guard + try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: NSLocalizedString( + "remote.authenticate_to \(hostUrl.host ?? "host")", comment: "")) + else { + throw WorkSpaceStorage.FSError.AuthFailure } guard let cred = KeychainAccessor.shared.getCredentials(for: host.url) else { throw WorkSpaceStorage.FSError.AuthFailure } + return cred + } - try await onConnectToHostWithCredentials(host: host, cred: cred) + private func requestAuthenticationForHost(host: RemoteHost) async throws -> URLCredential { + if KeychainAccessor.shared.hasCredentials(for: host.url) { + do { + return try await requestBiometricAuthenticationForHost(host: host) + } catch { + return try await requestManualAuthenticationForHost(host: host) + } + } else { + return try await requestManualAuthenticationForHost(host: host) + } } - func onConnectToHostWithCredentials( - host: RemoteHost, cred: URLCredential - ) async throws { - guard let hostUrl = URL(string: host.url) else { - throw RemoteHostError.invalidUrl + func onConnectToHost(host: RemoteHost) async throws { + if let jumpServerURL = host.jumpServerUrl { + guard + let jumpHost = UserDefaults.standard.remoteHosts.first(where: { + $0.url == jumpServerURL + }) + else { + throw WorkSpaceStorage.FSError.MissingJumpingServer + } + let jumpCred = try await requestAuthenticationForHost(host: jumpHost) + let cred = try await requestAuthenticationForHost(host: host) + try await connectToHostWithCredentialsUsingJumpHost( + host: host, jumpHost: jumpHost, hostCred: cred, jumpCred: jumpCred) + } else { + let cred = try await requestAuthenticationForHost(host: host) + try await onConnectToHostWithCredentials(host: host, cred: cred) } + } + private func authenticationModeForHost(host: RemoteHost, cred: URLCredential) throws + -> RemoteAuthenticationMode + { var authenticationMode: RemoteAuthenticationMode - if host.useKeyAuth { // Legacy in-file id_rsa authentication authenticationMode = .inFileSSHKey(cred, nil) @@ -107,6 +161,88 @@ struct RemoteContainer: View { } else { authenticationMode = .plainUsernamePassword(cred) } + return authenticationMode + } + + private func connectionResultHandler( + hostUrl: URL, error: (any Error)?, continuation: CheckedContinuation + ) { + if let error { + DispatchQueue.main.async { + App.notificationManager.showErrorMessage( + error.localizedDescription) + } + continuation.resume(throwing: error) + } else { + DispatchQueue.main.async { + App.loadRepository(url: hostUrl) + App.notificationManager.showInformationMessage( + "remote.connected") + App.terminalInstance.terminalServiceProvider = + App.workSpaceStorage.terminalServiceProvider + } + continuation.resume(returning: ()) + } + } + + private func connectToHostWithCredentialsUsingJumpHost( + host: RemoteHost, + jumpHost: RemoteHost, + hostCred: URLCredential, + jumpCred: URLCredential + ) async throws { + guard let hostUrl = URL(string: host.url), + let jumpServerUrlString = host.jumpServerUrl, + let jumpHostUrl = URL(string: jumpServerUrlString) + else { + throw RemoteHostError.invalidUrl + } + + let hostAuthenticationMode = try authenticationModeForHost(host: host, cred: hostCred) + let jumpHostAuthenticationMode = try authenticationModeForHost( + host: jumpHost, cred: jumpCred) + + try await App.notificationManager.withAsyncNotification( + title: "remote.connecting", + task: { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + App.workSpaceStorage.connectToServer( + host: hostUrl, authenticationModeForHost: hostAuthenticationMode, + jumpServer: jumpHostUrl, + authenticationModeForJumpServer: jumpHostAuthenticationMode + ) { + error in + connectionResultHandler( + hostUrl: hostUrl, error: error, continuation: continuation) + } + } + } + ) + } + + func onConnectToHostWithCredentials( + host: RemoteHost, cred: URLCredential + ) async throws { + + if host.jumpServerUrl != nil { + guard + let jumpHost = UserDefaults.standard.remoteHosts.first(where: { + $0.url == host.jumpServerUrl + }) + else { + throw WorkSpaceStorage.FSError.MissingJumpingServer + } + let jumpHostCred = try await requestAuthenticationForHost(host: jumpHost) + return try await connectToHostWithCredentialsUsingJumpHost( + host: host, jumpHost: jumpHost, hostCred: cred, jumpCred: jumpHostCred) + } + + guard let hostUrl = URL(string: host.url) else { + throw RemoteHostError.invalidUrl + } + + let authenticationMode = try authenticationModeForHost(host: host, cred: cred) try await App.notificationManager.withAsyncNotification( title: "remote.connecting", @@ -117,20 +253,8 @@ struct RemoteContainer: View { host: hostUrl, authenticationMode: authenticationMode ) { error in - if let error = error { - DispatchQueue.main.async { - App.notificationManager.showErrorMessage( - error.localizedDescription) - } - continuation.resume(throwing: error) - } else { - App.loadRepository(url: hostUrl) - App.notificationManager.showInformationMessage( - "remote.connected") - App.terminalInstance.terminalServiceProvider = - App.workSpaceStorage.terminalServiceProvider - continuation.resume(returning: ()) - } + connectionResultHandler( + hostUrl: hostUrl, error: error, continuation: continuation) } } } @@ -145,7 +269,6 @@ struct RemoteContainer: View { } else { RemoteListSection( hosts: hosts, onRemoveHost: onRemoveHost, onConnectToHost: onConnectToHost, - onConnectToHostWithCredentials: onConnectToHostWithCredentials, onRenameHost: onRenameHost) RemoteCreateSection( hosts: hosts, diff --git a/CodeApp/Errors/AppError.swift b/CodeApp/Errors/AppError.swift index 1d9762423..20358ee66 100644 --- a/CodeApp/Errors/AppError.swift +++ b/CodeApp/Errors/AppError.swift @@ -14,6 +14,7 @@ enum AppError: String { case editorIsNotReady = "errors.editor_is_not_ready" case encodingFailed = "errors.failed_to_save_file.encoding.failed" case fileModifiedByAnotherProcess = "errors.file_modified_by_another_process" + case operationCancelledByUser = "errors.operation_cancelled_by_user" } extension AppError: LocalizedError { diff --git a/CodeApp/Localization/de.lproj/Localizable.strings b/CodeApp/Localization/de.lproj/Localizable.strings index 7edb7d758..904ac0735 100644 --- a/CodeApp/Localization/de.lproj/Localizable.strings +++ b/CodeApp/Localization/de.lproj/Localizable.strings @@ -463,6 +463,9 @@ "common.create" = "Erstellen"; "common.remove" = "Entfernen"; "common.move" = "Bewegen"; +"common.username" = "Benutzername"; +"common.password" = "Passwort"; +"common.continue" = "Fortfahren"; "notification.source" = "Quelle: %@"; @@ -500,6 +503,8 @@ "errors.fs.attempting_to_copy_parent_to_child" = "Versuch, ein Verzeichnis in sich selbst zu kopieren."; "errors.fs.attempting_to_copy_oneself" = "Sie versuchen, sich selbst zu kopieren."; "errors.fs.already_connecting_to_a_host" = "Es wird bereits eine Verbindung zu einem Host hergestellt."; +"errors.fs.jumping_not_supported_for_host" = "Jumping wird für diesen Host nicht unterstützt."; +"errors.fs.missing_jumping_server" = "Fehlender Jumping-Server."; "errors.source_control.authentication_failed" = "Authentifizierung fehlgeschlagen: Möglicherweise müssen Sie Ihre Git-Anmeldeinformationen konfigurieren."; "errors.source_control.clone_authentication_failed" = "Authentifizierung fehlgeschlagen: Entweder ist die Repository-URL falsch oder Sie müssen Ihre Git-Anmeldeinformationen konfigurieren."; "errors.source_control.no_staged_changes" = "Es gibt keine inszenierten Änderungen."; @@ -514,6 +519,13 @@ "errors.port_forward.service_unavailable" = "Dienst nicht verfügbar"; "errors.port_forward.invalid_address" = "Ungültige Adresse"; "errors.port_forward.address_already_in_use" = "Errno 48: Adresse wird bereits verwendet"; +"errors.operation_cancelled_by_user" = "Operation abgebrochen"; +"errors.sftp.invalid_host_url" = "Ungültige Host-URL"; +"errors.sftp.invalid_jump_host_url" = "Ungültige Jump-Host-URL"; +"errors.sftp.unable_to_start_portforward_service" = "Portweiterleitungsdienst kann nicht gestartet werden"; +"errors.sftp.auth_failure" = "Authentifizierungsfehler"; +"errors.sftp.failed_to_perform_operation" = "Fehler beim Ausführen der Operation"; +"errors.sftp.unable_to_start_shell" = "Shell kann nicht gestartet werden"; "file.copy" = "Kopieren nach.."; "file.download" = "Herunterladen auf.."; @@ -532,6 +544,15 @@ "remote.connected" = "Erfolgreich verbunden"; "remote.ssh_key_not_found" = "SSH-Schlüssel nicht in Documents/.ssh/id_rsa gefunden. Generieren Sie eine, indem Sie ssh-keygen im Terminal ausführen."; "remote.reload_key" = "Schlüssel neu laden"; +"remote.passphrase_for_private_key" = "Passphrase für privaten Schlüssel"; +"remote.credentials_for %@" = "Anmeldeinformationen für %@"; +"remote.enter_credentials" = "Geben Sie Ihre Anmeldeinformationen ein"; +"remote.authenticate_to %@" = "Authentifizieren bei %@"; +"remote.one_or_more_hosts_use_this_host_as_jump_proxy" = "Einer oder mehrere Hosts verwenden diesen Host als Jump-Proxy"; +"remote.confirm_delete_are_you_sure_to_delete" = "Möchten Sie %@ wirklich löschen?"; +"remote.use_jump_server" = "Jump-Server verwenden"; +"remote.jump_using" = "Verwenden von %@"; + "source_control.title" = "Quellcodeverwaltung"; "source_control.username" = "Benutzername"; diff --git a/CodeApp/Localization/en.lproj/Localizable.strings b/CodeApp/Localization/en.lproj/Localizable.strings index d8cfe1226..4e7a74db5 100644 --- a/CodeApp/Localization/en.lproj/Localizable.strings +++ b/CodeApp/Localization/en.lproj/Localizable.strings @@ -356,6 +356,9 @@ are licensed under [BSD-3-Clause License](https://en.wikipedia.org/wiki/BSD_lice "common.create" = "Create"; "common.remove" = "Remove"; "common.move" = "Move"; +"common.username" = "Username"; +"common.password" = "Password"; +"common.continue" = "Continue"; "notification.source" = "Source: %@"; @@ -393,6 +396,8 @@ are licensed under [BSD-3-Clause License](https://en.wikipedia.org/wiki/BSD_lice "errors.fs.attempting_to_copy_parent_to_child" = "Source is a parent of the target."; "errors.fs.attempting_to_copy_oneself" = "Source has the same path as the parent."; "errors.fs.already_connecting_to_a_host" = "Already connecting to a host."; +"errors.fs.jumping_not_supported_for_host" = "Jumping is not supported for this host."; +"errors.fs.missing_jumping_server" = "Missing jumping server"; "errors.source_control.authentication_failed" = "Authentication failed: You might need to configure your git credentials."; "errors.source_control.clone_authentication_failed" = "Authentication failed: Check your credentials configuration."; "errors.source_control.no_staged_changes" = "There are no staged changes."; @@ -407,6 +412,13 @@ are licensed under [BSD-3-Clause License](https://en.wikipedia.org/wiki/BSD_lice "errors.port_forward.service_unavailable" = "Service unavailable"; "errors.port_forward.invalid_address" = "Invalid address"; "errors.port_forward.address_already_in_use" = "Errno 48: Address already in use"; +"errors.operation_cancelled_by_user" = "Operation cancelled by user"; +"errors.sftp.invalid_host_url" = "Invalid host URL"; +"errors.sftp.invalid_jump_host_url" = "Invalid jump host URL"; +"errors.sftp.unable_to_start_portforward_service" = "Unable to start portforward service"; +"errors.sftp.auth_failure" = "Authentication failure"; +"errors.sftp.failed_to_perform_operation" = "Failed to perform operation"; +"errors.sftp.unable_to_start_shell" = "Unable to start shell"; "file.copy" = "Copy to.."; "file.download" = "Download to.."; @@ -425,6 +437,16 @@ are licensed under [BSD-3-Clause License](https://en.wikipedia.org/wiki/BSD_lice "remote.connected" = "Connected successfully"; "remote.ssh_key_not_found" = "SSH key not found in Documents/.ssh/id_rsa. Generate one by running ssh-keygen in the terminal."; "remote.reload_key" = "Reload key"; +"remote.passphrase_for_private_key" = "Passphrase for private key"; +"remote.credentials_for %@" = "Credentials for %@"; +"remote.enter_credentials" = "Enter credentials"; +"remote.authenticate_to %@" = "Authenticate to %@"; +"remote.one_or_more_hosts_use_this_host_as_jump_proxy" = "One or more hosts use this host as a jump proxy."; +"remote.confirm_delete_are_you_sure_to_delete" = "Are you sure you want to delete this host?"; +"remote.use_jump_server" = "Use Jump Server"; +"remote.jump_using" = "Jump using"; + + "source_control.title" = "Source Control"; "source_control.username" = "Username"; diff --git a/CodeApp/Localization/ja.lproj/Localizable.strings b/CodeApp/Localization/ja.lproj/Localizable.strings index 9f85e8b54..054c3787e 100644 --- a/CodeApp/Localization/ja.lproj/Localizable.strings +++ b/CodeApp/Localization/ja.lproj/Localizable.strings @@ -464,6 +464,9 @@ "common.create" = "作成"; "common.remove" = "削除"; "common.move" = "移動"; +"common.username" = "ユーザー名"; +"common.password" = "パスワード"; +"common.continue" = "続行"; "notification.source" = "Source: %@"; @@ -501,6 +504,8 @@ "errors.fs.attempting_to_copy_parent_to_child" = "ソースはターゲットの親です。"; "errors.fs.attempting_to_copy_oneself" = "ソースは親と同じパスを持っています。"; "errors.fs.already_connecting_to_a_host" = "すでにホストに接続しています。"; +"errors.fs.jumping_not_supported_for_host" = "このホストではジャンプはサポートされていません。"; +"errors.fs.missing_jumping_server" = "ジャンプサーバーが見つかりません。"; "errors.source_control.authentication_failed" = "認証に失敗しました。Git の認証情報を設定する必要があるかもしれません。"; "errors.source_control.clone_authentication_failed" = "認証に失敗しました。リポジトリの URL が間違っているか、Git の認証情報を設定する必要があります。"; "errors.source_control.no_staged_changes" = "コミットする必要のあるステージされている変更はありません。"; @@ -515,6 +520,13 @@ "errors.port_forward.service_unavailable" = "サービスが利用できません"; "errors.port_forward.invalid_address" = "無効なアドレスです"; "errors.port_forward.address_already_in_use" = "Errno 48: アドレスはすでに使用されています"; +"errors.operation_cancelled_by_user" = "ユーザーによって操作がキャンセルされました"; +"errors.sftp.invalid_host_url" = "無効なホスト URL"; +"errors.sftp.invalid_jump_host_url" = "無効なジャンプホスト URL"; +"errors.sftp.unable_to_start_portforward_service" = "ポートフォワードサービスを開始できません"; +"errors.sftp.auth_failure" = "認証に失敗しました"; +"errors.sftp.failed_to_perform_operation" = "操作に失敗しました"; +"errors.sftp.unable_to_start_shell" = "シェルを開始できません"; "file.copy" = "コピー.."; "file.download" = "ダウンロード.."; @@ -533,6 +545,14 @@ "remote.connected" = "正常に接続されました"; "remote.ssh_key_not_found" = "SSH キーが Documents/.ssh/id_rsaに見つかりませんでした。ターミナルで ssh-keygen を実行して生成してください。"; "remote.reload_key" = "キーをリロードします"; +"remote.passphrase_for_private_key" = "秘密鍵のパスフレーズ"; +"remote.credentials_for %@" = "%@ の認証情報"; +"remote.enter_credentials" = "認証情報を入力してください"; +"remote.authenticate_to %@" = "%@ に認証してください"; +"remote.one_or_more_hosts_use_this_host_as_jump_proxy" = "1つ以上のホストがこのホストをジャンププロキシとして使用しています"; +"remote.confirm_delete_are_you_sure_to_delete" = "本当に削除しますか?"; +"remote.use_jump_server" = "ジャンプサーバーを使用する"; +"remote.jump_using" = "ジャンプサーバーを使用して接続"; "source_control.title" = "ソース管理"; "source_control.username" = "ユーザー名"; diff --git a/CodeApp/Localization/ko.lproj/Localizable.strings b/CodeApp/Localization/ko.lproj/Localizable.strings index 87cb6cf9b..197fc7962 100644 --- a/CodeApp/Localization/ko.lproj/Localizable.strings +++ b/CodeApp/Localization/ko.lproj/Localizable.strings @@ -463,6 +463,9 @@ "common.create" = "생성"; "common.rename" = "이름 바꾸기"; "common.move" = "이동"; +"common.username" = "사용자 이름"; +"common.password" = "암호"; +"common.continue" = "계속"; "notification.source" = "원천: %@"; @@ -500,6 +503,8 @@ "errors.fs.attempting_to_copy_parent_to_child" = "부모를 자식으로 복사하려고 시도했습니다."; "errors.fs.attempting_to_copy_oneself" = "자기 자신을 복사하려고합니다."; "errors.fs.already_connecting_to_a_host" = "호스트에 이미 연결 중."; +"errors.fs.jumping_not_supported_for_host" = "호스트에 대한 점프가 지원되지 않습니다."; +"errors.fs.missing_jumping_server" = "점프 서버가 없습니다."; "errors.source_control.authentication_failed" = "인증 실패: git 자격 증명을 구성해야 할 수 있습니다."; "errors.source_control.clone_authentication_failed" = "인증 실패: 리포지토리 URL이 잘못되었거나 git 자격 증명을 구성해야 합니다."; "errors.source_control.no_staged_changes" = "변경 사항이 없습니다."; @@ -514,6 +519,13 @@ "errors.port_forward.service_unavailable" = "서비스를 사용할 수 없습니다."; "errors.port_forward.invalid_address" = "잘못된 주소입니다."; "errors.port_forward.address_already_in_use" = "Errno 48: 주소가 이미 사용 중입니다."; +"errors.operation_cancelled_by_user" = "사용자에 의해 작업이 취소되었습니다."; +"errors.sftp.invalid_host_url" = "잘못된 호스트 URL"; +"errors.sftp.invalid_jump_host_url" = "잘못된 점프 호스트 URL"; +"errors.sftp.unable_to_start_portforward_service" = "포트포워드 서비스를 시작할 수 없습니다."; +"errors.sftp.auth_failure" = "인증 실패"; +"errors.sftp.failed_to_perform_operation" = "작업을 수행하지 못했습니다."; +"errors.sftp.unable_to_start_shell" = "쉘을 시작할 수 없습니다."; "file.copy" = "에게 복사.."; "file.download" = "다운로드.."; @@ -532,6 +544,14 @@ "remote.connected" = "성공적으로 연결됨"; "remote.ssh_key_not_found" = "Documents/.ssh/id_rsa에서 SSH 키를 찾을 수 없습니다. 터미널에서 ssh-keygen을 실행하여 하나를 생성합니다."; "remote.reload_key" = "키 다시로드"; +"remote.passphrase_for_private_key" = "개인 키의 암호"; +"remote.credentials_for %@" = "%@의 자격 증명"; +"remote.enter_credentials" = "자격 증명 입력"; +"remote.authenticate_to %@" = "%@에 인증"; +"remote.one_or_more_hosts_use_this_host_as_jump_proxy" = "하나 이상의 호스트가 이 호스트를 점프 프록시로 사용합니다."; +"remote.confirm_delete_are_you_sure_to_delete" = "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."; +"remote.use_jump_server" = "점프 서버 사용"; +"remote.jump_using" = "사용 중인 점프"; "source_control.title" = "소스 제어"; "source_control.username" = "사용자 이름"; diff --git a/CodeApp/Localization/zh-Hans.lproj/Localizable.strings b/CodeApp/Localization/zh-Hans.lproj/Localizable.strings index 10ab8c929..e84c910ec 100644 --- a/CodeApp/Localization/zh-Hans.lproj/Localizable.strings +++ b/CodeApp/Localization/zh-Hans.lproj/Localizable.strings @@ -454,6 +454,9 @@ "common.create" = "创建"; "common.remove" = "移除"; "common.move" = "移动"; +"common.username" = "用户名"; +"common.password" = "密码"; +"common.continue" = "继续"; "notification.source" = "来源: %@"; @@ -491,6 +494,8 @@ "errors.fs.attempting_to_copy_parent_to_child" = "尝试将父文件夹复制到子文件夹。"; "errors.fs.attempting_to_copy_oneself" = "尝试复制自己。"; "errors.fs.already_connecting_to_a_host" = "已经正在连接到主机。"; +"errors.fs.jumping_not_supported_for_host" = "不支持此主机的跳转。"; +"errors.fs.missing_jumping_server" = "缺少跳转服务器。"; "errors.source_control.authentication_failed" = "身份验证失败:您可能需要配置您的 git 凭据。"; "errors.source_control.clone_authentication_failed" = "身份验证失败:存储库 url 错误或您需要配置 git 凭据。"; "errors.source_control.no_staged_changes" = "没有任何缓存变更。"; @@ -505,6 +510,13 @@ "errors.port_forward.service_unavailable" = "服务不可用"; "errors.port_forward.invalid_address" = "无效的地址"; "errors.port_forward.address_already_in_use" = "地址已在使用中"; +"errors.operation_cancelled_by_user" = "操作被用户取消"; +"errors.sftp.invalid_host_url" = "无效的主机 URL"; +"errors.sftp.invalid_jump_host_url" = "无效的跳转主机 URL"; +"errors.sftp.unable_to_start_portforward_service" = "无法启动端口转发服务"; +"errors.sftp.auth_failure" = "身份验证失败"; +"errors.sftp.failed_to_perform_operation" = "无法执行操作"; +"errors.sftp.unable_to_start_shell" = "无法启动 shell"; "file.copy" = "复制到.."; "file.download" = "下载到.."; @@ -523,6 +535,14 @@ "remote.connected" = "连接成功"; "remote.ssh_key_not_found" = "在 Documents/.ssh/id_rsa 中找不到 SSH 密钥。 通过在终端中运行 ssh-keygen 生成一个。"; "remote.reload_key" = "重新加载密钥"; +"remote.passphrase_for_private_key" = "私钥密码"; +"remote.credentials_for %@" = "%@ 的凭据"; +"remote.enter_credentials" = "输入凭据"; +"remote.authenticate_to %@" = "验证到 %@"; +"remote.one_or_more_hosts_use_this_host_as_jump_proxy" = "一个或多个主机将此主机用作跳转代理"; +"remote.confirm_delete_are_you_sure_to_delete" = "您确定要删除主机吗?"; +"remote.use_jump_server" = "使用跳转服务器"; +"remote.jump_using" = "使用跳转服务器"; "source_control.title" = "源码管理"; "source_control.username" = "用户名"; diff --git a/CodeApp/Managers/FileSystem/SFTP/SFTPFileSystemProvider.swift b/CodeApp/Managers/FileSystem/SFTP/SFTPFileSystemProvider.swift index dc22f8332..a912df4b2 100644 --- a/CodeApp/Managers/FileSystem/SFTP/SFTPFileSystemProvider.swift +++ b/CodeApp/Managers/FileSystem/SFTP/SFTPFileSystemProvider.swift @@ -8,6 +8,22 @@ import Foundation import NMSSH +enum SFTPError: String { + case InvalidHostURL = "errors.sftp.invalid_host_url" + case InvalidJumpHostURL = "errors.sftp.invalid_jump_host_url" + case UnableToStartPortforwardService = "errors.sftp.unable_to_start_portforward_service" + case AuthFailure = "errors.sftp.auth_failure" + // TODO: Expose libssh2_sftp_last_error + case FailedToPerformOperation = "errors.sftp.failed_to_perform_operation" + case UnableToStartShell = "errors.sftp.unable_to_start_shell" +} + +extension SFTPError: LocalizedError { + var errorDescription: String? { + NSLocalizedString(self.rawValue, comment: "") + } +} + struct SFTPSocket: PortForwardSocket { var socket: NMSSHSocket var type: PortForwardType @@ -17,7 +33,13 @@ struct SFTPSocket: PortForwardSocket { } } -class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServiceProvider { +struct SFTPJumpHost { + var url: URL + var username: String + var authentication: RemoteAuthenticationMode +} + +class SFTPFileSystemProvider: NSObject { static var registeredScheme: String = "sftp" var gitServiceProvider: GitServiceProvider? = nil var searchServiceProvider: SearchServiceProvider? = nil @@ -27,43 +49,30 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr var _terminalServiceProvider: SFTPTerminalServiceProvider? = nil var portforwardServiceProvider: (any PortForwardServiceProvider)? { self } - var homePath: String? = "" var fingerPrint: String? = nil var sockets: [SFTPSocket] = [] private var didDisconnect: (Error) -> Void private var onSocketClosed: ((SFTPSocket) -> Void)? = nil + private var onTerminalData: ((Data) -> Void)? = nil private var session: NMSSHSession! private let queue = DispatchQueue(label: "sftp.serial.queue") + private var jumpHostFSS: [SFTPFileSystemProvider] = [] init?( - baseURL: URL, cred: URLCredential, didDisconnect: @escaping (Error) -> Void, + baseURL: URL, username: String, didDisconnect: @escaping (Error) -> Void, onTerminalData: ((Data) -> Void)? ) { - guard baseURL.scheme == "sftp", - let host = baseURL.host, - let port = baseURL.port, - let username = cred.user - else { - return nil - } self.didDisconnect = didDisconnect - + self.onTerminalData = onTerminalData super.init() - queue.async { - self.session = NMSSHSession(host: host, port: port, andUsername: username) - self.session.delegate = self - self.session.channel.socketDelegate = self - } - - self._terminalServiceProvider = SFTPTerminalServiceProvider( - baseURL: baseURL, cred: cred) - if let onTerminalData = onTerminalData { - self._terminalServiceProvider?.onStderr(callback: onTerminalData) - self._terminalServiceProvider?.onStdout(callback: onTerminalData) + do { + try configureSession(baseURL: baseURL, username: username) + try configureTerminalSession(baseURL: baseURL, username: username) + } catch { + return nil } - } deinit { @@ -73,95 +82,165 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr self.session.disconnect() } - func bindLocalPortToRemote(localAddress: Address, remoteAddress: Address) async throws - -> SFTPSocket - { - return try await withUnsafeThrowingContinuation { continuation in - queue.async { - do { - let socket = NMSSHChannel.createSocket() - try self.session.channel.bindLocalPortToRemoteHost( - with: socket, - localListenIP: localAddress.address, - localPort: localAddress.port, - host: remoteAddress.address, - port: remoteAddress.port, - in: self.queue - ) - let sftpSocket = SFTPSocket( - socket: socket, type: .forward(localAddress, remoteAddress)) - DispatchQueue.main.async { - self.sockets.append(sftpSocket) - } - continuation.resume(returning: sftpSocket) - } catch { - continuation.resume(throwing: error) - } - } + private func configureTerminalSession(baseURL: URL, username: String) throws { + self._terminalServiceProvider = SFTPTerminalServiceProvider( + baseURL: baseURL, username: username) + guard self._terminalServiceProvider != nil else { + throw SFTPError.InvalidHostURL + } + if let onTerminalData = self.onTerminalData { + self._terminalServiceProvider?.onStderr(callback: onTerminalData) + self._terminalServiceProvider?.onStdout(callback: onTerminalData) } } - func onSocketClosed(_ callback: @escaping (SFTPSocket) -> Void) { - self.onSocketClosed = callback + private func configureSession(baseURL: URL, username: String) throws { + guard baseURL.scheme == "sftp", + let host = baseURL.host, + let port = baseURL.port + else { + throw SFTPError.InvalidHostURL + } + queue.sync { + self.session = NMSSHSession(host: host, port: port, andUsername: username) + self.session.delegate = self + self.session.channel.socketDelegate = self + } } func connect( authentication: RemoteAuthenticationMode, - shouldResolveHomePath: Bool, - completionHandler: @escaping (Error?) -> Void - ) { + jumpHost: SFTPJumpHost? + ) async throws { + if let jumpHost { + let (sftpURL, terminalURL) = try await configureJumpHost(jumpHost: jumpHost) + try configureSession(baseURL: sftpURL, username: session.username) + try configureTerminalSession(baseURL: terminalURL, username: session.username) + } - self._terminalServiceProvider?.connect( - authentication: authentication, - completionHandler: { _ in - return - }) + async let r1: ()? = self._terminalServiceProvider?.connect(authentication: authentication) + async let r2: Void = withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + queue.async { + self.session.connect() + self.session.timeout = 10 - queue.async { - self.session.connect() - - if self.session.isConnected { - switch authentication { - case .plainUsernamePassword(let credentials): - self.session.authenticate(byPassword: credentials.password ?? "") - - case .inMemorySSHKey(let credentials, let privateKeyContent): - self.session.authenticateBy( - inMemoryPublicKey: nil, privateKey: privateKeyContent, - andPassword: credentials.password) - - case .inFileSSHKey(let credentials, let _privateKeyURL): - let privateKeyURL = - _privateKeyURL ?? getRootDirectory().appendingPathComponent(".ssh/id_rsa") - if let privateKeyContent = try? String(contentsOf: privateKeyURL) { + if self.session.isConnected { + switch authentication { + case .plainUsernamePassword(let credentials): + self.session.authenticate(byPassword: credentials.password ?? "") + + case .inMemorySSHKey(let credentials, let privateKeyContent): self.session.authenticateBy( inMemoryPublicKey: nil, privateKey: privateKeyContent, andPassword: credentials.password) + + case .inFileSSHKey(let credentials, let _privateKeyURL): + let privateKeyURL = + _privateKeyURL + ?? getRootDirectory().appendingPathComponent(".ssh/id_rsa") + if let privateKeyContent = try? String(contentsOf: privateKeyURL) { + self.session.authenticateBy( + inMemoryPublicKey: nil, privateKey: privateKeyContent, + andPassword: credentials.password) + } } } - } - guard self.session.isConnected && self.session.isAuthorized else { - completionHandler(WorkSpaceStorage.FSError.AuthFailure) - return - } + guard self.session.isConnected && self.session.isAuthorized else { + continuation.resume(throwing: SFTPError.AuthFailure) + return + } + + self.fingerPrint = self.session.fingerprint(self.session.fingerprintHash) + continuation.resume() - self.session.sftp.connect() - self.fingerPrint = self.session.fingerprint(self.session.fingerprintHash) - if shouldResolveHomePath { - self.homePath = self.session.sftp.resolveSymbolicLink(atPath: ".") + // This might blocks for 10 seconds on servers without SFTP support + // early resume to prevent prolonged connection + self.session.sftp.connect() } + } + _ = try await (r1, r2) + } + + private func configureJumpHost(jumpHost: SFTPJumpHost) async throws -> (URL, URL) { + guard + let primaryJumpServerFS = SFTPFileSystemProvider( + baseURL: jumpHost.url, + username: jumpHost.username, + didDisconnect: didDisconnect, + onTerminalData: nil + ), + let secondaryJumpServerFS = SFTPFileSystemProvider( + baseURL: jumpHost.url, + username: jumpHost.username, + didDisconnect: didDisconnect, + onTerminalData: nil + ) + else { + throw SFTPError.InvalidJumpHostURL + } - completionHandler(nil) + async let r1: Void = primaryJumpServerFS.connect( + authentication: jumpHost.authentication, + jumpHost: nil) + async let r2: Void = secondaryJumpServerFS.connect( + authentication: jumpHost.authentication, + jumpHost: nil) + _ = try await (r1, r2) + + guard + let primaryPortForwardServiceProvider = primaryJumpServerFS + .portforwardServiceProvider, + let secondaryPortForwardServiceProvider = secondaryJumpServerFS + .portforwardServiceProvider + else { + throw SFTPError.UnableToStartPortforwardService } + let port1 = Int.random(in: 49152...65535) + let port2 = Int.random(in: 49152...65535) + async let r3 = primaryPortForwardServiceProvider.bindLocalPortToRemote( + localAddress: Address(address: "127.0.0.1", port: port1), + remoteAddress: Address(address: session.host, port: Int(truncating: session.port)) + ) + async let r4 = secondaryPortForwardServiceProvider.bindLocalPortToRemote( + localAddress: Address(address: "127.0.0.1", port: port2), + remoteAddress: Address(address: session.host, port: Int(truncating: session.port)) + ) + _ = try await (r3, r4) + + // Add reference jumpServerFS before its scope ends so it does not get deallocated + self.jumpHostFSS = [primaryJumpServerFS, secondaryJumpServerFS] + + return ( + URL(string: "sftp://127.0.0.1:\(String(port1))")!, + URL(string: "sftp://127.0.0.1:\(String(port2))")! + ) + } +} + +extension SFTPFileSystemProvider: NMSSHSessionDelegate { + func session(_ session: NMSSHSession, didDisconnectWithError error: Error) { + didDisconnect(error) + } +} +extension SFTPFileSystemProvider: NMSSHSocketDelegate { + func socketDidClose(_ socket: NMSSHSocket) { + let sftpSocket = sockets.first { $0.socket.sock == socket.sock } + sockets = sockets.filter { $0.socket.sock != socket.sock } + if let sftpSocket { + self.onSocketClosed?(sftpSocket) + } } +} +extension SFTPFileSystemProvider: FileSystemProvider { func contentsOfDirectory(at url: URL, completionHandler: @escaping ([URL]?, Error?) -> Void) { queue.async { let files = self.session.sftp.contentsOfDirectory(atPath: url.path) guard let files = files else { - completionHandler(nil, WorkSpaceStorage.FSError.Unknown) + completionHandler(nil, SFTPError.FailedToPerformOperation) return } completionHandler( @@ -195,7 +274,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if success { completionHandler(nil) } else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) } } } @@ -212,7 +291,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if success { completionHandler(nil) } else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) } } } @@ -224,7 +303,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr let data = self.session.sftp.contents(atPath: at.path) guard let data = data else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) return } @@ -243,7 +322,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if success { completionHandler(nil) } else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) } } } @@ -254,7 +333,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if success { completionHandler(nil) } else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) } } } @@ -266,7 +345,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if data != nil { completionHandler(data, nil) } else { - completionHandler(data, WorkSpaceStorage.FSError.Unknown) + completionHandler(data, SFTPError.FailedToPerformOperation) } } } @@ -284,7 +363,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr if success { completionHandler(nil) } else { - completionHandler(WorkSpaceStorage.FSError.Unknown) + completionHandler(SFTPError.FailedToPerformOperation) } } } @@ -294,7 +373,7 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr ) { queue.async { guard let attributes = self.session.sftp.infoForFile(atPath: at.path) else { - completionHandler(nil, WorkSpaceStorage.FSError.Unknown) + completionHandler(nil, SFTPError.FailedToPerformOperation) return } @@ -305,20 +384,51 @@ class SFTPFileSystemProvider: NSObject, FileSystemProvider, PortForwardServicePr ], nil) } } -} -extension SFTPFileSystemProvider: NMSSHSessionDelegate { - func session(_ session: NMSSHSession, didDisconnectWithError error: Error) { - didDisconnect(error) + func resolveSymbolicLink(atPath: String) async -> String? { + return await withCheckedContinuation { continuation in + queue.async { + continuation.resume( + returning: + self.session.sftp.resolveSymbolicLink(atPath: atPath) + ) + + } + } + } } -extension SFTPFileSystemProvider: NMSSHSocketDelegate { - func socketDidClose(_ socket: NMSSHSocket) { - let sftpSocket = sockets.first { $0.socket.sock == socket.sock } - sockets = sockets.filter { $0.socket.sock != socket.sock } - if let sftpSocket { - self.onSocketClosed?(sftpSocket) +extension SFTPFileSystemProvider: PortForwardServiceProvider { + func bindLocalPortToRemote(localAddress: Address, remoteAddress: Address) async throws + -> SFTPSocket + { + return try await withUnsafeThrowingContinuation { continuation in + queue.async { + do { + let socket = NMSSHChannel.createSocket() + try self.session.channel.bindLocalPortToRemoteHost( + with: socket, + localListenIP: localAddress.address, + localPort: localAddress.port, + host: remoteAddress.address, + port: remoteAddress.port, + in: self.queue + ) + let sftpSocket = SFTPSocket( + socket: socket, type: .forward(localAddress, remoteAddress)) + DispatchQueue.main.async { + self.sockets.append(sftpSocket) + } + continuation.resume(returning: sftpSocket) + } catch { + continuation.resume(throwing: error) + } + } } } + + func onSocketClosed(_ callback: @escaping (SFTPSocket) -> Void) { + self.onSocketClosed = callback + } } diff --git a/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift b/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift index 93c037b2a..9a4f51cfb 100644 --- a/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift +++ b/CodeApp/Managers/FileSystem/SFTP/SFTPTerminalServiceProvider.swift @@ -16,11 +16,10 @@ class SFTPTerminalServiceProvider: NSObject, TerminalServiceProvider { private var onStderr: ((Data) -> Void)? = nil private let queue = DispatchQueue(label: "terminal.serial.queue") - init?(baseURL: URL, cred: URLCredential) { + init?(baseURL: URL, username: String) { guard baseURL.scheme == "sftp", let host = baseURL.host, - let port = baseURL.port, - let username = cred.user + let port = baseURL.port else { return nil } @@ -34,49 +33,52 @@ class SFTPTerminalServiceProvider: NSObject, TerminalServiceProvider { } func connect( - authentication: RemoteAuthenticationMode, - completionHandler: @escaping (Error?) -> Void - ) { - queue.async { - self.session.connect() - - if self.session.isConnected { - switch authentication { - case .plainUsernamePassword(let credentials): - self.session.authenticate(byPassword: credentials.password ?? "") - - case .inMemorySSHKey(let credentials, let privateKeyContent): - self.session.authenticateBy( - inMemoryPublicKey: nil, privateKey: privateKeyContent, - andPassword: credentials.password) - - case .inFileSSHKey(let credentials, let _privateKeyURL): - let privateKeyURL = - _privateKeyURL ?? getRootDirectory().appendingPathComponent(".ssh/id_rsa") - if let privateKeyContent = try? String(contentsOf: privateKeyURL) { + authentication: RemoteAuthenticationMode + ) async throws { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + queue.async { + self.session.connect() + + if self.session.isConnected { + switch authentication { + case .plainUsernamePassword(let credentials): + self.session.authenticate(byPassword: credentials.password ?? "") + + case .inMemorySSHKey(let credentials, let privateKeyContent): self.session.authenticateBy( inMemoryPublicKey: nil, privateKey: privateKeyContent, andPassword: credentials.password) + + case .inFileSSHKey(let credentials, let _privateKeyURL): + let privateKeyURL = + _privateKeyURL + ?? getRootDirectory().appendingPathComponent(".ssh/id_rsa") + if let privateKeyContent = try? String(contentsOf: privateKeyURL) { + self.session.authenticateBy( + inMemoryPublicKey: nil, privateKey: privateKeyContent, + andPassword: credentials.password) + } } } - } - guard self.session.isConnected && self.session.isAuthorized else { - completionHandler(WorkSpaceStorage.FSError.AuthFailure) - return - } + guard self.session.isConnected && self.session.isAuthorized else { + continuation.resume(throwing: SFTPError.AuthFailure) + return + } - do { - self.session.channel.requestPty = true - self.session.channel.ptyTerminalType = .xterm - try self.session.channel.startShell() - } catch { - print("Unable to start shell,", error) - } + do { + self.session.channel.requestPty = true + self.session.channel.ptyTerminalType = .xterm + try self.session.channel.startShell() + } catch { + print("Unable to start shell,", error) + continuation.resume(throwing: SFTPError.UnableToStartShell) + } - completionHandler(nil) + continuation.resume() + } } - } func disconnect() { diff --git a/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift b/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift index 8d5936264..d4dbafe23 100644 --- a/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift +++ b/CodeApp/Managers/FileSystem/WorkSpaceStorage.swift @@ -42,6 +42,8 @@ class WorkSpaceStorage: ObservableObject { case UnsupportedAuthenticationMethod = "errors.fs.unsupported_auth" case UnableToFindASuitableName = "errors.fs.unable_to_find_suitable_name" case TargetIsiCloudFile = "errors.fs.icloud.file" + case JumpingNotSupportedForHost = "errors.fs.jumping_not_supported_for_host" + case MissingJumpingServer = "errors.fs.missing_jumping_server" var errorDescription: String? { NSLocalizedString(self.rawValue, comment: "") @@ -92,6 +94,32 @@ class WorkSpaceStorage: ObservableObject { return fileURLWithSuffix(url: url, suffix: String(num)) } + func connectToServer( + host: URL, authenticationModeForHost: RemoteAuthenticationMode, + jumpServer: URL, authenticationModeForJumpServer: RemoteAuthenticationMode, + completionHandler: @escaping (Error?) -> Void + ) { + guard host.scheme == "sftp" && jumpServer.scheme == "sftp" else { + completionHandler(FSError.JumpingNotSupportedForHost) + return + } + if isConnecting { + completionHandler(FSError.AlreadyConnectingToAHost) + return + } + isConnecting = true + _connectToServer( + host: host, + authenticationMode: authenticationModeForHost, + sftpJumpHost: SFTPJumpHost( + url: jumpServer, username: authenticationModeForJumpServer.credentials.user!, + authentication: authenticationModeForJumpServer), + completionHandler: { error in + completionHandler(error) + self.isConnecting = false + }) + } + func connectToServer( host: URL, authenticationMode: RemoteAuthenticationMode, completionHandler: @escaping (Error?) -> Void @@ -102,7 +130,7 @@ class WorkSpaceStorage: ObservableObject { } isConnecting = true _connectToServer( - host: host, authenticationMode: authenticationMode, + host: host, authenticationMode: authenticationMode, sftpJumpHost: nil, completionHandler: { error in completionHandler(error) self.isConnecting = false @@ -111,6 +139,7 @@ class WorkSpaceStorage: ObservableObject { private func _connectToServer( host: URL, authenticationMode: RemoteAuthenticationMode, + sftpJumpHost: SFTPJumpHost?, completionHandler: @escaping (Error?) -> Void ) { switch host.scheme { @@ -133,62 +162,54 @@ class WorkSpaceStorage: ObservableObject { completionHandler(nil) } case "sftp": - guard - let credentials: URLCredential = { - switch authenticationMode { - case .inFileSSHKey(let credentials, _), .inMemorySSHKey(let credentials, _), - .plainUsernamePassword(let credentials): - return credentials - } - }() - else { - completionHandler(FSError.UnsupportedAuthenticationMethod) - return - } - guard let fs = SFTPFileSystemProvider( - baseURL: host, cred: credentials, + baseURL: host, + username: authenticationMode.credentials.user!, didDisconnect: { error in self.disconnect() - }, onTerminalData: self.onTerminalDataAction) + }, + onTerminalData: self.onTerminalDataAction) else { completionHandler(FSError.Unknown) return } - fs.connect( - authentication: authenticationMode, - shouldResolveHomePath: remoteShouldResolveHomePath - ) { error in - if let error = error { + Task { + do { + try await fs.connect( + authentication: authenticationMode, + jumpHost: sftpJumpHost + ) + } catch { completionHandler(error) return } - guard let homePath = fs.homePath, + + self.fss[host.scheme!] = fs + self.updateDirectory(name: "SFTP", url: host.absoluteString) + + if let fingerPrint = fs.fingerPrint { + DispatchQueue.main.async { + self.remoteFingerprint = fingerPrint + } + } + + completionHandler(nil) + + var homePath: String? = "" + if remoteShouldResolveHomePath { + homePath = await fs.resolveSymbolicLink(atPath: ".") + } + + guard let homePath, let hostName = host.host, let baseURL = URL(string: "sftp://\(hostName)/\(homePath)") else { - completionHandler(FSError.Unknown) return } - fs.contentsOfDirectory(at: baseURL) { urls, error in - if error != nil { - completionHandler(error) - return - } - self.fss[host.scheme!] = fs - self.updateDirectory(name: "SFTP", url: baseURL.absoluteString) - - if let fingerPrint = fs.fingerPrint { - DispatchQueue.main.async { - self.remoteFingerprint = fingerPrint - } - } - completionHandler(nil) - } + self.updateDirectory(name: "SFTP", url: baseURL.absoluteString) } - default: completionHandler(FSError.SchemeNotRegistered) return diff --git a/CodeApp/Managers/MainApp.swift b/CodeApp/Managers/MainApp.swift index 1f1739b9b..c90f73574 100644 --- a/CodeApp/Managers/MainApp.swift +++ b/CodeApp/Managers/MainApp.swift @@ -75,6 +75,53 @@ class AlertManager: ObservableObject { } } +class AuthenticationRequestManager: ObservableObject { + @Published var isShowingAlert = false + @Published var username = "" + @Published var password = "" + + var title: LocalizedStringKey = "" + var usernameTitleKey: LocalizedStringKey? = nil + var passwordTitleKey: LocalizedStringKey? = nil + var callback: (() -> Void) = {} + var callbackOnCancel: (() -> Void) = {} + + @MainActor + func requestPasswordAuthentication( + title: LocalizedStringKey, + usernameTitleKey: LocalizedStringKey? = nil, + passwordTitleKey: LocalizedStringKey? = nil + ) async throws -> (String, String) { + self.title = title + self.usernameTitleKey = usernameTitleKey + self.passwordTitleKey = passwordTitleKey + self.isShowingAlert = true + + return try await withCheckedThrowingContinuation { continuation in + callback = { + continuation.resume(returning: (self.username, self.password)) + self.username = "" + self.password = "" + self.title = "" + self.usernameTitleKey = nil + self.passwordTitleKey = nil + self.callback = {} + self.callbackOnCancel = {} + } + callbackOnCancel = { + continuation.resume(throwing: AppError.operationCancelledByUser) + self.username = "" + self.password = "" + self.title = "" + self.usernameTitleKey = nil + self.passwordTitleKey = nil + self.callback = {} + self.callbackOnCancel = {} + } + } + } +} + class MainStateManager: ObservableObject { @Published var showsNewFileSheet = false @Published var showsDirectoryPicker = false @@ -95,6 +142,7 @@ class MainApp: ObservableObject { let safariManager = SafariManager() let directoryPickerManager = DirectoryPickerManager() let createFileSheetManager = CreateFileSheetManager() + let authenticationRequestManager = AuthenticationRequestManager() @Published var editors: [EditorInstance] = [] var textEditors: [TextEditorInstance] { diff --git a/CodeApp/Types/Remote.swift b/CodeApp/Types/Remote.swift index b0e62c766..d1602ccff 100644 --- a/CodeApp/Types/Remote.swift +++ b/CodeApp/Types/Remote.swift @@ -20,6 +20,7 @@ struct RemoteHost: Codable { var displayName: String? var privateKeyContentKeychainID: String? var privateKeyPath: String? + var jumpServerUrl: String? var rowDisplayName: String { displayName ?? URL(string: self.url)?.host ?? "" @@ -31,4 +32,12 @@ enum RemoteAuthenticationMode { // File path of the ssh keys, default to Documents/.ssh case inFileSSHKey(URLCredential, URL?) case inMemorySSHKey(URLCredential, String) + + var credentials: URLCredential { + switch self { + case .inFileSSHKey(let credentials, _), .inMemorySSHKey(let credentials, _), + .plainUsernamePassword(let credentials): + return credentials + } + } } diff --git a/CodeApp/Views/RemoteAuthView.swift b/CodeApp/Views/RemoteAuthView.swift deleted file mode 100644 index 01ad526df..000000000 --- a/CodeApp/Views/RemoteAuthView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// ftpauth.swift -// ftpauth -// -// Created by Ken Chung on 11/8/2021. -// - -import SwiftUI - -struct RemoteAuthView: View { - - @State var username: String = "" - @State var password: String = "" - - let host: RemoteHost - var credCB: (String, String) -> Void - - var body: some View { - Form { - Section( - header: - Text("Credentials for \(host.url)") - .foregroundColor(Color(id: "sideBarSectionHeader.foreground")) - ) { - TextField("Username", text: $username) - .textContentType(.username) - .disableAutocorrection(true) - .autocapitalization(.none) - - SecureField( - (host.useKeyAuth || host.privateKeyContentKeychainID != nil - || host.privateKeyPath != nil) ? "Passphrase for private key" : "Password", - text: $password - ) - .textContentType(.password) - .disableAutocorrection(true) - .autocapitalization(.none) - - Button("Connect") { - credCB(username, password) - } - }.listRowBackground(Color.init(id: "list.inactiveSelectionBackground")) - }.background(Color(id: "sideBar.background")) - } -} diff --git a/CodeApp/Views/RemoteCreateSection.swift b/CodeApp/Views/RemoteCreateSection.swift index e346c55fa..68a6bc8b9 100644 --- a/CodeApp/Views/RemoteCreateSection.swift +++ b/CodeApp/Views/RemoteCreateSection.swift @@ -34,9 +34,15 @@ struct RemoteCreateSection: View { @State var saveCredentials: Bool = true @State var username: String = "" @State var hasSSHKey = true + @State var usesJumpServer = false + @State var jumpServerUrl: String? = nil @FocusState var focusedField: Field? + var hostsSuitableForJumphost: [RemoteHost] { + hosts.filter { $0.url.hasPrefix("sftp") && $0.jumpServerUrl == nil } + } + func resetAllFields() { saveAddress = true serverType = .sftp @@ -83,8 +89,11 @@ struct RemoteCreateSection: View { let cred = URLCredential( user: username, password: password, persistence: .none) let remoteHost = RemoteHost( - url: url.absoluteString, useKeyAuth: false, - privateKeyContentKeychainID: usesPrivateKey ? privateKeyContentKeyChainId : nil) + url: url.absoluteString, + useKeyAuth: false, + privateKeyContentKeychainID: usesPrivateKey ? privateKeyContentKeyChainId : nil, + jumpServerUrl: usesJumpServer ? jumpServerUrl : nil + ) if usesPrivateKey { KeychainAccessor.shared.storeObject( @@ -246,6 +255,25 @@ struct RemoteCreateSection: View { if serverType == .sftp { Toggle("Use Key Authentication", isOn: $usesPrivateKey) + Toggle("remote.use_jump_server", isOn: $usesJumpServer) + .disabled(jumpServerUrl == nil) + } + + if usesJumpServer { + HStack { + Image(systemName: "rectangle.connected.to.line.below") + .foregroundColor(.gray) + .font(.subheadline) + + Picker("remote.jump_using", selection: $jumpServerUrl) { + ForEach(hostsSuitableForJumphost, id: \.url) { host in + Text(host.rowDisplayName) + .tag(host.url as String?) + } + } + .pickerStyle(MenuPickerStyle()) + Spacer() + }.frame(maxHeight: 20) } Toggle("Remember address", isOn: $saveAddress) @@ -281,5 +309,8 @@ struct RemoteCreateSection: View { saveCredentials = false } } + .onAppear { + jumpServerUrl = hostsSuitableForJumphost.first?.url + } } } diff --git a/CodeApp/Views/RemoteHostCell.swift b/CodeApp/Views/RemoteHostCell.swift index aba3b7444..2a633f73b 100644 --- a/CodeApp/Views/RemoteHostCell.swift +++ b/CodeApp/Views/RemoteHostCell.swift @@ -11,7 +11,6 @@ import SwiftUI struct RemoteHostCell: View { @EnvironmentObject var App: MainApp - @State var showsPrompt = false @State var isRenaming = false @State var newName = "" @FocusState var focusedField: Field? @@ -22,8 +21,7 @@ struct RemoteHostCell: View { let host: RemoteHost let onRemove: () -> Void - let onConnect: (@escaping () -> Void) async throws -> Void - let onConnectWithCredentials: (URLCredential) async throws -> Void + let onConnect: () async throws -> Void let onRenameHost: (String) -> Void var body: some View { @@ -56,21 +54,7 @@ struct RemoteHostCell: View { }.onTapGesture { guard !isRenaming else { return } Task { - try? await onConnect { - DispatchQueue.main.async { - showsPrompt = true - } - } - } - }.sheet(isPresented: $showsPrompt) { - RemoteAuthView(host: host) { username, password in - showsPrompt = false - - let cred = URLCredential( - user: username, password: password, persistence: .forSession) - Task { - try? await onConnectWithCredentials(cred) - } + try? await onConnect() } }.contextMenu { diff --git a/CodeApp/Views/RemoteListSection.swift b/CodeApp/Views/RemoteListSection.swift index 686471784..4bece3d21 100644 --- a/CodeApp/Views/RemoteListSection.swift +++ b/CodeApp/Views/RemoteListSection.swift @@ -12,9 +12,8 @@ struct RemoteListSection: View { @EnvironmentObject var App: MainApp let hosts: [RemoteHost] - let onRemoveHost: (RemoteHost) -> Void - let onConnectToHost: (RemoteHost, () -> Void) async throws -> Void - let onConnectToHostWithCredentials: (RemoteHost, URLCredential) async throws -> Void + let onRemoveHost: (RemoteHost, Bool) -> Void + let onConnectToHost: (RemoteHost) async throws -> Void let onRenameHost: (RemoteHost, String) -> Void var body: some View { @@ -31,13 +30,10 @@ struct RemoteListSection: View { RemoteHostCell( host: host, onRemove: { - onRemoveHost(host) + onRemoveHost(host, false) }, - onConnect: { onRequestCredentials in - try await onConnectToHost(host, onRequestCredentials) - }, - onConnectWithCredentials: { cred in - try await onConnectToHostWithCredentials(host, cred) + onConnect: { + try await onConnectToHost(host) }, onRenameHost: { name in onRenameHost(host, name)