Prechádzať zdrojové kódy

implemented port forwarding (fixes #821)

Eugene Pankov 6 rokov pred
rodič
commit
0dbb16d859

+ 167 - 5
terminus-ssh/src/api.ts

@@ -1,4 +1,8 @@
 import { BaseSession } from 'terminus-terminal'
+import { Server, Socket, createServer } from 'net'
+import { Client, ClientChannel } from 'ssh2'
+import { Logger } from 'terminus-core'
+import { Subject, Observable } from 'rxjs'
 
 export interface LoginScript {
     expect: string
@@ -30,18 +34,78 @@ export interface SSHConnection {
     algorithms?: {[t: string]: string[]}
 }
 
+export enum PortForwardType {
+    Local, Remote
+}
+
+export class ForwardedPort {
+    type: PortForwardType
+    host = '127.0.0.1'
+    port: number
+    targetAddress: string
+    targetPort: number
+
+    private listener: Server
+
+    async startLocalListener (callback: (Socket) => void): Promise<void> {
+        this.listener = createServer(callback)
+        return new Promise((resolve, reject) => {
+            this.listener.listen(this.port, '127.0.0.1')
+            this.listener.on('error', reject)
+            this.listener.on('listening', resolve)
+        })
+    }
+
+    stopLocalListener () {
+        this.listener.close()
+    }
+
+    toString () {
+        if (this.type === PortForwardType.Local) {
+            return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}`
+        } else {
+            return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}`
+        }
+    }
+}
+
 export class SSHSession extends BaseSession {
     scripts?: LoginScript[]
-    shell: any
+    shell: ClientChannel
+    ssh: Client
+    forwardedPorts: ForwardedPort[] = []
+    logger: Logger
+
+    get serviceMessage$ (): Observable<string> { return this.serviceMessage }
+    private serviceMessage = new Subject<string>()
 
     constructor (public connection: SSHConnection) {
         super()
         this.scripts = connection.scripts || []
     }
 
-    start () {
+    async start () {
         this.open = true
 
+        this.shell = await new Promise<ClientChannel>((resolve, reject) => {
+            this.ssh.shell({ term: 'xterm-256color' }, (err, shell) => {
+                if (err) {
+                    this.emitServiceMessage(`Remote rejected opening a shell channel: ${err}`)
+                    reject(err)
+                } else {
+                    resolve(shell)
+                }
+            })
+        })
+
+        this.shell.on('greeting', greeting => {
+            this.emitServiceMessage(`Shell greeting: ${greeting}`)
+        })
+
+        this.shell.on('banner', banner => {
+            this.emitServiceMessage(`Shell banner: ${banner}`)
+        })
+
         this.shell.on('data', data => {
             const dataString = data.toString()
             this.emitOutput(dataString)
@@ -67,12 +131,12 @@ export class SSHSession extends BaseSession {
                     }
 
                     if (match) {
-                        console.log('Executing script: "' + cmd + '"')
+                        this.logger.info('Executing script: "' + cmd + '"')
                         this.shell.write(cmd + '\n')
                         this.scripts = this.scripts.filter(x => x !== script)
                     } else {
                         if (script.optional) {
-                            console.log('Skip optional script: ' + script.expect)
+                            this.logger.debug('Skip optional script: ' + script.expect)
                             found = true
                             this.scripts = this.scripts.filter(x => x !== script)
                         } else {
@@ -88,17 +152,110 @@ export class SSHSession extends BaseSession {
         })
 
         this.shell.on('end', () => {
+            this.logger.info('Shell session ended')
             if (this.open) {
                 this.destroy()
             }
         })
 
+        this.ssh.on('tcp connection', (details, accept, reject) => {
+            this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`)
+            const forward = this.forwardedPorts.find(x => x.port === details.destPort)
+            if (!forward) {
+                this.emitServiceMessage(`Rejected incoming forwarded connection for unrecognized port ${details.destPort}`)
+                return reject()
+            }
+            const socket = new Socket()
+            socket.connect(forward.targetPort, forward.targetAddress)
+            socket.on('error', e => {
+                this.emitServiceMessage(`Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`)
+                reject()
+            })
+            socket.on('connect', () => {
+                this.logger.info('Connection forwarded')
+                const stream = accept()
+                stream.pipe(socket)
+                socket.pipe(stream)
+                stream.on('close', () => {
+                    socket.destroy()
+                })
+                socket.on('close', () => {
+                    stream.close()
+                })
+            })
+        })
+
         this.executeUnconditionalScripts()
     }
 
+    emitServiceMessage (msg: string) {
+        this.serviceMessage.next(msg)
+        this.logger.info(msg)
+    }
+
+    async addPortForward (fw: ForwardedPort) {
+        if (fw.type === PortForwardType.Local) {
+            await fw.startLocalListener((socket: Socket) => {
+                this.logger.info(`New connection on ${fw}`)
+                this.ssh.forwardOut(
+                    socket.remoteAddress || '127.0.0.1',
+                    socket.remotePort || 0,
+                    fw.targetAddress,
+                    fw.targetPort,
+                    (err, stream) => {
+                        if (err) {
+                            this.emitServiceMessage(`Remote has rejected the forwaded connection via ${fw}: ${err}`)
+                            socket.destroy()
+                            return
+                        }
+                        stream.pipe(socket)
+                        socket.pipe(stream)
+                        stream.on('close', () => {
+                            socket.destroy()
+                        })
+                        socket.on('close', () => {
+                            stream.close()
+                        })
+                    }
+                )
+            }).then(() => {
+                this.emitServiceMessage(`Forwaded ${fw}`)
+                this.forwardedPorts.push(fw)
+            }).catch(e => {
+                this.emitServiceMessage(`Failed to forward port ${fw}: ${e}`)
+                throw e
+            })
+        }
+        if (fw.type === PortForwardType.Remote) {
+            await new Promise((resolve, reject) => {
+                this.ssh.forwardIn(fw.host, fw.port, err => {
+                    if (err) {
+                        this.emitServiceMessage(`Remote rejected port forwarding for ${fw}: ${err}`)
+                        return reject(err)
+                    }
+                    resolve()
+                })
+            })
+            this.emitServiceMessage(`Forwaded ${fw}`)
+            this.forwardedPorts.push(fw)
+        }
+    }
+
+    async removePortForward (fw: ForwardedPort) {
+        if (fw.type === PortForwardType.Local) {
+            fw.stopLocalListener()
+            this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
+        }
+        if (fw.type === PortForwardType.Remote) {
+            this.ssh.unforwardIn(fw.host, fw.port)
+            this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
+        }
+        this.emitServiceMessage(`Stopped forwarding ${fw}`)
+    }
+
     resize (columns, rows) {
         if (this.shell) {
-            this.shell.setWindow(rows, columns)
+            this.shell.setWindow(rows, columns, rows, columns)
         }
     }
 
@@ -114,6 +271,11 @@ export class SSHSession extends BaseSession {
         }
     }
 
+    async destroy (): Promise<void> {
+        this.serviceMessage.complete()
+        await super.destroy()
+    }
+
     async getChildProcesses (): Promise<any[]> {
         return []
     }

+ 48 - 0
terminus-ssh/src/components/sshPortForwardingModal.component.pug

@@ -0,0 +1,48 @@
+.modal-header
+    h5.m-0 Port forwarding
+
+.modal-body.pt-0
+    .list-group-light.mb-3
+        .list-group-item.d-flex.align-items-center(*ngFor='let fw of session.forwardedPorts')
+            strong(*ngIf='fw.type === PortForwardType.Local') Local
+            strong(*ngIf='fw.type === PortForwardType.Remote') Remote
+            .ml-3 {{fw.host}}:{{fw.port}} &rarr; {{fw.targetAddress}}:{{fw.targetPort}}
+            button.btn.btn-link.ml-auto((click)='remove(fw)')
+                i.fas.fa-trash-alt.mr-2
+                span Remove
+
+    .input-group.mb-2
+        input.form-control(type='text', [(ngModel)]='newForward.host')
+        .input-group-append
+            .input-group-text :
+        input.form-control(type='number', [(ngModel)]='newForward.port')
+        .input-group-append
+            .input-group-text &rarr;
+        input.form-control(type='text', [(ngModel)]='newForward.targetAddress')
+        .input-group-append
+            .input-group-text :
+        input.form-control(type='number', [(ngModel)]='newForward.targetPort')
+
+    .d-flex
+        .btn-group.mr-auto(
+            [(ngModel)]='newForward.type',
+            ngbRadioGroup
+        )
+            label.btn.btn-secondary.m-0(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    [value]='PortForwardType.Local'
+                )
+                | Local
+            label.btn.btn-secondary.m-0(ngbButtonLabel)
+                input(
+                    type='radio',
+                    ngbButton,
+                    [value]='PortForwardType.Remote'
+                )
+                | Remote
+
+        button.btn.btn-primary((click)='addForward()')
+            i.fas.fa-check.mr-2
+            span Forward port

+ 42 - 0
terminus-ssh/src/components/sshPortForwardingModal.component.ts

@@ -0,0 +1,42 @@
+import { Component, Input } from '@angular/core'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { ForwardedPort, PortForwardType, SSHSession } from '../api'
+
+/** @hidden */
+@Component({
+    template: require('./sshPortForwardingModal.component.pug'),
+    // styles: [require('./sshPortForwardingModal.component.scss')],
+})
+export class SSHPortForwardingModalComponent {
+    @Input() session: SSHSession
+    newForward = new ForwardedPort()
+    PortForwardType = PortForwardType
+
+    constructor (
+        public modalInstance: NgbActiveModal,
+    ) {
+        this.reset()
+    }
+
+    reset () {
+        this.newForward = new ForwardedPort()
+        this.newForward.type = PortForwardType.Local
+        this.newForward.host = '127.0.0.1'
+        this.newForward.port = 8000
+        this.newForward.targetAddress = '127.0.0.1'
+        this.newForward.targetPort = 80
+    }
+
+    async addForward () {
+        try {
+            await this.session.addPortForward(this.newForward)
+            this.reset()
+        } catch (e) {
+            console.error(e)
+        }
+    }
+
+    remove (fw: ForwardedPort) {
+        this.session.removePortForward(fw)
+    }
+}

+ 3 - 0
terminus-ssh/src/components/sshTab.component.pug

@@ -0,0 +1,3 @@
+button.btn.btn-outline-secondary((click)='showPortForwarding()')
+    i.fas.fa-plug
+    span Ports

+ 8 - 0
terminus-ssh/src/components/sshTab.component.scss

@@ -3,6 +3,7 @@
     display: flex;
     flex-direction: column;
     overflow: hidden;
+    position: relative;
 
     &> .content {
         flex: auto;
@@ -11,4 +12,11 @@
         overflow: hidden;
         margin: 15px;
     }
+
+    &> button {
+        position: absolute;
+        bottom: 20px;
+        right: 40px;
+        z-index: 4;
+    }
 }

+ 17 - 3
terminus-ssh/src/components/sshTab.component.ts

@@ -1,12 +1,14 @@
 import { Component } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { first } from 'rxjs/operators'
 import { BaseTerminalTabComponent } from 'terminus-terminal'
 import { SSHService } from '../services/ssh.service'
 import { SSHConnection, SSHSession } from '../api'
+import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
 
 /** @hidden */
 @Component({
-    template: BaseTerminalTabComponent.template,
+    template: BaseTerminalTabComponent.template + require<string>('./sshTab.component.pug'),
     styles: [require('./sshTab.component.scss'), ...BaseTerminalTabComponent.styles],
     animations: BaseTerminalTabComponent.animations,
 })
@@ -14,8 +16,11 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
     connection: SSHConnection
     ssh: SSHService
     session: SSHSession
+    private ngbModal: NgbModal
 
     ngOnInit () {
+        this.ngbModal = this.injector.get<NgbModal>(NgbModal)
+
         this.logger = this.log.create('terminalTab')
         this.ssh = this.injector.get(SSHService)
         this.frontendReady$.pipe(first()).subscribe(() => {
@@ -35,7 +40,11 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             return
         }
 
-        this.session = new SSHSession(this.connection)
+        this.session = this.ssh.createSession(this.connection)
+        this.session.serviceMessage$.subscribe(msg => {
+            this.write(`\r\n[SSH] ${msg}\r\n`)
+            this.session.resize(this.size.columns, this.size.rows)
+        })
         this.attachSessionHandlers()
         this.write(`Connecting to ${this.connection.host}`)
         const interval = setInterval(() => this.write('.'), 500)
@@ -51,8 +60,8 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             clearInterval(interval)
             this.write('\r\n')
         }
+        await this.session.start()
         this.session.resize(this.size.columns, this.size.rows)
-        this.session.start()
     }
 
     async getRecoveryToken (): Promise<any> {
@@ -61,4 +70,9 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
             connection: this.connection,
         }
     }
+
+    showPortForwarding () {
+        const modal = this.ngbModal.open(SSHPortForwardingModalComponent).componentInstance as SSHPortForwardingModalComponent
+        modal.session = this.session
+    }
 }

+ 3 - 0
terminus-ssh/src/index.ts

@@ -9,6 +9,7 @@ import TerminusTerminalModule from 'terminus-terminal'
 
 import { EditConnectionModalComponent } from './components/editConnectionModal.component'
 import { SSHModalComponent } from './components/sshModal.component'
+import { SSHPortForwardingModalComponent } from './components/sshPortForwardingModal.component'
 import { PromptModalComponent } from './components/promptModal.component'
 import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
 import { SSHTabComponent } from './components/sshTab.component'
@@ -40,6 +41,7 @@ import { SSHHotkeyProvider } from './hotkeys'
         EditConnectionModalComponent,
         PromptModalComponent,
         SSHModalComponent,
+        SSHPortForwardingModalComponent,
         SSHSettingsTabComponent,
         SSHTabComponent,
     ],
@@ -47,6 +49,7 @@ import { SSHHotkeyProvider } from './hotkeys'
         EditConnectionModalComponent,
         PromptModalComponent,
         SSHModalComponent,
+        SSHPortForwardingModalComponent,
         SSHSettingsTabComponent,
         SSHTabComponent,
     ],

+ 8 - 26
terminus-ssh/src/services/ssh.service.ts

@@ -20,7 +20,7 @@ export class SSHService {
     private logger: Logger
 
     private constructor (
-        log: LogService,
+        private log: LogService,
         private app: AppService,
         private zone: NgZone,
         private ngbModal: NgbModal,
@@ -38,6 +38,12 @@ export class SSHService {
         ) as SSHTabComponent)
     }
 
+    createSession (connection: SSHConnection): SSHSession {
+        const session = new SSHSession(connection)
+        session.logger = this.log.create(`ssh-${connection.host}-${connection.port}`)
+        return session
+    }
+
     async connectSession (session: SSHSession, logCallback?: (s: any) => void): Promise<void> {
         let privateKey: string|null = null
         let privateKeyPassphrase: string|null = null
@@ -91,6 +97,7 @@ export class SSHService {
         }
 
         const ssh = new Client()
+        session.ssh = ssh
         let connected = false
         let savedPassword: string|null = null
         await new Promise(async (resolve, reject) => {
@@ -210,31 +217,6 @@ export class SSHService {
                 }
             })
         })
-
-        try {
-            const shell: any = await new Promise<any>((resolve, reject) => {
-                ssh.shell({ term: 'xterm-256color' }, (err, shell) => {
-                    if (err) {
-                        reject(err)
-                    } else {
-                        resolve(shell)
-                    }
-                })
-            })
-
-            session.shell = shell
-
-            shell.on('greeting', greeting => {
-                log(`Shell Greeting: ${greeting}`)
-            })
-
-            shell.on('banner', banner => {
-                log(`Shell Banner: ${banner}`)
-            })
-        } catch (error) {
-            this.toastr.error(error.message)
-            throw error
-        }
     }
 }