diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index 1fb11fc8..efcefe96 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -27,18 +27,89 @@ export class SSHService { return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath } - async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise { + async generateWinSCPXTunnelURI (jumpHostProfile: SSHProfile|null): Promise<{ uri: string|null, privateKeyFile?: tmp.FileResult|null }> { + let uri = '' + let tmpFile: tmp.FileResult|null = null + if (jumpHostProfile) { + uri += ';x-tunnel=1' + const jumpHostname = jumpHostProfile.options.host + uri += `;x-tunnelhostname=${jumpHostname}` + const jumpPort = jumpHostProfile.options.port ?? 22 + uri += `;x-tunnelportnumber=${jumpPort}` + const jumpUsername = jumpHostProfile.options.user + uri += `;x-tunnelusername=${jumpUsername}` + if (jumpHostProfile.options.auth === 'password') { + const jumpPassword = await this.passwordStorage.loadPassword(jumpHostProfile, jumpUsername) + if (jumpPassword) { + uri += `;x-tunnelpasswordplain=${encodeURIComponent(jumpPassword)}` + } + } + if (jumpHostProfile.options.auth === 'publicKey' && jumpHostProfile.options.privateKeys && jumpHostProfile.options.privateKeys.length > 0) { + const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(jumpHostProfile) + tmpFile = privateKeyPairs.privateKeyFile + if (tmpFile) { + uri += `;x-tunnelpublickeyfile=${encodeURIComponent(tmpFile.path)}` + } + if (privateKeyPairs.passphrase != null) { + uri += `;x-tunnelpassphraseplain=${encodeURIComponent(privateKeyPairs.passphrase)}` + } + } + } + return { uri: uri, privateKeyFile: tmpFile?? null } + } + + async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<{ uri: string, privateKeyFile?: tmp.FileResult|null }> { let uri = `scp://${username ?? profile.options.user}` const password = await this.passwordStorage.loadPassword(profile, username) if (password) { uri += ':' + encodeURIComponent(password) } + let tmpFile: tmp.FileResult|null = null + if (profile.options.jumpHost) { + const jumpHostProfile = this.config.store.profiles.find(x => x.id === profile.options.jumpHost) ?? null + const xTunnelParams = await this.generateWinSCPXTunnelURI(jumpHostProfile) + uri += xTunnelParams.uri ?? '' + tmpFile = xTunnelParams.privateKeyFile ?? null + } if (profile.options.host.includes(':')) { uri += `@[${profile.options.host}]:${profile.options.port}${cwd ?? '/'}` }else { uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}` } - return uri + return { uri, privateKeyFile: tmpFile?? null } + } + + async convertPrivateKeyFileToPuTTYFormat (profile: SSHProfile): Promise<{ passphrase: string|null, privateKeyFile: tmp.FileResult|null }> { + if (!profile.options.privateKeys || profile.options.privateKeys.length === 0) { + throw new Error('No private keys in profile') + } + const path = this.getWinSCPPath() + if (!path) { + throw new Error('WinSCP not found') + } + let tmpPrivateKeyFile: tmp.FileResult|null = null + let passphrase: string|null = null + const tmpFile: tmp.FileResult = await tmp.file() + for (const pk of profile.options.privateKeys) { + let privateKeyContent: string|null = null + const buffer = await this.fileProviders.retrieveFile(pk) + privateKeyContent = buffer.toString() + await fs.writeFile(tmpFile.path, privateKeyContent) + const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex') + // need to pass an default passphrase, otherwise it might get stuck at the passphrase input + const curPassphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby' + const winSCPcom = path.slice(0, -3) + 'com' + try { + await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', curPassphrase]) + } catch (error) { + console.warn('Could not convert private key ', error) + continue + } + tmpPrivateKeyFile = tmpFile + passphrase = curPassphrase + break + } + return { passphrase, privateKeyFile: tmpPrivateKeyFile } } async launchWinSCP (session: SSHSession): Promise { @@ -46,38 +117,26 @@ export class SSHService { if (!path) { return } - const args = [await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)] + const winscpParms = await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined) + const args = [winscpParms.uri] let tmpFile: tmp.FileResult|null = null try { if (session.activePrivateKey && session.profile.options.privateKeys && session.profile.options.privateKeys.length > 0) { - tmpFile = await tmp.file() - let passphrase: string|null = null - for (const pk of session.profile.options.privateKeys) { - let privateKeyContent: string|null = null - const buffer = await this.fileProviders.retrieveFile(pk) - privateKeyContent = buffer.toString() - await fs.writeFile(tmpFile.path, privateKeyContent) - const keyHash = crypto.createHash('sha512').update(privateKeyContent).digest('hex') - // need to pass an default passphrase, otherwise it might get stuck at the passphrase input - passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) ?? 'tabby' - const winSCPcom = path.slice(0, -3) + 'com' - try { - await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, '-o', tmpFile.path, '--old-passphrase', passphrase]) - } catch (error) { - console.warn('Could not convert private key ', error) - continue - } - break + const profile = session.profile + const privateKeyPairs = await this.convertPrivateKeyFileToPuTTYFormat(profile) + tmpFile = privateKeyPairs.privateKeyFile + if (tmpFile) { + args.push(`/privatekey=${tmpFile.path}`) } - args.push(`/privatekey=${tmpFile.path}`) - if (passphrase != null) { - args.push(`/passphrase=${passphrase}`) + if (privateKeyPairs.passphrase != null) { + args.push(`/passphrase=${privateKeyPairs.passphrase}`) } } await this.platform.exec(path, args) } finally { tmpFile?.cleanup() + winscpParms.privateKeyFile?.cleanup() } } }