Просмотр исходного кода

ssh: reworked keyboard-interactive auth UI - fixes #4540

Eugene Pankov 4 лет назад
Родитель
Сommit
d6fa3b02a9

+ 26 - 0
tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug

@@ -0,0 +1,26 @@
+.d-flex
+    strong Keyboard-interactive auth
+    .ml-2 {{prompt.name}}
+
+.prompt-text {{prompt.prompts[step]}}
+
+input.form-control.mt-2(
+    #input,
+    autofocus,
+    [type]='isPassword() ? "password": "text"',
+    placeholder='Response',
+    (keyup.enter)='next()',
+    [(ngModel)]='prompt.responses[step]'
+)
+
+.d-flex.mt-3
+    button.btn.btn-secondary(
+        *ngIf='step > 0',
+        (click)='previous()'
+    )
+    .ml-auto
+    button.btn.btn-primary(
+        (click)='next()'
+    )
+        span(*ngIf='step < prompt.prompts.length - 1') Next
+        span(*ngIf='step == prompt.prompts.length - 1') Finish

+ 9 - 0
tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss

@@ -0,0 +1,9 @@
+:host {
+    display: flex;
+    flex-direction: column;
+    padding: 15px 20px;
+}
+
+.prompt-text {
+    white-space: pre-wrap;
+}

+ 37 - 0
tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts

@@ -0,0 +1,37 @@
+import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core'
+import { KeyboardInteractivePrompt } from '../session/ssh'
+
+
+@Component({
+    selector: 'keyboard-interactive-auth-panel',
+    template: require('./keyboardInteractiveAuthPanel.component.pug'),
+    styles: [require('./keyboardInteractiveAuthPanel.component.scss')],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class KeyboardInteractiveAuthComponent {
+    @Input() prompt: KeyboardInteractivePrompt
+    @Input() step = 0
+    @Output() done = new EventEmitter()
+    @ViewChild('input') input: ElementRef
+
+    isPassword (): boolean {
+        return this.prompt.prompts[this.step].toLowerCase().includes('password')
+    }
+
+    previous (): void {
+        if (this.step > 0) {
+            this.step--
+        }
+        this.input.nativeElement.focus()
+    }
+
+    next (): void {
+        if (this.step === this.prompt.prompts.length - 1) {
+            this.prompt.respond()
+            this.done.emit()
+            return
+        }
+        this.step++
+        this.input.nativeElement.focus()
+    }
+}

+ 7 - 0
tabby-ssh/src/components/sshTab.component.pug

@@ -45,3 +45,10 @@ sftp-panel.bg-dark(
     [session]='session',
     (closed)='sftpPanelVisible = false'
 )
+
+keyboard-interactive-auth-panel.bg-dark(
+    *ngIf='activeKIPrompt',
+    [prompt]='activeKIPrompt',
+    (click)='$event.stopPropagation()',
+    (done)='activeKIPrompt = null; frontend.focus()'
+)

+ 16 - 1
tabby-ssh/src/components/sshTab.component.ts

@@ -5,7 +5,7 @@ import { first } from 'rxjs'
 import { PartialProfile, Platform, ProfilesService, RecoveryToken } from 'tabby-core'
 import { BaseTerminalTabComponent } from 'tabby-terminal'
 import { SSHService } from '../services/ssh.service'
-import { SSHSession } from '../session/ssh'
+import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
 import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
 import { SSHProfile } from '../api'
 
@@ -24,6 +24,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
     sftpPanelVisible = false
     sftpPath = '/'
     enableToolbar = true
+    activeKIPrompt: KeyboardInteractivePrompt|null = null
     private sessionStack: SSHSession[] = []
     private recentInputs = ''
     private reconnectOffered = false
@@ -35,6 +36,9 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
         private profilesService: ProfilesService,
     ) {
         super(injector)
+        this.sessionChanged$.subscribe(() => {
+            this.activeKIPrompt = null
+        })
     }
 
     ngOnInit (): void {
@@ -126,6 +130,17 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             session.resize(this.size.columns, this.size.rows)
         })
 
