Skip to content

Commit 21ed054

Browse files
committed
remote: ssh jump / proxy #976
1 parent 1cc491e commit 21ed054

18 files changed

+709
-305
lines changed

Code.xcodeproj/project.pbxproj

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
94196964280316C7008AAEB2 /* CloudCodeExecutionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A77818257BC332008FE7B2 /* CloudCodeExecutionManager.swift */; };
5050
94196965280316C7008AAEB2 /* String+toCString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949B3CC425DEA89A00BC83B5 /* String+toCString.swift */; };
5151
94196967280316C7008AAEB2 /* Executor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948D12212583F2A5008F877A /* Executor.swift */; };
52-
94196968280316C7008AAEB2 /* RemoteAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437155A26C3C745000376FB /* RemoteAuthView.swift */; };
5352
94196969280316C7008AAEB2 /* CodeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944EEBF32563C381009D77FE /* CodeApp.swift */; };
5453
9419696A280316C7008AAEB2 /* openFilesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A777DB257B8C99008FE7B2 /* openFilesApp.swift */; };
5554
9419696B280316C7008AAEB2 /* View+If.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DDF9260526D200C4F2B1 /* View+If.swift */; };
@@ -473,7 +472,6 @@
473472
94369B1325E3DE02008419A0 /* NodeRunner.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94369B1225E3DE02008419A0 /* NodeRunner.mm */; };
474473
94369B4A25EAB262008419A0 /* npm.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 94369B4925EAB175008419A0 /* npm.bundle */; };
475474
9437153F26BF9FC3000376FB /* RemoteContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437153E26BF9FC3000376FB /* RemoteContainer.swift */; };
476-
9437155B26C3C745000376FB /* RemoteAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9437155A26C3C745000376FB /* RemoteAuthView.swift */; };
477475
9438C9A225CBD25F00335E82 /* EditorKeyboardToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9438C9A125CBD25F00335E82 /* EditorKeyboardToolBar.swift */; };
478476
9441129D28217D6A00A8F1D7 /* TerminalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */; };
479477
9441129E2821816700A8F1D7 /* TerminalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */; };
@@ -1734,7 +1732,6 @@
17341732
94369B1B25E3EDFC008419A0 /* extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = extension.entitlements; sourceTree = "<group>"; };
17351733
94369B4925EAB175008419A0 /* npm.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = npm.bundle; sourceTree = "<group>"; };
17361734
9437153E26BF9FC3000376FB /* RemoteContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteContainer.swift; sourceTree = "<group>"; };
1737-
9437155A26C3C745000376FB /* RemoteAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAuthView.swift; sourceTree = "<group>"; };
17381735
9438C9A125CBD25F00335E82 /* EditorKeyboardToolBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorKeyboardToolBar.swift; sourceTree = "<group>"; };
17391736
9441129C28217D6A00A8F1D7 /* TerminalProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalProvider.swift; sourceTree = "<group>"; };
17401737
944112A0282181E500A8F1D7 /* SFTPTerminalServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPTerminalServiceProvider.swift; sourceTree = "<group>"; };
@@ -2902,7 +2899,6 @@
29022899
94A777F2257B91CA008FE7B2 /* RemoteImage.swift */,
29032900
94A045F92804842500182275 /* RemoteConnectedSection.swift */,
29042901
94A045F3280481A900182275 /* RemoteTypeLabel.swift */,
2905-
9437155A26C3C745000376FB /* RemoteAuthView.swift */,
29062902
94A045ED280480E800182275 /* RemoteListSection.swift */,
29072903
94A045F62804822400182275 /* RemoteHostCell.swift */,
29082904
94A045F02804816000182275 /* RemoteCreateSection.swift */,
@@ -3591,7 +3587,6 @@
35913587
94795C4929314A0A0057C12F /* CompactSidebar.swift in Sources */,
35923588
94BEF1322B74AEBD003BBF5D /* UIFont+YYAdd.m in Sources */,
35933589
94196967280316C7008AAEB2 /* Executor.swift in Sources */,
3594-
94196968280316C7008AAEB2 /* RemoteAuthView.swift in Sources */,
35953590
94196969280316C7008AAEB2 /* CodeApp.swift in Sources */,
35963591
9FC673852AA068EE00346FD7 /* PortForwardServiceProvider.swift in Sources */,
35973592
9419696A280316C7008AAEB2 /* openFilesApp.swift in Sources */,
@@ -3774,7 +3769,6 @@
37743769
94795C4829314A0A0057C12F /* CompactSidebar.swift in Sources */,
37753770
94BEF1312B74AEBD003BBF5D /* UIFont+YYAdd.m in Sources */,
37763771
948D12222583F2A5008F877A /* Executor.swift in Sources */,
3777-
9437155B26C3C745000376FB /* RemoteAuthView.swift in Sources */,
37783772
944EEBF42563C381009D77FE /* CodeApp.swift in Sources */,
37793773
9FC673842AA068EE00346FD7 /* PortForwardServiceProvider.swift in Sources */,
37803774
94A777DC257B8C99008FE7B2 /* openFilesApp.swift in Sources */,

