mirror of
https://github.com/Eugeny/tabby
synced 2024-11-15 01:17:14 +00:00
automatically import and show OpenSSH connections - fixes #1528
This commit is contained in:
parent
32ecd48375
commit
d0469685d9
4 changed files with 162 additions and 25 deletions
|
@ -2,7 +2,7 @@ import colors from 'ansi-colors'
|
||||||
import { Component, Injector, HostListener } from '@angular/core'
|
import { Component, Injector, HostListener } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { first } from 'rxjs'
|
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 { BaseTerminalTabComponent } from 'tabby-terminal'
|
||||||
import { SSHService } from '../services/ssh.service'
|
import { SSHService } from '../services/ssh.service'
|
||||||
import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
|
import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh'
|
||||||
|
@ -84,12 +84,12 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async setupOneSession (injector: Injector, profile: SSHProfile): Promise<SSHSession> {
|
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) {
|
if (!session || !profile.options.reuseSession) {
|
||||||
session = new SSHSession(injector, profile)
|
session = new SSHSession(injector, profile)
|
||||||
|
|
||||||
if (profile.options.jumpHost) {
|
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) {
|
if (!jumpConnection) {
|
||||||
throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`)
|
throw new Error(`${profile.options.host}: jump host "${profile.options.jumpHost}" not found in your config`)
|
||||||
|
|
121
tabby-ssh/src/openSSHImport.ts
Normal file
121
tabby-ssh/src/openSSHImport.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { SSHProfileSettingsComponent } from './components/sshProfileSettings.com
|
||||||
import { SSHTabComponent } from './components/sshTab.component'
|
import { SSHTabComponent } from './components/sshTab.component'
|
||||||
import { PasswordStorageService } from './services/passwordStorage.service'
|
import { PasswordStorageService } from './services/passwordStorage.service'
|
||||||
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
|
import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api'
|
||||||
|
import { parseOpenSSHProfiles } from './openSSHImport'
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SSHProfilesService extends ProfileProvider<SSHProfile> {
|
export class SSHProfilesService extends ProfileProvider<SSHProfile> {
|
||||||
|
@ -60,20 +61,33 @@ export class SSHProfilesService extends ProfileProvider<SSHProfile> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBuiltinProfiles (): Promise<PartialProfile<SSHProfile>[]> {
|
async getBuiltinProfiles (): Promise<PartialProfile<SSHProfile>[]> {
|
||||||
return [{
|
let imported: PartialProfile<SSHProfile>[] = []
|
||||||
id: `ssh:template`,
|
try {
|
||||||
type: 'ssh',
|
imported = await parseOpenSSHProfiles()
|
||||||
name: 'SSH connection',
|
} catch (e) {
|
||||||
icon: 'fas fa-desktop',
|
console.warn('Could not parse OpenSSH config:', e)
|
||||||
options: {
|
}
|
||||||
host: '',
|
return [
|
||||||
port: 22,
|
{
|
||||||
user: 'root',
|
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,
|
...imported.map(p => ({
|
||||||
isTemplate: true,
|
...p,
|
||||||
weight: -1,
|
name: p.name + ' (.ssh/config)',
|
||||||
}]
|
isBuiltin: true,
|
||||||
|
})),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNewTabParameters (profile: SSHProfile): Promise<NewTabParameters<SSHTabComponent>> {
|
async getNewTabParameters (profile: SSHProfile): Promise<NewTabParameters<SSHTabComponent>> {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { SSHProfile } from '../api'
|
import { SSHProfile } from '../api'
|
||||||
import { ConfigService, PartialProfile, ProfilesService } from 'tabby-core'
|
import { PartialProfile, ProfilesService } from 'tabby-core'
|
||||||
import { SSHSession } from '../session/ssh'
|
import { SSHSession } from '../session/ssh'
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
|
@ -8,30 +8,32 @@ export class SSHMultiplexerService {
|
||||||
private sessions = new Map<string, SSHSession>()
|
private sessions = new Map<string, SSHSession>()
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private config: ConfigService,
|
|
||||||
private profilesService: ProfilesService,
|
private profilesService: ProfilesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
addSession (session: SSHSession): void {
|
async addSession (session: SSHSession): Promise<void> {
|
||||||
const key = this.getMultiplexerKey(session.profile)
|
const key = await this.getMultiplexerKey(session.profile)
|
||||||
this.sessions.set(key, session)
|
this.sessions.set(key, session)
|
||||||
session.willDestroy$.subscribe(() => {
|
session.willDestroy$.subscribe(() => {
|
||||||
this.sessions.delete(key)
|
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 fullProfile = this.profilesService.getConfigProxyForProfile(profile)
|
||||||
const key = this.getMultiplexerKey(fullProfile)
|
const key = await this.getMultiplexerKey(fullProfile)
|
||||||
return this.sessions.get(key) ?? null
|
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}`
|
let key = `${profile.options.host}:${profile.options.port}:${profile.options.user}:${profile.options.proxyCommand}:${profile.options.socksProxyHost}:${profile.options.socksProxyPort}`
|
||||||
if (profile.options.jumpHost) {
|
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)
|
const jumpProfile = this.profilesService.getConfigProxyForProfile(jumpConnection)
|
||||||
key += '$' + this.getMultiplexerKey(jumpProfile)
|
key += '$' + await this.getMultiplexerKey(jumpProfile)
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue