1
1
mirror of https://github.com/Eugeny/tabby.git synced 2024-09-20 01:18:39 +03:00

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

View File

@ -31,10 +31,6 @@
"rxjs": "5.3.0",
"zone.js": "0.8.12"
},
"optionalDependencies": {
"wincredmgr": "^2.0.0",
"xkeychain": "^0.0.6"
},
"devDependencies": {
"@types/mz": "0.0.31"
}

View File

@ -268,14 +268,6 @@ util@^0.10.3:
dependencies:
inherits "2.0.1"
wincredmgr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/wincredmgr/-/wincredmgr-2.0.0.tgz#cab8e3c6d98f0ea255d7638e82fd9f393ca7f515"
xkeychain@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/xkeychain/-/xkeychain-0.0.6.tgz#1c58b3dd2f80481f8f67949c3511aa14027c2b9b"
zone.js@0.8.12:
version "0.8.12"
resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.12.tgz#86ff5053c98aec291a0bf4bbac501d694a05cfbb"

View File

@ -3,5 +3,5 @@ const rebuild = require('electron-rebuild').default
const path = require('path')
const vars = require('./vars')
rebuild(path.resolve(__dirname, '../app'), vars.electronVersion, process.arch, [], true)
rebuild(path.resolve(__dirname, '../terminus-ssh'), vars.electronVersion, process.arch, [], true)
rebuild(path.resolve(__dirname, '../terminus-terminal'), vars.electronVersion, process.arch, [], true)

View File

@ -14,6 +14,7 @@ exports.builtinPlugins = [
'terminus-terminal',
'terminus-community-color-schemes',
'terminus-plugin-manager',
'terminus-ssh',
]
exports.nativeModules = ['node-pty-tmp', 'font-manager', 'xkeychain']
exports.electronVersion = pkgInfo.devDependencies.electron

View File

@ -43,3 +43,4 @@ hotkeys:
tab-10:
- 'Alt-0'
- ['Ctrl-A', '0']
pluginBlacklist: ['ssh']

View File

@ -43,3 +43,4 @@ hotkeys:
tab-10:
- '⌘-0'
- ['Ctrl-A', '0']
pluginBlacklist: ['ssh']

View File

@ -43,3 +43,4 @@ hotkeys:
tab-10:
- 'Alt-0'
- ['Ctrl-A', '0']
pluginBlacklist: []

View File

@ -6,4 +6,3 @@ appearance:
theme: Standard
frame: thin
css: '/* * { color: blue !important; } */'
pluginBlacklist: []

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider, ComponentType } from 'terminus-settings'
import { SettingsTabProvider } from 'terminus-settings'
import { PluginsSettingsTabComponent } from './components/pluginsSettingsTab.component'
@ -8,7 +8,7 @@ export class PluginsSettingsTabProvider extends SettingsTabProvider {
id = 'plugins'
title = 'Plugins'
getComponentType (): ComponentType {
getComponentType (): any {
return PluginsSettingsTabComponent
}
}

View File

@ -1,10 +1,8 @@
export declare type ComponentType = new (...args: any[]) => any
export abstract class SettingsTabProvider {
id: string
title: string
getComponentType (): ComponentType {
getComponentType (): any {
return null
}
}

48
terminus-ssh/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "terminus-ssh",
"version": "0.0.1",
"description": "SSH connection manager for Terminus",
"keywords": [
"terminus-builtin-plugin"
],
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"build": "webpack --progress --color",
"watch": "webpack --progress --color --watch"
},
"files": [
"dist"
],
"author": "Eugene Pankov",
"license": "MIT",
"devDependencies": {
"@types/ssh2": "^0.5.35",
"@types/webpack-env": "^1.13.0",
"apply-loader": "^2.0.0",
"awesome-typescript-loader": "^3.1.2",
"electron": "^1.6.11",
"pug": "^2.0.0-rc.3",
"pug-loader": "^2.3.0",
"rxjs": "^5.4.0",
"typescript": "^2.2.2",
"webpack": "^2.3.3"
},
"peerDependencies": {
"@angular/common": "^4.1.3",
"@angular/core": "^4.1.3",
"@angular/forms": "^4.1.3",
"@ng-bootstrap/ng-bootstrap": "^1.0.0-alpha.29",
"terminus-core": "*",
"terminus-settings": "*",
"terminus-terminal": "*"
},
"optionalDependencies": {
"wincredmgr": "^2.0.0",
"xkeychain": "^0.0.6"
},
"repository": "eugeny/terminus-ssh",
"dependencies": {
"ssh2": "^0.5.5"
}
}

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
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"baseUrl": "src",
"declarationDir": "dist"
}
}

View File

@ -0,0 +1,46 @@
const path = require('path')
module.exports = {
target: 'node',
entry: 'src/index.ts',
devtool: 'source-map',
context: __dirname,
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js',
pathinfo: true,
libraryTarget: 'umd',
devtoolModuleFilenameTemplate: 'webpack-terminus-ssh:///[resource-path]',
},
resolve: {
modules: ['.', 'src', 'node_modules', '../app/node_modules'].map(x => path.join(__dirname, x)),
extensions: ['.ts', '.js'],
},
module: {
loaders: [
{
test: /\.ts$/,
loader: 'awesome-typescript-loader',
query: {
configFileName: path.resolve(__dirname, 'tsconfig.json'),
typeRoots: [path.resolve(__dirname, 'node_modules/@types')],
paths: {
"terminus-*": [path.resolve(__dirname, '../terminus-*')],
"*": [path.resolve(__dirname, '../app/node_modules/*')],
}
}
},
{ test: /\.pug$/, use: ['apply-loader', 'pug-loader'] },
{ test: /\.scss$/, use: ['to-string-loader', 'css-loader', 'sass-loader'] },
]
},
externals: [
'fs',
'node-ssh',
'xkeychain',
/^rxjs/,
/^@angular/,
/^@ng-bootstrap/,
/^terminus-/,
]
}

2968
terminus-ssh/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider, ComponentType } from 'terminus-settings'
import { SettingsTabProvider } from 'terminus-settings'
import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.component'
@ -8,7 +8,7 @@ export class TerminalSettingsTabProvider extends SettingsTabProvider {
id = 'terminal'
title = 'Terminal'
getComponentType (): ComponentType {
getComponentType (): any {
return TerminalSettingsTabComponent
}
}

View File

@ -5,4 +5,5 @@ module.exports = [
require('./terminus-terminal/webpack.config.js'),
require('./terminus-community-color-schemes/webpack.config.js'),
require('./terminus-plugin-manager/webpack.config.js'),
require('./terminus-ssh/webpack.config.js'),
]