CodeApp/Containers/MainScene.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ struct MainScene: View {
9393
.environmentObject(App.safariManager)
9494
.environmentObject(App.directoryPickerManager)
9595
.environmentObject(App.createFileSheetManager)
96+
.environmentObject(App.authenticationRequestManager)
9697
.onAppear {
9798
restoreSceneState()
9899
App.extensionManager.initializeExtensions(app: App)
@@ -134,6 +135,7 @@ private struct MainView: View {
134135
@EnvironmentObject var directoryPickerManager: DirectoryPickerManager
135136
@EnvironmentObject var createFileSheetManager: CreateFileSheetManager
136137
@EnvironmentObject var themeManager: ThemeManager
138+
@EnvironmentObject var authenticationRequestManager: AuthenticationRequestManager
137139

138140
@Environment(\.horizontalSizeClass) var horizontalSizeClass
139141
@Environment(\.colorScheme) var colorScheme: ColorScheme
@@ -243,7 +245,6 @@ private struct MainView: View {
243245

244246
changeLogLastReadVersion = appVersion
245247
}
246-
247248
.alert(
248249
alertManager.title, isPresented: $alertManager.isShowingAlert,
249250
actions: {
@@ -257,6 +258,32 @@ private struct MainView: View {
257258
}
258259
}
259260
)
261+
.alert(
262+
authenticationRequestManager.title,
263+
isPresented: $authenticationRequestManager.isShowingAlert,
264+
actions: {
265+
TextField(
266+
authenticationRequestManager.usernameTitleKey ?? "common.username",
267+
text: $authenticationRequestManager.username
268+
)
269+
.textContentType(.username)
270+
.disableAutocorrection(true)
271+
.autocapitalization(.none)
272+
273+
SecureField(
274+
authenticationRequestManager.passwordTitleKey ?? "common.password",
275+
text: $authenticationRequestManager.password
276+
)
277+
.textContentType(.password)
278+
.disableAutocorrection(true)
279+
.autocapitalization(.none)
280+
281+
Button(
282+
"common.cancel", role: .cancel,
283+
action: authenticationRequestManager.callbackOnCancel)
284+
Button("common.continue", action: authenticationRequestManager.callback)
285+
}
286+
)
260287
.sheet(isPresented: $safariManager.showsSafari) {
261288
if let url = safariManager.urlToVisit {
262289
SafariView(url: url)

CodeApp/Containers/RemoteContainer.swift

Lines changed: 167 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import SwiftUI
1212
struct RemoteContainer: View {
1313

1414
@EnvironmentObject var App: MainApp
15+
@EnvironmentObject var authenticationRequestManager: AuthenticationRequestManager
16+
@EnvironmentObject var alertManager: AlertManager
17+
1518
@State var hosts: [RemoteHost] = []
1619

1720
func onSaveCredentialsForHost(for host: RemoteHost, cred: URLCredential) throws {
@@ -38,15 +41,31 @@ struct RemoteContainer: View {
3841
}
3942
}
4043

41-
func onRemoveHost(host: RemoteHost) {
42-
_ = KeychainAccessor.shared.removeCredentials(for: host.url)
43-
if let keyChainId = host.privateKeyContentKeychainID {
44-
_ = KeychainAccessor.shared.removeObjectForKey(for: keyChainId)
45-
}
44+
func onRemoveHost(host: RemoteHost, confirm: Bool = false) {
45+
if !confirm
46+
&& UserDefaults.standard.remoteHosts.contains(where: { $0.jumpServerUrl == host.url })
47+
{
48+
alertManager.showAlert(
49+
title: "remote.confirm_delete_are_you_sure_to_delete",
50+
message: "remote.one_or_more_hosts_use_this_host_as_jump_proxy",
51+
content: AnyView(
52+
Group {
53+
Button("common.delete", role: .destructive) {
54+
onRemoveHost(host: host, confirm: true)
55+
}
56+
Button("common.cancel", role: .cancel) {}
57+
}
58+
))
59+
} else {
60+
_ = KeychainAccessor.shared.removeCredentials(for: host.url)
61+
if let keyChainId = host.privateKeyContentKeychainID {
62+
_ = KeychainAccessor.shared.removeObjectForKey(for: keyChainId)
63+
}
4664

47-
DispatchQueue.main.async {
48-
hosts.removeAll(where: { $0.url == host.url })
49-
UserDefaults.standard.remoteHosts = hosts
65+
DispatchQueue.main.async {
66+
hosts.removeAll(where: { $0.url == host.url })
67+
UserDefaults.standard.remoteHosts = hosts
68+
}
5069
}
5170
}
5271

@@ -58,44 +77,79 @@ struct RemoteContainer: View {
5877
UserDefaults.standard.remoteHosts = hosts
5978
}
6079

61-
func onConnectToHost(host: RemoteHost, onRequestCredentials: () -> Void) async throws {
80+
private func requestManualAuthenticationForHost(host: RemoteHost) async throws -> URLCredential
81+
{
82+
let hostPasswordPair = try await authenticationRequestManager.requestPasswordAuthentication(
83+
title: "remote.credentials_for \(host.url)",
84+
usernameTitleKey: "common.username",
85+
passwordTitleKey: (host.useKeyAuth || host.privateKeyContentKeychainID != nil
86+
|| host.privateKeyPath != nil)
87+
? "remote.passphrase_for_private_key" : "common.password"
88+
)
89+
return URLCredential(
90+
user: hostPasswordPair.0, password: hostPasswordPair.1, persistence: .none)
91+
}
92+
93+
private func requestBiometricAuthenticationForHost(host: RemoteHost) async throws
94+
-> URLCredential
95+
{
6296
guard let hostUrl = URL(string: host.url) else {
6397
throw RemoteHostError.invalidUrl
6498
}
6599

66-
guard KeychainAccessor.shared.hasCredentials(for: host.url) else {
67-
onRequestCredentials()
68-
return
69-
}
70-
71100
let context = LAContext()
72-
context.localizedCancelTitle = "Enter Credentials"
101+
context.localizedCancelTitle = NSLocalizedString("remote.enter_credentials", comment: "")
73102

74-
let biometricAuthSuccess = try? await context.evaluatePolicy(
75-
.deviceOwnerAuthenticationWithBiometrics,
76-
localizedReason: "Authenticate to \(hostUrl.host ?? "server")")
77-
78-
guard biometricAuthSuccess == true else {
79-
onRequestCredentials()
80-
return
103+
guard
104+
try await context.evaluatePolicy(
105+
.deviceOwnerAuthenticationWithBiometrics,
106+
localizedReason: NSLocalizedString(
107+
"remote.authenticate_to \(hostUrl.host ?? "host")", comment: ""))
108+
else {
109+
throw WorkSpaceStorage.FSError.AuthFailure
81110
}
82111

83112
guard let cred = KeychainAccessor.shared.getCredentials(for: host.url) else {
84113
throw WorkSpaceStorage.FSError.AuthFailure
85114
}
115+
return cred
116+
}
86117

87-
try await onConnectToHostWithCredentials(host: host, cred: cred)
118+
private func requestAuthenticationForHost(host: RemoteHost) async throws -> URLCredential {
119+
if KeychainAccessor.shared.hasCredentials(for: host.url) {
120+
do {
121+
return try await requestBiometricAuthenticationForHost(host: host)
122+
} catch {
123+
return try await requestManualAuthenticationForHost(host: host)
124+
}
125+
} else {
126+
return try await requestManualAuthenticationForHost(host: host)
127+
}
88128
}
89129

90-
func onConnectToHostWithCredentials(
91-
host: RemoteHost, cred: URLCredential
92-
) async throws {
93-
guard let hostUrl = URL(string: host.url) else {
94-
throw RemoteHostError.invalidUrl
130+
func onConnectToHost(host: RemoteHost) async throws {
131+
if let jumpServerURL = host.jumpServerUrl {
132+
guard
133+
let jumpHost = UserDefaults.standard.remoteHosts.first(where: {
134+
$0.url == jumpServerURL
135+
})
136+
else {
137+
throw WorkSpaceStorage.FSError.MissingJumpingServer
138+
}
139+
let jumpCred = try await requestAuthenticationForHost(host: jumpHost)
140+
let cred = try await requestAuthenticationForHost(host: host)
141+
try await connectToHostWithCredentialsUsingJumpHost(
142+
host: host, jumpHost: jumpHost, hostCred: cred, jumpCred: jumpCred)
143+
} else {
144+
let cred = try await requestAuthenticationForHost(host: host)
145+
try await onConnectToHostWithCredentials(host: host, cred: cred)
95146
}
147+
}
96148

149+
private func authenticationModeForHost(host: RemoteHost, cred: URLCredential) throws
150+
-> RemoteAuthenticationMode
151+
{
97152
var authenticationMode: RemoteAuthenticationMode
98-
99153
if host.useKeyAuth {
100154
// Legacy in-file id_rsa authentication
101155
authenticationMode = .inFileSSHKey(cred, nil)
@@ -107,6 +161,88 @@ struct RemoteContainer: View {
107161
} else {
108162
authenticationMode = .plainUsernamePassword(cred)
109163
}
164+
return authenticationMode
165+
}
166+
167+
private func connectionResultHandler(
168+
hostUrl: URL, error: (any Error)?, continuation: CheckedContinuation<Void, Error>
169+
) {
170+
if let error {
171+
DispatchQueue.main.async {
172+
App.notificationManager.showErrorMessage(
173+
error.localizedDescription)
174+
}
175+
continuation.resume(throwing: error)
176+
} else {
177+
DispatchQueue.main.async {
178+
App.loadRepository(url: hostUrl)
179+
App.notificationManager.showInformationMessage(
180+
"remote.connected")
181+
App.terminalInstance.terminalServiceProvider =
182+
App.workSpaceStorage.terminalServiceProvider
183+
}
184+
continuation.resume(returning: ())
185+
}
186+
}
187+
188+
private func connectToHostWithCredentialsUsingJumpHost(
189+
host: RemoteHost,
190+
jumpHost: RemoteHost,
191+
hostCred: URLCredential,
192+
jumpCred: URLCredential
193+
) async throws {
194+
guard let hostUrl = URL(string: host.url),
195+
let jumpServerUrlString = host.jumpServerUrl,
196+
let jumpHostUrl = URL(string: jumpServerUrlString)
197+
else {
198+
throw RemoteHostError.invalidUrl
199+
}
200+
201+
let hostAuthenticationMode = try authenticationModeForHost(host: host, cred: hostCred)
202+
let jumpHostAuthenticationMode = try authenticationModeForHost(
203+
host: jumpHost, cred: jumpCred)
204+
205+
try await App.notificationManager.withAsyncNotification(
206+
title: "remote.connecting",
207+
task: {
208+
try await withCheckedThrowingContinuation {
209+
(continuation: CheckedContinuation<Void, Error>) in
210+
App.workSpaceStorage.connectToServer(
211+
host: hostUrl, authenticationModeForHost: hostAuthenticationMode,
212+
jumpServer: jumpHostUrl,
213+
authenticationModeForJumpServer: jumpHostAuthenticationMode
214+
) {
215+
error in
216+
connectionResultHandler(
217+
hostUrl: hostUrl, error: error, continuation: continuation)
218+
}
219+
}
220+
}
221+
)
222+
}
223+
224+
func onConnectToHostWithCredentials(
225+
host: RemoteHost, cred: URLCredential
226+
) async throws {
227+
228+
if host.jumpServerUrl != nil {
229+
guard
230+
let jumpHost = UserDefaults.standard.remoteHosts.first(where: {
231+
$0.url == host.jumpServerUrl
232+
})
233+
else {
234+
throw WorkSpaceStorage.FSError.MissingJumpingServer
235+
}
236+
let jumpHostCred = try await requestAuthenticationForHost(host: jumpHost)
237+
return try await connectToHostWithCredentialsUsingJumpHost(
238+
host: host, jumpHost: jumpHost, hostCred: cred, jumpCred: jumpHostCred)
239+
}
240+
241+
guard let hostUrl = URL(string: host.url) else {
242+
throw RemoteHostError.invalidUrl
243+
}
244+
245+
let authenticationMode = try authenticationModeForHost(host: host, cred: cred)
110246

111247
try await App.notificationManager.withAsyncNotification(
112248
title: "remote.connecting",
@@ -117,20 +253,8 @@ struct RemoteContainer: View {
117253
host: hostUrl, authenticationMode: authenticationMode
118254
) {
119255
error in
120-
if let error = error {
121-
DispatchQueue.main.async {
122-
App.notificationManager.showErrorMessage(
123-
error.localizedDescription)
124-
}
125-
continuation.resume(throwing: error)
126-
} else {
127-
App.loadRepository(url: hostUrl)
128-
App.notificationManager.showInformationMessage(
129-
"remote.connected")
130-
App.terminalInstance.terminalServiceProvider =
131-
App.workSpaceStorage.terminalServiceProvider
132-
continuation.resume(returning: ())
133-
}
256+
connectionResultHandler(
257+
hostUrl: hostUrl, error: error, continuation: continuation)
134258
}
135259
}
136260
}
@@ -145,7 +269,6 @@ struct RemoteContainer: View {
145269
} else {
146270
RemoteListSection(
147271
hosts: hosts, onRemoveHost: onRemoveHost, onConnectToHost: onConnectToHost,
148-
onConnectToHostWithCredentials: onConnectToHostWithCredentials,
149272
onRenameHost: onRenameHost)
150273
RemoteCreateSection(
151274
hosts: hosts,

CodeApp/Errors/AppError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ enum AppError: String {
1414
case editorIsNotReady = "errors.editor_is_not_ready"
1515
case encodingFailed = "errors.failed_to_save_file.encoding.failed"
1616
case fileModifiedByAnotherProcess = "errors.file_modified_by_another_process"
17+
case operationCancelledByUser = "errors.operation_cancelled_by_user"
1718
}
1819

1920
extension AppError: LocalizedError {

0 commit comments

Comments
 (0)