added SSH connection manager (fixes #220)

This commit is contained in:
Eugene Pankov
2017-11-27 16:30:59 +01:00
parent 13a76db9af
commit 5cdb7527c8
30 changed files with 3634 additions and 21 deletions

51
terminus-ssh/src/api.ts Normal file
View File

@@ -0,0 +1,51 @@
import { BaseSession } from 'terminus-terminal'
export interface SSHConnection {
name?: string
host: string
user: string
password?: string
privateKey?: string
}
export class SSHSession extends BaseSession {
constructor (private shell: any) {
super()
this.open = true
this.shell.on('data', data => {
this.emitOutput(data.toString())
})
this.shell.on('end', () => {
if (this.open) {
this.destroy()
}
})
}
resize (columns, rows) {
this.shell.setWindow(rows, columns)
}
write (data) {
this.shell.write(data)
}
kill (signal?: string) {
this.shell.signal(signal || 'TERM')
}
async getChildProcesses (): Promise<any[]> {
return []
}
async gracefullyKillProcess (): Promise<void> {
this.kill('TERM')
}
async getWorkingDirectory (): Promise<string> {
return null
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { HotkeysService, ToolbarButtonProvider, IToolbarButton } from 'terminus-core'
import { SSHModalComponent } from './components/sshModal.component'
@Injectable()
export class ButtonProvider extends ToolbarButtonProvider {
constructor (
private ngbModal: NgbModal,
hotkeys: HotkeysService,
) {
super()
hotkeys.matchedHotkey.subscribe(async (hotkey) => {
if (hotkey === 'ssh') {
this.activate()
}
})
}
activate () {
let modal = this.ngbModal.open(SSHModalComponent)
modal.result.then(() => {
//this.terminal.openTab(shell)
})
}
provide (): IToolbarButton[] {
return [{
icon: 'globe',
weight: 5,
title: 'SSH connections',
click: async () => {
this.activate()
}
}]
}
}

View File

@@ -0,0 +1,37 @@
.modal-body
.form-group
label Name
input.form-control(
type='text',
[(ngModel)]='connection.name',
)
.form-group
label Host
input.form-control(
type='text',
[(ngModel)]='connection.host',
)
.form-group
label Username
input.form-control(
type='text',
[(ngModel)]='connection.user',
)
.form-group
label Private key
.input-group
input.form-control(
type='text',
placeholder='Key file path',
[(ngModel)]='connection.privateKey'
)
.input-group-btn
button.btn.btn-secondary((click)='selectPrivateKey()')
i.fa.fa-folder-open
.modal-footer
button.btn.btn-outline-primary((click)='save()') Save
button.btn.btn-outline-danger((click)='cancel()') Cancel

View File

@@ -0,0 +1,38 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ElectronService, HostAppService } from 'terminus-core'
import { SSHConnection } from '../api'
@Component({
template: require('./editConnectionModal.component.pug'),
})
export class EditConnectionModalComponent {
connection: SSHConnection
constructor (
private modalInstance: NgbActiveModal,
private electron: ElectronService,
private hostApp: HostAppService,
) { }
selectPrivateKey () {
let path = this.electron.dialog.showOpenDialog(
this.hostApp.getWindow(),
{
title: 'Select private key',
properties: ['openDirectory']
}
)
if (path) {
this.connection.privateKey = path[0]
}
}
save () {
this.modalInstance.close(this.connection)
}
cancel () {
this.modalInstance.dismiss()
}
}

View File

@@ -0,0 +1,9 @@
.modal-body
input.form-control(
[type]='password ? "password" : "text"',
[(ngModel)]='value',
#input,
[placeholder]='prompt',
(keyup.enter)='ok()',
(keyup.esc)='cancel()',
)

View File

@@ -0,0 +1,27 @@
import { Component, Input, ViewChild, ElementRef } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@Component({
template: require('./promptModal.component.pug'),
})
export class PromptModalComponent {
@Input() value: string
@Input() password: boolean
@ViewChild('input') input: ElementRef
constructor (
private modalInstance: NgbActiveModal,
) { }
ngOnInit () {
this.input.nativeElement.focus()
}
ok () {
this.modalInstance.close(this.value)
}
cancel () {
this.modalInstance.close('')
}
}

View File

@@ -0,0 +1,24 @@
.modal-body
input.form-control(
type='text',
[(ngModel)]='quickTarget',
autofocus,
placeholder='Quick connect: [user@]host',
(keyup.enter)='quickConnect()'
)
.list-group.mt-3(*ngIf='lastConnection')
a.list-group-item.list-group-item-action((click)='connect(lastConnection)')
i.fa.fa-fw.fa-history
span {{lastConnection.name}}
.list-group.mt-3
a.list-group-item.list-group-item-action(*ngFor='let connection of connections', (click)='connect(connection)')
i.fa.fa-fw.fa-globe
span {{connection.name}}
a.list-group-item.list-group-item-action((click)='manageConnections()')
i.fa.fa-fw.fa-wrench
span Manage connections
//.modal-footer
button.btn.btn-outline-primary((click)='close()') Cancel

View File

@@ -0,0 +1,60 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, AppService } from 'terminus-core'
import { SettingsTabComponent } from 'terminus-settings'
import { SSHService } from '../services/ssh.service'
import { SSHConnection } from '../api'
@Component({
template: require('./sshModal.component.pug'),
//styles: [require('./sshModal.component.scss')],
})
export class SSHModalComponent {
connections: SSHConnection[]
quickTarget: string
lastConnection: SSHConnection
constructor (
public modalInstance: NgbActiveModal,
private config: ConfigService,
private ssh: SSHService,
private app: AppService,
) { }
ngOnInit () {
this.connections = this.config.store.ssh.connections
if (window.localStorage.lastConnection) {
this.lastConnection = JSON.parse(window.localStorage.lastConnection)
}
}
quickConnect () {
let user = 'root'
let host = this.quickTarget
if (host.includes('@')) {
[user, host] = host.split('@')
}
let connection: SSHConnection = {
name: this.quickTarget,
host, user,
}
window.localStorage.lastConnection = JSON.stringify(connection)
this.connect(connection)
}
connect (connection: SSHConnection) {
this.close()
this.ssh.connect(connection).catch(error => {
alert(`Could not connect: ${error}`)
})
}
manageConnections () {
this.close()
this.app.openNewTab(SettingsTabComponent, { activeTab: 'ssh' })
}
close () {
this.modalInstance.close()
}
}

View File

@@ -0,0 +1,15 @@
h3 Connections
.list-group.mt-3.mb-3
.list-group-item(*ngFor='let connection of connections')
.d-flex.w-100
.mr-auto
div
span {{connection.name}}
.text-muted {{connection.host}}
button.btn.btn-outline-info.ml-2((click)='editConnection(connection)')
i.fa.fa-pencil
button.btn.btn-outline-danger.ml-1((click)='deleteConnection(connection)')
i.fa.fa-trash-o
button.btn.btn-outline-primary((click)='createConnection()') Add connection

View File

@@ -0,0 +1,52 @@
import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService } from 'terminus-core'
import { SSHConnection } from '../api'
import { EditConnectionModalComponent } from './editConnectionModal.component'
@Component({
template: require('./sshSettingsTab.component.pug'),
})
export class SSHSettingsTabComponent {
connections: SSHConnection[]
constructor (
public config: ConfigService,
private ngbModal: NgbModal,
) {
this.connections = this.config.store.ssh.connections
}
async ngOnInit () {
}
createConnection () {
let connection: SSHConnection = {
name: '',
host: '',
user: 'root',
}
let modal = this.ngbModal.open(EditConnectionModalComponent)
modal.componentInstance.connection = connection
modal.result.then(result => {
this.connections.push(result)
this.config.store.ssh.connections = this.connections
})
}
editConnection (connection: SSHConnection) {
let modal = this.ngbModal.open(EditConnectionModalComponent)
modal.componentInstance.connection = Object.assign({}, connection)
modal.result.then(result => {
Object.assign(connection, result)
this.config.save()
})
}
deleteConnection (connection: SSHConnection) {
if (confirm(`Delete "${connection.name}"?`)) {
this.connections = this.connections.filter(x => x !== connection)
this.config.store.ssh.connections = this.connections
}
}
}

