Compare commits

..

18 Commits

Author SHA1 Message Date
Eugene Pankov
f7b603a631 fixed backtick key detection - fixes #3742 2021-05-02 16:03:36 +02:00
Eugene Pankov
44040ba54b permanent port forwards - fixes #3479, fixes #2395 2021-05-02 15:08:22 +02:00
Eugene Pankov
f87efcf5bd an option to remember private key passphrases - fixes #3689 2021-05-02 13:11:15 +02:00
Eugene Pankov
220ae6ccaa Merge branch 'master' of github.com:Eugeny/terminus 2021-04-25 21:06:26 +02:00
Eugene Pankov
3c6374be19 better ssh connection list management - fixes #1351 2021-04-25 21:06:23 +02:00
Eugene Pankov
d32e31d45e handle invalid pty ids in ipc 2021-04-25 20:12:49 +02:00
Eugene
d7a33dc0ce Merge pull request #3736 from Eugeny/imgbot
[ImgBot] Optimize images
2021-04-24 20:05:55 +02:00
ImgBotApp
bc736dd13a [ImgBot] Optimize images
/build/icons/Icon-MacOS-512x512@2x.png -- 188.59kb -> 165.81kb (12.08%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2021-04-24 17:58:57 +00:00
Eugene Pankov
7cf8f8d58e ssh - added proxy command support - fixes #3722 2021-04-24 19:57:05 +02:00
Eugene Pankov
ff0cd36b6a new macOS icon - fixes #3721 2021-04-24 19:56:48 +02:00
Eugene Pankov
767bc8e56f bumped webgl context limit (fixes #3729) 2021-04-24 11:05:37 +02:00
Eugene Pankov
8801839c7a fixed misaligned tab content - fixes #3715 2021-04-24 10:57:29 +02:00
Eugene Pankov
7ab3238617 build fix 2021-04-16 21:18:23 +02:00
Eugene Pankov
6d89d7a8d0 lint 2021-04-16 20:54:21 +02:00
Eugene Pankov
e277c52f71 handle empty env vars - fixed #3217 2021-04-16 20:49:57 +02:00
Eugene Pankov
2cafd97751 fixed Windows font name autocomplete - fixed #3686 2021-04-16 20:43:23 +02:00
Eugene Pankov
ad78f38210 sorted plugins list 2021-04-16 20:33:11 +02:00
Eugene Pankov
4f32908c48 node-pty and flow control fixes - fixed #3695, fixed #3701, fixed #3696, fixed #3690 2021-04-16 20:15:39 +02:00
36 changed files with 639 additions and 362 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ sentry.properties
sentry-symbols.js
terminus-ssh/util/pagent.exe
*.psd

View File

@@ -47,6 +47,7 @@ export class Application {
}
app.commandLine.appendSwitch('disable-http-cache')
app.commandLine.appendSwitch('max-active-webgl-contexts', '9000')
app.commandLine.appendSwitch('lang', 'EN')
app.allowRendererProcessReuse = false

View File

@@ -1,4 +1,4 @@
import * as nodePTY from 'node-pty'
import * as nodePTY from '@terminus-term/node-pty'
import { v4 as uuidv4 } from 'uuid'
import { ipcMain } from 'electron'
import { Application } from './app'
@@ -39,12 +39,22 @@ class PTYDataQueue {
totalLength += this.buffers[0].length
buffersToSend.push(this.buffers.shift())
}
if (buffersToSend.length === 0) {
return
}
let toSend = Buffer.concat(buffersToSend)
this.buffers.unshift(toSend.slice(this.maxChunk))
toSend = toSend.slice(0, this.maxChunk)
if (toSend.length > this.maxChunk) {
this.buffers.unshift(toSend.slice(this.maxChunk))
toSend = toSend.slice(0, this.maxChunk)
}
this.onData(toSend)
this.delta += toSend.length
this.buffers = []
if (this.buffers.length) {
setImmediate(() => this.maybeEmit())
}
}
}
@@ -107,7 +117,7 @@ export class PTY {
}
export class PTYManager {
private ptys: Record<string, PTY> = {}
private ptys: Record<string, PTY|undefined> = {}
init (app: Application): void {
//require('./bufferizedPTY')(nodePTY) // eslint-disable-line @typescript-eslint/no-var-requires
@@ -122,23 +132,23 @@ export class PTYManager {
})
ipcMain.on('pty:get-pid', (event, id) => {
event.returnValue = this.ptys[id].getPID()
event.returnValue = this.ptys[id]?.getPID()
})
ipcMain.on('pty:resize', (_event, id, columns, rows) => {
this.ptys[id].resize(columns, rows)
this.ptys[id]?.resize(columns, rows)
})
ipcMain.on('pty:write', (_event, id, data) => {
this.ptys[id].write(Buffer.from(data))
this.ptys[id]?.write(Buffer.from(data))
})
ipcMain.on('pty:kill', (_event, id, signal) => {
this.ptys[id].kill(signal)
this.ptys[id]?.kill(signal)
})
ipcMain.on('pty:ack-data', (_event, id, length) => {
this.ptys[id].ackData(length)
this.ptys[id]?.ackData(length)
})
}
}

