From d0469685d97091162427ae40c82bd27179cbbe9b Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Thu, 30 Dec 2021 20:09:02 +0100 Subject: [PATCH] automatically import and show OpenSSH connections - fixes #1528 --- tabby-ssh/src/components/sshTab.component.ts | 6 +- tabby-ssh/src/openSSHImport.ts | 121 ++++++++++++++++++ tabby-ssh/src/profiles.ts | 40 ++++-- .../src/services/sshMultiplexer.service.ts | 20 +-- 4 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 tabby-ssh/src/openSSHImport.ts diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index f6dc3333..822c2f35 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -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 { - 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|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`) diff --git a/tabby-ssh/src/openSSHImport.ts b/tabby-ssh/src/openSSHImport.ts new file mode 100644 index 00000000..072c25fa --- /dev/null +++ b/tabby-ssh/src/openSSHImport.ts @@ -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[]> { + const results: PartialProfile[] = [] + const configPath = path.join(process.env.HOME ?? '~', '.ssh', 'config') + try { + const lines = (await fs.readFile(configPath, 'utf8')).split('\n') + const globalOptions: Partial = {} + let currentProfile: PartialProfile|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 = 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 + } +} diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index b61cf502..a727b5eb 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -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 { @@ -60,20 +61,33 @@ export class SSHProfilesService extends ProfileProvider { } async getBuiltinProfiles (): Promise[]> { - return [{ - id: `ssh:template`, - type: 'ssh', - name: 'SSH connection', - icon: 'fas fa-desktop', - options: { - host: '', - port: 22, - user: 'root', + let imported: PartialProfile[] = [] + 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> { diff --git a/tabby-ssh/src/services/sshMultiplexer.service.ts b/tabby-ssh/src/services/sshMultiplexer.service.ts index 10360bfc..9f7e4965 100644 --- a/tabby-ssh/src/services/sshMultiplexer.service.ts +++ b/tabby-ssh/src/services/sshMultiplexer.service.ts @@ -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() constructor ( - private config: ConfigService, private profilesService: ProfilesService, ) { } - addSession (session: SSHSession): void { - const key = this.getMultiplexerKey(session.profile) + async addSession (session: SSHSession): Promise { + const key = await this.getMultiplexerKey(session.profile) this.sessions.set(key, session) session.willDestroy$.subscribe(() => { this.sessions.delete(key) }) } - getSession (profile: PartialProfile): SSHSession|null { + async getSession (profile: PartialProfile): Promise { 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 }