import * as fs from 'fs/promises' import * as fsSync from 'fs' import * as path from 'path' import slugify from 'slugify' import * as yaml from 'js-yaml' import { Injectable } from '@angular/core' import { PartialProfile } from 'tabby-core' import { SSHProfileImporter, PortForwardType, SSHProfile, AutoPrivateKeyLocator, ForwardedPortConfig, } from 'tabby-ssh' import { ElectronService } from './services/electron.service' import SSHConfig, { LineType } from 'ssh-config' // Enum to delineate the properties in SSHProfile options enum SSHProfilePropertyNames { Host = 'host', Port = 'port', User = 'user', X11 = 'x11', PrivateKeys = 'privateKeys', KeepaliveInterval = 'keepaliveInterval', KeepaliveCountMax = 'keepaliveCountMax', ReadyTimeout = 'readyTimeout', JumpHost = 'jumpHost', AgentForward = 'agentForward', ProxyCommand = 'proxyCommand', ForwardedPorts = 'forwardedPorts', } // Data structure to map the (lowercase) ssh-config attributes (as keys) to a tuple // containing the name of the corresponding SSHProfile attribute const decodeFields: Record = { hostname: SSHProfilePropertyNames.Host, user: SSHProfilePropertyNames.User, port: SSHProfilePropertyNames.Port, forwardx11: SSHProfilePropertyNames.X11, serveraliveinterval: SSHProfilePropertyNames.KeepaliveInterval, serveralivecountmax: SSHProfilePropertyNames.KeepaliveCountMax, connecttimeout: SSHProfilePropertyNames.ReadyTimeout, proxyjump: SSHProfilePropertyNames.JumpHost, forwardagent: SSHProfilePropertyNames.AgentForward, identityfile: SSHProfilePropertyNames.PrivateKeys, proxycommand: SSHProfilePropertyNames.ProxyCommand, localforward: SSHProfilePropertyNames.ForwardedPorts, remoteforward: SSHProfilePropertyNames.ForwardedPorts, dynamicforward: SSHProfilePropertyNames.ForwardedPorts, } // Function to use the above to return details corresponding to the supplied SSHProperty name. // If the name of the supplied SSH Config file Property is valid, and one that we process, // then we get back the name of the corresponding Property in the SSHProfile object function decodeTarget (SSHProperty: string): string { const lower = SSHProperty.toLowerCase() if (lower in decodeFields) { return decodeFields[lower] } return '' } // Function to combine SSHConfig values into a single string. This is used to smash // together the proxyCommand values which are split on whitespace and presented as // an array of objects in the SSHConfig object function convertSSHConfigValuesToString (arg: string | string[] | object[]): string { if (typeof arg === 'string') { return arg } let allStrings = true for (const item of arg) { if (typeof item !== 'string') { allStrings = false break } } if (allStrings) { return arg.join(' ') } // Have to explicitly unwrap the arg into a list of objects to avoid Typescript grumbles const objList: object[] = [] for (const item of arg) { if ( typeof item === 'object' && 'val' in item ) { objList.push(item) } } return objList.filter(obj => 'val' in obj) .map(obj => 'val' in obj ? obj.val as string: '') .join(' ') } // Function to read in the SSH config file and return it as a string async function readSSHConfigFile (filePath: string): Promise { try { return await fs.readFile(filePath, 'utf8') } catch (err) { console.error('Error reading SSH config file:', err) return '' } } // Function to take an ssh-config entry and convert it into an SSHProfile function convertHostToSSHProfile (host: string, settings: Record): PartialProfile { // inline function to generate an id for this profile const deriveID = (name: string) => 'openssh-config:' + slugify(name) // Start point of the profile, with an ID, name, type and group const thisProfile: PartialProfile = { id: deriveID(host), name: `${host} (.ssh/config)`, type: 'ssh', group: 'Imported from .ssh/config', } const options = {} function convertToForwardedPortDescriptor (forwardType: PortForwardType.Local | PortForwardType.Remote | PortForwardType.Dynamic, details: string): ForwardedPortConfig { const detailsParts = details.split(/\s/) const bindParts = detailsParts[0].trim().split(':') if (bindParts.length === 1) { bindParts.unshift('127.0.0.1') } let tgtParts = ['', '22'] if ( detailsParts.length > 1 ) { tgtParts = detailsParts[1].trim().split(':') } return { host: bindParts[0], port: parseInt(bindParts[1]), targetAddress: tgtParts[0], targetPort: parseInt(tgtParts[1]), type: forwardType, description: details, } } // for each ssh-config key in turn... for (const key in settings) { // decode a target attribute and an action const targetName = decodeTarget(key) switch (targetName) { // The following have single string values case SSHProfilePropertyNames.User: case SSHProfilePropertyNames.Host: case SSHProfilePropertyNames.JumpHost: const basicString = settings[key] if (typeof basicString === 'string') { if (targetName === SSHProfilePropertyNames.JumpHost) { options[targetName] = deriveID(basicString) } else { options[targetName] = basicString } } else { console.log('Unexpected value in settings for ' + key) } break // The following have single integer values case SSHProfilePropertyNames.Port: case SSHProfilePropertyNames.KeepaliveInterval: case SSHProfilePropertyNames.KeepaliveCountMax: case SSHProfilePropertyNames.ReadyTimeout: const numberString = settings[key] if (typeof numberString === 'string') { options[targetName] = parseInt(numberString, 10) } else { console.log('Unexpected value in settings for ' + key) } break // The following have single yes/no values case SSHProfilePropertyNames.X11: case SSHProfilePropertyNames.AgentForward: let booleanString = settings[key] booleanString = typeof booleanString === 'string' ? booleanString.toLowerCase() : '' if ( booleanString === 'yes' || booleanString === 'no' ) { options[targetName] = booleanString === 'yes' } else { console.log('Unexpected value in settings for ' + key) } break // ProxyCommand will be an array if unquoted and containing multiple words, // or a simple string otherwise case SSHProfilePropertyNames.ProxyCommand: const proxyCommand = convertSSHConfigValuesToString(settings[key]) options[targetName] = proxyCommand break // IdentityFile may have multiple values and the need to have '~' converted to the // path to the HOME directory case SSHProfilePropertyNames.PrivateKeys: const processedKeys: string [] = (settings[key] as string[]).map( s => { let retVal: string = s if (s.startsWith('~/')) { retVal = path.join(process.env.HOME ?? '~', s.slice(2)) } return retVal }) options[targetName] = processedKeys break // The port forwarding directives all end up in the same space, but with a different value // in the SSHProfileOptions object case SSHProfilePropertyNames.ForwardedPorts: const forwardTypeString = key.toLowerCase() let forwardType: PortForwardType | null = null switch (forwardTypeString) { case 'localforward': forwardType = PortForwardType.Local break case 'remoteforward': forwardType = PortForwardType.Remote break case 'dynamicforward': forwardType = PortForwardType.Dynamic break } if (forwardType) { options[targetName] ??= [] for (const forwarderDetails of settings[key]) { if (typeof forwarderDetails === 'string') { options[targetName].push(convertToForwardedPortDescriptor(forwardType, forwarderDetails)) } } } break } } thisProfile.options = options return thisProfile } function convertToSSHProfiles (config: SSHConfig): PartialProfile[] { const myMap = new Map>() function noWildCardsInName (name: string) { return !/[?*]/.test(name) } for (const entry of config) { // Each entry represents a line in the SSH Config. If the line is a 'Host' line, // then it will also contain the configuration for that identifiable Host. // There may be more than one host per line and some 'Hosts' have wildcards in their // names // If this is a genuine entry rather than a Comment... // ... and there is a 'Host' Parameter if (entry.type === LineType.DIRECTIVE && entry.param === 'Host') { // for each Name in this entry const hostList: string[] = [] // if there is more than one host specified on this line, then the names will be // in an array if (typeof entry.value === 'string') { hostList.push(entry.value) } else if (Array.isArray(entry.value)) { for (const item of entry.value) { hostList.push(item.val) } } // for each Host identified on this line, check that there are no wildcards in the // name and that we've not seen the name before. // If that is the case, then get the full configuration for this name. // If that has a 'Hostname' property (if that's missing, the name is not usable // for our purposes) then convert the configuration into an SSHProfile and stash it for (const host of hostList) { if (noWildCardsInName(host)) { if (!(host in myMap)) { // NOTE: SSHConfig.compute() lies about the return types const configuration: Record = config.compute(host) if (configuration['HostName']) { myMap[host] = convertHostToSSHProfile(host, configuration) } } } } } } // Convert the values from the map into a list of Partial SSHProfiles sorted // by Hostname return Object.keys(myMap).sort().map(key => myMap[key]) } @Injectable({ providedIn: 'root' }) export class OpenSSHImporter extends SSHProfileImporter { async getProfiles (): Promise[]> { const configPath = path.join(process.env.HOME ?? '~', '.ssh', 'config') try { const sshConfigContent = await readSSHConfigFile(configPath) const config: SSHConfig = SSHConfig.parse(sshConfigContent) return convertToSSHProfiles(config) } catch (e) { if (e.code === 'ENOENT') { return [] } throw e } } } @Injectable({ providedIn: 'root' }) export class StaticFileImporter extends SSHProfileImporter { private configPath: string constructor ( electron: ElectronService, ) { super() this.configPath = path.join(electron.app.getPath('userData'), 'ssh-profiles.yaml') } async getProfiles (): Promise[]> { const deriveID = name => 'file-config:' + slugify(name) if (!fsSync.existsSync(this.configPath)) { return [] } const content = await fs.readFile(this.configPath, 'utf8') if (!content) { return [] } return (yaml.load(content) as PartialProfile[]).map(item => ({ ...item, id: deriveID(item.name), type: 'ssh', })) } } @Injectable({ providedIn: 'root' }) export class PrivateKeyLocator extends AutoPrivateKeyLocator { async getKeys (): Promise<[string, Buffer][]> { const results: [string, Buffer][] = [] const keysPath = path.join(process.env.HOME!, '.ssh') if (!fsSync.existsSync(keysPath)) { return results } for (const file of await fs.readdir(keysPath)) { if (/^id_[\w\d]+$/.test(file)) { const privateKeyContents = await fs.readFile( path.join(keysPath, file), { encoding: null }, ) results.push([file, privateKeyContents]) } } return results } }