View File

@@ -0,0 +1,18 @@
import { ConfigProvider } from 'terminus-core'
export class SSHConfigProvider extends ConfigProvider {
defaults = {
ssh: {
connections: [],
options: {
}
},
hotkeys: {
'ssh': [
'Alt-S',
],
},
}
platformDefaults = { }
}

43
terminus-ssh/src/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { ToolbarButtonProvider, ConfigProvider } from 'terminus-core'
import { SettingsTabProvider } from 'terminus-settings'
import { EditConnectionModalComponent } from './components/editConnectionModal.component'
import { SSHModalComponent } from './components/sshModal.component'
import { PromptModalComponent } from './components/promptModal.component'
import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
import { SSHService } from './services/ssh.service'
import { ButtonProvider } from './buttonProvider'
import { SSHConfigProvider } from './config'
import { SSHSettingsTabProvider } from './settings'
@NgModule({
imports: [
NgbModule,
CommonModule,
FormsModule,
],
providers: [
SSHService,
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: ConfigProvider, useClass: SSHConfigProvider, multi: true },
{ provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
],
entryComponents: [
EditConnectionModalComponent,
PromptModalComponent,
SSHModalComponent,
SSHSettingsTabComponent,
],
declarations: [
EditConnectionModalComponent,
PromptModalComponent,
SSHModalComponent,
SSHSettingsTabComponent,
],
})
export default class SSHModule { }

