mirror of
https://github.com/Eugeny/tabby.git
synced 2025-10-06 06:54:56 +00:00
project rename
This commit is contained in:
799
tabby-ssh/src/api.ts
Normal file
799
tabby-ssh/src/api.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import * as crypto from 'crypto'
|
||||
import * as path from 'path'
|
||||
import * as C from 'constants'
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports, no-duplicate-imports
|
||||
import { posix as posixPath } from 'path'
|
||||
import * as sshpk from 'sshpk'
|
||||
import colors from 'ansi-colors'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import socksv5 from 'socksv5'
|
||||
import { Injector, NgZone } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ConfigService, FileProvidersService, HostAppService, Logger, NotificationsService, Platform, PlatformService, wrapPromise } from 'tabby-core'
|
||||
import { BaseSession } from 'tabby-terminal'
|
||||
import { Server, Socket, createServer, createConnection } from 'net'
|
||||
import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
|
||||
import type { FileEntry, Stats } from 'ssh2-streams'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { ProxyCommandStream } from './services/ssh.service'
|
||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||
import { PromptModalComponent } from './components/promptModal.component'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
||||
|
||||
export interface LoginScript {
|
||||
expect: string
|
||||
send: string
|
||||
isRegex?: boolean
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
export enum SSHAlgorithmType {
|
||||
HMAC = 'hmac',
|
||||
KEX = 'kex',
|
||||
CIPHER = 'cipher',
|
||||
HOSTKEY = 'serverHostKey',
|
||||
}
|
||||
|
||||
export interface SSHConnection {
|
||||
name: string
|
||||
host: string
|
||||
port?: number
|
||||
user: string
|
||||
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
|
||||
password?: string
|
||||
privateKeys?: string[]
|
||||
group: string | null
|
||||
scripts?: LoginScript[]
|
||||
keepaliveInterval?: number
|
||||
keepaliveCountMax?: number
|
||||
readyTimeout?: number
|
||||
color?: string
|
||||
x11?: boolean
|
||||
skipBanner?: boolean
|
||||
disableDynamicTitle?: boolean
|
||||
jumpHost?: string
|
||||
agentForward?: boolean
|
||||
warnOnClose?: boolean
|
||||
algorithms?: Record<string, string[]>
|
||||
proxyCommand?: string
|
||||
forwardedPorts?: ForwardedPortConfig[]
|
||||
}
|
||||
|
||||
export enum PortForwardType {
|
||||
Local = 'Local',
|
||||
Remote = 'Remote',
|
||||
Dynamic = 'Dynamic',
|
||||
}
|
||||
|
||||
export interface ForwardedPortConfig {
|
||||
type: PortForwardType
|
||||
host: string
|
||||
port: number
|
||||
targetAddress: string
|
||||
targetPort: number
|
||||
}
|
||||
|
||||
export class ForwardedPort implements ForwardedPortConfig {
|
||||
type: PortForwardType
|
||||
host = '127.0.0.1'
|
||||
port: number
|
||||
targetAddress: string
|
||||
targetPort: number
|
||||
|
||||
private listener: Server
|
||||
|
||||
async startLocalListener (callback: (accept: () => Socket, reject: () => void, sourceAddress: string|null, sourcePort: number|null, targetAddress: string, targetPort: number) => void): Promise<void> {
|
||||
if (this.type === PortForwardType.Local) {
|
||||
this.listener = createServer(s => callback(
|
||||
() => s,
|
||||
() => s.destroy(),
|
||||
s.remoteAddress ?? null,
|
||||
s.remotePort ?? null,
|
||||
this.targetAddress,
|
||||
this.targetPort,
|
||||
))
|
||||
return new Promise((resolve, reject) => {
|
||||
this.listener.listen(this.port, this.host)
|
||||
this.listener.on('error', reject)
|
||||
this.listener.on('listening', resolve)
|
||||
})
|
||||
} else if (this.type === PortForwardType.Dynamic) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.listener = socksv5.createServer((info, acceptConnection, rejectConnection) => {
|
||||
callback(
|
||||
() => acceptConnection(true),
|
||||
() => rejectConnection(),
|
||||
null,
|
||||
null,
|
||||
info.dstAddr,
|
||||
info.dstPort,
|
||||
)
|
||||
})
|
||||
this.listener.on('error', reject)
|
||||
this.listener.listen(this.port, this.host, resolve)
|
||||
;(this.listener as any).useAuth(socksv5.auth.None())
|
||||
})
|
||||
} else {
|
||||
throw new Error('Invalid forward type for a local listener')
|
||||
}
|
||||
}
|
||||
|
||||
stopLocalListener (): void {
|
||||
this.listener.close()
|
||||
}
|
||||
|
||||
toString (): string {
|
||||
if (this.type === PortForwardType.Local) {
|
||||
return `(local) ${this.host}:${this.port} → (remote) ${this.targetAddress}:${this.targetPort}`
|
||||
} if (this.type === PortForwardType.Remote) {
|
||||
return `(remote) ${this.host}:${this.port} → (local) ${this.targetAddress}:${this.targetPort}`
|
||||
} else {
|
||||
return `(dynamic) ${this.host}:${this.port}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthMethod {
|
||||
type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
|
||||
name?: string
|
||||
contents?: Buffer
|
||||
}
|
||||
|
||||
export interface SFTPFile {
|
||||
name: string
|
||||
fullPath: string
|
||||
isDirectory: boolean
|
||||
isSymlink: boolean
|
||||
mode: number
|
||||
size: number
|
||||
modified: Date
|
||||
}
|
||||
|
||||
export class SFTPFileHandle {
|
||||
position = 0
|
||||
|
||||
constructor (
|
||||
private sftp: SFTPWrapper,
|
||||
private handle: Buffer,
|
||||
private zone: NgZone,
|
||||
) { }
|
||||
|
||||
read (): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(256 * 1024)
|
||||
return wrapPromise(this.zone, new Promise((resolve, reject) => {
|
||||
while (true) {
|
||||
const wait = this.sftp.read(this.handle, buffer, 0, buffer.length, this.position, (err, read) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
this.position += read
|
||||
resolve(buffer.slice(0, read))
|
||||
})
|
||||
if (!wait) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
write (chunk: Buffer): Promise<void> {
|
||||
return wrapPromise(this.zone, new Promise<void>((resolve, reject) => {
|
||||
while (true) {
|
||||
const wait = this.sftp.write(this.handle, chunk, 0, chunk.length, this.position, err => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
this.position += chunk.length
|
||||
resolve()
|
||||
})
|
||||
if (!wait) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
close (): Promise<void> {
|
||||
return wrapPromise(this.zone, promisify(this.sftp.close.bind(this.sftp))(this.handle))
|
||||
}
|
||||
}
|
||||
|
||||
export class SFTPSession {
|
||||
constructor (private sftp: SFTPWrapper, private zone: NgZone) { }
|
||||
|
||||
async readdir (p: string): Promise<SFTPFile[]> {
|
||||
const entries = await wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))())
|
||||
return entries.map(entry => this._makeFile(
|
||||
posixPath.join(p, entry.filename), entry,
|
||||
))
|
||||
}
|
||||
|
||||
readlink (p: string): Promise<string> {
|
||||
return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
|
||||
}
|
||||
|
||||
async stat (p: string): Promise<SFTPFile> {
|
||||
const stats = await wrapPromise(this.zone, promisify<Stats>(f => this.sftp.stat(p, f))())
|
||||
return {
|
||||
name: posixPath.basename(p),
|
||||
fullPath: p,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isSymlink: stats.isSymbolicLink(),
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
modified: new Date(stats.mtime * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
async open (p: string, mode: string): Promise<SFTPFileHandle> {
|
||||
const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))())
|
||||
return new SFTPFileHandle(this.sftp, handle, this.zone)
|
||||
}
|
||||
|
||||
async rmdir (p: string): Promise<void> {
|
||||
await promisify((f: any) => this.sftp.rmdir(p, f))()
|
||||
}
|
||||
|
||||
async unlink (p: string): Promise<void> {
|
||||
await promisify((f: any) => this.sftp.unlink(p, f))()
|
||||
}
|
||||
|
||||
private _makeFile (p: string, entry: FileEntry): SFTPFile {
|
||||
return {
|
||||
fullPath: p,
|
||||
name: posixPath.basename(p),
|
||||
isDirectory: (entry.attrs.mode & C.S_IFDIR) === C.S_IFDIR,
|
||||
isSymlink: (entry.attrs.mode & C.S_IFLNK) === C.S_IFLNK,
|
||||
mode: entry.attrs.mode,
|
||||
size: entry.attrs.size,
|
||||
modified: new Date(entry.attrs.mtime * 1000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHSession extends BaseSession {
|
||||
scripts?: LoginScript[]
|
||||
shell?: ClientChannel
|
||||
ssh: Client
|
||||
sftp?: SFTPWrapper
|
||||
forwardedPorts: ForwardedPort[] = []
|
||||
logger: Logger
|
||||
jumpStream: any
|
||||
proxyCommandStream: ProxyCommandStream|null = null
|
||||
savedPassword?: string
|
||||
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
|
||||
|
||||
agentPath?: string
|
||||
activePrivateKey: string|null = null
|
||||
|
||||
private remainingAuthMethods: AuthMethod[] = []
|
||||
private serviceMessage = new Subject<string>()
|
||||
private keychainPasswordUsed = false
|
||||
|
||||
private passwordStorage: PasswordStorageService
|
||||
private ngbModal: NgbModal
|
||||
private hostApp: HostAppService
|
||||
private platform: PlatformService
|
||||
private notifications: NotificationsService
|
||||
private zone: NgZone
|
||||
private fileProviders: FileProvidersService
|
||||
private config: ConfigService
|
||||
|
||||
constructor (
|
||||
injector: Injector,
|
||||
public connection: SSHConnection,
|
||||
) {
|
||||
super()
|
||||
this.passwordStorage = injector.get(PasswordStorageService)
|
||||
this.ngbModal = injector.get(NgbModal)
|
||||
this.hostApp = injector.get(HostAppService)
|
||||
this.platform = injector.get(PlatformService)
|
||||
this.notifications = injector.get(NotificationsService)
|
||||
this.zone = injector.get(NgZone)
|
||||
this.fileProviders = injector.get(FileProvidersService)
|
||||
this.config = injector.get(ConfigService)
|
||||
|
||||
this.scripts = connection.scripts ?? []
|
||||
this.destroyed$.subscribe(() => {
|
||||
for (const port of this.forwardedPorts) {
|
||||
if (port.type === PortForwardType.Local) {
|
||||
port.stopLocalListener()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
if (this.config.store.ssh.agentType === 'auto') {
|
||||
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
|
||||
this.agentPath = WINDOWS_OPENSSH_AGENT_PIPE
|
||||
} else {
|
||||
if (await this.platform.isProcessRunning('pageant.exe')) {
|
||||
this.agentPath = 'pageant'
|
||||
}
|
||||
}
|
||||
} else if (this.config.store.ssh.agentType === 'pageant') {
|
||||
this.agentPath = 'pageant'
|
||||
} else {
|
||||
this.agentPath = this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE
|
||||
}
|
||||
} else {
|
||||
this.agentPath = process.env.SSH_AUTH_SOCK!
|
||||
}
|
||||
|
||||
this.remainingAuthMethods = [{ type: 'none' }]
|
||||
if (!this.connection.auth || this.connection.auth === 'publicKey') {
|
||||
if (this.connection.privateKeys?.length) {
|
||||
for (const pk of this.connection.privateKeys) {
|
||||
try {
|
||||
this.remainingAuthMethods.push({
|
||||
type: 'publickey',
|
||||
name: pk,
|
||||
contents: await this.fileProviders.retrieveFile(pk),
|
||||
})
|
||||
} catch (error) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Could not load private key ${pk}: ${error}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.remainingAuthMethods.push({
|
||||
type: 'publickey',
|
||||
name: 'auto',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'agent') {
|
||||
if (!this.agentPath) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
|
||||
} else {
|
||||
this.remainingAuthMethods.push({ type: 'agent' })
|
||||
}
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'password') {
|
||||
this.remainingAuthMethods.push({ type: 'password' })
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') {
|
||||
this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
|
||||
}
|
||||
this.remainingAuthMethods.push({ type: 'hostbased' })
|
||||
}
|
||||
|
||||
async openSFTP (): Promise<SFTPSession> {
|
||||
if (!this.sftp) {
|
||||
this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
|
||||
}
|
||||
return new SFTPSession(this.sftp, this.zone)
|
||||
}
|
||||
|
||||
async start (): Promise<void> {
|
||||
this.open = true
|
||||
|
||||
this.proxyCommandStream?.on('error', err => {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
|
||||
this.destroy()
|
||||
})
|
||||
|
||||
try {
|
||||
this.shell = await this.openShellChannel({ x11: this.connection.x11 })
|
||||
} catch (err) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected opening a shell channel: ${err}`)
|
||||
if (err.toString().includes('Unable to request X11')) {
|
||||
this.emitServiceMessage(' Make sure `xauth` is installed on the remote side')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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(data)
|
||||
|
||||
if (this.scripts) {
|
||||
let found = false
|
||||
for (const script of this.scripts) {
|
||||
let match = false
|
||||
let cmd = ''
|
||||
if (script.isRegex) {
|
||||
const re = new RegExp(script.expect, 'g')
|
||||
if (dataString.match(re)) {
|
||||
cmd = dataString.replace(re, script.send)
|
||||
match = true
|
||||
found = true
|
||||
}
|
||||
} else {
|
||||
if (dataString.includes(script.expect)) {
|
||||
cmd = script.send
|
||||
match = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
this.logger.info('Executing script: "' + cmd + '"')
|
||||
this.shell?.write(cmd + '\n')
|
||||
this.scripts = this.scripts.filter(x => x !== script)
|
||||
} else {
|
||||
if (script.optional) {
|
||||
this.logger.debug('Skip optional script: ' + script.expect)
|
||||
found = true
|
||||
this.scripts = this.scripts.filter(x => x !== script)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
this.executeUnconditionalScripts()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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(colors.bgRed.black(' X ') + ` 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 => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` 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.ssh.on('x11', (details, accept, reject) => {
|
||||
this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`)
|
||||
const displaySpec = process.env.DISPLAY ?? ':0'
|
||||
this.logger.debug(`Trying display ${displaySpec}`)
|
||||
const xHost = displaySpec.split(':')[0]
|
||||
const xDisplay = parseInt(displaySpec.split(':')[1].split('.')[0] || '0')
|
||||
const xPort = xDisplay < 100 ? xDisplay + 6000 : xDisplay
|
||||
|
||||
const socket = displaySpec.startsWith('/') ? createConnection(displaySpec) : new Socket()
|
||||
if (!displaySpec.startsWith('/')) {
|
||||
socket.connect(xPort, xHost)
|
||||
}
|
||||
socket.on('error', e => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not connect to the X server: ${e}`)
|
||||
this.emitServiceMessage(` Tabby tried to connect to ${xHost}:${xPort} based on the DISPLAY environment var (${displaySpec})`)
|
||||
if (process.platform === 'win32') {
|
||||
this.emitServiceMessage(' To use X forwarding, you need a local X server, e.g.:')
|
||||
this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
|
||||
this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
|
||||
}
|
||||
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): void {
|
||||
this.serviceMessage.next(msg)
|
||||
this.logger.info(stripAnsi(msg))
|
||||
}
|
||||
|
||||
async handleAuth (methodsLeft?: string[]): Promise<any> {
|
||||
this.activePrivateKey = null
|
||||
|
||||
while (true) {
|
||||
const method = this.remainingAuthMethods.shift()
|
||||
if (!method) {
|
||||
return false
|
||||
}
|
||||
if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') {
|
||||
// Agent can still be used even if not in methodsLeft
|
||||
this.logger.info('Server does not support auth method', method.type)
|
||||
continue
|
||||
}
|
||||
if (method.type === 'password') {
|
||||
if (this.connection.password) {
|
||||
this.emitServiceMessage('Using preset password')
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.connection.user,
|
||||
password: this.connection.password,
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.keychainPasswordUsed) {
|
||||
const password = await this.passwordStorage.loadPassword(this.connection)
|
||||
if (password) {
|
||||
this.emitServiceMessage('Trying saved password')
|
||||
this.keychainPasswordUsed = true
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.connection.user,
|
||||
password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
|
||||
modal.componentInstance.password = true
|
||||
modal.componentInstance.showRememberCheckbox = true
|
||||
|
||||
try {
|
||||
const result = await modal.result
|
||||
if (result) {
|
||||
if (result.remember) {
|
||||
this.savedPassword = result.value
|
||||
}
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.connection.user,
|
||||
password: result.value,
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (method.type === 'publickey') {
|
||||
try {
|
||||
const key = await this.loadPrivateKey(method.contents)
|
||||
return {
|
||||
type: 'publickey',
|
||||
username: this.connection.user,
|
||||
key,
|
||||
}
|
||||
} catch (e) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return method.type
|
||||
}
|
||||
}
|
||||
|
||||
async addPortForward (fw: ForwardedPort): Promise<void> {
|
||||
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
|
||||
await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
|
||||
this.logger.info(`New connection on ${fw}`)
|
||||
this.ssh.forwardOut(
|
||||
sourceAddress ?? '127.0.0.1',
|
||||
sourcePort ?? 0,
|
||||
targetAddress,
|
||||
targetPort,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
|
||||
return reject()
|
||||
}
|
||||
const socket = accept()
|
||||
stream.pipe(socket)
|
||||
socket.pipe(stream)
|
||||
stream.on('close', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('close', () => {
|
||||
stream.close()
|
||||
})
|
||||
}
|
||||
)
|
||||
}).then(() => {
|
||||
this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
|
||||
this.forwardedPorts.push(fw)
|
||||
}).catch(e => {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Failed to forward port ${fw}: ${e}`)
|
||||
throw e
|
||||
})
|
||||
}
|
||||
if (fw.type === PortForwardType.Remote) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.ssh.forwardIn(fw.host, fw.port, err => {
|
||||
if (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`)
|
||||
return reject(err)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
|
||||
this.forwardedPorts.push(fw)
|
||||
}
|
||||
}
|
||||
|
||||
async removePortForward (fw: ForwardedPort): Promise<void> {
|
||||
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
|
||||
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: number, rows: number): void {
|
||||
if (this.shell) {
|
||||
this.shell.setWindow(rows, columns, rows, columns)
|
||||
}
|
||||
}
|
||||
|
||||
write (data: Buffer): void {
|
||||
if (this.shell) {
|
||||
this.shell.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
kill (signal?: string): void {
|
||||
if (this.shell) {
|
||||
this.shell.signal(signal ?? 'TERM')
|
||||
}
|
||||
}
|
||||
|
||||
async destroy (): Promise<void> {
|
||||
this.serviceMessage.complete()
|
||||
this.proxyCommandStream?.destroy()
|
||||
this.kill()
|
||||
this.ssh.end()
|
||||
await super.destroy()
|
||||
}
|
||||
|
||||
async getChildProcesses (): Promise<any[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
async gracefullyKillProcess (): Promise<void> {
|
||||
this.kill('TERM')
|
||||
}
|
||||
|
||||
supportsWorkingDirectory (): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async getWorkingDirectory (): Promise<string|null> {
|
||||
return null
|
||||
}
|
||||
|
||||
private openShellChannel (options): Promise<ClientChannel> {
|
||||
return new Promise<ClientChannel>((resolve, reject) => {
|
||||
this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(shell)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private executeUnconditionalScripts () {
|
||||
if (this.scripts) {
|
||||
for (const script of this.scripts) {
|
||||
if (!script.expect) {
|
||||
console.log('Executing script:', script.send)
|
||||
this.shell?.write(script.send + '\n')
|
||||
this.scripts = this.scripts.filter(x => x !== script)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadPrivateKey (privateKeyContents?: Buffer): Promise<string|null> {
|
||||
if (!privateKeyContents) {
|
||||
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
|
||||
if (await fs.exists(userKeyPath)) {
|
||||
this.emitServiceMessage('Using user\'s default private key')
|
||||
privateKeyContents = await fs.readFile(userKeyPath, { encoding: null })
|
||||
}
|
||||
}
|
||||
|
||||
if (!privateKeyContents) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.emitServiceMessage('Loading private key')
|
||||
try {
|
||||
const parsedKey = await this.parsePrivateKey(privateKeyContents.toString())
|
||||
this.activePrivateKey = parsedKey.toString('openssh')
|
||||
return this.activePrivateKey
|
||||
} catch (error) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
|
||||
this.notifications.error('Could not read the private key file')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async parsePrivateKey (privateKey: string): Promise<any> {
|
||||
const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
|
||||
let triedSavedPassphrase = false
|
||||
let passphrase: string|null = null
|
||||
while (true) {
|
||||
try {
|
||||
return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase })
|
||||
} catch (e) {
|
||||
if (!triedSavedPassphrase) {
|
||||
passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
|
||||
triedSavedPassphrase = true
|
||||
continue
|
||||
}
|
||||
if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
|
||||
await this.passwordStorage.deletePrivateKeyPassword(keyHash)
|
||||
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = 'Private key passphrase'
|
||||
modal.componentInstance.password = true
|
||||
modal.componentInstance.showRememberCheckbox = true
|
||||
|
||||
try {
|
||||
const result = await modal.result
|
||||
passphrase = result?.value
|
||||
if (passphrase && result.remember) {
|
||||
this.passwordStorage.savePrivateKeyPassword(keyHash, passphrase)
|
||||
}
|
||||
} catch {
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
this.notifications.error('Could not read the private key', e.toString())
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ALGORITHM_BLACKLIST = [
|
||||
// cause native crashes in node crypto, use EC instead
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
]
|
43
tabby-ssh/src/buttonProvider.ts
Normal file
43
tabby-ssh/src/buttonProvider.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeysService, ToolbarButtonProvider, ToolbarButton, HostAppService, Platform } from 'tabby-core'
|
||||
import { SSHService } from './services/ssh.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class ButtonProvider extends ToolbarButtonProvider {
|
||||
constructor (
|
||||
hotkeys: HotkeysService,
|
||||
private hostApp: HostAppService,
|
||||
private ssh: SSHService,
|
||||
) {
|
||||
super()
|
||||
hotkeys.matchedHotkey.subscribe(async (hotkey: string) => {
|
||||
if (hotkey === 'ssh') {
|
||||
this.activate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
activate () {
|
||||
this.ssh.showConnectionSelector()
|
||||
}
|
||||
|
||||
provide (): ToolbarButton[] {
|
||||
if (this.hostApp.platform === Platform.Web) {
|
||||
return [{
|
||||
icon: require('../../tabby-local/src/icons/plus.svg'),
|
||||
title: 'SSH connections',
|
||||
click: () => this.activate(),
|
||||
}]
|
||||
} else {
|
||||
return [{
|
||||
icon: require('./icons/globe.svg'),
|
||||
weight: 5,
|
||||
title: 'SSH connections',
|
||||
touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
|
||||
click: () => this.activate(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
30
tabby-ssh/src/cli.ts
Normal file
30
tabby-ssh/src/cli.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
|
||||
import { SSHService } from './services/ssh.service'
|
||||
|
||||
@Injectable()
|
||||
export class SSHCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = 0
|
||||
|
||||
constructor (
|
||||
private ssh: SSHService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
|
||||
if (op === 'connect-ssh') {
|
||||
const connection = this.config.store.ssh.connections.find(x => x.name === event.argv.connectionName)
|
||||
if (connection) {
|
||||
this.ssh.connect(connection)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
269
tabby-ssh/src/components/editConnectionModal.component.pug
Normal file
269
tabby-ssh/src/components/editConnectionModal.component.pug
Normal file
@@ -0,0 +1,269 @@
|
||||
.modal-body
|
||||
ul.nav-tabs(ngbNav, #nav='ngbNav')
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) General
|
||||
ng-template(ngbNavContent)
|
||||
.form-group
|
||||
label Name
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.name',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Ungrouped',
|
||||
[(ngModel)]='connection.group',
|
||||
[ngbTypeahead]='groupTypeahead',
|
||||
)
|
||||
|
||||
.d-flex.w-100(*ngIf='!useProxyCommand')
|
||||
.form-group.w-100.mr-4
|
||||
label Host
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.host',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Port
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='22',
|
||||
[(ngModel)]='connection.port',
|
||||
)
|
||||
|
||||
.alert.alert-info(*ngIf='useProxyCommand')
|
||||
.mr-auto Using a proxy command instead of a network connection
|
||||
|
||||
.form-group
|
||||
label Username
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.user',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Authentication method
|
||||
|
||||
.btn-group.mt-1.w-100(
|
||||
[(ngModel)]='connection.auth',
|
||||
ngbRadioGroup
|
||||
)
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='null')
|
||||
i.far.fa-lightbulb
|
||||
.m-0 Auto
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"password"')
|
||||
i.fas.fa-font
|
||||
.m-0 Password
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"publicKey"')
|
||||
i.fas.fa-key
|
||||
.m-0 Key
|
||||
label.btn.btn-secondary(ngbButtonLabel, ng:if='hostApp.platform !== Platform.Web')
|
||||
input(type='radio', ngbButton, [value]='"agent"')
|
||||
i.fas.fa-user-secret
|
||||
.m-0 Agent
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
|
||||
i.far.fa-keyboard
|
||||
.m-0 Interactive
|
||||
|
||||
.form-line(*ngIf='!connection.auth || connection.auth === "password"')
|
||||
.header
|
||||
.title Password
|
||||
.description(*ngIf='!hasSavedPassword') Save a password in the keychain
|
||||
.description(*ngIf='hasSavedPassword') There is a saved password for this connection
|
||||
button.btn.btn-outline-success.ml-4(*ngIf='!hasSavedPassword', (click)='setPassword()')
|
||||
i.fas.fa-key
|
||||
span Set password
|
||||
button.btn.btn-danger.ml-4(*ngIf='hasSavedPassword', (click)='clearSavedPassword()')
|
||||
i.fas.fa-trash-alt
|
||||
span Forget
|
||||
|
||||
.form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
|
||||
label Private keys
|
||||
.list-group.mb-2
|
||||
.list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
|
||||
i.fas.fa-key
|
||||
.no-wrap.mr-auto {{path}}
|
||||
button.btn.btn-link((click)='removePrivateKey(path)')
|
||||
i.fas.fa-trash
|
||||
button.btn.btn-secondary((click)='addPrivateKey()')
|
||||
i.fas.fa-folder-open
|
||||
span Add a private key
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Ports
|
||||
ng-template(ngbNavContent)
|
||||
ssh-port-forwarding-config(
|
||||
[model]='connection.forwardedPorts',
|
||||
(forwardAdded)='onForwardAdded($event)',
|
||||
(forwardRemoved)='onForwardRemoved($event)'
|
||||
)
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Advanced
|
||||
ng-template(ngbNavContent)
|
||||
.form-line(*ngIf='!useProxyCommand')
|
||||
.header
|
||||
.title Jump host
|
||||
select.form-control([(ngModel)]='connection.jumpHost')
|
||||
option(value='') None
|
||||
option([ngValue]='x.name', *ngFor='let x of config.store.ssh.connections') {{x.name}}
|
||||
|
||||
.form-line(ng:if='hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title X11 forwarding
|
||||
toggle([(ngModel)]='connection.x11')
|
||||
|
||||
.form-line(ng:if='hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title Agent forwarding
|
||||
toggle([(ngModel)]='connection.agentForward')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Tab color
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='connection.color',
|
||||
placeholder='#000000'
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Disable dynamic tab title
|
||||
.description Connection name will be used as a title instead
|
||||
toggle([(ngModel)]='connection.disableDynamicTitle')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Skip MoTD/banner
|
||||
.description Will prevent the SSH greeting from showing up
|
||||
toggle([(ngModel)]='connection.skipBanner')
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Keep Alive Interval (Milliseconds)
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='0',
|
||||
[(ngModel)]='connection.keepaliveInterval',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Max Keep Alive Count
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='3',
|
||||
[(ngModel)]='connection.keepaliveCountMax',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Ready Timeout (Milliseconds)
|
||||
input.form-control(
|
||||
type='number',
|
||||
placeholder='20000',
|
||||
[(ngModel)]='connection.readyTimeout',
|
||||
)
|
||||
|
||||
.form-line(*ngIf='!connection.jumpHost && hostApp.platform !== Platform.Web')
|
||||
.header
|
||||
.title Use a proxy command
|
||||
.description Command's stdin/stdout is used instead of a network connection
|
||||
toggle([(ngModel)]='useProxyCommand')
|
||||
|
||||
.form-group(*ngIf='useProxyCommand && !connection.jumpHost')
|
||||
label Proxy command
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='connection.proxyCommand',
|
||||
)
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Ciphers
|
||||
ng-template(ngbNavContent)
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Ciphers
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.cipher')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.cipher[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Key exchange
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.kex')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.kex[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title HMAC
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.hmac')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.hmac[alg]')
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Host key
|
||||
.w-75
|
||||
div(*ngFor='let alg of supportedAlgorithms.serverHostKey')
|
||||
checkbox([text]='alg', [(ngModel)]='algorithms.serverHostKey[alg]')
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Login scripts
|
||||
ng-template(ngbNavContent)
|
||||
table(*ngIf='connection.scripts.length > 0')
|
||||
tr
|
||||
th String to expect
|
||||
th String to be sent
|
||||
th.pl-2 Regex
|
||||
th.pl-2 Optional
|
||||
th.pl-2 Actions
|
||||
tr(*ngFor='let script of connection.scripts')
|
||||
td.pr-2
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.expect'
|
||||
)
|
||||
td
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='script.send'
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.isRegex',
|
||||
)
|
||||
td.pl-2
|
||||
checkbox(
|
||||
[(ngModel)]='script.optional',
|
||||
)
|
||||
td.pl-2
|
||||
.input-group.flex-nowrap
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
|
||||
i.fas.fa-arrow-up
|
||||
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
|
||||
i.fas.fa-arrow-down
|
||||
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
|
||||
i.fas.fa-trash
|
||||
|
||||
button.btn.btn-outline-info.mt-2((click)='addScript()')
|
||||
i.fas.fa-plus
|
||||
span New item
|
||||
|
||||
div([ngbNavOutlet]='nav')
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='save()') Save
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
189
tabby-ssh/src/components/editConnectionModal.component.ts
Normal file
189
tabby-ssh/src/components/editConnectionModal.component.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Observable } from 'rxjs'
|
||||
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
|
||||
|
||||
import { ConfigService, PlatformService, FileProvidersService, Platform, HostAppService } from 'tabby-core'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { SSHConnection, LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
|
||||
import { PromptModalComponent } from './promptModal.component'
|
||||
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./editConnectionModal.component.pug'),
|
||||
})
|
||||
export class EditConnectionModalComponent {
|
||||
Platform = Platform
|
||||
connection: SSHConnection
|
||||
hasSavedPassword: boolean
|
||||
useProxyCommand: boolean
|
||||
|
||||
supportedAlgorithms: Record<string, string> = {}
|
||||
defaultAlgorithms: Record<string, string[]> = {}
|
||||
algorithms: Record<string, Record<string, boolean>> = {}
|
||||
|
||||
private groupNames: string[]
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
private modalInstance: NgbActiveModal,
|
||||
private platform: PlatformService,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
private ngbModal: NgbModal,
|
||||
private fileProviders: FileProvidersService,
|
||||
) {
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
const supportedAlg = {
|
||||
[SSHAlgorithmType.KEX]: 'SUPPORTED_KEX',
|
||||
[SSHAlgorithmType.HOSTKEY]: 'SUPPORTED_SERVER_HOST_KEY',
|
||||
[SSHAlgorithmType.CIPHER]: 'SUPPORTED_CIPHER',
|
||||
[SSHAlgorithmType.HMAC]: 'SUPPORTED_MAC',
|
||||
}[k]
|
||||
const defaultAlg = {
|
||||
[SSHAlgorithmType.KEX]: 'DEFAULT_KEX',
|
||||
[SSHAlgorithmType.HOSTKEY]: 'DEFAULT_SERVER_HOST_KEY',
|
||||
[SSHAlgorithmType.CIPHER]: 'DEFAULT_CIPHER',
|
||||
[SSHAlgorithmType.HMAC]: 'DEFAULT_MAC',
|
||||
}[k]
|
||||
this.supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort()
|
||||
this.defaultAlgorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
}
|
||||
|
||||
this.groupNames = [...new Set(config.store.ssh.connections.map(x => x.group))] as string[]
|
||||
this.groupNames = this.groupNames.filter(x => x).sort()
|
||||
}
|
||||
|
||||
groupTypeahead = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
map(q => this.groupNames.filter(x => !q || x.toLowerCase().includes(q.toLowerCase())))
|
||||
)
|
||||
|
||||
async ngOnInit () {
|
||||
this.connection.algorithms = this.connection.algorithms ?? {}
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!this.connection.algorithms[k]) {
|
||||
this.connection.algorithms[k] = this.defaultAlgorithms[k]
|
||||
}
|
||||
|
||||
this.algorithms[k] = {}
|
||||
for (const alg of this.connection.algorithms[k]) {
|
||||
this.algorithms[k][alg] = true
|
||||
}
|
||||
}
|
||||
|
||||
this.connection.scripts = this.connection.scripts ?? []
|
||||
this.connection.auth = this.connection.auth ?? null
|
||||
this.connection.privateKeys ??= []
|
||||
|
||||
this.useProxyCommand = !!this.connection.proxyCommand
|
||||
try {
|
||||
this.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.connection)
|
||||
} catch (e) {
|
||||
console.error('Could not check for saved password', e)
|
||||
}
|
||||
}
|
||||
|
||||
async setPassword () {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Password for ${this.connection.user}@${this.connection.host}`
|
||||
modal.componentInstance.password = true
|
||||
try {
|
||||
const result = await modal.result
|
||||
if (result?.value) {
|
||||
this.passwordStorage.savePassword(this.connection, result.value)
|
||||
this.hasSavedPassword = true
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
clearSavedPassword () {
|
||||
this.hasSavedPassword = false
|
||||
this.passwordStorage.deletePassword(this.connection)
|
||||
}
|
||||
|
||||
async addPrivateKey () {
|
||||
const ref = await this.fileProviders.selectAndStoreFile(`private key for ${this.connection.name}`)
|
||||
this.connection.privateKeys = [
|
||||
...this.connection.privateKeys!,
|
||||
ref,
|
||||
]
|
||||
}
|
||||
|
||||
removePrivateKey (path: string) {
|
||||
this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path)
|
||||
}
|
||||
|
||||
save () {
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
this.connection.algorithms![k] = Object.entries(this.algorithms[k])
|
||||
.filter(([_, v]) => !!v)
|
||||
.map(([key, _]) => key)
|
||||
}
|
||||
if (!this.useProxyCommand) {
|
||||
this.connection.proxyCommand = undefined
|
||||
}
|
||||
this.modalInstance.close(this.connection)
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
|
||||
moveScriptUp (script: LoginScript) {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
}
|
||||
const index = this.connection.scripts.indexOf(script)
|
||||
if (index > 0) {
|
||||
this.connection.scripts.splice(index, 1)
|
||||
this.connection.scripts.splice(index - 1, 0, script)
|
||||
}
|
||||
}
|
||||
|
||||
moveScriptDown (script: LoginScript) {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
}
|
||||
const index = this.connection.scripts.indexOf(script)
|
||||
if (index >= 0 && index < this.connection.scripts.length - 1) {
|
||||
this.connection.scripts.splice(index, 1)
|
||||
this.connection.scripts.splice(index + 1, 0, script)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript (script: LoginScript) {
|
||||
if (this.connection.scripts && (await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: 'Delete this script?',
|
||||
detail: script.expect,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
this.connection.scripts = this.connection.scripts.filter(x => x !== script)
|
||||
}
|
||||
}
|
||||
|
||||
addScript () {
|
||||
if (!this.connection.scripts) {
|
||||
this.connection.scripts = []
|
||||
}
|
||||
this.connection.scripts.push({ expect: '', send: '' })
|
||||
}
|
||||
|
||||
onForwardAdded (fw: ForwardedPortConfig) {
|
||||
this.connection.forwardedPorts = this.connection.forwardedPorts ?? []
|
||||
this.connection.forwardedPorts.push(fw)
|
||||
}
|
||||
|
||||
onForwardRemoved (fw: ForwardedPortConfig) {
|
||||
this.connection.forwardedPorts = this.connection.forwardedPorts?.filter(x => x !== fw)
|
||||
}
|
||||
}
|
19
tabby-ssh/src/components/promptModal.component.pug
Normal file
19
tabby-ssh/src/components/promptModal.component.pug
Normal file
@@ -0,0 +1,19 @@
|
||||
.modal-body
|
||||
input.form-control(
|
||||
[type]='password ? "password" : "text"',
|
||||
autofocus,
|
||||
[(ngModel)]='value',
|
||||
#input,
|
||||
[placeholder]='prompt',
|
||||
(keyup.enter)='ok()',
|
||||
(keyup.esc)='cancel()',
|
||||
)
|
||||
.d-flex.align-items-start.mt-2
|
||||
checkbox(
|
||||
*ngIf='showRememberCheckbox',
|
||||
[(ngModel)]='remember',
|
||||
text='Remember'
|
||||
)
|
||||
button.btn.btn-primary.ml-auto(
|
||||
(click)='ok()',
|
||||
) Enter
|
35
tabby-ssh/src/components/promptModal.component.ts
Normal file
35
tabby-ssh/src/components/promptModal.component.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Component, Input, ViewChild, ElementRef } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./promptModal.component.pug'),
|
||||
})
|
||||
export class PromptModalComponent {
|
||||
@Input() value: string
|
||||
@Input() password: boolean
|
||||
@Input() remember: boolean
|
||||
@Input() showRememberCheckbox: boolean
|
||||
@ViewChild('input') input: ElementRef
|
||||
|
||||
constructor (
|
||||
private modalInstance: NgbActiveModal,
|
||||
) { }
|
||||
|
||||
ngOnInit (): void {
|
||||
setTimeout(() => {
|
||||
this.input.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
|
||||
ok (): void {
|
||||
this.modalInstance.close({
|
||||
value: this.value,
|
||||
remember: this.remember,
|
||||
})
|
||||
}
|
||||
|
||||
cancel (): void {
|
||||
this.modalInstance.close(null)
|
||||
}
|
||||
}
|
6
tabby-ssh/src/components/sftpDeleteModal.component.pug
Normal file
6
tabby-ssh/src/components/sftpDeleteModal.component.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
.modal-body
|
||||
label Deleting
|
||||
.no-wrap {{progressMessage}}
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
49
tabby-ssh/src/components/sftpDeleteModal.component.ts
Normal file
49
tabby-ssh/src/components/sftpDeleteModal.component.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { BaseComponent } from 'tabby-core'
|
||||
import { SFTPFile, SFTPSession } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./sftpDeleteModal.component.pug'),
|
||||
})
|
||||
export class SFTPDeleteModalComponent extends BaseComponent {
|
||||
sftp: SFTPSession
|
||||
item: SFTPFile
|
||||
progressMessage = ''
|
||||
cancelled = false
|
||||
|
||||
constructor (
|
||||
private modalInstance: NgbActiveModal,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit (): void {
|
||||
this.destroyed$.subscribe(() => this.cancel())
|
||||
this.run(this.item).then(() => {
|
||||
this.modalInstance.close()
|
||||
})
|
||||
}
|
||||
|
||||
cancel (): void {
|
||||
this.cancelled = true
|
||||
this.modalInstance.close()
|
||||
}
|
||||
|
||||
async run (file: SFTPFile): Promise<void> {
|
||||
this.progressMessage = file.fullPath
|
||||
|
||||
if (file.isDirectory) {
|
||||
for (const child of await this.sftp.readdir(file.fullPath)) {
|
||||
await this.run(child)
|
||||
if (this.cancelled) {
|
||||
break
|
||||
}
|
||||
}
|
||||
await this.sftp.rmdir(file.fullPath)
|
||||
} else {
|
||||
this.sftp.unlink(file.fullPath)
|
||||
}
|
||||
}
|
||||
}
|
36
tabby-ssh/src/components/sftpPanel.component.pug
Normal file
36
tabby-ssh/src/components/sftpPanel.component.pug
Normal file
@@ -0,0 +1,36 @@
|
||||
.header
|
||||
.breadcrumb.mr-auto
|
||||
a.breadcrumb-item((click)='navigate("/")') SFTP
|
||||
a.breadcrumb-item(
|
||||
*ngFor='let segment of pathSegments',
|
||||
(click)='navigate(segment.path)'
|
||||
) {{segment.name}}
|
||||
|
||||
button.btn.btn-link.btn-sm.d-flex((click)='upload()')
|
||||
i.fas.fa-upload.mr-1
|
||||
div Upload
|
||||
|
||||
button.btn.btn-link.btn-close((click)='close()') !{require('../../../tabby-core/src/icons/times.svg')}
|
||||
|
||||
.body(dropZone, (transfer)='uploadOne($event)')
|
||||
div(*ngIf='!sftp') Connecting
|
||||
div(*ngIf='sftp')
|
||||
div(*ngIf='fileList === null') Loading
|
||||
.list-group.list-group-light(*ngIf='fileList !== null')
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
*ngIf='path !== "/"',
|
||||
(click)='goUp()'
|
||||
)
|
||||
i.fas.fa-fw.fa-level-up-alt
|
||||
div Go up
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
*ngFor='let item of fileList',
|
||||
(contextmenu)='showContextMenu(item, $event)',
|
||||
(click)='open(item)'
|
||||
)
|
||||
i.fa-fw([class]='getIcon(item)')
|
||||
div {{item.name}}
|
||||
.mr-auto
|
||||
.size(*ngIf='!item.isDirectory') {{item.size|filesize}}
|
||||
.date {{item.modified|date:'medium'}}
|
||||
.mode {{getModeString(item)}}
|
54
tabby-ssh/src/components/sftpPanel.component.scss
Normal file
54
tabby-ssh/src/components/sftpPanel.component.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;;
|
||||
|
||||
> * {
|
||||
}
|
||||
|
||||
> .header {
|
||||
padding: 5px 15px 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
> .body {
|
||||
padding: 10px 20px;
|
||||
flex: 1 1 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-item:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mode, .size, .date {
|
||||
font-family: monospace;
|
||||
opacity: .5;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.size {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.date {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close svg {
|
||||
width: 12px;
|
||||
}
|
190
tabby-ssh/src/components/sftpPanel.component.ts
Normal file
190
tabby-ssh/src/components/sftpPanel.component.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SSHSession, SFTPSession, SFTPFile } from '../api'
|
||||
import { posix as path } from 'path'
|
||||
import * as C from 'constants'
|
||||
import { FileUpload, PlatformService } from 'tabby-core'
|
||||
import { SFTPDeleteModalComponent } from './sftpDeleteModal.component'
|
||||
|
||||
interface PathSegment {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'sftp-panel',
|
||||
template: require('./sftpPanel.component.pug'),
|
||||
styles: [require('./sftpPanel.component.scss')],
|
||||
})
|
||||
export class SFTPPanelComponent {
|
||||
@Input() session: SSHSession
|
||||
@Output() closed = new EventEmitter<void>()
|
||||
sftp: SFTPSession
|
||||
fileList: SFTPFile[]|null = null
|
||||
@Input() path = '/'
|
||||
@Output() pathChange = new EventEmitter<string>()
|
||||
pathSegments: PathSegment[] = []
|
||||
|
||||
constructor (
|
||||
private platform: PlatformService,
|
||||
private ngbModal: NgbModal,
|
||||
) { }
|
||||
|
||||
async ngOnInit (): Promise<void> {
|
||||
this.sftp = await this.session.openSFTP()
|
||||
this.navigate(this.path)
|
||||
}
|
||||
|
||||
async navigate (newPath: string): Promise<void> {
|
||||
this.path = newPath
|
||||
this.pathChange.next(this.path)
|
||||
|
||||
let p = newPath
|
||||
this.pathSegments = []
|
||||
while (p !== '/') {
|
||||
this.pathSegments.unshift({
|
||||
name: path.basename(p),
|
||||
path: p,
|
||||
})
|
||||
p = path.dirname(p)
|
||||
}
|
||||
|
||||
this.fileList = null
|
||||
this.fileList = await this.sftp.readdir(this.path)
|
||||
|
||||
const dirKey = a => a.isDirectory ? 1 : 0
|
||||
this.fileList.sort((a, b) =>
|
||||
dirKey(b) - dirKey(a) ||
|
||||
a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
getIcon (item: SFTPFile): string {
|
||||
if (item.isDirectory) {
|
||||
return 'fas fa-folder text-info'
|
||||
}
|
||||
if (item.isSymlink) {
|
||||
return 'fas fa-link text-warning'
|
||||
}
|
||||
return 'fas fa-file'
|
||||
}
|
||||
|
||||
goUp (): void {
|
||||
this.navigate(path.dirname(this.path))
|
||||
}
|
||||
|
||||
async open (item: SFTPFile): Promise<void> {
|
||||
if (item.isDirectory) {
|
||||
this.navigate(item.fullPath)
|
||||
} else if (item.isSymlink) {
|
||||
const target = await this.sftp.readlink(item.fullPath)
|
||||
const stat = await this.sftp.stat(target)
|
||||
if (stat.isDirectory) {
|
||||
this.navigate(item.fullPath)
|
||||
} else {
|
||||
this.download(item.fullPath, stat.size)
|
||||
}
|
||||
} else {
|
||||
this.download(item.fullPath, item.size)
|
||||
}
|
||||
}
|
||||
|
||||
async upload (): Promise<void> {
|
||||
const transfers = await this.platform.startUpload({ multiple: true })
|
||||
for (const transfer of transfers) {
|
||||
this.uploadOne(transfer)
|
||||
}
|
||||
}
|
||||
|
||||
async uploadOne (transfer: FileUpload): Promise<void> {
|
||||
const itemPath = path.join(this.path, transfer.getName())
|
||||
const savedPath = this.path
|
||||
try {
|
||||
const handle = await this.sftp.open(itemPath, 'w')
|
||||
while (true) {
|
||||
const chunk = await transfer.read()
|
||||
if (!chunk.length) {
|
||||
break
|
||||
}
|
||||
await handle.write(chunk)
|
||||
}
|
||||
handle.close()
|
||||
transfer.close()
|
||||
if (this.path === savedPath) {
|
||||
this.navigate(this.path)
|
||||
}
|
||||
} catch (e) {
|
||||
transfer.cancel()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async download (itemPath: string, size: number): Promise<void> {
|
||||
const transfer = await this.platform.startDownload(path.basename(itemPath), size)
|
||||
if (!transfer) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const handle = await this.sftp.open(itemPath, 'r')
|
||||
while (true) {
|
||||
const chunk = await handle.read()
|
||||
if (!chunk.length) {
|
||||
break
|
||||
}
|
||||
await transfer.write(chunk)
|
||||
}
|
||||
transfer.close()
|
||||
handle.close()
|
||||
} catch (e) {
|
||||
transfer.cancel()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
getModeString (item: SFTPFile): string {
|
||||
const s = 'SGdrwxrwxrwx'
|
||||
const e = ' ---------'
|
||||
const c = [
|
||||
0o4000, 0o2000, C.S_IFDIR,
|
||||
C.S_IRUSR, C.S_IWUSR, C.S_IXUSR,
|
||||
C.S_IRGRP, C.S_IWGRP, C.S_IXGRP,
|
||||
C.S_IROTH, C.S_IWOTH, C.S_IXOTH,
|
||||
]
|
||||
let result = ''
|
||||
for (let i = 0; i < c.length; i++) {
|
||||
result += item.mode & c[i] ? s[i] : e[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
showContextMenu (item: SFTPFile, event: MouseEvent): void {
|
||||
event.preventDefault()
|
||||
this.platform.popupContextMenu([
|
||||
{
|
||||
click: async () => {
|
||||
if ((await this.platform.showMessageBox({
|
||||
type: 'warning',
|
||||
message: `Delete ${item.fullPath}?`,
|
||||
defaultId: 0,
|
||||
buttons: ['Delete', 'Cancel'],
|
||||
})).response === 0) {
|
||||
await this.deleteItem(item)
|
||||
this.navigate(this.path)
|
||||
}
|
||||
},
|
||||
label: 'Delete',
|
||||
},
|
||||
], event)
|
||||
}
|
||||
|
||||
async deleteItem (item: SFTPFile): Promise<void> {
|
||||
const modal = this.ngbModal.open(SFTPDeleteModalComponent)
|
||||
modal.componentInstance.item = item
|
||||
modal.componentInstance.sftp = this.sftp
|
||||
await modal.result
|
||||
}
|
||||
|
||||
close (): void {
|
||||
this.closed.emit()
|
||||
}
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
.list-group-light.mb-3
|
||||
.list-group-item.d-flex.align-items-center(*ngFor='let fw of model')
|
||||
strong(*ngIf='fw.type === PortForwardType.Local') Local
|
||||
strong(*ngIf='fw.type === PortForwardType.Remote') Remote
|
||||
strong(*ngIf='fw.type === PortForwardType.Dynamic') Dynamic
|
||||
.ml-3 {{fw.host}}:{{fw.port}}
|
||||
.ml-2 →
|
||||
.ml-2(*ngIf='fw.type !== PortForwardType.Dynamic') {{fw.targetAddress}}:{{fw.targetPort}}
|
||||
.ml-2(*ngIf='fw.type === PortForwardType.Dynamic') SOCKS proxy
|
||||
button.btn.btn-link.ml-auto((click)='remove(fw)')
|
||||
i.fas.fa-trash-alt.mr-2
|
||||
span Remove
|
||||
|
||||
.input-group.mb-2(*ngIf='newForward.type === PortForwardType.Dynamic')
|
||||
input.form-control(type='text', [(ngModel)]='newForward.host')
|
||||
.input-group-append
|
||||
.input-group-text :
|
||||
input.form-control(type='number', [(ngModel)]='newForward.port')
|
||||
|
||||
.input-group.mb-2(*ngIf='newForward.type !== PortForwardType.Dynamic')
|
||||
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
|
||||
label.btn.btn-secondary.m-0(ngbButtonLabel)
|
||||
input(
|
||||
type='radio',
|
||||
ngbButton,
|
||||
[value]='PortForwardType.Dynamic'
|
||||
)
|
||||
| Dynamic
|
||||
|
||||
button.btn.btn-primary((click)='addForward()')
|
||||
i.fas.fa-check.mr-2
|
||||
span Forward port
|
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||
import { ForwardedPortConfig, PortForwardType } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'ssh-port-forwarding-config',
|
||||
template: require('./sshPortForwardingConfig.component.pug'),
|
||||
})
|
||||
export class SSHPortForwardingConfigComponent {
|
||||
@Input() model: ForwardedPortConfig[]
|
||||
@Output() forwardAdded = new EventEmitter<ForwardedPortConfig>()
|
||||
@Output() forwardRemoved = new EventEmitter<ForwardedPortConfig>()
|
||||
newForward: ForwardedPortConfig
|
||||
PortForwardType = PortForwardType
|
||||
|
||||
constructor (
|
||||
) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
reset () {
|
||||
this.newForward = {
|
||||
type: PortForwardType.Local,
|
||||
host: '127.0.0.1',
|
||||
port: 8000,
|
||||
targetAddress: '127.0.0.1',
|
||||
targetPort: 80,
|
||||
}
|
||||
}
|
||||
|
||||
async addForward () {
|
||||
try {
|
||||
this.forwardAdded.emit(this.newForward)
|
||||
this.reset()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
remove (fw: ForwardedPortConfig) {
|
||||
this.forwardRemoved.emit(fw)
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
.modal-header
|
||||
h5.m-0 Port forwarding
|
||||
|
||||
.modal-body.pt-0
|
||||
ssh-port-forwarding-config(
|
||||
[model]='session.forwardedPorts',
|
||||
(forwardAdded)='onForwardAdded($event)',
|
||||
(forwardRemoved)='onForwardRemoved($event)'
|
||||
)
|
21
tabby-ssh/src/components/sshPortForwardingModal.component.ts
Normal file
21
tabby-ssh/src/components/sshPortForwardingModal.component.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ForwardedPort, ForwardedPortConfig, SSHSession } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./sshPortForwardingModal.component.pug'),
|
||||
})
|
||||
export class SSHPortForwardingModalComponent {
|
||||
@Input() session: SSHSession
|
||||
|
||||
onForwardAdded (fw: ForwardedPortConfig) {
|
||||
const newForward = new ForwardedPort()
|
||||
Object.assign(newForward, fw)
|
||||
this.session.addPortForward(newForward)
|
||||
}
|
||||
|
||||
onForwardRemoved (fw: ForwardedPortConfig) {
|
||||
this.session.removePortForward(fw as ForwardedPort)
|
||||
}
|
||||
}
|
96
tabby-ssh/src/components/sshSettingsTab.component.pug
Normal file
96
tabby-ssh/src/components/sshSettingsTab.component.pug
Normal file
@@ -0,0 +1,96 @@
|
||||
.d-flex.align-items-center.mb-3
|
||||
h3.m-0 SSH Connections
|
||||
|
||||
button.btn.btn-primary.ml-auto((click)='createConnection()')
|
||||
i.fas.fa-fw.fa-plus
|
||||
span.ml-2 Add connection
|
||||
|
||||
.input-group.mb-3
|
||||
.input-group-prepend
|
||||
.input-group-text
|
||||
i.fas.fa-fw.fa-search
|
||||
input.form-control(type='search', placeholder='Filter', [(ngModel)]='filter')
|
||||
|
||||
.list-group.list-group-light.mt-3.mb-3
|
||||
ng-container(*ngFor='let group of childGroups')
|
||||
ng-container(*ngIf='isGroupVisible(group)')
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
(click)='groupCollapsed[group.name] = !groupCollapsed[group.name]'
|
||||
)
|
||||
.fa.fa-fw.fa-chevron-right(*ngIf='groupCollapsed[group.name]')
|
||||
.fa.fa-fw.fa-chevron-down(*ngIf='!groupCollapsed[group.name]')
|
||||
span.ml-3.mr-auto {{group.name || "Ungrouped"}}
|
||||
button.btn.btn-sm.btn-link.hover-reveal.ml-2(
|
||||
[class.invisible]='!group.name',
|
||||
(click)='$event.stopPropagation(); editGroup(group)'
|
||||
)
|
||||
i.fas.fa-edit
|
||||
button.btn.btn-sm.btn-link.hover-reveal.ml-2(
|
||||
[class.invisible]='!group.name',
|
||||
(click)='$event.stopPropagation(); deleteGroup(group)'
|
||||
)
|
||||
i.fas.fa-trash
|
||||
|
||||
ng-container(*ngIf='!groupCollapsed[group.name]')
|
||||
ng-container(*ngFor='let connection of group.connections')
|
||||
.list-group-item.list-group-item-action.pl-5.d-flex.align-items-center(
|
||||
*ngIf='isConnectionVisible(connection)',
|
||||
(click)='editConnection(connection)'
|
||||
)
|
||||
.mr-3 {{connection.name}}
|
||||
.mr-auto.text-muted {{connection.host}}
|
||||
|
||||
.hover-reveal(ngbDropdown, placement='bottom-right')
|
||||
button.btn.btn-link(ngbDropdownToggle, (click)='$event.stopPropagation()')
|
||||
i.fas.fa-fw.fa-ellipsis-v
|
||||
div(ngbDropdownMenu)
|
||||
button.dropdown-item((click)='$event.stopPropagation(); copyConnection(connection)')
|
||||
i.fas.fa-copy
|
||||
span Duplicate
|
||||
button.dropdown-item((click)='$event.stopPropagation(); deleteConnection(connection)')
|
||||
i.fas.fa-trash
|
||||
span Delete
|
||||
|
||||
h3.mt-5 Options
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Warn when closing active connections
|
||||
toggle(
|
||||
[(ngModel)]='config.store.ssh.warnOnClose',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
|
||||
.form-line(*ngIf='hostApp.platform === Platform.Windows')
|
||||
.header
|
||||
.title WinSCP path
|
||||
.description When WinSCP is detected, you can launch an SCP session from the context menu.
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Auto-detect',
|
||||
[(ngModel)]='config.store.ssh.winSCPPath',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
|
||||
.form-line(*ngIf='hostApp.platform === Platform.Windows')
|
||||
.header
|
||||
.title Agent type
|
||||
.description Forces a specific SSH agent connection type.
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.ssh.agentType',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option(value='auto') Automatic
|
||||
option(value='pageant') Pageant
|
||||
option(value='pipe') Named pipe
|
||||
|
||||
.form-line(*ngIf='config.store.ssh.agentType === "pipe"')
|
||||
.header
|
||||
.title Agent pipe path
|
||||
.description Sets the SSH agent's named pipe path.
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Default: \\\\.\\pipe\\openssh-ssh-agent',
|
||||
[(ngModel)]='config.store.ssh.agentPath',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
3
tabby-ssh/src/components/sshSettingsTab.component.scss
Normal file
3
tabby-ssh/src/components/sshSettingsTab.component.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.list-group-item {
|
||||
padding: 0.3rem 1rem;
|
||||
}
|
158
tabby-ssh/src/components/sshSettingsTab.component.ts
Normal file
158
tabby-ssh/src/components/sshSettingsTab.component.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import deepClone from 'clone-deep'
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { SSHConnection } from '../api'
|
||||
import { EditConnectionModalComponent } from './editConnectionModal.component'
|
||||
import { PromptModalComponent } from './promptModal.component'
|
||||
|
||||
interface SSHConnectionGroup {
|
||||
name: string|null
|
||||
connections: SSHConnection[]
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./sshSettingsTab.component.pug'),
|
||||
styles: [require('./sshSettingsTab.component.scss')],
|
||||
})
|
||||
export class SSHSettingsTabComponent {
|
||||
connections: SSHConnection[]
|
||||
childGroups: SSHConnectionGroup[]
|
||||
groupCollapsed: Record<string, boolean> = {}
|
||||
filter = ''
|
||||
Platform = Platform
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
private platform: PlatformService,
|
||||
private ngbModal: NgbModal,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
) {
|
||||
this.connections = this.config.store.ssh.connections
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
createConnection () {
|
||||
const connection: SSHConnection = {
|
||||
name: '',
|
||||
group: null,
|
||||
host: '',
|
||||
port: 22,
|
||||
user: 'root',
|
||||
}
|
||||
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent)
|
||||
modal.componentInstance.connection = connection
|
||||
modal.result.then(result => {
|
||||
this.connections.push(result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
copyConnection (connection: SSHConnection) {
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent)
|
||||
modal.componentInstance.connection = {
|
||||
...deepClone(connection),
|
||||
name: `${connection.name} Copy`,
|
||||
}
|
||||
modal.result.then(result => {
|
||||
this.connections.push(result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
editConnection (connection: SSHConnection) {
|
||||
const modal = this.ngbModal.open(EditConnectionModalComponent, { size: 'lg' })
|
||||
modal.componentInstance.connection = deepClone(connection)
|
||||
modal.result.then(result => {
|
||||
Object.assign(connection, result)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
async deleteConnection (connection: SSHConnection) {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${connection.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
this.connections = this.connections.filter(x => x !== connection)
|
||||
this.passwordStorage.deletePassword(connection)
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
editGroup (group: SSHConnectionGroup) {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = 'New group name'
|
||||
modal.componentInstance.value = group.name
|
||||
modal.result.then(result => {
|
||||
if (result) {
|
||||
for (const connection of this.connections.filter(x => x.group === group.name)) {
|
||||
connection.group = result.value
|
||||
}
|
||||
this.config.store.ssh.connections = this.connections
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async deleteGroup (group: SSHConnectionGroup) {
|
||||
if ((await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Delete "${group.name}"?`,
|
||||
buttons: ['Keep', 'Delete'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1) {
|
||||
for (const connection of this.connections.filter(x => x.group === group.name)) {
|
||||
connection.group = null
|
||||
}
|
||||
this.config.save()
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
refresh () {
|
||||
this.connections = this.config.store.ssh.connections
|
||||
this.childGroups = []
|
||||
|
||||
for (const connection of this.connections) {
|
||||
connection.group = connection.group ?? null
|
||||
let group = this.childGroups.find(x => x.name === connection.group)
|
||||
if (!group) {
|
||||
group = {
|
||||
name: connection.group,
|
||||
connections: [],
|
||||
}
|
||||
this.childGroups.push(group)
|
||||
}
|
||||
group.connections.push(connection)
|
||||
}
|
||||
}
|
||||
|
||||
isGroupVisible (group: SSHConnectionGroup): boolean {
|
||||
return !this.filter || group.connections.some(x => this.isConnectionVisible(x))
|
||||
}
|
||||
|
||||
isConnectionVisible (connection: SSHConnection): boolean {
|
||||
return !this.filter || `${connection.name}$${connection.host}`.toLowerCase().includes(this.filter.toLowerCase())
|
||||
}
|
||||
}
|
32
tabby-ssh/src/components/sshTab.component.pug
Normal file
32
tabby-ssh/src/components/sshTab.component.pug
Normal file
@@ -0,0 +1,32 @@
|
||||
.tab-toolbar([class.show]='!session || !session.open')
|
||||
.btn.btn-outline-secondary.reveal-button
|
||||
i.fas.fa-ellipsis-h
|
||||
.toolbar
|
||||
i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
|
||||
i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
|
||||
strong.mr-auto {{connection.user}}@{{connection.host}}:{{connection.port}}
|
||||
|
||||
button.btn.btn-secondary.mr-2((click)='reconnect()', [class.btn-info]='!session || !session.open')
|
||||
span Reconnect
|
||||
|
||||
button.btn.btn-secondary.mr-2((click)='openSFTP()', *ngIf='session && session.open')
|
||||
span SFTP
|
||||
span.badge.badge-info.ml-2
|
||||
i.fas.fa-flask
|
||||
span Experimental
|
||||
|
||||
button.btn.btn-secondary(
|
||||
*ngIf='session && session.open && hostApp.platform !== Platform.Web',
|
||||
(click)='showPortForwarding()'
|
||||
)
|
||||
i.fas.fa-plug
|
||||
span Ports
|
||||
|
||||
sftp-panel.bg-dark(
|
||||
@panelSlide,
|
||||
[(path)]='sftpPath',
|
||||
*ngIf='sftpPanelVisible',
|
||||
(click)='$event.stopPropagation()',
|
||||
[session]='session',
|
||||
(closed)='sftpPanelVisible = false'
|
||||
)
|
80
tabby-ssh/src/components/sshTab.component.scss
Normal file
80
tabby-ssh/src/components/sshTab.component.scss
Normal file
@@ -0,0 +1,80 @@
|
||||
:host {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&> .content {
|
||||
flex: auto;
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
> .tab-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
|
||||
.reveal-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 30px;
|
||||
border-radius: 50%;
|
||||
width: 35px;
|
||||
padding: 0;
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
transition: 0.125s opacity;
|
||||
opacity: .5;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
&:hover .reveal-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .toolbar {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
opacity: 0;
|
||||
background: rgba(0, 0, 0, .75);
|
||||
padding: 10px 20px;
|
||||
transition: 0.25s opacity;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
transform: translate(0, -100px);
|
||||
transition: 0.25s transform ease-out;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
&.show {
|
||||
.reveal-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sftp-panel {
|
||||
position: absolute;
|
||||
height: 80%;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
261
tabby-ssh/src/components/sshTab.component.ts
Normal file
261
tabby-ssh/src/components/sshTab.component.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import colors from 'ansi-colors'
|
||||
import { Spinner } from 'cli-spinner'
|
||||
import { Component, Injector, HostListener } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Platform, RecoveryToken } from 'tabby-core'
|
||||
import { BaseTerminalTabComponent } from 'tabby-terminal'
|
||||
import { SSHService } from '../services/ssh.service'
|
||||
import { SSHConnection, SSHSession } from '../api'
|
||||
import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component'
|
||||
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'ssh-tab',
|
||||
template: `${BaseTerminalTabComponent.template} ${require('./sshTab.component.pug')}`,
|
||||
styles: [require('./sshTab.component.scss'), ...BaseTerminalTabComponent.styles],
|
||||
animations: BaseTerminalTabComponent.animations,
|
||||
})
|
||||
export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
Platform = Platform
|
||||
connection?: SSHConnection
|
||||
session: SSHSession|null = null
|
||||
sftpPanelVisible = false
|
||||
sftpPath = '/'
|
||||
private sessionStack: SSHSession[] = []
|
||||
private recentInputs = ''
|
||||
private reconnectOffered = false
|
||||
private spinner = new Spinner({
|
||||
text: 'Connecting',
|
||||
stream: {
|
||||
write: x => this.write(x),
|
||||
},
|
||||
})
|
||||
private spinnerActive = false
|
||||
|
||||
constructor (
|
||||
injector: Injector,
|
||||
public ssh: SSHService,
|
||||
private ngbModal: NgbModal,
|
||||
) {
|
||||
super(injector)
|
||||
}
|
||||
|
||||
ngOnInit (): void {
|
||||
if (!this.connection) {
|
||||
throw new Error('Connection not set')
|
||||
}
|
||||
|
||||
this.logger = this.log.create('terminalTab')
|
||||
|
||||
this.enableDynamicTitle = !this.connection.disableDynamicTitle
|
||||
|
||||
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
|
||||
if (!this.hasFocus) {
|
||||
return
|
||||
}
|
||||
switch (hotkey) {
|
||||
case 'home':
|
||||
this.sendInput('\x1b[H' )
|
||||
break
|
||||
case 'end':
|
||||
this.sendInput('\x1b[F' )
|
||||
break
|
||||
case 'restart-ssh-session':
|
||||
this.reconnect()
|
||||
break
|
||||
case 'launch-winscp':
|
||||
if (this.session) {
|
||||
this.ssh.launchWinSCP(this.session)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
this.frontendReady$.pipe(first()).subscribe(() => {
|
||||
this.initializeSession()
|
||||
this.input$.subscribe(data => {
|
||||
this.recentInputs += data
|
||||
this.recentInputs = this.recentInputs.substring(this.recentInputs.length - 32)
|
||||
})
|
||||
})
|
||||
|
||||
super.ngOnInit()
|
||||
|
||||
setImmediate(() => {
|
||||
this.setTitle(this.connection!.name)
|
||||
})
|
||||
}
|
||||
|
||||
async setupOneSession (session: SSHSession): Promise<void> {
|
||||
if (session.connection.jumpHost) {
|
||||
const jumpConnection: SSHConnection|null = this.config.store.ssh.connections.find(x => x.name === session.connection.jumpHost)
|
||||
|
||||
if (!jumpConnection) {
|
||||
throw new Error(`${session.connection.host}: jump host "${session.connection.jumpHost}" not found in your config`)
|
||||
}
|
||||
|
||||
const jumpSession = this.ssh.createSession(jumpConnection)
|
||||
|
||||
await this.setupOneSession(jumpSession)
|
||||
|
||||
this.attachSessionHandler(jumpSession.destroyed$, () => {
|
||||
if (session.open) {
|
||||
session.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
|
||||
'127.0.0.1', 0, session.connection.host, session.connection.port ?? 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
|
||||
return reject(err)
|
||||
}
|
||||
resolve(stream)
|
||||
}
|
||||
))
|
||||
|
||||
session.jumpStream.on('close', () => {
|
||||
jumpSession.destroy()
|
||||
})
|
||||
|
||||
this.sessionStack.push(session)
|
||||
}
|
||||
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
|
||||
|
||||
this.startSpinner()
|
||||
|
||||
this.attachSessionHandler(session.serviceMessage$, msg => {
|
||||
this.pauseSpinner(() => {
|
||||
this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
|
||||
session.resize(this.size.columns, this.size.rows)
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await this.ssh.connectSession(session)
|
||||
this.stopSpinner()
|
||||
} catch (e) {
|
||||
this.stopSpinner()
|
||||
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
protected attachSessionHandlers (): void {
|
||||
const session = this.session!
|
||||
this.attachSessionHandler(session.destroyed$, () => {
|
||||
if (
|
||||
// Ctrl-D
|
||||
this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 ||
|
||||
this.recentInputs.endsWith('exit\r')
|
||||
) {
|
||||
// User closed the session
|
||||
this.destroy()
|
||||
} else if (this.frontend) {
|
||||
// Session was closed abruptly
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
|
||||
if (!this.reconnectOffered) {
|
||||
this.reconnectOffered = true
|
||||
this.write('Press any key to reconnect\r\n')
|
||||
this.input$.pipe(first()).subscribe(() => {
|
||||
if (!this.session?.open && this.reconnectOffered) {
|
||||
this.reconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
super.attachSessionHandlers()
|
||||
}
|
||||
|
||||
async initializeSession (): Promise<void> {
|
||||
this.reconnectOffered = false
|
||||
if (!this.connection) {
|
||||
this.logger.error('No SSH connection info supplied')
|
||||
return
|
||||
}
|
||||
|
||||
const session = this.ssh.createSession(this.connection)
|
||||
this.setSession(session)
|
||||
|
||||
try {
|
||||
await this.setupOneSession(session)
|
||||
} catch (e) {
|
||||
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
|
||||
}
|
||||
|
||||
await this.session!.start()
|
||||
this.session!.resize(this.size.columns, this.size.rows)
|
||||
}
|
||||
|
||||
async getRecoveryToken (): Promise<RecoveryToken> {
|
||||
return {
|
||||
type: 'app:ssh-tab',
|
||||
connection: this.connection,
|
||||
savedState: this.frontend?.saveState(),
|
||||
}
|
||||
}
|
||||
|
||||
showPortForwarding (): void {
|
||||
const modal = this.ngbModal.open(SSHPortForwardingModalComponent).componentInstance as SSHPortForwardingModalComponent
|
||||
modal.session = this.session!
|
||||
}
|
||||
|
||||
async reconnect (): Promise<void> {
|
||||
this.session?.destroy()
|
||||
await this.initializeSession()
|
||||
this.session?.releaseInitialDataBuffer()
|
||||
}
|
||||
|
||||
async canClose (): Promise<boolean> {
|
||||
if (!this.session?.open) {
|
||||
return true
|
||||
}
|
||||
if (!(this.connection?.warnOnClose ?? this.config.store.ssh.warnOnClose)) {
|
||||
return true
|
||||
}
|
||||
return (await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `Disconnect from ${this.connection?.host}?`,
|
||||
buttons: ['Cancel', 'Disconnect'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1
|
||||
}
|
||||
|
||||
openSFTP (): void {
|
||||
setTimeout(() => {
|
||||
this.sftpPanelVisible = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@HostListener('click')
|
||||
onClick (): void {
|
||||
this.sftpPanelVisible = false
|
||||
}
|
||||
|
||||
private startSpinner () {
|
||||
this.spinner.setSpinnerString(6)
|
||||
this.spinner.start()
|
||||
this.spinnerActive = true
|
||||
}
|
||||
|
||||
private stopSpinner () {
|
||||
this.spinner.stop(true)
|
||||
this.spinnerActive = false
|
||||
}
|
||||
|
||||
private pauseSpinner (work: () => void) {
|
||||
const wasActive = this.spinnerActive
|
||||
this.stopSpinner()
|
||||
work()
|
||||
if (wasActive) {
|
||||
this.startSpinner()
|
||||
}
|
||||
}
|
||||
}
|
23
tabby-ssh/src/config.ts
Normal file
23
tabby-ssh/src/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ConfigProvider } from 'tabby-core'
|
||||
|
||||
/** @hidden */
|
||||
export class SSHConfigProvider extends ConfigProvider {
|
||||
defaults = {
|
||||
ssh: {
|
||||
connections: [],
|
||||
recentConnections: [],
|
||||
warnOnClose: false,
|
||||
winSCPPath: null,
|
||||
agentType: 'auto',
|
||||
agentPath: null,
|
||||
},
|
||||
hotkeys: {
|
||||
ssh: [
|
||||
'Alt-S',
|
||||
],
|
||||
'restart-ssh-session': [],
|
||||
},
|
||||
}
|
||||
|
||||
platformDefaults = { }
|
||||
}
|
25
tabby-ssh/src/hotkeys.ts
Normal file
25
tabby-ssh/src/hotkeys.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SSHHotkeyProvider extends HotkeyProvider {
|
||||
hotkeys: HotkeyDescription[] = [
|
||||
{
|
||||
id: 'ssh',
|
||||
name: 'Show SSH connections',
|
||||
},
|
||||
{
|
||||
id: 'restart-ssh-session',
|
||||
name: 'Restart current SSH session',
|
||||
},
|
||||
{
|
||||
id: 'launch-winscp',
|
||||
name: 'Launch WinSCP for current SSH session',
|
||||
},
|
||||
]
|
||||
|
||||
async provide (): Promise<HotkeyDescription[]> {
|
||||
return this.hotkeys
|
||||
}
|
||||
}
|
1
tabby-ssh/src/icons/globe.svg
Normal file
1
tabby-ssh/src/icons/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="#fff" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm193.2 152h-82.5c-9-44.4-24.1-82.2-43.2-109.1 55 18.2 100.2 57.9 125.7 109.1zM336 256c0 22.9-1.6 44.2-4.3 64H164.3c-2.7-19.8-4.3-41.1-4.3-64s1.6-44.2 4.3-64h167.4c2.7 19.8 4.3 41.1 4.3 64zM248 40c26.9 0 61.4 44.1 78.1 120H169.9C186.6 84.1 221.1 40 248 40zm-67.5 10.9c-19 26.8-34.2 64.6-43.2 109.1H54.8c25.5-51.2 70.7-90.9 125.7-109.1zM32 256c0-22.3 3.4-43.8 9.7-64h90.5c-2.6 20.5-4.2 41.8-4.2 64s1.5 43.5 4.2 64H41.7c-6.3-20.2-9.7-41.7-9.7-64zm22.8 96h82.5c9 44.4 24.1 82.2 43.2 109.1-55-18.2-100.2-57.9-125.7-109.1zM248 472c-26.9 0-61.4-44.1-78.1-120h156.2c-16.7 75.9-51.2 120-78.1 120zm67.5-10.9c19-26.8 34.2-64.6 43.2-109.1h82.5c-25.5 51.2-70.7 90.9-125.7 109.1zM363.8 320c2.6-20.5 4.2-41.8 4.2-64s-1.5-43.5-4.2-64h90.5c6.3 20.2 9.7 41.7 9.7 64s-3.4 43.8-9.7 64h-90.5z"></path></svg>
|
After Width: | Height: | Size: 939 B |
67
tabby-ssh/src/index.ts
Normal file
67
tabby-ssh/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastrModule } from 'ngx-toastr'
|
||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, TabContextMenuItemProvider, CLIHandler } from 'tabby-core'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
import TabbyTerminalModule from 'tabby-terminal'
|
||||
|
||||
import { EditConnectionModalComponent } from './components/editConnectionModal.component'
|
||||
import { SSHPortForwardingModalComponent } from './components/sshPortForwardingModal.component'
|
||||
import { SSHPortForwardingConfigComponent } from './components/sshPortForwardingConfig.component'
|
||||
import { PromptModalComponent } from './components/promptModal.component'
|
||||
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 { ButtonProvider } from './buttonProvider'
|
||||
import { SSHConfigProvider } from './config'
|
||||
import { SSHSettingsTabProvider } from './settings'
|
||||
import { RecoveryProvider } from './recoveryProvider'
|
||||
import { SSHHotkeyProvider } from './hotkeys'
|
||||
import { SFTPContextMenu } from './tabContextMenu'
|
||||
import { SSHCLIHandler } from './cli'
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
imports: [
|
||||
NgbModule,
|
||||
NgxFilesizeModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ToastrModule,
|
||||
TabbyCoreModule,
|
||||
TabbyTerminalModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: ConfigProvider, useClass: SSHConfigProvider, multi: true },
|
||||
{ provide: SettingsTabProvider, useClass: SSHSettingsTabProvider, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
{ provide: HotkeyProvider, useClass: SSHHotkeyProvider, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true },
|
||||
{ provide: CLIHandler, useClass: SSHCLIHandler, multi: true },
|
||||
],
|
||||
entryComponents: [
|
||||
EditConnectionModalComponent,
|
||||
PromptModalComponent,
|
||||
SFTPDeleteModalComponent,
|
||||
SSHPortForwardingModalComponent,
|
||||
SSHSettingsTabComponent,
|
||||
SSHTabComponent,
|
||||
],
|
||||
declarations: [
|
||||
EditConnectionModalComponent,
|
||||
PromptModalComponent,
|
||||
SFTPDeleteModalComponent,
|
||||
SSHPortForwardingModalComponent,
|
||||
SSHPortForwardingConfigComponent,
|
||||
SSHSettingsTabComponent,
|
||||
SSHTabComponent,
|
||||
SFTPPanelComponent,
|
||||
],
|
||||
})
|
||||
export default class SSHModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class
|
29
tabby-ssh/src/recoveryProvider.ts
Normal file
29
tabby-ssh/src/recoveryProvider.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
|
||||
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class RecoveryProvider extends TabRecoveryProvider {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:ssh-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
return {
|
||||
type: SSHTabComponent,
|
||||
options: {
|
||||
connection: recoveryToken['connection'],
|
||||
savedState: recoveryToken['savedState'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
duplicate (recoveryToken: RecoveryToken): RecoveryToken {
|
||||
return {
|
||||
...recoveryToken,
|
||||
savedState: null,
|
||||
}
|
||||
}
|
||||
}
|
96
tabby-ssh/src/services/passwordStorage.service.ts
Normal file
96
tabby-ssh/src/services/passwordStorage.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as keytar from 'keytar'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { SSHConnection } from '../api'
|
||||
import { VaultService } from 'tabby-core'
|
||||
|
||||
export const VAULT_SECRET_TYPE_PASSWORD = 'ssh:password'
|
||||
export const VAULT_SECRET_TYPE_PASSPHRASE = 'ssh:key-passphrase'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PasswordStorageService {
|
||||
constructor (private vault: VaultService) { }
|
||||
|
||||
async savePassword (connection: SSHConnection, password: string): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSWORD, key, value: password })
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
return keytar.setPassword(key, connection.user, password)
|
||||
}
|
||||
}
|
||||
|
||||
async deletePassword (connection: SSHConnection): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
this.vault.removeSecret(VAULT_SECRET_TYPE_PASSWORD, key)
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
await keytar.deletePassword(key, connection.user)
|
||||
}
|
||||
}
|
||||
|
||||
async loadPassword (connection: SSHConnection): Promise<string|null> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForConnection(connection)
|
||||
return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSWORD, key))?.value ?? null
|
||||
} else {
|
||||
const key = this.getKeytarKeyForConnection(connection)
|
||||
return keytar.getPassword(key, connection.user)
|
||||
}
|
||||
}
|
||||
|
||||
async savePrivateKeyPassword (id: string, password: string): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForPrivateKey(id)
|
||||
this.vault.addSecret({ type: VAULT_SECRET_TYPE_PASSPHRASE, key, value: password })
|
||||
} else {
|
||||
const key = this.getKeytarKeyForPrivateKey(id)
|
||||
return keytar.setPassword(key, 'user', password)
|
||||
}
|
||||
}
|
||||
|
||||
async deletePrivateKeyPassword (id: string): Promise<void> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForPrivateKey(id)
|
||||
this.vault.removeSecret(VAULT_SECRET_TYPE_PASSPHRASE, key)
|
||||
} else {
|
||||
const key = this.getKeytarKeyForPrivateKey(id)
|
||||
await keytar.deletePassword(key, 'user')
|
||||
}
|
||||
}
|
||||
|
||||
async loadPrivateKeyPassword (id: string): Promise<string|null> {
|
||||
if (this.vault.isEnabled()) {
|
||||
const key = this.getVaultKeyForPrivateKey(id)
|
||||
return (await this.vault.getSecret(VAULT_SECRET_TYPE_PASSPHRASE, key))?.value ?? null
|
||||
} else {
|
||||
const key = this.getKeytarKeyForPrivateKey(id)
|
||||
return keytar.getPassword(key, 'user')
|
||||
}
|
||||
}
|
||||
|
||||
private getKeytarKeyForConnection (connection: SSHConnection): string {
|
||||
let key = `ssh@${connection.host}`
|
||||
if (connection.port) {
|
||||
key = `ssh@${connection.host}:${connection.port}`
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
private getKeytarKeyForPrivateKey (id: string): string {
|
||||
return `ssh-private-key:${id}`
|
||||
}
|
||||
|
||||
private getVaultKeyForConnection (connection: SSHConnection) {
|
||||
return {
|
||||
user: connection.user,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
}
|
||||
}
|
||||
|
||||
private getVaultKeyForPrivateKey (id: string) {
|
||||
return { hash: id }
|
||||
}
|
||||
}
|
363
tabby-ssh/src/services/ssh.service.ts
Normal file
363
tabby-ssh/src/services/ssh.service.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import colors from 'ansi-colors'
|
||||
import { Duplex } from 'stream'
|
||||
import { Injectable, Injector, NgZone } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Client } from 'ssh2'
|
||||
import { exec } from 'child_process'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { Logger, LogService, AppService, SelectorOption, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, SelectorService } from 'tabby-core'
|
||||
import { SettingsTabComponent } from 'tabby-settings'
|
||||
import { ALGORITHM_BLACKLIST, ForwardedPort, SSHConnection, SSHSession } from '../api'
|
||||
import { PromptModalComponent } from '../components/promptModal.component'
|
||||
import { PasswordStorageService } from './passwordStorage.service'
|
||||
import { SSHTabComponent } from '../components/sshTab.component'
|
||||
import { ChildProcess } from 'node:child_process'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SSHService {
|
||||
private logger: Logger
|
||||
private detectedWinSCPPath: string | null
|
||||
|
||||
private constructor (
|
||||
private injector: Injector,
|
||||
private log: LogService,
|
||||
private zone: NgZone,
|
||||
private ngbModal: NgbModal,
|
||||
private passwordStorage: PasswordStorageService,
|
||||
private notifications: NotificationsService,
|
||||
private app: AppService,
|
||||
private selector: SelectorService,
|
||||
private config: ConfigService,
|
||||
hostApp: HostAppService,
|
||||
private platform: PlatformService,
|
||||
) {
|
||||
this.logger = log.create('ssh')
|
||||
if (hostApp.platform === Platform.Windows) {
|
||||
this.detectedWinSCPPath = platform.getWinSCPPath()
|
||||
}
|
||||
}
|
||||
|
||||
createSession (connection: SSHConnection): SSHSession {
|
||||
const session = new SSHSession(this.injector, connection)
|
||||
session.logger = this.log.create(`ssh-${connection.host}-${connection.port}`)
|
||||
return session
|
||||
}
|
||||
|
||||
async connectSession (session: SSHSession): Promise<void> {
|
||||
const log = (s: any) => session.emitServiceMessage(s)
|
||||
|
||||
const ssh = new Client()
|
||||
session.ssh = ssh
|
||||
await session.init()
|
||||
|
||||
let connected = false
|
||||
const algorithms = {}
|
||||
for (const key of Object.keys(session.connection.algorithms ?? {})) {
|
||||
algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
}
|
||||
|
||||
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
|
||||
ssh.on('ready', () => {
|
||||
connected = true
|
||||
if (session.savedPassword) {
|
||||
this.passwordStorage.savePassword(session.connection, session.savedPassword)
|
||||
}
|
||||
|
||||
for (const fw of session.connection.forwardedPorts ?? []) {
|
||||
session.addPortForward(Object.assign(new ForwardedPort(), fw))
|
||||
}
|
||||
|
||||
this.zone.run(resolve)
|
||||
})
|
||||
ssh.on('handshake', negotiated => {
|
||||
this.logger.info('Handshake complete:', negotiated)
|
||||
})
|
||||
ssh.on('error', error => {
|
||||
if (error.message === 'All configured authentication methods failed') {
|
||||
this.passwordStorage.deletePassword(session.connection)
|
||||
}
|
||||
this.zone.run(() => {
|
||||
if (connected) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
this.notifications.error(error.toString())
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
ssh.on('close', () => {
|
||||
if (session.open) {
|
||||
session.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}))
|
||||
|
||||
ssh.on('greeting', greeting => {
|
||||
if (!session.connection.skipBanner) {
|
||||
log('Greeting: ' + greeting)
|
||||
}
|
||||
})
|
||||
|
||||
ssh.on('banner', banner => {
|
||||
if (!session.connection.skipBanner) {
|
||||
log(banner)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (session.connection.proxyCommand) {
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${session.connection.proxyCommand}`)
|
||||
session.proxyCommandStream = new ProxyCommandStream(session.connection.proxyCommand)
|
||||
|
||||
session.proxyCommandStream.output$.subscribe((message: string) => {
|
||||
session.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ' ' + message.trim())
|
||||
})
|
||||
|
||||
await session.proxyCommandStream.start()
|
||||
}
|
||||
|
||||
ssh.connect({
|
||||
host: session.connection.host.trim(),
|
||||
port: session.connection.port ?? 22,
|
||||
sock: session.proxyCommandStream ?? session.jumpStream,
|
||||
username: session.connection.user,
|
||||
tryKeyboard: true,
|
||||
agent: session.agentPath,
|
||||
agentForward: session.connection.agentForward && !!session.agentPath,
|
||||
keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
|
||||
keepaliveCountMax: session.connection.keepaliveCountMax,
|
||||
readyTimeout: session.connection.readyTimeout,
|
||||
hostVerifier: (digest: string) => {
|
||||
log('Host key fingerprint:')
|
||||
log(colors.white.bgBlack(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
|
||||
return true
|
||||
},
|
||||
hostHash: 'sha256' as any,
|
||||
algorithms,
|
||||
authHandler: (methodsLeft, partialSuccess, callback) => {
|
||||
this.zone.run(async () => {
|
||||
callback(await session.handleAuth(methodsLeft))
|
||||
})
|
||||
},
|
||||
} as any)
|
||||
} catch (e) {
|
||||
this.notifications.error(e.message)
|
||||
throw e
|
||||
}
|
||||
|
||||
return resultPromise
|
||||
}
|
||||
|
||||
async showConnectionSelector (): Promise<void> {
|
||||
const options: SelectorOption<void>[] = []
|
||||
const recentConnections = this.config.store.ssh.recentConnections
|
||||
|
||||
for (const connection of recentConnections) {
|
||||
options.push({
|
||||
name: connection.name,
|
||||
description: connection.host,
|
||||
icon: 'history',
|
||||
callback: () => this.connect(connection),
|
||||
})
|
||||
}
|
||||
|
||||
if (recentConnections.length) {
|
||||
options.push({
|
||||
name: 'Clear recent connections',
|
||||
icon: 'eraser',
|
||||
callback: () => {
|
||||
this.config.store.ssh.recentConnections = []
|
||||
this.config.save()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const groups: { name: string, connections: SSHConnection[] }[] = []
|
||||
const connections = this.config.store.ssh.connections
|
||||
for (const connection of connections) {
|
||||
connection.group = connection.group || null
|
||||
let group = groups.find(x => x.name === connection.group)
|
||||
if (!group) {
|
||||
group = {
|
||||
name: connection.group!,
|
||||
connections: [],
|
||||
}
|
||||
groups.push(group)
|
||||
}
|
||||
group.connections.push(connection)
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
for (const connection of group.connections) {
|
||||
options.push({
|
||||
name: (group.name ? `${group.name} / ` : '') + connection.name,
|
||||
description: connection.host,
|
||||
icon: 'desktop',
|
||||
callback: () => this.connect(connection),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
options.push({
|
||||
name: 'Manage connections',
|
||||
icon: 'cog',
|
||||
callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'ssh' }),
|
||||
})
|
||||
|
||||
options.push({
|
||||
name: 'Quick connect',
|
||||
freeInputPattern: 'Connect to "%s"...',
|
||||
icon: 'arrow-right',
|
||||
callback: query => this.quickConnect(query),
|
||||
})
|
||||
|
||||
|
||||
await this.selector.show('Open an SSH connection', options)
|
||||
}
|
||||
|
||||
async connect (connection: SSHConnection): Promise<SSHTabComponent> {
|
||||
try {
|
||||
const tab = this.app.openNewTab(
|
||||
SSHTabComponent,
|
||||
{ connection }
|
||||
) as SSHTabComponent
|
||||
if (connection.color) {
|
||||
(this.app.getParentTab(tab) ?? tab).color = connection.color
|
||||
}
|
||||
|
||||
setTimeout(() => this.app.activeTab?.emitFocused())
|
||||
|
||||
return tab
|
||||
} catch (error) {
|
||||
this.notifications.error(`Could not connect: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
quickConnect (query: string): Promise<SSHTabComponent> {
|
||||
let user = 'root'
|
||||
let host = query
|
||||
let port = 22
|
||||
if (host.includes('@')) {
|
||||
const parts = host.split(/@/g)
|
||||
host = parts[parts.length - 1]
|
||||
user = parts.slice(0, parts.length - 1).join('@')
|
||||
}
|
||||
if (host.includes('[')) {
|
||||
port = parseInt(host.split(']')[1].substring(1))
|
||||
host = host.split(']')[0].substring(1)
|
||||
} else if (host.includes(':')) {
|
||||
port = parseInt(host.split(/:/g)[1])
|
||||
host = host.split(':')[0]
|
||||
}
|
||||
|
||||
const connection: SSHConnection = {
|
||||
name: query,
|
||||
group: null,
|
||||
host,
|
||||
user,
|
||||
port,
|
||||
}
|
||||
|
||||
const recentConnections = this.config.store.ssh.recentConnections
|
||||
recentConnections.unshift(connection)
|
||||
if (recentConnections.length > 5) {
|
||||
recentConnections.pop()
|
||||
}
|
||||
this.config.store.ssh.recentConnections = recentConnections
|
||||
this.config.save()
|
||||
return this.connect(connection)
|
||||
}
|
||||
|
||||
getWinSCPPath (): string|undefined {
|
||||
return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
|
||||
}
|
||||
|
||||
async getWinSCPURI (connection: SSHConnection): Promise<string> {
|
||||
let uri = `scp://${connection.user}`
|
||||
const password = await this.passwordStorage.loadPassword(connection)
|
||||
if (password) {
|
||||
uri += ':' + encodeURIComponent(password)
|
||||
}
|
||||
uri += `@${connection.host}:${connection.port}/`
|
||||
return uri
|
||||
}
|
||||
|
||||
async launchWinSCP (session: SSHSession): Promise<void> {
|
||||
const path = this.getWinSCPPath()
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
const args = [await this.getWinSCPURI(session.connection)]
|
||||
if (session.activePrivateKey) {
|
||||
args.push('/privatekey')
|
||||
args.push(session.activePrivateKey)
|
||||
}
|
||||
this.platform.exec(path, args)
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyCommandStream extends Duplex {
|
||||
private process: ChildProcess
|
||||
|
||||
get output$ (): Observable<string> { return this.output }
|
||||
private output = new Subject<string>()
|
||||
|
||||
constructor (private command: string) {
|
||||
super({
|
||||
allowHalfOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
async start (): Promise<void> {
|
||||
this.process = exec(this.command, {
|
||||
windowsHide: true,
|
||||
encoding: 'buffer',
|
||||
})
|
||||
this.process.on('exit', code => {
|
||||
this.destroy(new Error(`Proxy command has exited with code ${code}`))
|
||||
})
|
||||
this.process.stdout?.on('data', data => {
|
||||
this.push(data)
|
||||
})
|
||||
this.process.stdout?.on('error', (err) => {
|
||||
this.destroy(err)
|
||||
})
|
||||
this.process.stderr?.on('data', data => {
|
||||
this.output.next(data.toString())
|
||||
})
|
||||
}
|
||||
|
||||
_read (size: number): void {
|
||||
process.stdout.read(size)
|
||||
}
|
||||
|
||||
_write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
|
||||
this.process.stdin?.write(chunk, callback)
|
||||
}
|
||||
|
||||
_destroy (error: Error|null, callback: (error: Error|null) => void): void {
|
||||
this.process.kill()
|
||||
this.output.complete()
|
||||
callback(error)
|
||||
}
|
||||
}
|
16
tabby-ssh/src/settings.ts
Normal file
16
tabby-ssh/src/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
|
||||
import { SSHSettingsTabComponent } from './components/sshSettingsTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SSHSettingsTabProvider extends SettingsTabProvider {
|
||||
id = 'ssh'
|
||||
icon = 'globe'
|
||||
title = 'SSH'
|
||||
|
||||
getComponentType (): any {
|
||||
return SSHSettingsTabComponent
|
||||
}
|
||||
}
|
37
tabby-ssh/src/tabContextMenu.ts
Normal file
37
tabby-ssh/src/tabContextMenu.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, HostAppService, Platform, MenuItemOptions } from 'tabby-core'
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
import { SSHService } from './services/ssh.service'
|
||||
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SFTPContextMenu extends TabContextMenuItemProvider {
|
||||
weight = 10
|
||||
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
private ssh: SSHService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
if (!(tab instanceof SSHTabComponent) || !tab.connection) {
|
||||
return []
|
||||
}
|
||||
const items = [{
|
||||
label: 'Open SFTP panel',
|
||||
click: () => tab.openSFTP(),
|
||||
}]
|
||||
if (this.hostApp.platform === Platform.Windows && this.ssh.getWinSCPPath()) {
|
||||
items.push({
|
||||
label: 'Launch WinSCP',
|
||||
click: (): void => {
|
||||
this.ssh.launchWinSCP(tab.session!)
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user