mirror of
https://github.com/Eugeny/tabby.git
synced 2025-07-20 02:18:01 +00:00
replace ssh2 with russh
This commit is contained in:
@@ -11,22 +11,17 @@
|
||||
"build": "webpack --progress --color",
|
||||
"watch": "webpack --progress --color --watch",
|
||||
"postinstall": "run-script-os",
|
||||
"postinstall:darwin:linux": "exit",
|
||||
"postinstall:win32": "xcopy /i /y ..\\node_modules\\ssh2\\util\\pagent.exe util\\"
|
||||
"postinstall:darwin:linux": "exit"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"util/pagent.exe",
|
||||
"typings"
|
||||
],
|
||||
"author": "Eugene Pankov",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "20.3.1",
|
||||
"@types/ssh2": "^0.5.46",
|
||||
"ansi-colors": "^4.1.1",
|
||||
"diffie-hellman": "^5.0.3",
|
||||
"sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136",
|
||||
"strip-ansi": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -45,5 +40,8 @@
|
||||
"tabby-core": "*",
|
||||
"tabby-settings": "*",
|
||||
"tabby-terminal": "*"
|
||||
},
|
||||
"resolutions": {
|
||||
"glob": "7.2.3"
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,45 @@
|
||||
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
|
||||
import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api'
|
||||
import * as russh from 'russh'
|
||||
import { SSHAlgorithmType } from './api'
|
||||
|
||||
// Counteracts https://github.com/mscdex/ssh2/commit/f1b5ac3c81734c194740016eab79a699efae83d8
|
||||
ALGORITHMS.DEFAULT_CIPHER.push('aes128-gcm')
|
||||
ALGORITHMS.DEFAULT_CIPHER.push('aes256-gcm')
|
||||
ALGORITHMS.SUPPORTED_CIPHER.push('aes128-gcm')
|
||||
ALGORITHMS.SUPPORTED_CIPHER.push('aes256-gcm')
|
||||
|
||||
export const supportedAlgorithms: Record<string, string> = {}
|
||||
|
||||
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]
|
||||
supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort()
|
||||
export const supportedAlgorithms = {
|
||||
[SSHAlgorithmType.KEX]: russh.getSupportedKexAlgorithms().filter(x => x !== 'none'),
|
||||
[SSHAlgorithmType.HOSTKEY]: russh.getSupportedKeyTypes().filter(x => x !== 'none'),
|
||||
[SSHAlgorithmType.CIPHER]: russh.getSupportedCiphers().filter(x => x !== 'clear'),
|
||||
[SSHAlgorithmType.HMAC]: russh.getSupportedMACs().filter(x => x !== 'none'),
|
||||
}
|
||||
|
||||
export const defaultAlgorithms = {
|
||||
[SSHAlgorithmType.KEX]: [
|
||||
'curve25519-sha256',
|
||||
'curve25519-sha256@libssh.org',
|
||||
'diffie-hellman-group16-sha512',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'ext-info-c',
|
||||
'ext-info-s',
|
||||
'kex-strict-c-v00@openssh.com',
|
||||
'kex-strict-s-v00@openssh.com',
|
||||
],
|
||||
[SSHAlgorithmType.HOSTKEY]: [
|
||||
'ssh-ed25519',
|
||||
'ecdsa-sha2-nistp256',
|
||||
'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-256',
|
||||
'rsa-sha2-512',
|
||||
'ssh-rsa',
|
||||
],
|
||||
[SSHAlgorithmType.CIPHER]: [
|
||||
'chacha20-poly1305@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes256-ctr',
|
||||
'aes192-ctr',
|
||||
'aes128-ctr',
|
||||
],
|
||||
[SSHAlgorithmType.HMAC]: [
|
||||
'hmac-sha2-512-etm@openssh.com',
|
||||
'hmac-sha2-256-etm@openssh.com',
|
||||
'hmac-sha2-512',
|
||||
'hmac-sha2-256',
|
||||
'hmac-sha1-etm@openssh.com',
|
||||
'hmac-sha1',
|
||||
],
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
export * from './contextMenu'
|
||||
export * from './interfaces'
|
||||
export * from './importer'
|
||||
export * from './proxyStream'
|
||||
export { SSHMultiplexerService } from '../services/sshMultiplexer.service'
|
||||
|
@@ -51,13 +51,3 @@ export interface ForwardedPortConfig {
|
||||
targetPort: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export let ALGORITHM_BLACKLIST = [
|
||||
// cause native crashes in node crypto, use EC instead
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group-exchange-sha1',
|
||||
]
|
||||
|
||||
if (!process.env.TABBY_ENABLE_SSH_ALG_BLACKLIST) {
|
||||
ALGORITHM_BLACKLIST = []
|
||||
}
|
||||
|
@@ -1,61 +0,0 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { Duplex } from 'stream'
|
||||
|
||||
export class SSHProxyStreamSocket extends Duplex {
|
||||
constructor (private parent: SSHProxyStream) {
|
||||
super({
|
||||
allowHalfOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
_read (size: number): void {
|
||||
this.parent.requestData(size)
|
||||
}
|
||||
|
||||
_write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
|
||||
this.parent.consumeInput(chunk).then(() => callback(null), e => callback(e))
|
||||
}
|
||||
|
||||
_destroy (error: Error|null, callback: (error: Error|null) => void): void {
|
||||
this.parent.handleStopRequest(error).then(() => callback(null), e => callback(e))
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class SSHProxyStream {
|
||||
get message$ (): Observable<string> { return this.message }
|
||||
get destroyed$ (): Observable<Error|null> { return this.destroyed }
|
||||
get socket (): SSHProxyStreamSocket|null { return this._socket }
|
||||
private message = new Subject<string>()
|
||||
private destroyed = new Subject<Error|null>()
|
||||
private _socket: SSHProxyStreamSocket|null = null
|
||||
|
||||
async start (): Promise<SSHProxyStreamSocket> {
|
||||
if (!this._socket) {
|
||||
this._socket = new SSHProxyStreamSocket(this)
|
||||
}
|
||||
return this._socket
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
abstract requestData (size: number): void
|
||||
|
||||
abstract consumeInput (data: Buffer): Promise<void>
|
||||
|
||||
protected emitMessage (message: string): void {
|
||||
this.message.next(message)
|
||||
}
|
||||
|
||||
protected emitOutput (data: Buffer): void {
|
||||
this._socket?.push(data)
|
||||
}
|
||||
|
||||
async handleStopRequest (error: Error|null): Promise<void> {
|
||||
this.destroyed.next(error)
|
||||
this.destroyed.complete()
|
||||
this.message.complete()
|
||||
}
|
||||
|
||||
stop (error?: Error): void {
|
||||
this._socket?.destroy(error)
|
||||
}
|
||||
}
|
@@ -14,11 +14,19 @@ input.form-control.mt-2(
|
||||
)
|
||||
|
||||
.d-flex.mt-3
|
||||
button.btn.btn-secondary(
|
||||
checkbox(
|
||||
*ngIf='isPassword()',
|
||||
[(ngModel)]='remember',
|
||||
[text]='"Save password"|translate'
|
||||
)
|
||||
|
||||
.ms-auto
|
||||
|
||||
button.btn.btn-secondary.me-3(
|
||||
*ngIf='step > 0',
|
||||
(click)='previous()'
|
||||
)
|
||||
.ms-auto
|
||||
|
||||
button.btn.btn-primary(
|
||||
(click)='next()'
|
||||
)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core'
|
||||
import { KeyboardInteractivePrompt } from '../session/ssh'
|
||||
|
||||
import { SSHProfile } from '../api'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
|
||||
@Component({
|
||||
selector: 'keyboard-interactive-auth-panel',
|
||||
@@ -9,13 +10,17 @@ import { KeyboardInteractivePrompt } from '../session/ssh'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class KeyboardInteractiveAuthComponent {
|
||||
@Input() profile: SSHProfile
|
||||
@Input() prompt: KeyboardInteractivePrompt
|
||||
@Input() step = 0
|
||||
@Output() done = new EventEmitter()
|
||||
@ViewChild('input') input: ElementRef
|
||||
remember = false
|
||||
|
||||
constructor (private passwordStorage: PasswordStorageService) {}
|
||||
|
||||
isPassword (): boolean {
|
||||
return this.prompt.prompts[this.step].prompt.toLowerCase().includes('password') || !this.prompt.prompts[this.step].echo
|
||||
return this.prompt.isAPasswordPrompt(this.step)
|
||||
}
|
||||
|
||||
previous (): void {
|
||||
@@ -26,6 +31,10 @@ export class KeyboardInteractiveAuthComponent {
|
||||
}
|
||||
|
||||
next (): void {
|
||||
if (this.isPassword() && this.remember) {
|
||||
this.passwordStorage.savePassword(this.profile, this.prompt.responses[this.step])
|
||||
}
|
||||
|
||||
if (this.step === this.prompt.prompts.length - 1) {
|
||||
this.prompt.respond()
|
||||
this.done.emit()
|
||||
|
@@ -51,6 +51,7 @@ sftp-panel.bg-dark(
|
||||
keyboard-interactive-auth-panel.bg-dark(
|
||||
*ngIf='activeKIPrompt',
|
||||
[prompt]='activeKIPrompt',
|
||||
[profile]='profile',
|
||||
(click)='$event.stopPropagation()',
|
||||
(done)='activeKIPrompt = null; frontend?.focus()'
|
||||
)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import * as russh from 'russh'
|
||||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'
|
||||
import colors from 'ansi-colors'
|
||||
import { Component, Injector, HostListener } from '@angular/core'
|
||||
@@ -94,17 +95,21 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
|
||||
}
|
||||
})
|
||||
|
||||
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
|
||||
'127.0.0.1', 0, profile.options.host, profile.options.port ?? 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(stream)
|
||||
},
|
||||
))
|
||||
if (!(jumpSession.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
throw new Error('Jump session is not authenticated yet somehow')
|
||||
}
|
||||
|
||||
try {
|
||||
session.jumpChannel = await jumpSession.ssh.openTCPForwardChannel({
|
||||
addressToConnectTo: profile.options.host,
|
||||
portToConnectTo: profile.options.port ?? 22,
|
||||
originatorAddress: '127.0.0.1',
|
||||
originatorPort: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +130,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent<SSHProfile>
|
||||
})
|
||||
|
||||
if (!session.open) {
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`)
|
||||
this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.name}\r\n`)
|
||||
|
||||
this.startSpinner(this.translate.instant(_('Connecting')))
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import './polyfills'
|
||||
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
@@ -1,12 +0,0 @@
|
||||
import 'ssh2'
|
||||
const nodeCrypto = require('crypto')
|
||||
const browserDH = require('diffie-hellman/browser')
|
||||
nodeCrypto.createDiffieHellmanGroup = browserDH.createDiffieHellmanGroup
|
||||
nodeCrypto.createDiffieHellman = browserDH.createDiffieHellman
|
||||
|
||||
// Declare function missing from @types
|
||||
declare module 'ssh2' {
|
||||
interface Client {
|
||||
setNoDelay: (enable?: boolean) => this
|
||||
}
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
import { Injectable, InjectFlags, Injector } from '@angular/core'
|
||||
import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core'
|
||||
import * as ALGORITHMS from 'ssh2/lib/protocol/constants'
|
||||
import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component'
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
|
||||
import { SSHAlgorithmType, SSHProfile } from './api'
|
||||
import { SSHProfileImporter } from './api/importer'
|
||||
import { defaultAlgorithms } from './algorithms'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile> {
|
||||
@@ -29,10 +29,10 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
|
||||
agentForward: false,
|
||||
warnOnClose: null,
|
||||
algorithms: {
|
||||
hmac: [],
|
||||
kex: [],
|
||||
cipher: [],
|
||||
serverHostKey: [],
|
||||
hmac: [] as string[],
|
||||
kex: [] as string[],
|
||||
cipher: [] as string[],
|
||||
serverHostKey: [] as string[],
|
||||
},
|
||||
proxyCommand: null,
|
||||
forwardedPorts: [],
|
||||
@@ -54,13 +54,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
|
||||
) {
|
||||
super()
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
const defaultAlg = {
|
||||
[SSHAlgorithmType.KEX]: 'DEFAULT_KEX',
|
||||
[SSHAlgorithmType.HOSTKEY]: 'DEFAULT_SERVER_HOST_KEY',
|
||||
[SSHAlgorithmType.CIPHER]: 'DEFAULT_CIPHER',
|
||||
[SSHAlgorithmType.HMAC]: 'DEFAULT_MAC',
|
||||
}[k]
|
||||
this.configDefaults.options.algorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
|
||||
this.configDefaults.options.algorithms[k] = [...defaultAlgorithms[k]]
|
||||
this.configDefaults.options.algorithms[k].sort()
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,9 @@
|
||||
import * as shellQuote from 'shell-quote'
|
||||
import * as net from 'net'
|
||||
import * as fs from 'fs/promises'
|
||||
// import * as fs from 'fs/promises'
|
||||
import * as tmp from 'tmp-promise'
|
||||
import socksv5 from '@luminati-io/socksv5'
|
||||
import { Duplex } from 'stream'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { spawn } from 'child_process'
|
||||
import { ChildProcess } from 'node:child_process'
|
||||
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
|
||||
import { SSHSession } from '../session/ssh'
|
||||
import { SSHProfile, SSHProxyStream, SSHProxyStreamSocket } from '../api'
|
||||
import { SSHProfile } from '../api'
|
||||
import { PasswordStorageService } from './passwordStorage.service'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -55,7 +49,7 @@ export class SSHService {
|
||||
let tmpFile: tmp.FileResult|null = null
|
||||
if (session.activePrivateKey) {
|
||||
tmpFile = await tmp.file()
|
||||
await fs.writeFile(tmpFile.path, session.activePrivateKey)
|
||||
// await fs.writeFile(tmpFile.path, session.activePrivateKey)
|
||||
const winSCPcom = path.slice(0, -3) + 'com'
|
||||
await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, `/output=${tmpFile.path}`])
|
||||
args.push(`/privatekey=${tmpFile.path}`)
|
||||
@@ -64,171 +58,3 @@ export class SSHService {
|
||||
tmpFile?.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyCommandStream extends SSHProxyStream {
|
||||
private process: ChildProcess|null
|
||||
|
||||
constructor (private command: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
async start (): Promise<SSHProxyStreamSocket> {
|
||||
const argv = shellQuote.parse(this.command)
|
||||
this.process = spawn(argv[0], argv.slice(1), {
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
this.process.on('error', error => {
|
||||
this.stop(new Error(`Proxy command has failed to start: ${error.message}`))
|
||||
})
|
||||
this.process.on('exit', code => {
|
||||
this.stop(new Error(`Proxy command has exited with code ${code}`))
|
||||
})
|
||||
this.process.stdout?.on('data', data => {
|
||||
this.emitOutput(data)
|
||||
})
|
||||
this.process.stdout?.on('error', (err) => {
|
||||
this.stop(err)
|
||||
})
|
||||
this.process.stderr?.on('data', data => {
|
||||
this.emitMessage(data.toString())
|
||||
})
|
||||
return super.start()
|
||||
}
|
||||
|
||||
requestData (size: number): void {
|
||||
this.process?.stdout?.read(size)
|
||||
}
|
||||
|
||||
async consumeInput (data: Buffer): Promise<void> {
|
||||
const process = this.process
|
||||
if (process) {
|
||||
await new Promise(resolve => process.stdin?.write(data, resolve))
|
||||
}
|
||||
}
|
||||
|
||||
async stop (error?: Error): Promise<void> {
|
||||
this.process?.kill()
|
||||
super.stop(error)
|
||||
}
|
||||
}
|
||||
|
||||
export class SocksProxyStream extends SSHProxyStream {
|
||||
private client: Duplex|null
|
||||
private header: Buffer|null
|
||||
|
||||
constructor (private profile: SSHProfile) {
|
||||
super()
|
||||
}
|
||||
|
||||
async start (): Promise<SSHProxyStreamSocket> {
|
||||
this.client = await new Promise((resolve, reject) => {
|
||||
const connector = socksv5.connect({
|
||||
host: this.profile.options.host,
|
||||
port: this.profile.options.port,
|
||||
proxyHost: this.profile.options.socksProxyHost ?? '127.0.0.1',
|
||||
proxyPort: this.profile.options.socksProxyPort ?? 5000,
|
||||
auths: [socksv5.auth.None()],
|
||||
strictLocalDNS: false,
|
||||
}, s => {
|
||||
resolve(s)
|
||||
this.header = s.read()
|
||||
if (this.header) {
|
||||
this.emitOutput(this.header)
|
||||
}
|
||||
})
|
||||
connector.on('error', (err) => {
|
||||
reject(err)
|
||||
this.stop(new Error(`SOCKS connection failed: ${err.message}`))
|
||||
})
|
||||
})
|
||||
this.client?.on('data', data => {
|
||||
if (!this.header || data !== this.header) {
|
||||
// socksv5 doesn't reliably emit the first data event
|
||||
this.emitOutput(data)
|
||||
this.header = null
|
||||
}
|
||||
})
|
||||
this.client?.on('close', error => {
|
||||
this.stop(error)
|
||||
})
|
||||
|
||||
return super.start()
|
||||
}
|
||||
|
||||
requestData (size: number): void {
|
||||
this.client?.read(size)
|
||||
}
|
||||
|
||||
async consumeInput (data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client?.write(data, undefined, err => err ? reject(err) : resolve())
|
||||
})
|
||||
}
|
||||
|
||||
async stop (error?: Error): Promise<void> {
|
||||
this.client?.destroy()
|
||||
super.stop(error)
|
||||
}
|
||||
}
|
||||
|
||||
export class HTTPProxyStream extends SSHProxyStream {
|
||||
private client: Duplex|null
|
||||
private connected = false
|
||||
|
||||
constructor (private profile: SSHProfile) {
|
||||
super()
|
||||
}
|
||||
|
||||
async start (): Promise<SSHProxyStreamSocket> {
|
||||
this.client = await new Promise((resolve, reject) => {
|
||||
const connector = net.createConnection({
|
||||
host: this.profile.options.httpProxyHost!,
|
||||
port: this.profile.options.httpProxyPort!,
|
||||
}, () => resolve(connector))
|
||||
connector.on('error', error => {
|
||||
reject(error)
|
||||
this.stop(new Error(`Proxy connection failed: ${error.message}`))
|
||||
})
|
||||
})
|
||||
this.client?.write(Buffer.from(`CONNECT ${this.profile.options.host}:${this.profile.options.port} HTTP/1.1\r\n\r\n`))
|
||||
this.client?.on('data', (data: Buffer) => {
|
||||
if (this.connected) {
|
||||
this.emitOutput(data)
|
||||
} else {
|
||||
if (data.slice(0, 5).equals(Buffer.from('HTTP/'))) {
|
||||
const idx = data.indexOf('\n\n')
|
||||
const headers = data.slice(0, idx).toString()
|
||||
const code = parseInt(headers.split(' ')[1])
|
||||
if (code >= 200 && code < 300) {
|
||||
this.emitMessage('Connected')
|
||||
this.emitOutput(data.slice(idx + 2))
|
||||
this.connected = true
|
||||
} else {
|
||||
this.stop(new Error(`Connection failed, code ${code}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
this.client?.on('close', error => {
|
||||
this.stop(error)
|
||||
})
|
||||
|
||||
return super.start()
|
||||
}
|
||||
|
||||
requestData (size: number): void {
|
||||
this.client?.read(size)
|
||||
}
|
||||
|
||||
async consumeInput (data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client?.write(data, undefined, err => err ? reject(err) : resolve())
|
||||
})
|
||||
}
|
||||
|
||||
async stop (error?: Error): Promise<void> {
|
||||
this.client?.destroy()
|
||||
super.stop(error)
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +1,9 @@
|
||||
import * as C from 'constants'
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { posix as posixPath } from 'path'
|
||||
import { Injector, NgZone } from '@angular/core'
|
||||
import { FileDownload, FileUpload, Logger, LogService, wrapPromise } from 'tabby-core'
|
||||
import { SFTPWrapper } from 'ssh2'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import type { FileEntry, Stats } from 'ssh2-streams'
|
||||
import { Injector } from '@angular/core'
|
||||
import { FileDownload, FileUpload, Logger, LogService } from 'tabby-core'
|
||||
import * as russh from 'russh'
|
||||
|
||||
export interface SFTPFile {
|
||||
name: string
|
||||
@@ -22,63 +19,37 @@ export class SFTPFileHandle {
|
||||
position = 0
|
||||
|
||||
constructor (
|
||||
private sftp: SFTPWrapper,
|
||||
private handle: Buffer,
|
||||
private zone: NgZone,
|
||||
private inner: russh.SFTPFile|null,
|
||||
) { }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}))
|
||||
async read (): Promise<Uint8Array> {
|
||||
if (!this.inner) {
|
||||
return Promise.resolve(new Uint8Array(0))
|
||||
}
|
||||
return this.inner.read(256 * 1024)
|
||||
}
|
||||
|
||||
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) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
this.position += chunk.length
|
||||
resolve()
|
||||
})
|
||||
if (!wait) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
async write (chunk: Uint8Array): Promise<void> {
|
||||
if (!this.inner) {
|
||||
throw new Error('File handle is closed')
|
||||
}
|
||||
await this.inner.writeAll(chunk)
|
||||
}
|
||||
|
||||
close (): Promise<void> {
|
||||
return wrapPromise(this.zone, promisify(this.sftp.close.bind(this.sftp))(this.handle))
|
||||
async close (): Promise<void> {
|
||||
await this.inner?.shutdown()
|
||||
this.inner = null
|
||||
}
|
||||
}
|
||||
|
||||
export class SFTPSession {
|
||||
get closed$ (): Observable<void> { return this.closed }
|
||||
private closed = new Subject<void>()
|
||||
private zone: NgZone
|
||||
private logger: Logger
|
||||
|
||||
constructor (private sftp: SFTPWrapper, injector: Injector) {
|
||||
this.zone = injector.get(NgZone)
|
||||
constructor (private sftp: russh.SFTP, injector: Injector) {
|
||||
this.logger = injector.get(LogService).create('sftp')
|
||||
sftp.on('close', () => {
|
||||
sftp.closed$.subscribe(() => {
|
||||
this.closed.next()
|
||||
this.closed.complete()
|
||||
})
|
||||
@@ -86,67 +57,64 @@ export class SFTPSession {
|
||||
|
||||
async readdir (p: string): Promise<SFTPFile[]> {
|
||||
this.logger.debug('readdir', p)
|
||||
const entries = await wrapPromise(this.zone, promisify<FileEntry[]>(f => this.sftp.readdir(p, f))())
|
||||
const entries = await this.sftp.readDirectory(p)
|
||||
return entries.map(entry => this._makeFile(
|
||||
posixPath.join(p, entry.filename), entry,
|
||||
posixPath.join(p, entry.name), entry,
|
||||
))
|
||||
}
|
||||
|
||||
readlink (p: string): Promise<string> {
|
||||
this.logger.debug('readlink', p)
|
||||
return wrapPromise(this.zone, promisify<string>(f => this.sftp.readlink(p, f))())
|
||||
return this.sftp.readlink(p)
|
||||
}
|
||||
|
||||
async stat (p: string): Promise<SFTPFile> {
|
||||
this.logger.debug('stat', p)
|
||||
const stats = await wrapPromise(this.zone, promisify<Stats>(f => this.sftp.stat(p, f))())
|
||||
const stats = await this.sftp.stat(p)
|
||||
return {
|
||||
name: posixPath.basename(p),
|
||||
fullPath: p,
|
||||
isDirectory: stats.isDirectory(),
|
||||
isSymlink: stats.isSymbolicLink(),
|
||||
mode: stats.mode,
|
||||
isDirectory: stats.type === russh.SFTPFileType.Directory,
|
||||
isSymlink: stats.type === russh.SFTPFileType.Symlink,
|
||||
mode: stats.permissions ?? 0,
|
||||
size: stats.size,
|
||||
modified: new Date(stats.mtime * 1000),
|
||||
modified: new Date((stats.mtime ?? 0) * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
async open (p: string, mode: string): Promise<SFTPFileHandle> {
|
||||
this.logger.debug('open', p)
|
||||
const handle = await wrapPromise(this.zone, promisify<Buffer>(f => this.sftp.open(p, mode, f))())
|
||||
return new SFTPFileHandle(this.sftp, handle, this.zone)
|
||||
async open (p: string, mode: number): Promise<SFTPFileHandle> {
|
||||
this.logger.debug('open', p, mode)
|
||||
const handle = await this.sftp.open(p, mode)
|
||||
return new SFTPFileHandle(handle)
|
||||
}
|
||||
|
||||
async rmdir (p: string): Promise<void> {
|
||||
this.logger.debug('rmdir', p)
|
||||
await promisify((f: any) => this.sftp.rmdir(p, f))()
|
||||
await this.sftp.removeDirectory(p)
|
||||
}
|
||||
|
||||
async mkdir (p: string): Promise<void> {
|
||||
this.logger.debug('mkdir', p)
|
||||
await promisify((f: any) => this.sftp.mkdir(p, f))()
|
||||
await this.sftp.createDirectory(p)
|
||||
}
|
||||
|
||||
async rename (oldPath: string, newPath: string): Promise<void> {
|
||||
this.logger.debug('rename', oldPath, newPath)
|
||||
await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))()
|
||||
await this.sftp.rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
async unlink (p: string): Promise<void> {
|
||||
this.logger.debug('unlink', p)
|
||||
await promisify((f: any) => this.sftp.unlink(p, f))()
|
||||
await this.sftp.removeFile(p)
|
||||
}
|
||||
|
||||
async chmod (p: string, mode: string|number): Promise<void> {
|
||||
this.logger.debug('chmod', p, mode)
|
||||
await promisify((f: any) => this.sftp.chmod(p, mode, f))()
|
||||
await this.sftp.chmod(p, mode)
|
||||
}
|
||||
|
||||
async upload (path: string, transfer: FileUpload): Promise<void> {
|
||||
this.logger.info('Uploading into', path)
|
||||
const tempPath = path + '.tabby-upload'
|
||||
try {
|
||||
const handle = await this.open(tempPath, 'w')
|
||||
const handle = await this.open(tempPath, russh.OPEN_WRITE | russh.OPEN_CREATE)
|
||||
while (true) {
|
||||
const chunk = await transfer.read()
|
||||
if (!chunk.length) {
|
||||
@@ -154,15 +122,13 @@ export class SFTPSession {
|
||||
}
|
||||
await handle.write(chunk)
|
||||
}
|
||||
handle.close()
|
||||
try {
|
||||
await this.unlink(path)
|
||||
} catch { }
|
||||
await handle.close()
|
||||
await this.unlink(path).catch(() => null)
|
||||
await this.rename(tempPath, path)
|
||||
transfer.close()
|
||||
} catch (e) {
|
||||
transfer.cancel()
|
||||
this.unlink(tempPath)
|
||||
this.unlink(tempPath).catch(() => null)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -170,7 +136,7 @@ export class SFTPSession {
|
||||
async download (path: string, transfer: FileDownload): Promise<void> {
|
||||
this.logger.info('Downloading', path)
|
||||
try {
|
||||
const handle = await this.open(path, 'r')
|
||||
const handle = await this.open(path, russh.OPEN_READ)
|
||||
while (true) {
|
||||
const chunk = await handle.read()
|
||||
if (!chunk.length) {
|
||||
@@ -186,15 +152,15 @@ export class SFTPSession {
|
||||
}
|
||||
}
|
||||
|
||||
private _makeFile (p: string, entry: FileEntry): SFTPFile {
|
||||
private _makeFile (p: string, entry: russh.SFTPDirectoryEntry): 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),
|
||||
isDirectory: entry.metadata.type === russh.SFTPFileType.Directory,
|
||||
isSymlink: entry.metadata.type === russh.SFTPFileType.Symlink,
|
||||
mode: entry.metadata.permissions ?? 0,
|
||||
size: entry.metadata.size,
|
||||
modified: new Date((entry.metadata.mtime ?? 0) * 1000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,15 @@
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { ClientChannel } from 'ssh2'
|
||||
import { Injector } from '@angular/core'
|
||||
import { LogService } from 'tabby-core'
|
||||
import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal'
|
||||
import { SSHSession } from './ssh'
|
||||
import { SSHProfile } from '../api'
|
||||
import * as russh from 'russh'
|
||||
|
||||
|
||||
export class SSHShellSession extends BaseSession {
|
||||
shell?: ClientChannel
|
||||
shell?: russh.Channel
|
||||
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
|
||||
private serviceMessage = new Subject<string>()
|
||||
private ssh: SSHSession|null
|
||||
@@ -53,19 +53,11 @@ export class SSHShellSession extends BaseSession {
|
||||
|
||||
this.loginScriptProcessor?.executeUnconditionalScripts()
|
||||
|
||||
this.shell.on('greeting', greeting => {
|
||||
this.emitServiceMessage(`Shell greeting: ${greeting}`)
|
||||
this.shell.data$.subscribe(data => {
|
||||
this.emitOutput(Buffer.from(data))
|
||||
})
|
||||
|
||||
this.shell.on('banner', banner => {
|
||||
this.emitServiceMessage(`Shell banner: ${banner}`)
|
||||
})
|
||||
|
||||
this.shell.on('data', data => {
|
||||
this.emitOutput(data)
|
||||
})
|
||||
|
||||
this.shell.on('end', () => {
|
||||
this.shell.eof$.subscribe(() => {
|
||||
this.logger.info('Shell session ended')
|
||||
if (this.open) {
|
||||
this.destroy()
|
||||
@@ -79,19 +71,22 @@ export class SSHShellSession extends BaseSession {
|
||||
}
|
||||
|
||||
resize (columns: number, rows: number): void {
|
||||
if (this.shell) {
|
||||
this.shell.setWindow(rows, columns, rows, columns)
|
||||
}
|
||||
this.shell?.resizePTY({
|
||||
columns,
|
||||
rows,
|
||||
pixHeight: 0,
|
||||
pixWidth: 0,
|
||||
})
|
||||
}
|
||||
|
||||
write (data: Buffer): void {
|
||||
if (this.shell) {
|
||||
this.shell.write(data)
|
||||
this.shell.write(new Uint8Array(data))
|
||||
}
|
||||
}
|
||||
|
||||
kill (signal?: string): void {
|
||||
this.shell?.signal(signal ?? 'TERM')
|
||||
kill (_signal?: string): void {
|
||||
// this.shell?.signal(signal ?? 'TERM')
|
||||
}
|
||||
|
||||
async destroy (): Promise<void> {
|
||||
|
@@ -1,24 +1,22 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import * as crypto from 'crypto'
|
||||
import * as sshpk from 'sshpk'
|
||||
import colors from 'ansi-colors'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { Injector, NgZone } from '@angular/core'
|
||||
import * as shellQuote from 'shell-quote'
|
||||
import { Injector } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core'
|
||||
import { ConfigService, FileProvidersService, NotificationsService, PromptModalComponent, LogService, Logger, TranslateService, Platform, HostAppService } from 'tabby-core'
|
||||
import { Socket } from 'net'
|
||||
import { Client, ClientChannel, SFTPWrapper } from 'ssh2'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component'
|
||||
import { HTTPProxyStream, ProxyCommandStream, SocksProxyStream } from '../services/ssh.service'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { SSHKnownHostsService } from '../services/sshKnownHosts.service'
|
||||
import { promisify } from 'util'
|
||||
import { SFTPSession } from './sftp'
|
||||
import { SSHAlgorithmType, PortForwardType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api'
|
||||
import { SSHAlgorithmType, SSHProfile, AutoPrivateKeyLocator, PortForwardType } from '../api'
|
||||
import { ForwardedPort } from './forwards'
|
||||
import { X11Socket } from './x11'
|
||||
import { supportedAlgorithms } from '../algorithms'
|
||||
import * as russh from 'russh'
|
||||
|
||||
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
||||
|
||||
@@ -27,48 +25,74 @@ export interface Prompt {
|
||||
echo?: boolean
|
||||
}
|
||||
|
||||
interface AuthMethod {
|
||||
type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
|
||||
name?: string
|
||||
contents?: Buffer
|
||||
}
|
||||
|
||||
interface Handshake {
|
||||
kex: string
|
||||
serverHostKey: string
|
||||
type AuthMethod = {
|
||||
type: 'none'|'prompt-password'|'hostbased'
|
||||
} | {
|
||||
type: 'keyboard-interactive',
|
||||
savedPassword?: string
|
||||
} | {
|
||||
type: 'saved-password',
|
||||
password: string
|
||||
} | {
|
||||
type: 'publickey'
|
||||
name: string
|
||||
contents: Buffer
|
||||
} | {
|
||||
type: 'agent',
|
||||
kind: 'unix-socket',
|
||||
path: string
|
||||
} | {
|
||||
type: 'agent',
|
||||
kind: 'named-pipe',
|
||||
path: string
|
||||
} | {
|
||||
type: 'agent',
|
||||
kind: 'pageant',
|
||||
}
|
||||
|
||||
export class KeyboardInteractivePrompt {
|
||||
responses: string[] = []
|
||||
readonly responses: string[] = []
|
||||
|
||||
private _resolve: (value: string[]) => void
|
||||
private _reject: (reason: any) => void
|
||||
readonly promise = new Promise<string[]>((resolve, reject) => {
|
||||
this._resolve = resolve
|
||||
this._reject = reject
|
||||
})
|
||||
|
||||
constructor (
|
||||
public name: string,
|
||||
public instruction: string,
|
||||
public prompts: Prompt[],
|
||||
private callback: (_: string[]) => void,
|
||||
) {
|
||||
this.responses = new Array(this.prompts.length).fill('')
|
||||
}
|
||||
|
||||
isAPasswordPrompt (index: number): boolean {
|
||||
return this.prompts[index].prompt.toLowerCase().includes('password') && !this.prompts[index].echo
|
||||
}
|
||||
|
||||
respond (): void {
|
||||
this.callback(this.responses)
|
||||
this._resolve(this.responses)
|
||||
}
|
||||
|
||||
reject (): void {
|
||||
this._reject(new Error('Keyboard-interactive auth rejected'))
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHSession {
|
||||
shell?: ClientChannel
|
||||
ssh: Client
|
||||
sftp?: SFTPWrapper
|
||||
shell?: russh.Channel
|
||||
ssh: russh.SSHClient|russh.AuthenticatedSSHClient
|
||||
sftp?: russh.SFTP
|
||||
forwardedPorts: ForwardedPort[] = []
|
||||
jumpStream: any
|
||||
proxyCommandStream: SSHProxyStream|null = null
|
||||
jumpChannel: russh.Channel|null = null
|
||||
savedPassword?: string
|
||||
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
|
||||
get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
|
||||
get willDestroy$ (): Observable<void> { return this.willDestroy }
|
||||
|
||||
agentPath?: string
|
||||
activePrivateKey: string|null = null
|
||||
activePrivateKey: russh.KeyPair|null = null
|
||||
authUsername: string|null = null
|
||||
|
||||
open = false
|
||||
@@ -79,15 +103,11 @@ export class SSHSession {
|
||||
private serviceMessage = new Subject<string>()
|
||||
private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
|
||||
private willDestroy = new Subject<void>()
|
||||
private keychainPasswordUsed = false
|
||||
private hostKeyDigest = ''
|
||||
|
||||
private passwordStorage: PasswordStorageService
|
||||
private ngbModal: NgbModal
|
||||
private hostApp: HostAppService
|
||||
private platform: PlatformService
|
||||
private notifications: NotificationsService
|
||||
private zone: NgZone
|
||||
private fileProviders: FileProvidersService
|
||||
private config: ConfigService
|
||||
private translate: TranslateService
|
||||
@@ -103,9 +123,7 @@ export class SSHSession {
|
||||
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.translate = injector.get(TranslateService)
|
||||
@@ -120,27 +138,6 @@ export class SSHSession {
|
||||
}
|
||||
|
||||
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') ||
|
||||
await this.platform.isProcessRunning('gpg-agent.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.profile.options.auth || this.profile.options.auth === 'publicKey') {
|
||||
if (this.profile.options.privateKeys?.length) {
|
||||
@@ -167,184 +164,192 @@ export class SSHSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.profile.options.auth || this.profile.options.auth === 'agent') {
|
||||
if (!this.agentPath) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
|
||||
const spec = await this.getAgentConnectionSpec()
|
||||
if (!spec) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running Agent process is found`)
|
||||
} else {
|
||||
this.remainingAuthMethods.push({ type: 'agent' })
|
||||
this.remainingAuthMethods.push({
|
||||
type: 'agent',
|
||||
...spec,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!this.profile.options.auth || this.profile.options.auth === 'password') {
|
||||
this.remainingAuthMethods.push({ type: 'password' })
|
||||
if (this.profile.options.password) {
|
||||
this.remainingAuthMethods.push({ type: 'saved-password', password: this.profile.options.password })
|
||||
}
|
||||
const password = await this.passwordStorage.loadPassword(this.profile)
|
||||
if (password) {
|
||||
this.remainingAuthMethods.push({ type: 'saved-password', password })
|
||||
}
|
||||
this.remainingAuthMethods.push({ type: 'prompt-password' })
|
||||
}
|
||||
if (!this.profile.options.auth || this.profile.options.auth === 'keyboardInteractive') {
|
||||
const savedPassword = this.profile.options.password ?? await this.passwordStorage.loadPassword(this.profile)
|
||||
if (savedPassword) {
|
||||
this.remainingAuthMethods.push({ type: 'keyboard-interactive', savedPassword })
|
||||
}
|
||||
this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
|
||||
}
|
||||
this.remainingAuthMethods.push({ type: 'hostbased' })
|
||||
}
|
||||
|
||||
private async getAgentConnectionSpec (): Promise<russh.AgentConnectionSpec|null> {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
if (this.config.store.ssh.agentType === 'auto') {
|
||||
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
|
||||
return {
|
||||
kind: 'named-pipe',
|
||||
path: WINDOWS_OPENSSH_AGENT_PIPE,
|
||||
}
|
||||
} else if (russh.isPageantRunning()) {
|
||||
return {
|
||||
kind: 'pageant',
|
||||
}
|
||||
} else {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running Agent process is found`)
|
||||
}
|
||||
} else if (this.config.store.ssh.agentType === 'pageant') {
|
||||
return {
|
||||
kind: 'pageant',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
kind: 'named-pipe',
|
||||
path: this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
kind: 'unix-socket',
|
||||
path: process.env.SSH_AUTH_SOCK!,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async openSFTP (): Promise<SFTPSession> {
|
||||
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
throw new Error('Cannot open SFTP session before auth')
|
||||
}
|
||||
if (!this.sftp) {
|
||||
this.sftp = await wrapPromise(this.zone, promisify<SFTPWrapper>(f => this.ssh.sftp(f))())
|
||||
this.sftp = await this.ssh.openSFTPChannel()
|
||||
}
|
||||
return new SFTPSession(this.sftp, this.injector)
|
||||
}
|
||||
|
||||
|
||||
async start (): Promise<void> {
|
||||
const log = (s: any) => this.emitServiceMessage(s)
|
||||
|
||||
const ssh = new Client()
|
||||
this.ssh = ssh
|
||||
await this.init()
|
||||
|
||||
let connected = false
|
||||
const algorithms = {}
|
||||
for (const key of Object.values(SSHAlgorithmType)) {
|
||||
algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x))
|
||||
}
|
||||
|
||||
const hostVerifiedPromise: Promise<void> = new Promise((resolve, reject) => {
|
||||
ssh.on('handshake', async handshake => {
|
||||
if (!await this.verifyHostKey(handshake)) {
|
||||
this.ssh.end()
|
||||
reject(new Error('Host key verification failed'))
|
||||
}
|
||||
this.logger.info('Handshake complete:', handshake)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let transport: russh.SshTransport
|
||||
if (this.profile.options.proxyCommand) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`)
|
||||
|
||||
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
|
||||
ssh.on('ready', () => {
|
||||
connected = true
|
||||
// Fix SSH Lagging
|
||||
ssh.setNoDelay(true)
|
||||
if (this.savedPassword) {
|
||||
this.passwordStorage.savePassword(this.profile, this.savedPassword)
|
||||
}
|
||||
|
||||
this.zone.run(resolve)
|
||||
})
|
||||
ssh.on('error', error => {
|
||||
if (error.message === 'All configured authentication methods failed') {
|
||||
this.passwordStorage.deletePassword(this.profile)
|
||||
}
|
||||
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 (this.open) {
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => {
|
||||
this.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt(
|
||||
name,
|
||||
instructions,
|
||||
prompts,
|
||||
finish,
|
||||
))
|
||||
}))
|
||||
|
||||
ssh.on('greeting', greeting => {
|
||||
if (!this.profile.options.skipBanner) {
|
||||
log('Greeting: ' + greeting)
|
||||
}
|
||||
})
|
||||
|
||||
ssh.on('banner', banner => {
|
||||
if (!this.profile.options.skipBanner) {
|
||||
log(banner)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (this.profile.options.socksProxyHost) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`)
|
||||
this.proxyCommandStream = new SocksProxyStream(this.profile)
|
||||
}
|
||||
if (this.profile.options.httpProxyHost) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`)
|
||||
this.proxyCommandStream = new HTTPProxyStream(this.profile)
|
||||
}
|
||||
if (this.profile.options.proxyCommand) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`)
|
||||
this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand)
|
||||
}
|
||||
if (this.proxyCommandStream) {
|
||||
this.proxyCommandStream.destroyed$.subscribe(err => {
|
||||
if (err) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`)
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
this.proxyCommandStream.message$.subscribe(message => {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim())
|
||||
})
|
||||
|
||||
await this.proxyCommandStream.start()
|
||||
}
|
||||
|
||||
this.authUsername ??= this.profile.options.user
|
||||
if (!this.authUsername) {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
|
||||
try {
|
||||
const result = await modal.result.catch(() => null)
|
||||
this.authUsername = result?.value ?? null
|
||||
} catch {
|
||||
this.authUsername = 'root'
|
||||
}
|
||||
}
|
||||
if (this.authUsername?.startsWith('$')) {
|
||||
try {
|
||||
const result = process.env[this.authUsername.slice(1)]
|
||||
this.authUsername = result ?? this.authUsername
|
||||
} catch {
|
||||
this.authUsername = 'root'
|
||||
}
|
||||
}
|
||||
|
||||
ssh.connect({
|
||||
host: this.profile.options.host.trim(),
|
||||
port: this.profile.options.port ?? 22,
|
||||
sock: this.proxyCommandStream?.socket ?? this.jumpStream,
|
||||
username: this.authUsername ?? undefined,
|
||||
tryKeyboard: true,
|
||||
agent: this.agentPath,
|
||||
agentForward: this.profile.options.agentForward && !!this.agentPath,
|
||||
keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000,
|
||||
keepaliveCountMax: this.profile.options.keepaliveCountMax,
|
||||
readyTimeout: this.profile.options.readyTimeout,
|
||||
hostVerifier: (key: any) => {
|
||||
this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64')
|
||||
return true
|
||||
},
|
||||
algorithms,
|
||||
authHandler: (methodsLeft, partialSuccess, callback) => {
|
||||
this.zone.run(async () => {
|
||||
await hostVerifiedPromise
|
||||
callback(await this.handleAuth(methodsLeft))
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
this.notifications.error(e.message)
|
||||
throw e
|
||||
const argv = shellQuote.parse(this.profile.options.proxyCommand)
|
||||
transport = await russh.SshTransport.newCommand(argv[0], argv.slice(1))
|
||||
} else if (this.jumpChannel) {
|
||||
transport = await russh.SshTransport.newSshChannel(await this.jumpChannel.take())
|
||||
this.jumpChannel = null
|
||||
} else if (this.profile.options.socksProxyHost) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`)
|
||||
transport = await russh.SshTransport.newSocksProxy(
|
||||
this.profile.options.socksProxyHost,
|
||||
this.profile.options.socksProxyPort ?? 1080,
|
||||
this.profile.options.host,
|
||||
this.profile.options.port ?? 22,
|
||||
)
|
||||
} else if (this.profile.options.httpProxyHost) {
|
||||
this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`)
|
||||
transport = await russh.SshTransport.newHttpProxy(
|
||||
this.profile.options.httpProxyHost,
|
||||
this.profile.options.httpProxyPort ?? 8080,
|
||||
this.profile.options.host,
|
||||
this.profile.options.port ?? 22,
|
||||
)
|
||||
} else {
|
||||
transport = await russh.SshTransport.newSocket(`${this.profile.options.host.trim()}:${this.profile.options.port ?? 22}`)
|
||||
}
|
||||
|
||||
await resultPromise
|
||||
await hostVerifiedPromise
|
||||
this.ssh = await russh.SSHClient.connect(
|
||||
transport,
|
||||
async key => {
|
||||
if (!await this.verifyHostKey(key)) {
|
||||
return false
|
||||
}
|
||||
this.logger.info('Host key verified')
|
||||
return true
|
||||
},
|
||||
{
|
||||
preferred: {
|
||||
ciphers: this.profile.options.algorithms?.[SSHAlgorithmType.CIPHER]?.filter(x => supportedAlgorithms[SSHAlgorithmType.CIPHER].includes(x)),
|
||||
kex: this.profile.options.algorithms?.[SSHAlgorithmType.KEX]?.filter(x => supportedAlgorithms[SSHAlgorithmType.KEX].includes(x)),
|
||||
mac: this.profile.options.algorithms?.[SSHAlgorithmType.HMAC]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HMAC].includes(x)),
|
||||
key: this.profile.options.algorithms?.[SSHAlgorithmType.HOSTKEY]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HOSTKEY].includes(x)),
|
||||
},
|
||||
keepaliveIntervalSeconds: Math.round((this.profile.options.keepaliveInterval ?? 15000) / 1000),
|
||||
keepaliveCountMax: this.profile.options.keepaliveCountMax,
|
||||
connectionTimeoutSeconds: this.profile.options.readyTimeout ? Math.round(this.profile.options.readyTimeout / 1000) : undefined,
|
||||
},
|
||||
)
|
||||
|
||||
this.ssh.banner$.subscribe(banner => {
|
||||
if (!this.profile.options.skipBanner) {
|
||||
this.emitServiceMessage(banner)
|
||||
}
|
||||
})
|
||||
|
||||
this.ssh.disconnect$.subscribe(() => {
|
||||
if (this.open) {
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// Authentication
|
||||
|
||||
this.authUsername ??= this.profile.options.user
|
||||
if (!this.authUsername) {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Username for ${this.profile.options.host}`
|
||||
try {
|
||||
const result = await modal.result.catch(() => null)
|
||||
this.authUsername = result?.value ?? null
|
||||
} catch {
|
||||
this.authUsername = 'root'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.authUsername?.startsWith('$')) {
|
||||
try {
|
||||
const result = process.env[this.authUsername.slice(1)]
|
||||
this.authUsername = result ?? this.authUsername
|
||||
} catch {
|
||||
this.authUsername = 'root'
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedClient = await this.handleAuth()
|
||||
if (authenticatedClient) {
|
||||
this.ssh = authenticatedClient
|
||||
} else {
|
||||
this.ssh.disconnect()
|
||||
this.passwordStorage.deletePassword(this.profile)
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
throw new Error('Authentication rejected')
|
||||
}
|
||||
|
||||
// auth success
|
||||
|
||||
if (this.savedPassword) {
|
||||
this.passwordStorage.savePassword(this.profile, this.savedPassword)
|
||||
}
|
||||
|
||||
for (const fw of this.profile.options.forwardedPorts ?? []) {
|
||||
this.addPortForward(Object.assign(new ForwardedPort(), fw))
|
||||
@@ -352,12 +357,11 @@ export class SSHSession {
|
||||
|
||||
this.open = true
|
||||
|
||||
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)
|
||||
this.ssh.tcpChannelOpen$.subscribe(async event => {
|
||||
this.logger.info(`Incoming forwarded connection: ${event.clientAddress}:${event.clientPort} -> ${event.targetAddress}:${event.targetPort}`)
|
||||
const forward = this.forwardedPorts.find(x => x.port === event.targetPort && x.host === event.targetAddress)
|
||||
if (!forward) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`)
|
||||
reject()
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${event.targetAddress}:${event.targetPort}`)
|
||||
return
|
||||
}
|
||||
const socket = new Socket()
|
||||
@@ -365,24 +369,19 @@ export class SSHSession {
|
||||
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()
|
||||
event.channel.close()
|
||||
})
|
||||
event.channel.data$.subscribe(data => socket.write(data))
|
||||
socket.on('data', data => event.channel.write(Uint8Array.from(data)))
|
||||
event.channel.closed$.subscribe(() => socket.destroy())
|
||||
socket.on('close', () => event.channel.close())
|
||||
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', async (details, accept, reject) => {
|
||||
this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`)
|
||||
this.ssh.x11ChannelOpen$.subscribe(async event => {
|
||||
this.logger.info(`Incoming X11 connection from ${event.clientAddress}:${event.clientPort}`)
|
||||
const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0'
|
||||
this.logger.debug(`Trying display ${displaySpec}`)
|
||||
|
||||
@@ -390,14 +389,18 @@ export class SSHSession {
|
||||
try {
|
||||
const x11Stream = await socket.connect(displaySpec)
|
||||
this.logger.info('Connection forwarded')
|
||||
const stream = accept()
|
||||
stream.pipe(x11Stream)
|
||||
x11Stream.pipe(stream)
|
||||
stream.on('close', () => {
|
||||
|
||||
event.channel.data$.subscribe(data => {
|
||||
x11Stream.write(data)
|
||||
})
|
||||
x11Stream.on('data', data => {
|
||||
event.channel.write(Uint8Array.from(data))
|
||||
})
|
||||
event.channel.closed$.subscribe(() => {
|
||||
socket.destroy()
|
||||
})
|
||||
x11Stream.on('close', () => {
|
||||
stream.close()
|
||||
event.channel.close()
|
||||
})
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
@@ -408,27 +411,43 @@ export class SSHSession {
|
||||
this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/')
|
||||
this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/')
|
||||
}
|
||||
reject()
|
||||
event.channel.close()
|
||||
}
|
||||
})
|
||||
|
||||
this.ssh.agentChannelOpen$.subscribe(async channel => {
|
||||
const spec = await this.getAgentConnectionSpec()
|
||||
if (!spec) {
|
||||
await channel.close()
|
||||
return
|
||||
}
|
||||
|
||||
const agent = await russh.SSHAgentStream.connect(spec)
|
||||
channel.data$.subscribe(data => agent.write(data))
|
||||
agent.data$.subscribe(data => channel.write(data), undefined, () => channel.close())
|
||||
channel.closed$.subscribe(() => agent.close())
|
||||
})
|
||||
}
|
||||
|
||||
private async verifyHostKey (handshake: Handshake): Promise<boolean> {
|
||||
private async verifyHostKey (key: russh.SshPublicKey): Promise<boolean> {
|
||||
this.emitServiceMessage('Host key fingerprint:')
|
||||
this.emitServiceMessage(colors.white.bgBlack(` ${handshake.serverHostKey} `) + colors.bgBlackBright(' ' + this.hostKeyDigest + ' '))
|
||||
this.emitServiceMessage(colors.white.bgBlack(` ${key.algorithm()} `) + colors.bgBlackBright(' ' + key.fingerprint() + ' '))
|
||||
if (!this.config.store.ssh.verifyHostKeys) {
|
||||
return true
|
||||
}
|
||||
const selector = {
|
||||
host: this.profile.options.host,
|
||||
port: this.profile.options.port ?? 22,
|
||||
type: handshake.serverHostKey,
|
||||
type: key.algorithm(),
|
||||
}
|
||||
|
||||
const keyDigest = crypto.createHash('sha256').update(key.bytes()).digest('base64')
|
||||
|
||||
const knownHost = this.knownHosts.getFor(selector)
|
||||
if (!knownHost || knownHost.digest !== this.hostKeyDigest) {
|
||||
if (!knownHost || knownHost.digest !== keyDigest) {
|
||||
const modal = this.ngbModal.open(HostKeyPromptModalComponent)
|
||||
modal.componentInstance.selector = selector
|
||||
modal.componentInstance.digest = this.hostKeyDigest
|
||||
modal.componentInstance.digest = keyDigest
|
||||
return modal.result.catch(() => false)
|
||||
}
|
||||
return true
|
||||
@@ -450,57 +469,49 @@ export class SSHSession {
|
||||
this.keyboardInteractivePrompt.next(prompt)
|
||||
}
|
||||
|
||||
async handleAuth (methodsLeft?: string[] | null): Promise<any> {
|
||||
async handleAuth (methodsLeft?: string[] | null): Promise<russh.AuthenticatedSSHClient|null> {
|
||||
this.activePrivateKey = null
|
||||
|
||||
if (!(this.ssh instanceof russh.SSHClient)) {
|
||||
throw new Error('Wrong state for auth handling')
|
||||
}
|
||||
|
||||
if (!this.authUsername) {
|
||||
throw new Error('No username')
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const method = this.remainingAuthMethods.shift()
|
||||
if (!method) {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
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.profile.options.password) {
|
||||
this.emitServiceMessage(this.translate.instant('Using preset password'))
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.authUsername,
|
||||
password: this.profile.options.password,
|
||||
}
|
||||
if (method.type === 'saved-password') {
|
||||
this.emitServiceMessage(this.translate.instant('Using saved password'))
|
||||
const result = await this.ssh.authenticateWithPassword(this.authUsername, method.password)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
|
||||
if (!this.keychainPasswordUsed && this.profile.options.user) {
|
||||
const password = await this.passwordStorage.loadPassword(this.profile)
|
||||
if (password) {
|
||||
this.emitServiceMessage(this.translate.instant('Trying saved password'))
|
||||
this.keychainPasswordUsed = true
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.authUsername,
|
||||
password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (method.type === 'prompt-password') {
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
modal.componentInstance.prompt = `Password for ${this.authUsername}@${this.profile.options.host}`
|
||||
modal.componentInstance.password = true
|
||||
modal.componentInstance.showRememberCheckbox = true
|
||||
|
||||
try {
|
||||
const result = await modal.result.catch(() => null)
|
||||
if (result) {
|
||||
if (result.remember) {
|
||||
this.savedPassword = result.value
|
||||
const promptResult = await modal.result.catch(() => null)
|
||||
if (promptResult) {
|
||||
if (promptResult.remember) {
|
||||
this.savedPassword = promptResult.value
|
||||
}
|
||||
return {
|
||||
type: 'password',
|
||||
username: this.authUsername,
|
||||
password: result.value,
|
||||
const result = await this.ssh.authenticateWithPassword(this.authUsername, promptResult.value)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
@@ -509,50 +520,104 @@ export class SSHSession {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (method.type === 'publickey' && method.contents) {
|
||||
if (method.type === 'publickey') {
|
||||
try {
|
||||
const key = await this.loadPrivateKey(method.name!, method.contents)
|
||||
return {
|
||||
type: 'publickey',
|
||||
username: this.authUsername,
|
||||
key,
|
||||
const key = await this.loadPrivateKey(method.name, method.contents)
|
||||
const result = await this.ssh.authenticateWithKeyPair(this.authUsername, key)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
} catch (e) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return method.type
|
||||
if (method.type === 'keyboard-interactive') {
|
||||
let state: russh.AuthenticatedSSHClient|russh.KeyboardInteractiveAuthenticationState = await this.ssh.startKeyboardInteractiveAuthentication(this.authUsername)
|
||||
|
||||
while (true) {
|
||||
if (state.state === 'failure') {
|
||||
break
|
||||
}
|
||||
|
||||
const prompts = state.prompts()
|
||||
|
||||
let responses: string[] = []
|
||||
// OpenSSH can send a k-i request without prompts
|
||||
// just respond ok to it
|
||||
if (prompts.length > 0) {
|
||||
const prompt = new KeyboardInteractivePrompt(
|
||||
state.name,
|
||||
state.instructions,
|
||||
state.prompts(),
|
||||
)
|
||||
|
||||
if (method.savedPassword) {
|
||||
// eslint-disable-next-line max-depth
|
||||
for (let i = 0; i < prompt.prompts.length; i++) {
|
||||
// eslint-disable-next-line max-depth
|
||||
if (prompt.isAPasswordPrompt(i)) {
|
||||
prompt.responses[i] = method.savedPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emitKeyboardInteractivePrompt(prompt)
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
responses = await prompt.promise
|
||||
} catch {
|
||||
break // this loop
|
||||
}
|
||||
}
|
||||
|
||||
state = await this.ssh .continueKeyboardInteractiveAuthentication(responses)
|
||||
|
||||
if (state instanceof russh.AuthenticatedSSHClient) {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method.type === 'agent') {
|
||||
try {
|
||||
const result = await this.ssh.authenticateWithAgent(this.authUsername, method)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
} catch (e) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to authenticate using agent: ${e}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async addPortForward (fw: ForwardedPort): Promise<void> {
|
||||
if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) {
|
||||
await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => {
|
||||
await fw.startLocalListener(async (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}`)
|
||||
reject()
|
||||
return
|
||||
}
|
||||
const socket = accept()
|
||||
stream.pipe(socket)
|
||||
socket.pipe(stream)
|
||||
stream.on('close', () => {
|
||||
socket.destroy()
|
||||
})
|
||||
socket.on('close', () => {
|
||||
stream.close()
|
||||
})
|
||||
},
|
||||
)
|
||||
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
this.logger.error(`Connection while unauthenticated on ${fw}`)
|
||||
reject()
|
||||
return
|
||||
}
|
||||
const channel = await this.ssh.openTCPForwardChannel({
|
||||
addressToConnectTo: targetAddress,
|
||||
portToConnectTo: targetPort,
|
||||
originatorAddress: sourceAddress ?? '127.0.0.1',
|
||||
originatorPort: sourcePort ?? 0,
|
||||
}).catch(err => {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`)
|
||||
reject()
|
||||
throw err
|
||||
})
|
||||
const socket = accept()
|
||||
channel.data$.subscribe(data => socket.write(data))
|
||||
socket.on('data', data => channel.write(Uint8Array.from(data)))
|
||||
channel.closed$.subscribe(() => socket.destroy())
|
||||
socket.on('close', () => channel.close())
|
||||
}).then(() => {
|
||||
this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`)
|
||||
this.forwardedPorts.push(fw)
|
||||
@@ -562,17 +627,16 @@ export class SSHSession {
|
||||
})
|
||||
}
|
||||
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}`)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
throw new Error('Cannot add remote port forward before auth')
|
||||
}
|
||||
try {
|
||||
await this.ssh.forwardTCPPort(fw.host, fw.port)
|
||||
} catch (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
|
||||
}
|
||||
this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`)
|
||||
this.forwardedPorts.push(fw)
|
||||
}
|
||||
@@ -584,7 +648,10 @@ export class SSHSession {
|
||||
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
|
||||
}
|
||||
if (fw.type === PortForwardType.Remote) {
|
||||
this.ssh.unforwardIn(fw.host, fw.port)
|
||||
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
throw new Error('Cannot remove remote port forward before auth')
|
||||
}
|
||||
this.ssh.stopForwardingTCPPort(fw.host, fw.port)
|
||||
this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw)
|
||||
}
|
||||
this.emitServiceMessage(`Stopped forwarding ${fw}`)
|
||||
@@ -595,43 +662,55 @@ export class SSHSession {
|
||||
this.willDestroy.next()
|
||||
this.willDestroy.complete()
|
||||
this.serviceMessage.complete()
|
||||
this.proxyCommandStream?.stop()
|
||||
this.ssh.end()
|
||||
this.ssh.disconnect()
|
||||
}
|
||||
|
||||
openShellChannel (options: { x11: boolean }): Promise<ClientChannel> {
|
||||
return new Promise<ClientChannel>((resolve, reject) => {
|
||||
this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(shell)
|
||||
}
|
||||
})
|
||||
async openShellChannel (options: { x11: boolean }): Promise<russh.Channel> {
|
||||
if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) {
|
||||
throw new Error('Cannot open shell channel before auth')
|
||||
}
|
||||
const ch = await this.ssh.openSessionChannel()
|
||||
await ch.requestPTY('xterm-256color', {
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
pixHeight: 0,
|
||||
pixWidth: 0,
|
||||
})
|
||||
if (options.x11) {
|
||||
await ch.requestX11Forwarding({
|
||||
singleConnection: false,
|
||||
authProtocol: 'MIT-MAGIC-COOKIE-1',
|
||||
authCookie: crypto.randomBytes(16).toString('hex'),
|
||||
screenNumber: 0,
|
||||
})
|
||||
}
|
||||
if (this.profile.options.agentForward) {
|
||||
await ch.requestAgentForwarding()
|
||||
}
|
||||
await ch.requestShell()
|
||||
return ch
|
||||
}
|
||||
|
||||
async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<string|null> {
|
||||
async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise<russh.KeyPair> {
|
||||
this.emitServiceMessage(`Loading private key: ${name}`)
|
||||
const parsedKey = await this.parsePrivateKey(privateKeyContents.toString())
|
||||
this.activePrivateKey = parsedKey.toString('openssh')
|
||||
this.activePrivateKey = await this.loadPrivateKeyWithPassphraseMaybe(privateKeyContents.toString())
|
||||
return this.activePrivateKey
|
||||
}
|
||||
|
||||
async parsePrivateKey (privateKey: string): Promise<any> {
|
||||
async loadPrivateKeyWithPassphraseMaybe (privateKey: string): Promise<russh.KeyPair> {
|
||||
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 })
|
||||
return await russh.KeyPair.parse(privateKey, passphrase ?? undefined)
|
||||
} catch (e) {
|
||||
if (!triedSavedPassphrase) {
|
||||
passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
|
||||
triedSavedPassphrase = true
|
||||
continue
|
||||
}
|
||||
if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
|
||||
if (e.toString() === 'Error: Keys(KeyIsEncrypted)' || e.toString() === 'Error: Keys(SshKey(Crypto))') {
|
||||
await this.passwordStorage.deletePrivateKeyPassword(keyHash)
|
||||
|
||||
const modal = this.ngbModal.open(PromptModalComponent)
|
||||
|
@@ -7,9 +7,4 @@ import config from '../webpack.plugin.config.mjs'
|
||||
export default () => config({
|
||||
name: 'ssh',
|
||||
dirname: __dirname,
|
||||
alias: {
|
||||
'cpu-features': false,
|
||||
'./crypto/build/Release/sshcrypto.node': false,
|
||||
'../build/Release/cpufeatures.node': false,
|
||||
},
|
||||
})
|
||||
|
@@ -9,33 +9,11 @@
|
||||
dependencies:
|
||||
ipv6 "*"
|
||||
|
||||
"@types/node@*":
|
||||
version "22.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.1.0.tgz#6d6adc648b5e03f0e83c78dc788c2b037d0ad94b"
|
||||
integrity sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==
|
||||
dependencies:
|
||||
undici-types "~6.13.0"
|
||||
|
||||
"@types/node@20.3.1":
|
||||
version "20.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.1.tgz#e8a83f1aa8b649377bb1fb5d7bac5cb90e784dfe"
|
||||
integrity sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==
|
||||
|
||||
"@types/ssh2-streams@*":
|
||||
version "0.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz#e68795ba2bf01c76b93f9c9809e1f42f0eaaec5f"
|
||||
integrity sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ssh2@^0.5.46":
|
||||
version "0.5.52"
|
||||
resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741"
|
||||
integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/ssh2-streams" "*"
|
||||
|
||||
ansi-colors@^4.1.1:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
|
||||
@@ -46,40 +24,16 @@ ansi-regex@^6.0.1:
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
asn1@~0.2.3:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
|
||||
integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
|
||||
dependencies:
|
||||
safer-buffer "~2.1.0"
|
||||
|
||||
assert-plus@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
|
||||
integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
|
||||
|
||||
async@0.2.x:
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
|
||||
integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
|
||||
integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
||||
|
||||
bcrypt-pbkdf@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
|
||||
integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
|
||||
dependencies:
|
||||
tweetnacl "^0.14.3"
|
||||
|
||||
bn.js@^4.0.0, bn.js@^4.1.0:
|
||||
version "4.12.0"
|
||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
@@ -88,22 +42,17 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brorand@^1.0.1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
|
||||
integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
|
||||
|
||||
cli@0.4.x:
|
||||
version "0.4.5"
|
||||
resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.5.tgz#78f9485cd161b566e9a6c72d7170c4270e81db61"
|
||||
integrity sha1-ePlIXNFhtWbppsctcXDEJw6B22E=
|
||||
integrity sha512-dbn5HyeJWSOU58RwOEiF1VWrl7HRvDsKLpu0uiI/vExH6iNoyUzjB5Mr3IJY5DVUfnbpe9793xw4DFJVzC9nWQ==
|
||||
dependencies:
|
||||
glob ">= 3.1.4"
|
||||
|
||||
cliff@0.1.x:
|
||||
version "0.1.10"
|
||||
resolved "https://registry.yarnpkg.com/cliff/-/cliff-0.1.10.tgz#53be33ea9f59bec85609ee300ac4207603e52013"
|
||||
integrity sha1-U74z6p9ZvshWCe4wCsQgdgPlIBM=
|
||||
integrity sha512-roZWcC2Cxo/kKjRXw7YUpVNtxJccbvcl7VzTjUYgLQk6Ot0R8bm2netbhSZYWWNrKlOO/7HD6GXHl8dtzE6SiQ==
|
||||
dependencies:
|
||||
colors "~1.0.3"
|
||||
eyes "~0.1.8"
|
||||
@@ -112,12 +61,12 @@ cliff@0.1.x:
|
||||
colors@0.6.x:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc"
|
||||
integrity sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w=
|
||||
integrity sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw==
|
||||
|
||||
colors@~1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
|
||||
integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
|
||||
integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
@@ -127,62 +76,19 @@ concat-map@0.0.1:
|
||||
cycle@1.0.x:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2"
|
||||
integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI=
|
||||
|
||||
dashdash@^1.12.0:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
|
||||
integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
diffie-hellman@^5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
|
||||
integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==
|
||||
dependencies:
|
||||
bn.js "^4.1.0"
|
||||
miller-rabin "^4.0.0"
|
||||
randombytes "^2.0.0"
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
|
||||
dependencies:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
integrity sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==
|
||||
|
||||
eyes@0.1.x, eyes@~0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0"
|
||||
integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A=
|
||||
integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
||||
|
||||
getpass@^0.1.1:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
|
||||
integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
|
||||
dependencies:
|
||||
assert-plus "^1.0.0"
|
||||
|
||||
"glob@>= 3.1.4":
|
||||
version "7.1.6"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
|
||||
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.0.4"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@^7.1.3:
|
||||
glob@7.2.3, "glob@>= 3.1.4", glob@^7.1.3:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
|
||||
@@ -210,7 +116,7 @@ inherits@2:
|
||||
ipv6@*:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ipv6/-/ipv6-3.1.3.tgz#4d9064f9c2dafa0dd10b8b7d76ffca4aad31b3b9"
|
||||
integrity sha1-TZBk+cLa+g3RC4t9dv/KSq0xs7k=
|
||||
integrity sha512-TmLbUIURMAZ161GZDddTtAAb3aceRNLn7PRmP8fANp8xDRCW9oIQva8eenA48bRvw347jBqSREXMI38DybbUiQ==
|
||||
dependencies:
|
||||
cli "0.4.x"
|
||||
cliff "0.1.x"
|
||||
@@ -219,27 +125,7 @@ ipv6@*:
|
||||
isstream@0.1.x:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
|
||||
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
|
||||
|
||||
jsbn@~0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||
integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
|
||||
|
||||
miller-rabin@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
|
||||
integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==
|
||||
dependencies:
|
||||
bn.js "^4.0.0"
|
||||
brorand "^1.0.1"
|
||||
|
||||
minimatch@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
|
||||
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
|
||||
|
||||
minimatch@^3.1.1:
|
||||
version "3.1.2"
|
||||
@@ -263,14 +149,7 @@ path-is-absolute@^1.0.0:
|
||||
pkginfo@0.3.x:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21"
|
||||
integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE=
|
||||
|
||||
randombytes@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.1.0"
|
||||
integrity sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==
|
||||
|
||||
rimraf@^3.0.0:
|
||||
version "3.0.2"
|
||||
@@ -284,39 +163,15 @@ run-script-os@^1.1.3:
|
||||
resolved "https://registry.yarnpkg.com/run-script-os/-/run-script-os-1.1.6.tgz#8b0177fb1b54c99a670f95c7fdc54f18b9c72347"
|
||||
integrity sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw==
|
||||
|
||||
safe-buffer@^5.1.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
sprintf@0.1.x:
|
||||
version "0.1.5"
|
||||
resolved "https://registry.yarnpkg.com/sprintf/-/sprintf-0.1.5.tgz#8f83e39a9317c1a502cb7db8050e51c679f6edcf"
|
||||
integrity sha1-j4PjmpMXwaUCy324BQ5Rxnn27c8=
|
||||
|
||||
sshpk@Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136:
|
||||
version "1.18.0"
|
||||
resolved "https://codeload.github.com/Eugeny/node-sshpk/tar.gz/c2b71d1243714d2daf0988f84c3323d180817136"
|
||||
dependencies:
|
||||
asn1 "~0.2.3"
|
||||
assert-plus "^1.0.0"
|
||||
bcrypt-pbkdf "^1.0.0"
|
||||
dashdash "^1.12.0"
|
||||
ecc-jsbn "~0.1.1"
|
||||
getpass "^0.1.1"
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.0.2"
|
||||
tweetnacl "~0.14.0"
|
||||
integrity sha512-4X5KsuXFQ7f+d7Y+bi4qSb6eI+YoifDTGr0MQJXRoYO7BO7evfRCjds6kk3z7l5CiJYxgDN1x5Er4WiyCt+zTQ==
|
||||
|
||||
stack-trace@0.0.x:
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
|
||||
integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
|
||||
integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==
|
||||
|
||||
strip-ansi@^7.0.0:
|
||||
version "7.1.0"
|
||||
@@ -339,20 +194,10 @@ tmp@^0.2.0:
|
||||
dependencies:
|
||||
rimraf "^3.0.0"
|
||||
|
||||
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
|
||||
version "0.14.5"
|
||||
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
|
||||
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
|
||||
|
||||
undici-types@~6.13.0:
|
||||
version "6.13.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.13.0.tgz#e3e79220ab8c81ed1496b5812471afd7cf075ea5"
|
||||
integrity sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==
|
||||
|
||||
winston@0.8.x:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/winston/-/winston-0.8.3.tgz#64b6abf4cd01adcaefd5009393b1d8e8bec19db0"
|
||||
integrity sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA=
|
||||
integrity sha512-fPoamsHq8leJ62D1M9V/f15mjQ1UHe4+7j1wpAT3fqgA5JqhJkk4aIfPEjfMTI9x6ZTjaLOpMAjluLtmgO5b6g==
|
||||
dependencies:
|
||||
async "0.2.x"
|
||||
colors "0.6.x"
|
||||
|
Reference in New Issue
Block a user