sshTab.component.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import colors from 'ansi-colors'
  2. import { Spinner } from 'cli-spinner'
  3. import { Component, Injector } from '@angular/core'
  4. import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
  5. import { first } from 'rxjs/operators'
  6. import { RecoveryToken } from 'terminus-core'
  7. import { BaseTerminalTabComponent } from 'terminus-terminal'
  8. import { SSHService } from '../services/ssh.service'
  9. import { SSHConnection, SSHSession } from '../api'
  10. import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
  11. import { Subscription } from 'rxjs'
  12. /** @hidden */
  13. @Component({
  14. selector: 'ssh-tab',
  15. template: `${BaseTerminalTabComponent.template} ${require('./sshTab.component.pug')}`,
  16. styles: [require('./sshTab.component.scss'), ...BaseTerminalTabComponent.styles],
  17. animations: BaseTerminalTabComponent.animations,
  18. })
  19. export class SSHTabComponent extends BaseTerminalTabComponent {
  20. connection?: SSHConnection
  21. session: SSHSession|null = null
  22. private sessionStack: SSHSession[] = []
  23. private homeEndSubscription: Subscription
  24. private recentInputs = ''
  25. private reconnectOffered = false
  26. constructor (
  27. injector: Injector,
  28. public ssh: SSHService,
  29. private ngbModal: NgbModal,
  30. ) {
  31. super(injector)
  32. }
  33. ngOnInit (): void {
  34. if (!this.connection) {
  35. throw new Error('Connection not set')
  36. }
  37. this.logger = this.log.create('terminalTab')
  38. this.enableDynamicTitle = !this.connection.disableDynamicTitle
  39. this.homeEndSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => {
  40. if (!this.hasFocus) {
  41. return
  42. }
  43. switch (hotkey) {
  44. case 'home':
  45. this.sendInput('\x1b[H' )
  46. break
  47. case 'end':
  48. this.sendInput('\x1b[F' )
  49. break
  50. }
  51. })
  52. this.frontendReady$.pipe(first()).subscribe(() => {
  53. this.initializeSession()
  54. this.input$.subscribe(data => {
  55. this.recentInputs += data
  56. this.recentInputs = this.recentInputs.substring(this.recentInputs.length - 32)
  57. })
  58. })
  59. super.ngOnInit()
  60. setImmediate(() => {
  61. this.setTitle(this.connection!.name)
  62. })
  63. }
  64. async setupOneSession (session: SSHSession): Promise<void> {
  65. if (session.connection.jumpHost) {
  66. const jumpConnection: SSHConnection|null = this.config.store.ssh.connections.find(x => x.name === session.connection.jumpHost)
  67. if (!jumpConnection) {
  68. throw new Error(`${session.connection.host}: jump host "${session.connection.jumpHost}" not found in your config`)
  69. }
  70. const jumpSession = this.ssh.createSession(jumpConnection)
  71. await this.setupOneSession(jumpSession)
  72. this.attachSessionHandler(
  73. jumpSession.destroyed$.subscribe(() => {
  74. if (session.open) {
  75. session.destroy()
  76. }
  77. })
  78. )
  79. session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
  80. '127.0.0.1', 0, session.connection.host, session.connection.port ?? 22,
  81. (err, stream) => {
  82. if (err) {
  83. jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
  84. return reject(err)
  85. }
  86. resolve(stream)
  87. }
  88. ))
  89. session.jumpStream.on('close', () => {
  90. jumpSession.destroy()
  91. })
  92. this.sessionStack.push(session)
  93. }
  94. this.attachSessionHandler(session.serviceMessage$.subscribe(msg => {
  95. this.write(`\r\n${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
  96. session.resize(this.size.columns, this.size.rows)
  97. }))
  98. this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
  99. const spinner = new Spinner({
  100. text: 'Connecting',
  101. stream: {
  102. write: x => this.write(x),
  103. },
  104. })
  105. spinner.setSpinnerString(6)
  106. spinner.start()
  107. try {
  108. await this.ssh.connectSession(session, (message: string) => {
  109. spinner.stop(true)
  110. this.write(message + '\r\n')
  111. spinner.start()
  112. })
  113. spinner.stop(true)
  114. } catch (e) {
  115. spinner.stop(true)
  116. this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
  117. return
  118. }
  119. }
  120. protected attachSessionHandlers (): void {
  121. const session = this.session!
  122. super.attachSessionHandlers()
  123. this.attachSessionHandler(session.destroyed$.subscribe(() => {
  124. if (
  125. // Ctrl-D
  126. this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 ||
  127. this.recentInputs.endsWith('exit\r')
  128. ) {
  129. // User closed the session
  130. this.destroy()
  131. } else {
  132. // Session was closed abruptly
  133. this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
  134. if (!this.reconnectOffered) {
  135. this.reconnectOffered = true
  136. this.write('Press any key to reconnect\r\n')
  137. this.attachSessionHandler(this.input$.pipe(first()).subscribe(() => {
  138. this.reconnect()
  139. }))
  140. }
  141. }
  142. }))
  143. }
  144. async initializeSession (): Promise<void> {
  145. this.reconnectOffered = false
  146. if (!this.connection) {
  147. this.logger.error('No SSH connection info supplied')
  148. return
  149. }
  150. const session = this.ssh.createSession(this.connection)
  151. this.setSession(session)
  152. try {
  153. await this.setupOneSession(session)
  154. } catch (e) {
  155. this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
  156. }
  157. await this.session!.start()
  158. this.session!.resize(this.size.columns, this.size.rows)
  159. }
  160. async getRecoveryToken (): Promise<RecoveryToken> {
  161. return {
  162. type: 'app:ssh-tab',
  163. connection: this.connection,
  164. savedState: this.frontend?.saveState(),
  165. }
  166. }
  167. showPortForwarding (): void {
  168. const modal = this.ngbModal.open(SSHPortForwardingModalComponent).componentInstance as SSHPortForwardingModalComponent
  169. modal.session = this.session!
  170. }
  171. async reconnect (): Promise<void> {
  172. this.session?.destroy()
  173. await this.initializeSession()
  174. this.session?.releaseInitialDataBuffer()
  175. }
  176. async canClose (): Promise<boolean> {
  177. if (!this.session?.open) {
  178. return true
  179. }
  180. if (!(this.connection?.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
  181. return true
  182. }
  183. return (await this.electron.showMessageBox(
  184. this.hostApp.getWindow(),
  185. {
  186. type: 'warning',
  187. message: `Disconnect from ${this.connection?.host}?`,
  188. buttons: ['Cancel', 'Disconnect'],
  189. defaultId: 1,
  190. }
  191. )).response === 1
  192. }
  193. ngOnDestroy (): void {
  194. this.homeEndSubscription.unsubscribe()
  195. super.ngOnDestroy()
  196. }
  197. }