automatically import and show OpenSSH connections - fixes #1528

This commit is contained in:
Eugene Pankov 2021-12-30 20:09:02 +01:00
parent 32ecd48375
commit d0469685d9
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
4 changed files with 162 additions and 25 deletions

View file

@ -2,7 +2,7 @@ import colors from 'ansi-colors'
import { Component, Injector, HostListener } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { first } from 'rxjs'
import { PartialProfile, Platform, ProfilesService, RecoveryToken } from 'tabby-core'
import { Platform, ProfilesService, RecoveryToken } from 'tabby-core'
import { BaseTerminalTabComponent } from 'tabby-terminal'
import { SSHService } from '../services/ssh.service'
import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
@ -84,12 +84,12 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
}
async setupOneSession (injector: Injector, profile: SSHProfile): Promise<SSHSession> {
let session = this.sshMultiplexer.getSession(profile)
let session = await this.sshMultiplexer.getSession(profile)
if (!session || !profile.options.reuseSession) {
session = new SSHSession(injector, profile)
if (profile.options.jumpHost) {
const jumpConnection: PartialProfile<SSHProfile>|null = this.config.store.profiles.find(x => x.id === profile.options.jumpHost)
const jumpConnection = (await this.profilesService.getProfiles()).find(x => x.id === profile.options.jumpHost)
if (!jumpConnection) {
throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`)

View file

@ -0,0 +1,121 @@
import * as fs from 'fs/promises'
import * as path from 'path'
import slugify from 'slugify'
import { PortForwardType, SSHProfile, SSHProfileOptions } from './api/interfaces'
import { PartialProfile } from 'tabby-core'
function deriveID (name: string): string {
return 'openssh-config:' + slugify(name)
}
export async function parseOpenSSHProfiles (): Promise<PartialProfile<SSHProfile>[]> {
const results: PartialProfile<SSHProfile>[] = []
const configPath = path.join(process.env.HOME ?? '~', '.ssh', 'config')
try {
const lines = (await fs.readFile(configPath, 'utf8')).split('\n')
const globalOptions: Partial<SSHProfileOptions> = {}
let currentProfile: PartialProfile<SSHProfile>|null = null
for (let line of lines) {
if (line.trim().startsWith('#') || !line.trim()) {
continue
}
if (line.startsWith('Host ')) {
if (currentProfile) {
results.push(currentProfile)
}
const name = line.substr(5).trim()
currentProfile = {
id: deriveID(name),
name,
type: 'ssh',
group: 'Imported from .ssh/config',
options: {
...globalOptions,
host: name,
},
}
} else {
const target: Partial<SSHProfileOptions> = currentProfile?.options ?? globalOptions
line = line.trim()
const idx = /\s/.exec(line)?.index ?? -1
if (idx === -1) {
continue
}
const key = line.substr(0, idx).trim()
const value = line.substr(idx + 1).trim()
if (key === 'IdentityFile') {
target.privateKeys = value.split(',').map(s => s.trim())
} else if (key === 'RemoteForward') {
const bind = value.split(/\s/)[0].trim()
const tgt = value.split(/\s/)[1].trim()
target.forwardedPorts ??= []
target.forwardedPorts.push({
type: PortForwardType.Remote,
description: value,
host: bind.split(':')[0] ?? '127.0.0.1',
port: parseInt(bind.split(':')[1] ?? bind),
targetAddress: tgt.split(':')[0],
targetPort: parseInt(tgt.split(':')[1]),
})
} else if (key === 'LocalForward') {
const bind = value.split(/\s/)[0].trim()
const tgt = value.split(/\s/)[1].trim()
target.forwardedPorts ??= []
target.forwardedPorts.push({
type: PortForwardType.Local,
description: value,
host: bind.split(':')[0] ?? '127.0.0.1',
port: parseInt(bind.split(':')[1] ?? bind),
targetAddress: tgt.split(':')[0],
targetPort: parseInt(tgt.split(':')[1]),
})
} else if (key === 'DynamicForward') {
const bind = value.trim()
target.forwardedPorts ??= []
target.forwardedPorts.push({
type: PortForwardType.Dynamic,
description: value,
host: bind.split(':')[0] ?? '127.0.0.1',
port: parseInt(bind.split(':')[1] ?? bind),
targetAddress: '',
targetPort: 22,
})
} else {
const mappedKey = {
Hostname: 'host',
Port: 'port',
User: 'user',
ForwardX11: 'x11',
ServerAliveInterval: 'keepaliveInterval',
ServerAliveCountMax: 'keepaliveCountMax',
ProxyCommand: 'proxyCommand',
ProxyJump: 'jumpHost',
}[key]
if (mappedKey) {
target[mappedKey] = value
}
}
}
}
if (currentProfile) {
results.push(currentProfile)
}
for (const p of results) {
if (p.options?.proxyCommand) {
p.options.proxyCommand = p.options.proxyCommand
.replace('%h', p.options.host ?? '')
.replace('%p', (p.options.port ?? 22).toString())
}
if (p.options?.jumpHost) {
p.options.jumpHost = deriveID(p.options.jumpHost)
}
}
return results
} catch (e) {
if (e.code === 'ENOENT') {
return []
}
throw e
}
}

View file

@ -5,6 +5,7 @@ import { SSHProfileSettingsComponent } from './components/sshProfileSettings.com
import { SSHTabComponent } from './components/sshTab.component'
import { PasswordStorageService } from './services/passwordStorage.service'
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
import { parseOpenSSHProfiles } from './openSSHImport'
@Injectable({ providedIn: 'root' })
export class SSHProfilesService extends ProfileProvider<SSHProfile> {
@ -60,20 +61,33 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
}
async getBuiltinProfiles (): Promise<PartialProfile<SSHProfile>[]> {
return [{
id: `ssh:template`,
type: 'ssh',
name: 'SSH connection',
icon: 'fas fa-desktop',
options: {
host: '',
port: 22,
user: 'root',
let imported: PartialProfile<SSHProfile>[] = []
try {
imported = await parseOpenSSHProfiles()
} catch (e) {
console.warn('Could not parse OpenSSH config:', e)
}
return [
{
id: `ssh:template`,
type: 'ssh',
name: 'SSH connection',
icon: 'fas fa-desktop',
options: {
host: '',
port: 22,
user: 'root',
},
isBuiltin: true,
isTemplate: true,
weight: -1,
},
isBuiltin: true,
isTemplate: true,
weight: -1,
}]
...imported.map(p => ({
...p,
name: p.name + ' (.ssh/config)',
isBuiltin: true,
})),
]
}
async getNewTabParameters (profile: SSHProfile): Promise<NewTabParameters<SSHTabComponent>> {

View file

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'
import { SSHProfile } from '../api'
import { ConfigService, PartialProfile, ProfilesService } from 'tabby-core'
import { PartialProfile, ProfilesService } from 'tabby-core'
import { SSHSession } from '../session/ssh'
@Injectable({ providedIn: 'root' })
@ -8,30 +8,32 @@ export class SSHMultiplexerService {
private sessions = new Map<string, SSHSession>()
constructor (
private config: ConfigService,
private profilesService: ProfilesService,
) { }
addSession (session: SSHSession): void {
const key = this.getMultiplexerKey(session.profile)
async addSession (session: SSHSession): Promise<void> {
const key = await this.getMultiplexerKey(session.profile)
this.sessions.set(key, session)
session.willDestroy$.subscribe(() => {
this.sessions.delete(key)
})
}
getSession (profile: PartialProfile<SSHProfile>): SSHSession|null {
async getSession (profile: PartialProfile<SSHProfile>): Promise<SSHSession|null> {
const fullProfile = this.profilesService.getConfigProxyForProfile(profile)
const key = this.getMultiplexerKey(fullProfile)
const key = await this.getMultiplexerKey(fullProfile)
return this.sessions.get(key) ?? null
}
private getMultiplexerKey (profile: SSHProfile) {
private async getMultiplexerKey (profile: SSHProfile) {
let key = `${profile.options.host}:${profile.options.port}:${profile.options.user}:${profile.options.proxyCommand}:${profile.options.socksProxyHost}:${profile.options.socksProxyPort}`
if (profile.options.jumpHost) {
const jumpConnection = this.config.store.profiles.find(x => x.id === profile.options.jumpHost)
const jumpConnection = (await this.profilesService.getProfiles()).find(x => x.id === profile.options.jumpHost)
if (!jumpConnection) {
return key
}
const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection)
key += '$' + this.getMultiplexerKey(jumpProfile)
key += '$' + await this.getMultiplexerKey(jumpProfile)
}
return key
}