View File

@@ -0,0 +1,128 @@
import { Injectable, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Client } from 'ssh2'
import * as fs from 'mz/fs'
import { AppService } from 'terminus-core'
import { TerminalTabComponent } from 'terminus-terminal'
import { SSHConnection, SSHSession } from '../api'
import { PromptModalComponent } from '../components/promptModal.component'
const { SSH2Stream } = require('ssh2-streams')
const keychain = require('xkeychain')
@Injectable()
export class SSHService {
constructor (
private app: AppService,
private zone: NgZone,
private ngbModal: NgbModal,
) {
}
async connect (connection: SSHConnection): Promise<TerminalTabComponent> {
let privateKey: string = null
if (connection.privateKey) {
try {
privateKey = (await fs.readFile(connection.privateKey)).toString()
} catch (error) {
}
}
let ssh = new Client()
let connected = false
await new Promise((resolve, reject) => {
ssh.on('ready', () => {
connected = true
this.zone.run(resolve)
})
ssh.on('error', error => {
this.zone.run(() => {
if (connected) {
alert(`SSH error: ${error}`)
} else {
reject(error)
}
})
})
ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => {
console.log(name, instructions, instructionsLang)
let results = []
for (let prompt of prompts) {
let modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = prompt.prompt
modal.componentInstance.password = !prompt.echo
results.push(await modal.result)
}
finish(results)
}))
ssh.connect({
host: connection.host,
username: connection.user,
password: privateKey ? undefined : '',
privateKey,
tryKeyboard: true,
})
let keychainPasswordUsed = false
;(ssh as any).config.password = () => this.zone.run(async () => {
if (connection.password) {
return connection.password
}
if (!keychainPasswordUsed && keychain.isSupported()) {
let password = await new Promise(resolve => {
keychain.getPassword({
account: connection.user,
service: `ssh@${connection.host}`,
}, (_, result) => resolve(result))
})
if (password) {
keychainPasswordUsed = true
return password
}
}
let modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${connection.user}@${connection.host}`
modal.componentInstance.password = true
let password = await modal.result
keychain.setPassword({
account: connection.user,
service: `ssh@${connection.host}`,
password
}, () => null)
return password
})
})
try {
let shell = await new Promise((resolve, reject) => {
ssh.shell({ term: 'xterm-256color' }, (err, shell) => {
if (err) {
reject(err)
} else {
resolve(shell)
}
})
})
let session = new SSHSession(shell)
return this.zone.run(() => this.app.openNewTab(
TerminalTabComponent,
{ session, sessionOptions: {} }
) as TerminalTabComponent)
} catch (error) {
console.log(error)
throw error
}
}
}
const _authPassword = SSH2Stream.prototype.authPassword
SSH2Stream.prototype.authPassword = async function (username, passwordFn) {
_authPassword.bind(this)(username, await passwordFn())
}

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider } from 'terminus-settings'
import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
@Injectable()
export class SSHSettingsTabProvider extends SettingsTabProvider {
id = 'ssh'
title = 'SSH'
getComponentType (): any {
return SSHSettingsTabComponent
}
}