+        this.attachSessionHandler(session.destroyed$, () => {
+            this.activeKIPrompt = null
+        })
+
+        this.attachSessionHandler(session.keyboardInteractivePrompt$, prompt => {
+            this.activeKIPrompt = prompt
+            setTimeout(() => {
+                this.frontend?.scrollToBottom()
+            })
+        })
+
         try {
             await this.ssh.connectSession(session)
             this.stopSpinner()

+ 2 - 0
tabby-ssh/src/index.ts

@@ -15,6 +15,7 @@ import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
 import { SSHTabComponent } from './components/sshTab.component'
 import { SFTPPanelComponent } from './components/sftpPanel.component'
 import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component'
+import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component'
 
 import { SSHConfigProvider } from './config'
 import { SSHSettingsTabProvider } from './settings'
@@ -60,6 +61,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu'
         SSHSettingsTabComponent,
         SSHTabComponent,
         SFTPPanelComponent,
+        KeyboardInteractiveAuthComponent,
     ],
 })
 // eslint-disable-next-line @typescript-eslint/no-extraneous-class

+ 8 - 20
tabby-ssh/src/services/ssh.service.ts

@@ -2,13 +2,12 @@ import colors from 'ansi-colors'
 import * as shellQuote from 'shell-quote'
 import { Duplex } from 'stream'
 import { Injectable, NgZone } from '@angular/core'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { Client } from 'ssh2'
 import { spawn } from 'child_process'
 import { ChildProcess } from 'node:child_process'
 import { Subject, Observable } from 'rxjs'
-import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core'
-import { SSHSession } from '../session/ssh'
+import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService } from 'tabby-core'
+import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
 import { ForwardedPort } from '../session/forwards'
 import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from '../api'
 import { PasswordStorageService } from './passwordStorage.service'
@@ -21,7 +20,6 @@ export class SSHService {
     private constructor (
         log: LogService,
         private zone: NgZone,
-        private ngbModal: NgbModal,
         private passwordStorage: PasswordStorageService,
         private notifications: NotificationsService,
         private config: ConfigService,
@@ -83,22 +81,12 @@ export class SSHService {
             })
 
             ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => {
-                log(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${name}`)
-                this.logger.info('Keyboard-interactive auth:', name, instructions, instructionsLang)
-                const results: string[] = []
-                for (const prompt of prompts) {
-                    const modal = this.ngbModal.open(PromptModalComponent)
-                    modal.componentInstance.prompt = prompt.prompt
-                    modal.componentInstance.password = !prompt.echo
-
-                    try {
-                        const result = await modal.result
-                        results.push(result ? result.value : '')
-                    } catch {
-                        results.push('')
-                    }
-                }
-                finish(results)
+                session.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt(
+                    name,
+                    instructions,
+                    prompts.map(x => x.prompt),
+                    finish,
+                ))
             }))
 
             ssh.on('greeting', greeting => {

+ 28 - 0
tabby-ssh/src/session/ssh.ts

@@ -27,6 +27,21 @@ interface AuthMethod {
     contents?: Buffer
 }
 
+export class KeyboardInteractivePrompt {
+    responses: string[] = []
+
+    constructor (
+        public name: string,
+        public instruction: string,
+        public prompts: string[],
+        private callback: (_: string[]) => void,
+    ) { }
+
+    respond (): void {
+        this.callback(this.responses)
+    }
+}
+
 export class SSHSession extends BaseSession {
     shell?: ClientChannel
     ssh: Client
@@ -36,12 +51,14 @@ export class SSHSession extends BaseSession {
     proxyCommandStream: ProxyCommandStream|null = null
     savedPassword?: string
     get serviceMessage$ (): Observable<string> { return this.serviceMessage }
+    get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
 
     agentPath?: string
     activePrivateKey: string|null = null
 
     private remainingAuthMethods: AuthMethod[] = []
     private serviceMessage = new Subject<string>()
+    private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
     private keychainPasswordUsed = false
 
     private passwordStorage: PasswordStorageService
@@ -246,6 +263,17 @@ export class SSHSession extends BaseSession {
         this.logger.info(stripAnsi(msg))
     }
 
+    emitKeyboardInteractivePrompt (prompt: KeyboardInteractivePrompt): void {
+        this.logger.info('Keyboard-interactive auth:', prompt.name, prompt.instruction)
+        this.emitServiceMessage(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${prompt.name}`)
+        if (prompt.instruction) {
+            for (const line of prompt.instruction.split('\n')) {
+                this.emitServiceMessage(line)
+            }
+        }
+        this.keyboardInteractivePrompt.next(prompt)
+    }
+
     async handleAuth (methodsLeft?: string[] | null): Promise<any> {
         this.activePrivateKey = null
 

+ 3 - 1
tabby-terminal/src/api/baseTerminalTab.component.ts

@@ -723,7 +723,9 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
             this.spinner.text = text
         }
         this.spinner.setSpinnerString(6)
-        this.spinner.start()
+        this.zone.runOutsideAngular(() => {
+            this.spinner.start()
+        })
         this.spinnerActive = true
     }