1
1
import * as fs from 'fs/promises'
2
2
import * as fsSync from 'fs'
3
3
import * as path from 'path'
4
+ import * as glob from 'glob'
4
5
import slugify from 'slugify'
5
6
import * as yaml from 'js-yaml'
6
7
import { Injectable } from '@angular/core'
@@ -14,7 +15,7 @@ import {
14
15
} from 'tabby-ssh'
15
16
16
17
import { ElectronService } from './services/electron.service'
17
- import SSHConfig , { LineType } from 'ssh-config'
18
+ import SSHConfig , { Directive , LineType } from 'ssh-config'
18
19
19
20
// Enum to delineate the properties in SSHProfile options
20
21
enum SSHProfilePropertyNames {
@@ -90,15 +91,60 @@ function convertSSHConfigValuesToString (arg: string | string[] | object[]): str
90
91
. join ( ' ' )
91
92
}
92
93
93
- // Function to read in the SSH config file and return it as a string
94
- async function readSSHConfigFile ( filePath : string ) : Promise < string > {
94
+ // Function to read in the SSH config file recursively and parse any Include directives
95
+ async function parseSSHConfigFile (
96
+ filePath : string ,
97
+ visited = new Set < string > ( ) ,
98
+ ) : Promise < SSHConfig > {
99
+ // If we've already processed this file, return an empty config to avoid infinite recursion
100
+ if ( visited . has ( filePath ) ) {
101
+ return SSHConfig . parse ( '' )
102
+ }
103
+ visited . add ( filePath )
104
+
105
+ let raw = ''
95
106
try {
96
- return await fs . readFile ( filePath , 'utf8' )
107
+ raw = await fs . readFile ( filePath , 'utf8' )
97
108
} catch ( err ) {
98
- console . error ( 'Error reading SSH config file:' , err )
99
- return ''
109
+ console . error ( `Error reading SSH config file: ${ filePath } ` , err )
110
+ return SSHConfig . parse ( '' )
111
+ }
112
+
113
+ const parsed = SSHConfig . parse ( raw )
114
+ const merged = SSHConfig . parse ( '' )
115
+ for ( const entry of parsed ) {
116
+ if ( entry . type === LineType . DIRECTIVE && entry . param . toLowerCase ( ) === 'include' ) {
117
+ const directive = entry as Directive
118
+ if ( typeof directive . value !== 'string' ) {
119
+ continue
120
+ }
121
+
122
+ // ssh_config(5) says "Files without absolute paths are assumed to be in ~/.ssh if included in a user configuration file or /etc/ssh if included from the system configuration file."
123
+ let incPath = ''
124
+ if ( path . isAbsolute ( directive . value ) ) {
125
+ incPath = directive . value
126
+ } else if ( directive . value . startsWith ( '~' ) ) {
127
+ incPath = path . join ( process . env . HOME ?? '~' , directive . value . slice ( 1 ) )
128
+ } else {
129
+ incPath = path . join ( process . env . HOME ?? '~' , '.ssh' , directive . value )
130
+ }
131
+
132
+ const matches = glob . sync ( incPath )
133
+ for ( const match of matches ) {
134
+ const stat = await fs . stat ( match )
135
+ if ( stat . isDirectory ( ) ) {
136
+ continue
137
+ }
138
+ const matchedConfig = await parseSSHConfigFile ( match , visited )
139
+ merged . push ( ...matchedConfig )
140
+ }
141
+ } else {
142
+ merged . push ( entry )
143
+ }
100
144
}
145
+ return merged
101
146
}
147
+
102
148
// Function to take an ssh-config entry and convert it into an SSHProfile
103
149
function convertHostToSSHProfile ( host : string , settings : Record < string , string | string [ ] | object [ ] > ) : PartialProfile < SSHProfile > {
104
150
@@ -293,8 +339,7 @@ export class OpenSSHImporter extends SSHProfileImporter {
293
339
const configPath = path . join ( process . env . HOME ?? '~' , '.ssh' , 'config' )
294
340
295
341
try {
296
- const sshConfigContent = await readSSHConfigFile ( configPath )
297
- const config : SSHConfig = SSHConfig . parse ( sshConfigContent )
342
+ const config : SSHConfig = await parseSSHConfigFile ( configPath )
298
343
return convertToSSHProfiles ( config )
299
344
} catch ( e ) {
300
345
if ( e . code === 'ENOENT' ) {
0 commit comments