mirror of
https://github.com/Eugeny/tabby.git
synced 2025-01-06 13:44:36 +08:00
allow multiple private key paths - fixes #3921
This commit is contained in:
parent
79a429be5d
commit
a9069a4a49
@ -21,3 +21,4 @@ enableWelcomeTab: true
|
||||
electronFlags:
|
||||
- ['force_discrete_gpu', '0']
|
||||
enableAutomaticUpdates: true
|
||||
version: 1
|
||||
|
@ -8,6 +8,8 @@ const deepmerge = require('deepmerge')
|
||||
|
||||
const configMerge = (a, b) => deepmerge(a, b, { arrayMerge: (_d, s) => s }) // eslint-disable-line @typescript-eslint/no-var-requires
|
||||
|
||||
const LATEST_VERSION = 1
|
||||
|
||||
function isStructuralMember (v) {
|
||||
return v instanceof Object && !(v instanceof Array) &&
|
||||
Object.keys(v).length > 0 && !v.__nonStructural
|
||||
@ -148,8 +150,9 @@ export class ConfigService {
|
||||
if (content) {
|
||||
this._store = yaml.load(content)
|
||||
} else {
|
||||
this._store = {}
|
||||
this._store = { version: LATEST_VERSION }
|
||||
}
|
||||
this.migrate(this._store)
|
||||
this.store = new ConfigProxy(this._store, this.defaults)
|
||||
}
|
||||
|
||||
@ -225,4 +228,17 @@ export class ConfigService {
|
||||
private emitChange (): void {
|
||||
this.changed.next()
|
||||
}
|
||||
|
||||
private migrate (config) {
|
||||
config.version ??= 0
|
||||
if (config.version < 1) {
|
||||
for (const connection of config.ssh?.connections) {
|
||||
if (connection.privateKey) {
|
||||
connection.privateKeys = [connection.privateKey]
|
||||
delete connection.privateKey
|
||||
}
|
||||
}
|
||||
config.version = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
|
||||
textarea.form-control.h-100(
|
||||
[(ngModel)]='configFile'
|
||||
)
|
||||
.w-100.d-flex.flex-column
|
||||
.w-100.d-flex.flex-column(*ngIf='showConfigDefaults')
|
||||
h3 Defaults
|
||||
textarea.form-control.h-100(
|
||||
[(ngModel)]='configDefaults',
|
||||
@ -102,6 +102,9 @@ button.btn.btn-outline-warning.btn-block(*ngIf='config.restartRequested', '(clic
|
||||
i.fas.fa-exclamation-triangle.mr-2
|
||||
| Invalid syntax
|
||||
button.btn.btn-secondary.ml-auto(
|
||||
(click)='showConfigDefaults = !showConfigDefaults'
|
||||
) Show defaults
|
||||
button.btn.btn-secondary.ml-3(
|
||||
*ngIf='platform.getConfigPath()',
|
||||
(click)='showConfigFile()'
|
||||
)
|
||||
|
@ -39,5 +39,6 @@
|
||||
|
||||
textarea {
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
font-size: 12px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ export class SettingsTabComponent extends BaseTabComponent {
|
||||
isShellIntegrationInstalled = false
|
||||
checkingForUpdate = false
|
||||
updateAvailable = false
|
||||
showConfigDefaults = false
|
||||
@HostBinding('class.pad-window-controls') padWindowControls = false
|
||||
|
||||
constructor (
|
||||
|
@ -39,7 +39,7 @@ export interface SSHConnection {
|
||||
user: string
|
||||
auth?: null|'password'|'publicKey'|'agent'|'keyboardInteractive'
|
||||
password?: string
|
||||
privateKey?: string
|
||||
privateKeys?: string[]
|
||||
group: string | null
|
||||
scripts?: LoginScript[]
|
||||
keepaliveInterval?: number
|
||||
@ -131,6 +131,11 @@ export class ForwardedPort implements ForwardedPortConfig {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthMethod {
|
||||
type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased'
|
||||
path?: string
|
||||
}
|
||||
|
||||
export class SSHSession extends BaseSession {
|
||||
scripts?: LoginScript[]
|
||||
shell?: ClientChannel
|
||||
@ -143,9 +148,9 @@ export class SSHSession extends BaseSession {
|
||||
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
|
||||
|
||||
agentPath?: string
|
||||
privateKey?: string
|
||||
activePrivateKey: string|null = null
|
||||
|
||||
private authMethodsLeft: string[] = []
|
||||
private remainingAuthMethods: AuthMethod[] = []
|
||||
private serviceMessage = new Subject<string>()
|
||||
private keychainPasswordUsed = false
|
||||
|
||||
@ -189,33 +194,31 @@ export class SSHSession extends BaseSession {
|
||||
this.agentPath = process.env.SSH_AUTH_SOCK!
|
||||
}
|
||||
|
||||
this.authMethodsLeft = ['none']
|
||||
this.remainingAuthMethods = [{ type: 'none' }]
|
||||
if (!this.connection.auth || this.connection.auth === 'publicKey') {
|
||||
try {
|
||||
await this.loadPrivateKey()
|
||||
} catch (e) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key: ${e}`)
|
||||
}
|
||||
if (!this.privateKey) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Private key auth selected, but no key is loaded`)
|
||||
} else {
|
||||
this.authMethodsLeft.push('publickey')
|
||||
for (const pk of this.connection.privateKeys ?? []) {
|
||||
if (await fs.exists(pk)) {
|
||||
this.remainingAuthMethods.push({
|
||||
type: 'publickey',
|
||||
path: pk,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'agent') {
|
||||
if (!this.agentPath) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`)
|
||||
} else {
|
||||
this.authMethodsLeft.push('agent')
|
||||
this.remainingAuthMethods.push({ type: 'agent' })
|
||||
}
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'password') {
|
||||
this.authMethodsLeft.push('password')
|
||||
this.remainingAuthMethods.push({ type: 'password' })
|
||||
}
|
||||
if (!this.connection.auth || this.connection.auth === 'keyboardInteractive') {
|
||||
this.authMethodsLeft.push('keyboard-interactive')
|
||||
this.remainingAuthMethods.push({ type: 'keyboard-interactive' })
|
||||
}
|
||||
this.authMethodsLeft.push('hostbased')
|
||||
this.remainingAuthMethods.push({ type: 'hostbased' })
|
||||
}
|
||||
|
||||
async start (): Promise<void> {
|
||||
@ -370,17 +373,19 @@ export class SSHSession extends BaseSession {
|
||||
}
|
||||
|
||||
async handleAuth (methodsLeft?: string[]): Promise<any> {
|
||||
this.activePrivateKey = null
|
||||
|
||||
while (true) {
|
||||
const method = this.authMethodsLeft.shift()
|
||||
const method = this.remainingAuthMethods.shift()
|
||||
if (!method) {
|
||||
return false
|
||||
}
|
||||
if (methodsLeft && !methodsLeft.includes(method) && method !== 'agent') {
|
||||
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)
|
||||
this.logger.info('Server does not support auth method', method.type)
|
||||
continue
|
||||
}
|
||||
if (method === 'password') {
|
||||
if (method.type === 'password') {
|
||||
if (this.connection.password) {
|
||||
this.emitServiceMessage('Using preset password')
|
||||
return {
|
||||
@ -426,6 +431,19 @@ export class SSHSession extends BaseSession {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (method.type === 'publickey') {
|
||||
try {
|
||||
const key = await this.loadPrivateKey(method.path!)
|
||||
return {
|
||||
type: 'publickey',
|
||||
username: this.connection.user,
|
||||
key,
|
||||
}
|
||||
} catch (e) {
|
||||
this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.path}: ${e}`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return method
|
||||
}
|
||||
}
|
||||
@ -560,9 +578,7 @@ export class SSHSession extends BaseSession {
|
||||
}
|
||||
}
|
||||
|
||||
async loadPrivateKey (): Promise<void> {
|
||||
let privateKeyPath = this.connection.privateKey
|
||||
|
||||
async loadPrivateKey (privateKeyPath: string): Promise<string|null> {
|
||||
if (!privateKeyPath) {
|
||||
const userKeyPath = path.join(process.env.HOME!, '.ssh', 'id_rsa')
|
||||
if (await fs.exists(userKeyPath)) {
|
||||
@ -571,18 +587,21 @@ export class SSHSession extends BaseSession {
|
||||
}
|
||||
}
|
||||
|
||||
if (privateKeyPath) {
|
||||
this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
|
||||
try {
|
||||
const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
|
||||
const parsedKey = await this.parsePrivateKey(privateKeyContents)
|
||||
this.privateKey = parsedKey.toString('openssh')
|
||||
} catch (error) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
|
||||
this.notifications.error('Could not read the private key file')
|
||||
return
|
||||
}
|
||||
if (!privateKeyPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.emitServiceMessage('Loading private key from ' + colors.bgWhite.blackBright(' ' + privateKeyPath + ' '))
|
||||
try {
|
||||
const privateKeyContents = (await fs.readFile(privateKeyPath)).toString()
|
||||
const parsedKey = await this.parsePrivateKey(privateKeyContents)
|
||||
this.activePrivateKey = parsedKey.toString('openssh')
|
||||
return this.activePrivateKey
|
||||
} catch (error) {
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ' Could not read the private key file')
|
||||
this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${error}`)
|
||||
this.notifications.error('Could not read the private key file')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,30 +49,30 @@
|
||||
.form-group
|
||||
label Authentication method
|
||||
|
||||
.btn-group.w-100(
|
||||
[(ngModel)]='connection.auth',
|
||||
ngbRadioGroup
|
||||
)
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='null')
|
||||
i.far.fa-lightbulb
|
||||
.m-0 Auto
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"password"')
|
||||
i.fas.fa-font
|
||||
.m-0 Password
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"publicKey"')
|
||||
i.fas.fa-key
|
||||
.m-0 Key
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"agent"')
|
||||
i.fas.fa-user-secret
|
||||
.m-0 Agent
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
|
||||
i.far.fa-keyboard
|
||||
.m-0 Interactive
|
||||
.btn-group.mt-1.w-100(
|
||||
[(ngModel)]='connection.auth',
|
||||
ngbRadioGroup
|
||||
)
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='null')
|
||||
i.far.fa-lightbulb
|
||||
.m-0 Auto
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"password"')
|
||||
i.fas.fa-font
|
||||
.m-0 Password
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"publicKey"')
|
||||
i.fas.fa-key
|
||||
.m-0 Key
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"agent"')
|
||||
i.fas.fa-user-secret
|
||||
.m-0 Agent
|
||||
label.btn.btn-secondary(ngbButtonLabel)
|
||||
input(type='radio', ngbButton, [value]='"keyboardInteractive"')
|
||||
i.far.fa-keyboard
|
||||
.m-0 Interactive
|
||||
|
||||
.form-line(*ngIf='!connection.auth || connection.auth === "password"')
|
||||
.header
|
||||
@ -86,19 +86,17 @@
|
||||
i.fas.fa-trash-alt
|
||||
span Forget
|
||||
|
||||
.form-line(*ngIf='!connection.auth || connection.auth === "publicKey"')
|
||||
.header
|
||||
.title Private key
|
||||
.description Path to the private key file
|
||||
.input-group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Key file path',
|
||||
[(ngModel)]='connection.privateKey'
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='selectPrivateKey()')
|
||||
i.fas.fa-folder-open
|
||||
.form-group(*ngIf='!connection.auth || connection.auth === "publicKey"')
|
||||
label Private keys
|
||||
.list-group.mb-2
|
||||
.list-group-item.d-flex.align-items-center.p-1.pl-3(*ngFor='let path of connection.privateKeys')
|
||||
i.fas.fa-key
|
||||
.mr-auto {{path}}
|
||||
button.btn.btn-link((click)='removePrivateKey(path)')
|
||||
i.fas.fa-trash
|
||||
button.btn.btn-secondary((click)='addPrivateKey()')
|
||||
i.fas.fa-folder-open
|
||||
span Add a private key
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink) Ports
|
||||
|
@ -67,6 +67,7 @@ export class EditConnectionModalComponent {
|
||||
this.connection.algorithms = this.connection.algorithms ?? {}
|
||||
this.connection.scripts = this.connection.scripts ?? []
|
||||
this.connection.auth = this.connection.auth ?? null
|
||||
this.connection.privateKeys ??= []
|
||||
|
||||
this.useProxyCommand = !!this.connection.proxyCommand
|
||||
|
||||
@ -101,20 +102,27 @@ export class EditConnectionModalComponent {
|
||||
this.passwordStorage.deletePassword(this.connection)
|
||||
}
|
||||
|
||||
selectPrivateKey () {
|
||||
addPrivateKey () {
|
||||
this.electron.dialog.showOpenDialog(
|
||||
this.hostApp.getWindow(),
|
||||
{
|
||||
defaultPath: this.connection.privateKey,
|
||||
defaultPath: this.connection.privateKeys![0],
|
||||
title: 'Select private key',
|
||||
}
|
||||
).then(result => {
|
||||
if (!result.canceled) {
|
||||
this.connection.privateKey = result.filePaths[0]
|
||||
this.connection.privateKeys = [
|
||||
...this.connection.privateKeys!,
|
||||
...result.filePaths,
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removePrivateKey (path: string) {
|
||||
this.connection.privateKeys = this.connection.privateKeys?.filter(x => x !== path)
|
||||
}
|
||||
|
||||
save () {
|
||||
for (const k of Object.values(SSHAlgorithmType)) {
|
||||
this.connection.algorithms![k] = Object.entries(this.algorithms[k])
|
||||
|
@ -133,8 +133,6 @@ export class SSHService {
|
||||
port: session.connection.port ?? 22,
|
||||
sock: session.proxyCommandStream ?? session.jumpStream,
|
||||
username: session.connection.user,
|
||||
password: session.connection.privateKey ? undefined : '',
|
||||
privateKey: session.privateKey ?? undefined,
|
||||
tryKeyboard: true,
|
||||
agent: session.agentPath,
|
||||
agentForward: session.connection.agentForward && !!session.agentPath,
|
||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
|
||||
import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, HostAppService, Platform, PlatformService, MenuItemOptions } from 'terminus-core'
|
||||
import { SSHTabComponent } from './components/sshTab.component'
|
||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||
import { SSHConnection } from './api'
|
||||
import { SSHConnection, SSHSession } from './api'
|
||||
|
||||
|
||||
/** @hidden */
|
||||
@ -40,7 +40,7 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
|
||||
{
|
||||
label: 'Launch WinSCP',
|
||||
click: (): void => {
|
||||
this.launchWinSCP(tab.connection!)
|
||||
this.launchWinSCP(tab.session!)
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -60,15 +60,15 @@ export class WinSCPContextMenu extends TabContextMenuItemProvider {
|
||||
return uri
|
||||
}
|
||||
|
||||
async launchWinSCP (connection: SSHConnection): Promise<void> {
|
||||
async launchWinSCP (session: SSHSession): Promise<void> {
|
||||
const path = this.getPath()
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
const args = [await this.getURI(connection)]
|
||||
if ((!connection.auth || connection.auth === 'publicKey') && connection.privateKey) {
|
||||
const args = [await this.getURI(session.connection)]
|
||||
if (session.activePrivateKey) {
|
||||
args.push('/privatekey')
|
||||
args.push(connection.privateKey)
|
||||
args.push(session.activePrivateKey)
|
||||
}
|
||||
this.platform.exec(path, args)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user