View File

@@ -34,7 +34,7 @@
"mz": "^2.7.0",
"native-process-working-directory": "^1.0.2",
"ngx-toastr": "^13.2.1",
"node-pty": "^0.10.0",
"@terminus-term/node-pty": "0.10.0-terminus.3",
"npm": "6",
"path": "0.12.7",
"rxjs": "^6.6.6",

View File

@@ -17,6 +17,10 @@ body {
}
.btn {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
& > svg {
pointer-events: none;
}
@@ -104,3 +108,14 @@ ngb-typeahead-window {
max-height: 60vh;
overflow: auto;
}
.hover-reveal {
opacity: 0;
.hover-reveal-parent:hover &,
*:hover > &,
&:hover {
opacity: 1;
}
}

View File

@@ -44,7 +44,7 @@ module.exports = {
glasstron: 'commonjs glasstron',
mz: 'commonjs mz',
npm: 'commonjs npm',
'node-pty': 'commonjs node-pty',
'@terminus-term/node-pty': 'commonjs @terminus-term/node-pty',
path: 'commonjs path',
rxjs: 'commonjs rxjs',
'rxjs/operators': 'commonjs rxjs/operators',

View File

@@ -143,6 +143,13 @@
dependencies:
debug "^4.3.1"
"@terminus-term/node-pty@0.10.0-terminus.3":
version "0.10.0-terminus.3"
resolved "https://registry.yarnpkg.com/@terminus-term/node-pty/-/node-pty-0.10.0-terminus.3.tgz#9dbd64d52afda5079e66265a89d313fe42affab7"
integrity sha512-HvIOts22dnoBXhRfLiK9DyPasuixYVgEUvgqZmOr0B0Ki9tF8e074oYPUtzLRll6Y553QiUzTWhriCS99MChNQ==
dependencies:
nan "^2.14.0"
"@types/mz@2.7.3":
version "2.7.3"
resolved "https://registry.yarnpkg.com/@types/mz/-/mz-2.7.3.tgz#e42a21e73f5f9340fe4a176981fafb1eb8cc6c12"
@@ -250,12 +257,7 @@ ansistyles@~0.1.3:
resolved "https://registry.yarnpkg.com/ansistyles/-/ansistyles-0.1.3.tgz"
integrity sha1-XeYEFb2gcbs3EnhUyGT0GyMlRTk=
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz"
integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
any-promise@^1.3.0:
any-promise@^1.0.0, any-promise@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
@@ -2146,13 +2148,6 @@ node-gyp@^5.0.2, node-gyp@^5.1.0:
tar "^4.4.12"
which "^1.3.1"
node-pty@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.10.0.tgz#c98d23967b076b35c9fb216c542a04d0b5db4821"
integrity sha512-Q65ookKbjhqWUYKmtZ6iPn0nnqNdzpm3YJOBmzwWJde/TrenBxK9FgqGGtSW0Wjz4YsR1grQF4a7RS5nBwuW9A==
dependencies:
nan "^2.14.0"
noop-logger@^0.1.1:
version "0.1.1"
resolved "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz"

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

View File

@@ -18,7 +18,7 @@
"core-js": "^3.9.1",
"cross-env": "7.0.3",
"css-loader": "5.2.0",
"electron": "12.0.2",
"electron": "12.0.4",
"electron-builder": "22.10.5",
"electron-download": "^4.1.1",
"electron-installer-snap": "^5.1.0",

View File

@@ -9,7 +9,7 @@ sh.exec(`${sentryCli} releases new ${vars.version}`)
if (process.platform === 'darwin') {
for (const path of [
'app/node_modules/@serialport/bindings/build/Release/bindings.node',
'app/node_modules/node-pty/build/Release/pty.node',
'app/node_modules/@terminus-term/node-pty/build/Release/pty.node',
'app/node_modules/fontmanager-redux/build/Release/fontmanager.node',
'app/node_modules/macos-native-processlist/build/Release/native.node',
]) {

View File

@@ -146,7 +146,7 @@ $side-tab-width: 200px;
width: 100%;
height: 100%;
left: 100%;
left: -100%;
&.content-tab-active {
left: 0;

View File

@@ -17,3 +17,8 @@
.title {
margin-left: 10px;
}
input {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}

View File

@@ -51,8 +51,8 @@ export function stringifyKeySequence (events: KeyboardEvent[]): string[] {
Period: '.',
Slash: '/',
Backslash: '\\',
IntlBackslash: '\\',
Backquote: '`',
IntlBackslash: '`',
Backquote: '~', // Electron says it's the tilde
Minus: '-',
Equal: '=',
Semicolon: ';',

View File

@@ -239,10 +239,6 @@ hotkey-input-modal {
border: none;
border-top: 1px solid rgba(255, 255, 255, .1);
&:not(.combi) {
padding: $list-group-item-padding-y $list-group-item-padding-x;
}
&:first-child {
border-top: none;
}

View File

@@ -46,7 +46,6 @@ $body-color: #ccc;
$body-bg: #131d27;
$body-bg2: #20333e;
$font-family-sans-serif: "Source Sans Pro";
$font-family-monospace: "Source Code Pro";
$font-size-base: 14rem / 16;
@@ -55,6 +54,12 @@ $font-size-sm: .85rem;
$line-height-base: 1.6;
$border-radius: .4rem;
$border-radius-lg: .6rem;
$border-radius-sm: .2rem;
// -----
$headings-color: #ced9e2;
$headings-font-weight: lighter;

View File

@@ -57,6 +57,7 @@ export class PluginManagerService {
}))),
map(plugins => plugins.filter(x => x.packageName.startsWith(NAME_PREFIX))),
map(plugins => plugins.filter(x => !BLACKLIST.includes(x.packageName))),
map(plugins => plugins.sort((a, b) => a.name.localeCompare(b.name))),
)
}

View File

@@ -17,6 +17,7 @@
> .nav {
padding: 20px 10px;
width: 190px;
flex: none;
overflow-y: auto;
}

View File

@@ -6,6 +6,7 @@ import { Server, Socket, createServer, createConnection } from 'net'
import { Client, ClientChannel } from 'ssh2'
import { Logger } from 'terminus-core'
import { Subject, Observable } from 'rxjs'
import { ProxyCommandStream } from './services/ssh.service'
export interface LoginScript {
expect: string
@@ -42,6 +43,8 @@ export interface SSHConnection {
agentForward?: boolean
warnOnClose?: boolean
algorithms?: Record<string, string[]>
proxyCommand?: string
forwardedPorts?: ForwardedPortConfig[]
}
export enum PortForwardType {
@@ -50,7 +53,15 @@ export enum PortForwardType {
Dynamic = 'Dynamic',
}
export class ForwardedPort {
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
@@ -117,6 +128,7 @@ export class SSHSession extends BaseSession {
forwardedPorts: ForwardedPort[] = []
logger: Logger
jumpStream: any
proxyCommandStream: ProxyCommandStream|null = null
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
private serviceMessage = new Subject<string>()
@@ -136,6 +148,11 @@ export class SSHSession extends BaseSession {
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) {
@@ -361,6 +378,7 @@ export class SSHSession extends BaseSession {
async destroy (): Promise<void> {
this.serviceMessage.complete()
this.proxyCommandStream?.destroy()
await super.destroy()
}
@@ -407,11 +425,6 @@ export class SSHSession extends BaseSession {
}
}
export interface SSHConnectionGroup {
name: string
connections: SSHConnection[]
}
export const ALGORITHM_BLACKLIST = [
// cause native crashes in node crypto, use EC instead
'diffie-hellman-group-exchange-sha256',

View File

@@ -17,10 +17,11 @@
type='text',
placeholder='Ungrouped',
[(ngModel)]='connection.group',
[ngbTypeahead]='groupTypeahead',
)
.d-flex
.form-group
.d-flex.w-100(*ngIf='!useProxyCommand')
.form-group.w-100.mr-4
label Host
input.form-control(
type='text',
@@ -35,6 +36,9 @@
[(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(
@@ -42,9 +46,9 @@
[(ngModel)]='connection.user',
)
.form-line
.header
.title Authentication
.form-group
label Authentication method
.btn-group.w-100(
[(ngModel)]='connection.auth',
ngbRadioGroup
@@ -96,10 +100,19 @@
button.btn.btn-secondary((click)='selectPrivateKey()')
i.fas.fa-folder-open
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
.form-line(*ngIf='!useProxyCommand')
.header
.title Jump host
select.form-control([(ngModel)]='connection.jumpHost')
@@ -165,6 +178,19 @@
[(ngModel)]='connection.readyTimeout',
)
.form-line(*ngIf='!connection.jumpHost')
.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)

View File

@@ -1,9 +1,12 @@
/* 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 { ElectronService, HostAppService, ConfigService } from 'terminus-core'
import { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHConnection, LoginScript, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
import { SSHConnection, LoginScript, ForwardedPortConfig, SSHAlgorithmType, ALGORITHM_BLACKLIST } from '../api'
import { PromptModalComponent } from './promptModal.component'
import { ALGORITHMS } from 'ssh2-streams/lib/constants'
@@ -14,11 +17,14 @@ import { ALGORITHMS } from 'ssh2-streams/lib/constants'
export class EditConnectionModalComponent {
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,
private modalInstance: NgbActiveModal,
@@ -43,14 +49,26 @@ export class EditConnectionModalComponent {
this.supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x))
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.hasSavedPassword = !!await this.passwordStorage.loadPassword(this.connection)
this.connection.algorithms = this.connection.algorithms ?? {}
this.connection.scripts = this.connection.scripts ?? []
this.connection.auth = this.connection.auth ?? null
this.useProxyCommand = !!this.connection.proxyCommand
for (const k of Object.values(SSHAlgorithmType)) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!this.connection.algorithms[k]) {
@@ -102,6 +120,9 @@ export class EditConnectionModalComponent {
.filter(([_, v]) => !!v)
.map(([key, _]) => key)
}
if (!this.useProxyCommand) {
this.connection.proxyCommand = undefined
}
this.modalInstance.close(this.connection)
}
@@ -152,4 +173,13 @@ export class EditConnectionModalComponent {
}
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)
}
}

View File

@@ -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 &rarr;
.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 &rarr;
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

View File

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

View File

@@ -2,64 +2,8 @@
h5.m-0 Port forwarding
.modal-body.pt-0
.list-group-light.mb-3
.list-group-item.d-flex.align-items-center(*ngFor='let fw of session.forwardedPorts')
strong(*ngIf='fw.type === PortForwardType.Local') Local
strong(*ngIf='fw.type === PortForwardType.Remote') Remote
strong(*ngIf='fw.type === PortForwardType.Dynamic') Dynamic
.ml-3 {{fw.host}}:{{fw.port}}
.ml-2 &rarr;
.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 &rarr;
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
ssh-port-forwarding-config(
[model]='session.forwardedPorts',
(forwardAdded)='onForwardAdded($event)',
(forwardRemoved)='onForwardRemoved($event)'
)

View File

@@ -1,43 +1,21 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component, Input } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ForwardedPort, PortForwardType, SSHSession } from '../api'
import { ForwardedPort, ForwardedPortConfig, SSHSession } from '../api'
/** @hidden */
@Component({
template: require('./sshPortForwardingModal.component.pug'),
// styles: [require('./sshPortForwardingModal.component.scss')],
})
export class SSHPortForwardingModalComponent {
@Input() session: SSHSession
newForward = new ForwardedPort()
PortForwardType = PortForwardType
constructor (
public modalInstance: NgbActiveModal,
) {
this.reset()
onForwardAdded (fw: ForwardedPortConfig) {
const newForward = new ForwardedPort()
Object.assign(newForward, fw)
this.session.addPortForward(newForward)
}
reset () {
this.newForward = new ForwardedPort()
this.newForward.type = PortForwardType.Local
this.newForward.host = '127.0.0.1'
this.newForward.port = 8000
this.newForward.targetAddress = '127.0.0.1'
this.newForward.targetPort = 80
}
async addForward () {
try {
await this.session.addPortForward(this.newForward)
this.reset()
} catch (e) {
console.error(e)
}
}
remove (fw: ForwardedPort) {
this.session.removePortForward(fw)
onForwardRemoved (fw: ForwardedPortConfig) {
this.session.removePortForward(fw as ForwardedPort)
}
}

View File

@@ -1,33 +1,55 @@
h3 Connections
.d-flex.align-items-center.mb-3
h3.m-0 SSH Connections
.list-group.list-group-flush.mt-3.mb-3
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')
.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-outline-info.ml-2((click)='editGroup(group)')
i.fas.fa-edit
button.btn.btn-outline-danger.ml-1((click)='deleteGroup(group)')
i.fas.fa-trash
ng-container(*ngIf='!groupCollapsed[group.name]')
.list-group-item.list-group-item-action.pl-5.d-flex.align-items-center(
*ngFor='let connection of group.connections',
(click)='editConnection(connection)'
ng-container(*ngIf='isGroupVisible(group)')
.list-group-item.list-group-item-action.d-flex.align-items-center(
(click)='groupCollapsed[group.name] = !groupCollapsed[group.name]'
)
.mr-auto
div {{connection.name}}
.text-muted {{connection.host}}
button.btn.btn-outline-info.ml-1((click)='$event.stopPropagation(); copyConnection(connection)')
i.fas.fa-copy
button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteConnection(connection)')
.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
button.btn.btn-primary((click)='createConnection()')
i.fas.fa-fw.fa-plus
span.ml-2 Add connection
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

View File

@@ -0,0 +1,3 @@
.list-group-item {
padding: 0.3rem 1rem;
}

View File

@@ -4,18 +4,25 @@ import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, ElectronService, HostAppService } from 'terminus-core'
import { PasswordStorageService } from '../services/passwordStorage.service'
import { SSHConnection, SSHConnectionGroup } from '../api'
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 = ''
constructor (
public config: ConfigService,
@@ -133,7 +140,7 @@ export class SSHSettingsTabComponent {
let group = this.childGroups.find(x => x.name === connection.group)
if (!group) {
group = {
name: connection.group!,
name: connection.group,
connections: [],
}
this.childGroups.push(group)
@@ -141,4 +148,12 @@ export class SSHSettingsTabComponent {
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())
}
}

View File

@@ -25,6 +25,13 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
private homeEndSubscription: Subscription
private recentInputs = ''
private reconnectOffered = false
private spinner = new Spinner({
text: 'Connecting',
stream: {
write: x => this.write(x),
},
})
private spinnerActive = false
constructor (
injector: Injector,
@@ -113,32 +120,22 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
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$.subscribe(msg => {
this.write(`\r\n${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
session.resize(this.size.columns, this.size.rows)
this.pauseSpinner(() => {
this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
session.resize(this.size.columns, this.size.rows)
})
}))
this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
const spinner = new Spinner({
text: 'Connecting',
stream: {
write: x => this.write(x),
},
})
spinner.setSpinnerString(6)
spinner.start()
try {
await this.ssh.connectSession(session, (message: string) => {
spinner.stop(true)
this.write(message + '\r\n')
spinner.start()
})
spinner.stop(true)
await this.ssh.connectSession(session)
this.stopSpinner()
} catch (e) {
spinner.stop(true)
this.stopSpinner()
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
return
}
@@ -156,7 +153,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
this.destroy()
} else {
// Session was closed abruptly
this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
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')
@@ -232,4 +229,24 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
this.homeEndSubscription.unsubscribe()
super.ngOnDestroy()
}
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()
}
}
}

View File

@@ -9,6 +9,7 @@ import TerminusTerminalModule from 'terminus-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'
@@ -49,6 +50,7 @@ import { WinSCPContextMenu } from './winSCPIntegration'
EditConnectionModalComponent,
PromptModalComponent,
SSHPortForwardingModalComponent,
SSHPortForwardingConfigComponent,
SSHSettingsTabComponent,
SSHTabComponent,
],

View File

@@ -5,26 +5,44 @@ import * as keytar from 'keytar'
@Injectable({ providedIn: 'root' })
export class PasswordStorageService {
async savePassword (connection: SSHConnection, password: string): Promise<void> {
let key = `ssh@${connection.host}`
if (connection.port) {
key = `ssh@${connection.host}:${connection.port}`
}
const key = this.getKeyForConnection(connection)
return keytar.setPassword(key, connection.user, password)
}
async deletePassword (connection: SSHConnection): Promise<void> {
let key = `ssh@${connection.host}`
if (connection.port) {
key = `ssh@${connection.host}:${connection.port}`
}
const key = this.getKeyForConnection(connection)
await keytar.deletePassword(key, connection.user)
}
async loadPassword (connection: SSHConnection): Promise<string|null> {
const key = this.getKeyForConnection(connection)
return keytar.getPassword(key, connection.user)
}
async savePrivateKeyPassword (id: string, password: string): Promise<void> {
const key = this.getKeyForPrivateKey(id)
return keytar.setPassword(key, 'user', password)
}
async deletePrivateKeyPassword (id: string): Promise<void> {
const key = this.getKeyForPrivateKey(id)
await keytar.deletePassword(key, 'user')
}
async loadPrivateKeyPassword (id: string): Promise<string|null> {
const key = this.getKeyForPrivateKey(id)
return keytar.getPassword(key, 'user')
}
private getKeyForConnection (connection: SSHConnection): string {
let key = `ssh@${connection.host}`
if (connection.port) {
key = `ssh@${connection.host}:${connection.port}`
}
return keytar.getPassword(key, connection.user)
return key
}
private getKeyForPrivateKey (id: string): string {
return `ssh-private-key:${id}`
}
}

View File

@@ -1,5 +1,6 @@
import colors from 'ansi-colors'
import stripAnsi from 'strip-ansi'
import { Duplex } from 'stream'
import * as crypto from 'crypto'
import { open as openTemp } from 'temp'
import { Injectable, NgZone } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -7,14 +8,17 @@ import { Client } from 'ssh2'
import { SSH2Stream } from 'ssh2-streams'
import * as fs from 'mz/fs'
import { execFile } from 'mz/child_process'
import { exec } from 'child_process'
import * as path from 'path'
import * as sshpk from 'sshpk'
import { Subject, Observable } from 'rxjs'
import { HostAppService, Platform, Logger, LogService, ElectronService, AppService, SelectorOption, ConfigService, NotificationsService } from 'terminus-core'
import { SettingsTabComponent } from 'terminus-settings'
import { ALGORITHM_BLACKLIST, SSHConnection, SSHSession } from '../api'
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'
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
@@ -51,51 +55,29 @@ export class SSHService {
return session
}
async loadPrivateKeyForSession (session: SSHSession, logCallback?: SSHLogCallback): Promise<string|null> {
async loadPrivateKeyForSession (session: SSHSession): Promise<string|null> {
let privateKey: string|null = null
let privateKeyPath = session.connection.privateKey
if (!privateKeyPath) {
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
if (await fs.exists(userKeyPath)) {
logCallback?.('Using user\'s default private key')
session.emitServiceMessage('Using user\'s default private key')
privateKeyPath = userKeyPath
}
}
if (privateKeyPath) {
logCallback?.('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
session.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
try {
privateKey = (await fs.readFile(privateKeyPath)).toString()
} catch (error) {
logCallback?.(colors.bgRed.black(' X ') + 'Could not read the private key file')
session.emitServiceMessage(colors.bgRed.black(' X ') + 'Could not read the private key file')
this.notifications.error('Could not read the private key file')
}
if (privateKey) {
let parsedKey: any = null
try {
parsedKey = sshpk.parsePrivateKey(privateKey, 'auto')
} catch (e) {
if (e instanceof sshpk.KeyEncryptedError) {
const modal = this.ngbModal.open(PromptModalComponent)
logCallback?.(colors.bgYellow.yellow.black(' ! ') + ' Key requires passphrase')
modal.componentInstance.prompt = 'Private key passphrase'
modal.componentInstance.password = true
let passphrase = ''
try {
const result = await modal.result
passphrase = result?.value
} catch { }
parsedKey = sshpk.parsePrivateKey(
privateKey,
'auto',
{ passphrase: passphrase }
)
} else {
throw e
}
}
const parsedKey = await this.parsePrivateKey(privateKey)
const sshFormatKey = parsedKey.toString('openssh')
const temp = await openTemp()
@@ -130,15 +112,40 @@ export class SSHService {
return privateKey
}
async connectSession (session: SSHSession, logCallback?: SSHLogCallback): Promise<void> {
if (!logCallback) {
logCallback = () => null
}
async parsePrivateKey (privateKey: string): Promise<any> {
const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex')
let passphrase: string|null = await this.passwordStorage.loadPrivateKeyPassword(keyHash)
while (true) {
try {
return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase })
} catch (e) {
this.notifications.error('Could not read the private key', e.toString())
if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) {
await this.passwordStorage.deletePrivateKeyPassword(keyHash)
const log = (s: any) => {
logCallback!(s)
this.logger.info(stripAnsi(s))
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 {
throw e
}
}
}
}
async connectSession (session: SSHSession): Promise<void> {
const log = (s: any) => session.emitServiceMessage(s)
let privateKey: string|null = null
@@ -150,12 +157,18 @@ export class SSHService {
for (const key of Object.keys(session.connection.algorithms ?? {})) {
algorithms[key] = session.connection.algorithms![key].filter(x => !ALGORITHM_BLACKLIST.includes(x))
}
await new Promise(async (resolve, reject) => {
const resultPromise: Promise<void> = new Promise(async (resolve, reject) => {
ssh.on('ready', () => {
connected = true
if (savedPassword) {
this.passwordStorage.savePassword(session.connection, savedPassword)
}
for (const fw of session.connection.forwardedPorts ?? []) {
session.addPortForward(Object.assign(new ForwardedPort(), fw))
}
this.zone.run(resolve)
})
ssh.on('error', error => {
@@ -207,126 +220,143 @@ export class SSHService {
log(banner)
}
})
let agent: string|null = null
if (this.hostApp.platform === Platform.Windows) {
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
agent = WINDOWS_OPENSSH_AGENT_PIPE
} else {
// eslint-disable-next-line @typescript-eslint/no-shadow
const pageantRunning = await new Promise<boolean>(resolve => {
windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var
resolve(list.some(x => x.name === 'pageant.exe'))
}, 0)
})
if (pageantRunning) {
agent = 'pageant'
}
}
} else {
agent = process.env.SSH_AUTH_SOCK!
}
const authMethodsLeft = ['none']
if (!session.connection.auth || session.connection.auth === 'publicKey') {
privateKey = await this.loadPrivateKeyForSession(session, log)
if (!privateKey) {
log('\r\nPrivate key auth selected, but no key is loaded\r\n')
} else {
authMethodsLeft.push('publickey')
}
}
if (!session.connection.auth || session.connection.auth === 'agent') {
if (!agent) {
log('\r\nAgent auth selected, but no running agent is detected\r\n')
} else {
authMethodsLeft.push('agent')
}
}
if (!session.connection.auth || session.connection.auth === 'password') {
authMethodsLeft.push('password')
}
if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') {
authMethodsLeft.push('keyboard-interactive')
}
authMethodsLeft.push('hostbased')
try {
ssh.connect({
host: session.connection.host,
port: session.connection.port ?? 22,
username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: privateKey ?? undefined,
tryKeyboard: true,
agent: agent ?? undefined,
agentForward: session.connection.agentForward && !!agent,
keepaliveInterval: session.connection.keepaliveInterval ?? 15000,
keepaliveCountMax: session.connection.keepaliveCountMax,
readyTimeout: session.connection.readyTimeout,
hostVerifier: (digest: string) => {
log(colors.bgWhite(' ') + ' Host key fingerprint:')
log(colors.bgWhite(' ') + ' ' + colors.black.bgWhite(' SHA256 ') + colors.bgBlackBright(' ' + digest + ' '))
return true
},
hostHash: 'sha256' as any,
algorithms,
sock: session.jumpStream,
authHandler: methodsLeft => {
while (true) {
const method = authMethodsLeft.shift()
if (!method) {
return false
}
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') {
// Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method)
continue
}
return method
}
},
} as any)
} catch (e) {
this.notifications.error(e.message)
return reject(e)
}
let keychainPasswordUsed = false
;(ssh as any).config.password = () => this.zone.run(async () => {
if (session.connection.password) {
log('Using preset password')
return session.connection.password
}
if (!keychainPasswordUsed) {
const password = await this.passwordStorage.loadPassword(session.connection)
if (password) {
log('Trying saved password')
keychainPasswordUsed = true
return password
}
}
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${session.connection.user}@${session.connection.host}`
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const result = await modal.result
if (result) {
if (result.remember) {
savedPassword = result.value
}
return result.value
}
return ''
} catch (_) {
return ''
}
})
})
let agent: string|null = null
if (this.hostApp.platform === Platform.Windows) {
if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) {
agent = WINDOWS_OPENSSH_AGENT_PIPE
} else {
// eslint-disable-next-line @typescript-eslint/no-shadow
const pageantRunning = await new Promise<boolean>(resolve => {
windowsProcessTreeNative.getProcessList(list => { // eslint-disable-line block-scoped-var
resolve(list.some(x => x.name === 'pageant.exe'))
}, 0)
})
if (pageantRunning) {
agent = 'pageant'
}
}
} else {
agent = process.env.SSH_AUTH_SOCK!
}
const authMethodsLeft = ['none']
if (!session.connection.auth || session.connection.auth === 'publicKey') {
try {
privateKey = await this.loadPrivateKeyForSession(session)
} catch (e) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`)
}
if (!privateKey) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`)
} else {
authMethodsLeft.push('publickey')
}
}
if (!session.connection.auth || session.connection.auth === 'agent') {
if (!agent) {
session.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
} else {
authMethodsLeft.push('agent')
}
}
if (!session.connection.auth || session.connection.auth === 'password') {
authMethodsLeft.push('password')
}
if (!session.connection.auth || session.connection.auth === 'keyboardInteractive') {
authMethodsLeft.push('keyboard-interactive')
}
authMethodsLeft.push('hostbased')
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,
port: session.connection.port ?? 22,
sock: session.proxyCommandStream ?? session.jumpStream,
username: session.connection.user,
password: session.connection.privateKey ? undefined : '',
privateKey: privateKey ?? undefined,
tryKeyboard: true,
agent: agent ?? undefined,
agentForward: session.connection.agentForward && !!agent,
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 => {
while (true) {
const method = authMethodsLeft.shift()
if (!method) {
return false
}
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') {
// Agent can still be used even if not in methodsLeft
this.logger.info('Server does not support auth method', method)
continue
}
return method
}
},
} as any)
} catch (e) {
this.notifications.error(e.message)
throw e
}
let keychainPasswordUsed = false
;(ssh as any).config.password = () => this.zone.run(async () => {
if (session.connection.password) {
log('Using preset password')
return session.connection.password
}
if (!keychainPasswordUsed) {
const password = await this.passwordStorage.loadPassword(session.connection)
if (password) {
log('Trying saved password')
keychainPasswordUsed = true
return password
}
}
const modal = this.ngbModal.open(PromptModalComponent)
modal.componentInstance.prompt = `Password for ${session.connection.user}@${session.connection.host}`
modal.componentInstance.password = true
modal.componentInstance.showRememberCheckbox = true
try {
const result = await modal.result
if (result) {
if (result.remember) {
savedPassword = result.value
}
return result.value
}
return ''
} catch {
return ''
}
})
return resultPromise
}
async showConnectionSelector (): Promise<void> {
@@ -448,6 +478,52 @@ export class SSHService {
}
}
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)
}
}
/* eslint-disable */
const _authPassword = SSH2Stream.prototype.authPassword
SSH2Stream.prototype.authPassword = async function (username, passwordFn: any) {

View File

@@ -114,7 +114,7 @@ h3.mb-3 Appearance
.header
.title Custom CSS
textarea.form-control(
textarea.form-control.mb-5(
[(ngModel)]='config.store.appearance.css',
(ngModelChange)='saveConfiguration()',
)

View File

@@ -23,11 +23,7 @@ export class AppearanceSettingsTabComponent {
async ngOnInit () {
if (this.hostApp.platform === Platform.Windows || this.hostApp.platform === Platform.macOS) {
const fonts = await new Promise<any[]>((resolve) => fontManager.findFonts({ monospace: true }, resolve))
if (this.hostApp.platform === Platform.Windows) {
this.fonts = fonts.map(x => `${x.family} ${x.style}`.trim())
} else {
this.fonts = fonts.map(x => x.family.trim())
}
this.fonts = fonts.map(x => x.family.trim())
this.fonts.sort()
}
if (this.hostApp.platform === Platform.Linux) {

View File

@@ -175,6 +175,8 @@ export class Session extends BaseSession {
...this.config.store.terminal.environment || {},
}
delete env['']
if (process.platform === 'darwin' && !process.env.LC_ALL) {
const locale = process.env.LC_CTYPE ?? 'en_US.UTF-8'
Object.assign(env, {

View File

@@ -2560,10 +2560,10 @@ electron-to-chromium@^1.3.621:
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.629.tgz"
integrity sha512-iSPPJtPvHrMAvYOt+9cdbDmTasPqwnwz4lkP8Dn200gDNUBQOLQ96xUsWXBwXslAo5XxdoXAoQQ3RAy4uao9IQ==
electron@12.0.2:
version "12.0.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-12.0.2.tgz#d92be205f1937627bd6718aad44ac161382b4c2d"
integrity sha512-14luh9mGzfL4e0sncyy0+kW37IU7Y0Y1tvI97FDRSW0ZBQxi5cmAwSs5dmPmNBFBIGtzkaGaEB01j9RjZuCmow==
electron@12.0.4:
version "12.0.4"
resolved "https://registry.yarnpkg.com/electron/-/electron-12.0.4.tgz#c2ca4710d0e4da7db6d31c4f55777b08bfcb08e5"
integrity sha512-A8Lq3YMZ1CaO1z5z5nsyFxIwkgwXLHUwL2pf9MVUHpq7fv3XUewCMD98EnLL3DdtiyCvw5KMkeT1WGsZh8qFug==
dependencies:
"@electron/get" "^1.0.1"
"@types/node" "^14.6.2"