api.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  1. import * as fs from 'mz/fs'
  2. import * as crypto from 'crypto'
  3. import * as path from 'path'
  4. import * as C from 'constants'
  5. // eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
  6. import { posix as posixPath } from 'path'
  7. import * as sshpk from 'sshpk'
  8. import colors from 'ansi-colors'
  9. import stripAnsi from 'strip-ansi'
  10. import socksv5 from 'socksv5'
  11. import { Injector, NgZone } from '@angular/core'
  12. import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
  13. import { FileProvidersService, HostAppService, Logger, NotificationsService, Platform, PlatformService, wrapPromise } from 'terminus-core'
  14. import { BaseSession } from 'terminus-terminal'
  15. import { Server, Socket, createServer, createConnection } from 'net'
  16. import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
  17. import type { FileEntry, Stats } from 'ssh2-streams'
  18. import { Subject, Observable } from 'rxjs'
  19. import { ProxyCommandStream } from './services/ssh.service'
  20. import { PasswordStorageService } from './services/passwordStorage.service'
  21. import { PromptModalComponent } from './components/promptModal.component'
  22. import { promisify } from 'util'
  23. const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
  24. export interface LoginScript {
  25. expect: string
  26. send: string
  27. isRegex?: boolean
  28. optional?: boolean
  29. }
  30. export enum SSHAlgorithmType {
  31. HMAC = 'hmac',
  32. KEX = 'kex',
  33. CIPHER = 'cipher',
  34. HOSTKEY = 'serverHostKey',
  35. }
  36. export interface SSHConnection {
  37. name: string
  38. host: string
  39. port?: number
  40. user: string
  41. auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
  42. password?: string
  43. privateKeys?: string[]
  44. group: string | null
  45. scripts?: LoginScript[]
  46. keepaliveInterval?: number
  47. keepaliveCountMax?: number
  48. readyTimeout?: number
  49. color?: string
  50. x11?: boolean
  51. skipBanner?: boolean
  52. disableDynamicTitle?: boolean
  53. jumpHost?: string
  54. agentForward?: boolean
  55. warnOnClose?: boolean
  56. algorithms?: Record<string, string[]>
  57. proxyCommand?: string
  58. forwardedPorts?: ForwardedPortConfig[]
  59. }
  60. export enum PortForwardType {
  61. Local = 'Local',
  62. Remote = 'Remote',
  63. Dynamic = 'Dynamic',
  64. }
  65. export interface ForwardedPortConfig {
  66. type: PortForwardType
  67. host: string
  68. port: number
  69. targetAddress: string
  70. targetPort: number
  71. }
  72. export class ForwardedPort implements ForwardedPortConfig {
  73. type: PortForwardType
  74. host = '127.0.0.1'
  75. port: number
  76. targetAddress: string
  77. targetPort: number
  78. private listener: Server
  79. async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise<void> {
  80. if (this.type === PortForwardType.Local) {
  81. this.listener = createServer(s => callback(
  82. () => s,
  83. () => s.destroy(),
  84. s.remoteAddress ?? null,
  85. s.remotePort ?? null,
  86. this.targetAddress,
  87. this.targetPort,
  88. ))
  89. return new Promise((resolve, reject) => {
  90. this.listener.listen(this.port, this.host)
  91. this.listener.on('error', reject)
  92. this.listener.on('listening', resolve)
  93. })
  94. } else if (this.type === PortForwardType.Dynamic) {
  95. return new Promise((resolve, reject) => {
  96. this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => {
  97. callback(
  98. () => acceptConnection(true),
  99. () => rejectConnection(),
  100. null,
  101. null,
  102. info.dstAddr,
  103. info.dstPort,
  104. )
  105. })
  106. this.listener.on('error', reject)
  107. this.listener.listen(this.port, this.host, resolve)
  108. ;(this.listener as any).useAuth(socksv5.auth.None())
  109. })
  110. } else {
  111. throw new Error('Invalid forward type for a local listener')
  112. }
  113. }
  114. stopLocalListener (): void {
  115. this.listener.close()
  116. }
  117. toString (): string {
  118. if (this.type === PortForwardType.Local) {
  119. return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}`
  120. } if (this.type === PortForwardType.Remote) {
  121. return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}`
  122. } else {
  123. return `(dynamic) ${this.host}:${this.port}`
  124. }
  125. }
  126. }
  127. interface AuthMethod {
  128. type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
  129. name?: string
  130. contents?: Buffer
  131. }
  132. export interface SFTPFile {
  133. name: string
  134. fullPath: string
  135. isDirectory: boolean
  136. isSymlink: boolean
  137. mode: number
  138. size: number
  139. modified: Date
  140. }
  141. export class SFTPFileHandle {
  142. position = 0
  143. constructor (
  144. private sftp: SFTPWrapper,
  145. private handle: Buffer,
  146. private zone: NgZone,
  147. ) { }
  148. read (): Promise<Buffer> {
  149. const buffer = Buffer.alloc(256 * 1024)
  150. return wrapPromise(this.zone, new Promise((resolve, reject) => {
  151. while (true) {
  152. const wait = this.sftp.read(this.handle, buffer, 0, buffer.length, this.position, (err, read) => {
  153. if (err) {
  154. reject(err)
  155. return
  156. }
  157. this.position += read
  158. resolve(buffer.slice(0, read))
  159. })
  160. if (!wait) {
  161. break
  162. }
  163. }
  164. }))
  165. }
  166. write (chunk: Buffer): Promise<void> {
  167. return wrapPromise(this.zone, new Promise<void>((resolve, reject) => {
  168. while (true) {
  169. const wait = this.sftp.write(this.handle, chunk, 0, chunk.length, this.position, err => {
  170. if (err) {
  171. return reject(err)
  172. }
  173. this.position += chunk.length
  174. resolve()
  175. })
  176. if (!wait) {
  177. break
  178. }
  179. }
  180. }))
  181. }
  182. close (): Promise<void> {
  183. return wrapPromise(this.zone, promisify(this.sftp.close.bind(this.sftp))(this.handle))
  184. }
  185. }
  186. export class SFTPSession {
  187. constructor (private sftp: SFTPWrapper, private zone: NgZone) { }
  188. async readdir (p: string): Promise<SFTPFile[]> {
  189. const entries = await wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))())
  190. return entries.map(entry => this._makeFile(
  191. posixPath.join(p, entry.filename), entry,
  192. ))
  193. }
  194. readlink (p: string): Promise<string> {
  195. return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
  196. }
  197. async stat (p: string): Promise<SFTPFile> {
  198. const stats = await wrapPromise(this.zone, promisify<Stats>(f => this.sftp.stat(p, f))())
  199. return {
  200. name: posixPath.basename(p),
  201. fullPath: p,
  202. isDirectory: stats.isDirectory(),
  203. isSymlink: stats.isSymbolicLink(),
  204. mode: stats.mode,
  205. size: stats.size,
  206. modified: new Date(stats.mtime * 1000),
  207. }
  208. }
  209. async open (p: string, mode: string): Promise<SFTPFileHandle> {
  210. const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))())
  211. return new SFTPFileHandle(this.sftp, handle, this.zone)
  212. }
  213. async rmdir (p: string): Promise<void> {
  214. await promisify((f: any) => this.sftp.rmdir(p, f))()
  215. }
  216. async unlink (p: string): Promise<void> {
  217. await promisify((f: any) => this.sftp.unlink(p, f))()
  218. }
  219. private _makeFile (p: string, entry: FileEntry): SFTPFile {
  220. return {
  221. fullPath: p,
  222. name: posixPath.basename(p),
  223. isDirectory: (entry.attrs.mode & C.S_IFDIR) === C.S_IFDIR,
  224. isSymlink: (entry.attrs.mode & C.S_IFLNK) === C.S_IFLNK,
  225. mode: entry.attrs.mode,
  226. size: entry.attrs.size,
  227. modified: new Date(entry.attrs.mtime * 1000),
  228. }
  229. }
  230. }
  231. export class SSHSession extends BaseSession {
  232. scripts?: LoginScript[]
  233. shell?: ClientChannel
  234. ssh: Client
  235. sftp?: SFTPWrapper
  236. forwardedPorts: ForwardedPort[] = []
  237. logger: Logger
  238. jumpStream: any
  239. proxyCommandStream: ProxyCommandStream|null = null
  240. savedPassword?: string
  241. get serviceMessage$ (): Observable<string> { return this.serviceMessage }
  242. agentPath?: string
  243. activePrivateKey: string|null = null
  244. private remainingAuthMethods: AuthMethod[] = []
  245. private serviceMessage = new Subject<string>()
  246. private keychainPasswordUsed = false
  247. private passwordStorage: PasswordStorageService
  248. private ngbModal: NgbModal
  249. private hostApp: HostAppService
  250. private platform: PlatformService
  251. private notifications: NotificationsService
  252. private zone: NgZone
  253. private fileProviders: FileProvidersService
  254. constructor (
  255. injector: Injector,
  256. public connection: SSHConnection,
  257. ) {
  258. super()
  259. this.passwordStorage = injector.get(PasswordStorageService)
  260. this.ngbModal = injector.get(NgbModal)
  261. this.hostApp = injector.get(HostAppService)
  262. this.platform = injector.get(PlatformService)
  263. this.notifications = injector.get(NotificationsService)
  264. this.zone = injector.get(NgZone)
  265. this.fileProviders = injector.get(FileProvidersService)
  266. this.scripts = connection.scripts ?? []
  267. this.destroyed$.subscribe(() => {
  268. for (const port of this.forwardedPorts) {
  269. if (port.type === PortForwardType.Local) {
  270. port.stopLocalListener()
  271. }
  272. }
  273. })
  274. }
  275. async init (): Promise<void> {
  276. if (this.hostApp.platform === Platform.Windows) {
  277. if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
  278. this.agentPath = WINDOWS_OPENSSH_AGENT_PIPE
  279. } else {
  280. if (await this.platform.isProcessRunning('pageant.exe')) {
  281. this.agentPath = 'pageant'
  282. }
  283. }
  284. } else {
  285. this.agentPath = process.env.SSH_AUTH_SOCK!
  286. }
  287. this.remainingAuthMethods = [{ type: 'none' }]
  288. if (!this.connection.auth || this.connection.auth === 'publicKey') {
  289. for (const pk of this.connection.privateKeys ?? []) {
  290. try {
  291. this.remainingAuthMethods.push({
  292. type: 'publickey',
  293. name: pk,
  294. contents: await this.fileProviders.retrieveFile(pk),
  295. })
  296. } catch (error) {
  297. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Could not load private key ${pk}: ${error}`)
  298. }
  299. }
  300. }
  301. if (!this.connection.auth || this.connection.auth === 'agent') {
  302. if (!this.agentPath) {
  303. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
  304. } else {
  305. this.remainingAuthMethods.push({ type: 'agent' })
  306. }
  307. }
  308. if (!this.connection.auth || this.connection.auth === 'password') {
  309. this.remainingAuthMethods.push({ type: 'password' })
  310. }
  311. if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') {
  312. this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
  313. }
  314. this.remainingAuthMethods.push({ type: 'hostbased' })
  315. }
  316. async openSFTP (): Promise<SFTPSession> {
  317. if (!this.sftp) {
  318. this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
  319. }
  320. return new SFTPSession(this.sftp, this.zone)
  321. }
  322. async start (): Promise<void> {
  323. this.open = true
  324. this.proxyCommandStream?.on('error', err => {
  325. this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
  326. this.destroy()
  327. })
  328. try {
  329. this.shell = await this.openShellChannel({ x11: this.connection.x11 })
  330. } catch (err) {
  331. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`)
  332. if (err.toString().includes('Unable to request X11')) {
  333. this.emitServiceMessage(' Make sure `xauth` is installed on the remote side')
  334. }
  335. return
  336. }
  337. this.shell.on('greeting', greeting => {
  338. this.emitServiceMessage(`Shell greeting: ${greeting}`)
  339. })
  340. this.shell.on('banner', banner => {
  341. this.emitServiceMessage(`Shell banner: ${banner}`)
  342. })
  343. this.shell.on('data', data => {
  344. const dataString = data.toString()
  345. this.emitOutput(data)
  346. if (this.scripts) {
  347. let found = false
  348. for (const script of this.scripts) {
  349. let match = false
  350. let cmd = ''
  351. if (script.isRegex) {
  352. const re = new RegExp(script.expect, 'g')
  353. if (dataString.match(re)) {
  354. cmd = dataString.replace(re, script.send)
  355. match = true
  356. found = true
  357. }
  358. } else {
  359. if (dataString.includes(script.expect)) {
  360. cmd = script.send
  361. match = true
  362. found = true
  363. }
  364. }
  365. if (match) {
  366. this.logger.info('Executing script: "' + cmd + '"')
  367. this.shell?.write(cmd + '\n')
  368. this.scripts = this.scripts.filter(x => x !== script)
  369. } else {
  370. if (script.optional) {
  371. this.logger.debug('Skip optional script: ' + script.expect)
  372. found = true
  373. this.scripts = this.scripts.filter(x => x !== script)
  374. } else {
  375. break
  376. }
  377. }
  378. }
  379. if (found) {
  380. this.executeUnconditionalScripts()
  381. }
  382. }
  383. })
  384. this.shell.on('end', () => {
  385. this.logger.info('Shell session ended')
  386. if (this.open) {
  387. this.destroy()
  388. }
  389. })
  390. this.ssh.on('tcp connection', (details, accept, reject) => {
  391. this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`)
  392. const forward = this.forwardedPorts.find(x => x.port === details.destPort)
  393. if (!forward) {
  394. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`)
  395. return reject()
  396. }
  397. const socket = new Socket()
  398. socket.connect(forward.targetPort, forward.targetAddress)
  399. socket.on('error', e => {
  400. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  401. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`)
  402. reject()
  403. })
  404. socket.on('connect', () => {
  405. this.logger.info('Connection forwarded')
  406. const stream = accept()
  407. stream.pipe(socket)
  408. socket.pipe(stream)
  409. stream.on('close', () => {
  410. socket.destroy()
  411. })
  412. socket.on('close', () => {
  413. stream.close()
  414. })
  415. })
  416. })
  417. this.ssh.on('x11', (details, accept, reject) => {
  418. this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`)
  419. const displaySpec = process.env.DISPLAY ?? ':0'
  420. this.logger.debug(`Trying display ${displaySpec}`)
  421. const xHost = displaySpec.split(':')[0]
  422. const xDisplay = parseInt(displaySpec.split(':')[1].split('.')[0] || '0')
  423. const xPort = xDisplay < 100 ? xDisplay + 6000 : xDisplay
  424. const socket = displaySpec.startsWith('/') ? createConnection(displaySpec) : new Socket()
  425. if (!displaySpec.startsWith('/')) {
  426. socket.connect(xPort, xHost)
  427. }
  428. socket.on('error', e => {
  429. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  430. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`)
  431. this.emitServiceMessage(` Terminus tried to connect to ${xHost}:${xPort} based on the DISPLAY environment var (${displaySpec})`)
  432. if (process.platform === 'win32') {
  433. this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:')
  434. this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
  435. this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
  436. }
  437. reject()
  438. })
  439. socket.on('connect', () => {
  440. this.logger.info('Connection forwarded')
  441. const stream = accept()
  442. stream.pipe(socket)
  443. socket.pipe(stream)
  444. stream.on('close', () => {
  445. socket.destroy()
  446. })
  447. socket.on('close', () => {
  448. stream.close()
  449. })
  450. })
  451. })
  452. this.executeUnconditionalScripts()
  453. }
  454. emitServiceMessage (msg: string): void {
  455. this.serviceMessage.next(msg)
  456. this.logger.info(stripAnsi(msg))
  457. }
  458. async handleAuth (methodsLeft?: string[]): Promise<any> {
  459. this.activePrivateKey = null
  460. while (true) {
  461. const method = this.remainingAuthMethods.shift()
  462. if (!method) {
  463. return false
  464. }
  465. if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
  466. // Agent can still be used even if not in methodsLeft
  467. this.logger.info('Server does not support auth method', method.type)
  468. continue
  469. }
  470. if (method.type === 'password') {
  471. if (this.connection.password) {
  472. this.emitServiceMessage('Using preset password')
  473. return {
  474. type: 'password',
  475. username: this.connection.user,
  476. password: this.connection.password,
  477. }
  478. }
  479. if (!this.keychainPasswordUsed) {
  480. const password = await this.passwordStorage.loadPassword(this.connection)
  481. if (password) {
  482. this.emitServiceMessage('Trying saved password')
  483. this.keychainPasswordUsed = true
  484. return {
  485. type: 'password',
  486. username: this.connection.user,
  487. password,
  488. }
  489. }
  490. }
  491. const modal = this.ngbModal.open(PromptModalComponent)
  492. modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
  493. modal.componentInstance.password = true
  494. modal.componentInstance.showRememberCheckbox = true
  495. try {
  496. const result = await modal.result
  497. if (result) {
  498. if (result.remember) {
  499. this.savedPassword = result.value
  500. }
  501. return {
  502. type: 'password',
  503. username: this.connection.user,
  504. password: result.value,
  505. }
  506. } else {
  507. continue
  508. }
  509. } catch {
  510. continue
  511. }
  512. }
  513. if (method.type === 'publickey') {
  514. try {
  515. const key = await this.loadPrivateKey(method.contents)
  516. return {
  517. type: 'publickey',
  518. username: this.connection.user,
  519. key,
  520. }
  521. } catch (e) {
  522. this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
  523. continue
  524. }
  525. }
  526. return method
  527. }
  528. }
  529. async addPortForward (fw: ForwardedPort): Promise<void> {
  530. if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
  531. await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
  532. this.logger.info(`New connection on ${fw}`)
  533. this.ssh.forwardOut(
  534. sourceAddress ?? '127.0.0.1',
  535. sourcePort ?? 0,
  536. targetAddress,
  537. targetPort,
  538. (err, stream) => {
  539. if (err) {
  540. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  541. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
  542. return reject()
  543. }
  544. const socket = accept()
  545. stream.pipe(socket)
  546. socket.pipe(stream)
  547. stream.on('close', () => {
  548. socket.destroy()
  549. })
  550. socket.on('close', () => {
  551. stream.close()
  552. })
  553. }
  554. )
  555. }).then(() => {
  556. this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
  557. this.forwardedPorts.push(fw)
  558. }).catch(e => {
  559. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`)
  560. throw e
  561. })
  562. }
  563. if (fw.type === PortForwardType.Remote) {
  564. await new Promise<void>((resolve, reject) => {
  565. this.ssh.forwardIn(fw.host, fw.port, err => {
  566. if (err) {
  567. // eslint-disable-next-line @typescript-eslint/no-base-to-string
  568. this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`)
  569. return reject(err)
  570. }
  571. resolve()
  572. })
  573. })
  574. this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
  575. this.forwardedPorts.push(fw)
  576. }
  577. }
  578. async removePortForward (fw: ForwardedPort): Promise<void> {
  579. if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
  580. fw.stopLocalListener()
  581. this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
  582. }
  583. if (fw.type === PortForwardType.Remote) {
  584. this.ssh.unforwardIn(fw.host, fw.port)
  585. this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
  586. }
  587. this.emitServiceMessage(`Stopped forwarding ${fw}`)
  588. }
  589. resize (columns: number, rows: number): void {
  590. if (this.shell) {
  591. this.shell.setWindow(rows, columns, rows, columns)
  592. }
  593. }
  594. write (data: Buffer): void {
  595. if (this.shell) {
  596. this.shell.write(data)
  597. }
  598. }
  599. kill (signal?: string): void {
  600. if (this.shell) {
  601. this.shell.signal(signal ?? 'TERM')
  602. }
  603. }
  604. async destroy (): Promise<void> {
  605. this.serviceMessage.complete()
  606. this.proxyCommandStream?.destroy()
  607. this.kill()
  608. this.ssh.end()
  609. await super.destroy()
  610. }
  611. async getChildProcesses (): Promise<any[]> {
  612. return []
  613. }
  614. async gracefullyKillProcess (): Promise<void> {
  615. this.kill('TERM')
  616. }
  617. supportsWorkingDirectory (): boolean {
  618. return true
  619. }
  620. async getWorkingDirectory (): Promise<string|null> {
  621. return null
  622. }
  623. private openShellChannel (options): Promise<ClientChannel> {
  624. return new Promise<ClientChannel>((resolve, reject) => {
  625. this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => {
  626. if (err) {
  627. reject(err)
  628. } else {
  629. resolve(shell)
  630. }
  631. })
  632. })
  633. }
  634. private executeUnconditionalScripts () {
  635. if (this.scripts) {
  636. for (const script of this.scripts) {
  637. if (!script.expect) {
  638. console.log('Executing script:', script.send)
  639. this.shell?.write(script.send + '\n')
  640. this.scripts = this.scripts.filter(x => x !== script)
  641. } else {
  642. break
  643. }
  644. }
  645. }
  646. }
  647. async loadPrivateKey (privateKeyContents?: Buffer): Promise<string|null> {
  648. if (!privateKeyContents) {
  649. const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
  650. if (await fs.exists(userKeyPath)) {
  651. this.emitServiceMessage('Using user\'s default private key')
  652. privateKeyContents = fs.readFile(userKeyPath, { encoding: null })
  653. }
  654. }
  655. if (!privateKeyContents) {
  656. return null
  657. }
  658. this.emitServiceMessage('Loading private key')
  659. try {
  660. const parsedKey = await this.parsePrivateKey(privateKeyContents.toString())
  661. this.activePrivateKey = parsedKey.toString('openssh')
  662. return this.activePrivateKey
  663. } catch (error) {
  664. this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
  665. this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
  666. this.notifications.error('Could not read the private key file')
  667. return null
  668. }
  669. }
  670. async parsePrivateKey (privateKey: string): Promise<any> {
  671. const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
  672. let passphrase: string|null = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
  673. while (true) {
  674. try {
  675. return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase })
  676. } catch (e) {
  677. if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
  678. await this.passwordStorage.deletePrivateKeyPassword(keyHash)
  679. const modal = this.ngbModal.open(PromptModalComponent)
  680. modal.componentInstance.prompt = 'Private key passphrase'
  681. modal.componentInstance.password = true
  682. modal.componentInstance.showRememberCheckbox = true
  683. try {
  684. const result = await modal.result
  685. passphrase = result?.value
  686. if (passphrase && result.remember) {
  687. this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase)
  688. }
  689. } catch {
  690. throw e
  691. }
  692. } else {
  693. this.notifications.error('Could not read the private key', e.toString())
  694. throw e
  695. }
  696. }
  697. }
  698. }
  699. }
  700. export const ALGORITHM_BLACKLIST = [
  701. // cause native crashes in node crypto, use EC instead
  702. 'diffie-hellman-group-exchange-sha256',
  703. 'diffie-hellman-group-exchange-sha1',
  704. ]