diff --git a/terminus-ssh/src/api.ts b/terminus-ssh/src/api.ts index 7357a357..4e749511 100644 --- a/terminus-ssh/src/api.ts +++ b/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 { + 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 { return this.serviceMessage } + private serviceMessage = new Subject() constructor (public connection: SSHConnection) { super() this.scripts = connection.scripts || [] } - start () { + async start () { this.open = true + this.shell = await new Promise((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 { + this.serviceMessage.complete() + await super.destroy() + } + async getChildProcesses (): Promise { return [] } diff --git a/terminus-ssh/src/components/sshPortForwardingModal.component.pug b/terminus-ssh/src/components/sshPortForwardingModal.component.pug new file mode 100644 index 00000000..f5afe02c --- /dev/null +++ b/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}} → {{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 → + 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 diff --git a/terminus-ssh/src/components/sshPortForwardingModal.component.ts b/terminus-ssh/src/components/sshPortForwardingModal.component.ts new file mode 100644 index 00000000..b31cc58f --- /dev/null +++ b/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) + } +} diff --git a/terminus-ssh/src/components/sshTab.component.pug b/terminus-ssh/src/components/sshTab.component.pug new file mode 100644 index 00000000..f03fee33 --- /dev/null +++ b/terminus-ssh/src/components/sshTab.component.pug @@ -0,0 +1,3 @@ +button.btn.btn-outline-secondary((click)='showPortForwarding()') + i.fas.fa-plug + span Ports diff --git a/terminus-ssh/src/components/sshTab.component.scss b/terminus-ssh/src/components/sshTab.component.scss index 31368be1..1664b366 100644 --- a/terminus-ssh/src/components/sshTab.component.scss +++ b/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; + } } diff --git a/terminus-ssh/src/components/sshTab.component.ts b/terminus-ssh/src/components/sshTab.component.ts index fcacf725..67ab3c51 100644 --- a/terminus-ssh/src/components/sshTab.component.ts +++ b/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('./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) + 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 { @@ -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 + } } diff --git a/terminus-ssh/src/index.ts b/terminus-ssh/src/index.ts index 10774b6e..792c64ab 100644 --- a/terminus-ssh/src/index.ts +++ b/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, ], diff --git a/terminus-ssh/src/services/ssh.service.ts b/terminus-ssh/src/services/ssh.service.ts index 98760a29..86f10d28 100644 --- a/terminus-ssh/src/services/ssh.service.ts +++ b/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 { 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((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 - } } }