返回 DeepSeek-Reasonix
sshconfig.go
根目录 / internal / remote / sshconfig.go
1 package remote
2
3 import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 ssh_config "github.com/kevinburke/ssh_config"
17 )
18
19 // SSHConfigSource discovers aliases from a parsed OpenSSH client config and
20 // resolves their effective values through the installed `ssh -G`. The embedded
21 // parser remains a compatibility fallback when the OpenSSH executable is not
22 // available.
23 type SSHConfigSource struct {
24 cfg *ssh_config.Config
25 path string
26 openSSHPath string
27 aliases []string
28 resolveOpenSSH func(context.Context, string, string) ([]byte, error)
29 effectiveMu sync.Mutex
30 effectiveByHost map[string]EffectiveSSHConfig
31 effectiveErr map[string]error
32 }
33
34 // EffectiveSSHConfig is the subset of `ssh -G` output consumed by Reasonix.
35 // Keeping every IdentityFile is important: OpenSSH permits the directive to be
36 // repeated and probes the resulting identities in order.
37 type EffectiveSSHConfig struct {
38 HostName string
39 User string
40 Port int
41 IdentityFiles []string
42 IdentityFileNone bool
43 ProxyJump string
44 IdentitiesOnly bool
45 }
46
47 // LoadUserSSHConfig parses ~/.ssh/config. A missing file yields an empty
48 // source (all lookups return zero values), not an error.
49 func LoadUserSSHConfig() (*SSHConfigSource, error) {
50 home, err := os.UserHomeDir()
51 if err != nil {
52 return newSSHConfigSource(nil, "", nil), nil
53 }
54 src, err := LoadSSHConfig(filepath.Join(home, ".ssh", "config"))
55 if src != nil {
56 // An empty -F argument means normal OpenSSH resolution: the default
57 // per-user file plus the system ssh_config. Passing the default user path
58 // explicitly with -F would incorrectly suppress the system configuration.
59 src.openSSHPath = ""
60 }
61 return src, err
62 }
63
64 // LoadSSHConfig parses one OpenSSH client config file.
65 func LoadSSHConfig(path string) (*SSHConfigSource, error) {
66 contents, err := os.ReadFile(path)
67 if err != nil {
68 if os.IsNotExist(err) {
69 return newSSHConfigSource(nil, path, nil), nil
70 }
71 return nil, err
72 }
73 aliases, _ := discoverSSHAliases(path, 0, map[string]bool{})
74 // The embedded parser is only a fallback. It intentionally rejects valid
75 // OpenSSH constructs such as `Match exec`, while the installed OpenSSH
76 // client accepts and evaluates them. Keep the discovered aliases and let
77 // `ssh -G` remain authoritative even when the fallback cannot decode the
78 // file.
79 cfg, _ := ssh_config.Decode(strings.NewReader(string(contents)))
80 return newSSHConfigSource(cfg, path, aliases), nil
81 }
82
83 func newSSHConfigSource(cfg *ssh_config.Config, path string, aliases []string) *SSHConfigSource {
84 return &SSHConfigSource{
85 cfg: cfg, path: path, openSSHPath: path, aliases: aliases,
86 resolveOpenSSH: runOpenSSHEffectiveConfig,
87 effectiveByHost: map[string]EffectiveSSHConfig{},
88 effectiveErr: map[string]error{},
89 }
90 }
91
92 // Path is the file this source was parsed from (may not exist).
93 func (s *SSHConfigSource) Path() string { return s.path }
94
95 func (s *SSHConfigSource) get(alias, key string) string {
96 if s == nil || s.cfg == nil {
97 return ""
98 }
99 v, err := s.cfg.Get(alias, key)
100 if err != nil {
101 return ""
102 }
103 return strings.TrimSpace(v)
104 }
105
106 // Effective resolves alias through the user's installed OpenSSH client. This
107 // is the same source of truth used by VS Code Remote-SSH and covers Include,
108 // Host wildcards, Match rules, token expansion, and OpenSSH's precedence. If
109 // ssh is unavailable, Reasonix falls back to its embedded parser so existing
110 // installations without the executable keep working.
111 func (s *SSHConfigSource) Effective(alias string) EffectiveSSHConfig {
112 effective, _ := s.EffectiveWithError(alias)
113 return effective
114 }
115
116 // EffectiveWithError resolves alias without hiding an installed OpenSSH
117 // client's timeout or configuration error. The embedded parser is used only
118 // when ssh is genuinely unavailable (or a test explicitly disables it).
119 func (s *SSHConfigSource) EffectiveWithError(alias string) (EffectiveSSHConfig, error) {
120 if s == nil || strings.TrimSpace(alias) == "" {
121 return EffectiveSSHConfig{}, nil
122 }
123 alias = strings.TrimSpace(alias)
124 s.effectiveMu.Lock()
125 if s.effectiveByHost == nil {
126 s.effectiveByHost = map[string]EffectiveSSHConfig{}
127 }
128 if s.effectiveErr == nil {
129 s.effectiveErr = map[string]error{}
130 }
131 if cfg, ok := s.effectiveByHost[alias]; ok {
132 err := s.effectiveErr[alias]
133 s.effectiveMu.Unlock()
134 return cloneEffectiveSSHConfig(cfg), err
135 }
136 s.effectiveMu.Unlock()
137
138 var effective EffectiveSSHConfig
139 var resolveErr error
140 if s.resolveOpenSSH != nil {
141 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
142 output, err := s.resolveOpenSSH(ctx, s.openSSHPath, alias)
143 cancel()
144 if err == nil {
145 effective, err = parseOpenSSHEffectiveConfig(output, alias)
146 if err != nil {
147 resolveErr = fmt.Errorf("parse OpenSSH config for %q: %w", alias, err)
148 }
149 } else if !errors.Is(err, exec.ErrNotFound) {
150 resolveErr = fmt.Errorf("resolve OpenSSH config for %q: %w", alias, err)
151 }
152 }
153 if resolveErr == nil && effective.HostName == "" {
154 effective = s.parserEffective(alias)
155 }
156
157 s.effectiveMu.Lock()
158 s.effectiveByHost[alias] = cloneEffectiveSSHConfig(effective)
159 s.effectiveErr[alias] = resolveErr
160 s.effectiveMu.Unlock()
161 return cloneEffectiveSSHConfig(effective), resolveErr
162 }
163
164 // HasAlias reports whether alias was declared as a concrete Host entry. It is
165 // intentionally stricter than `ssh -G`: OpenSSH returns defaults for arbitrary
166 // host names, which must not make a user-facing label override an older saved
167 // Host lookup key.
168 func (s *SSHConfigSource) HasAlias(alias string) bool {
169 alias = strings.TrimSpace(alias)
170 if s == nil || alias == "" {
171 return false
172 }
173 for _, candidate := range s.aliases {
174 if candidate == alias && !strings.ContainsAny(candidate, "*?!") {
175 return true
176 }
177 }
178 return false
179 }
180
181 func runOpenSSHEffectiveConfig(ctx context.Context, path, alias string) ([]byte, error) {
182 args := []string{"-G"}
183 if strings.TrimSpace(path) != "" {
184 args = append(args, "-F", path)
185 }
186 args = append(args, "--", alias)
187 cmd := exec.CommandContext(ctx, "ssh", args...)
188 output, err := cmd.Output()
189 if err != nil {
190 return nil, fmt.Errorf("ssh -G %q: %w", alias, err)
191 }
192 return output, nil
193 }
194
195 func parseOpenSSHEffectiveConfig(output []byte, alias string) (EffectiveSSHConfig, error) {
196 var effective EffectiveSSHConfig
197 scanner := bufio.NewScanner(strings.NewReader(string(output)))
198 for scanner.Scan() {
199 line := strings.TrimSpace(scanner.Text())
200 if line == "" {
201 continue
202 }
203 key, value, ok := strings.Cut(line, " ")
204 if !ok {
205 continue
206 }
207 value = strings.TrimSpace(value)
208 switch strings.ToLower(key) {
209 case "hostname":
210 effective.HostName = value
211 case "user":
212 effective.User = value
213 case "port":
214 port, err := strconv.Atoi(value)
215 if err == nil && port > 0 && port <= 65535 {
216 effective.Port = port
217 }
218 case "identityfile":
219 if strings.EqualFold(value, "none") {
220 effective.IdentityFileNone = true
221 } else if value != "" {
222 effective.IdentityFiles = append(effective.IdentityFiles, expandHome(value))
223 }
224 case "proxyjump":
225 if !strings.EqualFold(value, "none") {
226 effective.ProxyJump = value
227 }
228 case "identitiesonly":
229 effective.IdentitiesOnly = strings.EqualFold(value, "yes")
230 }
231 }
232 if err := scanner.Err(); err != nil {
233 return EffectiveSSHConfig{}, err
234 }
235 if effective.HostName == "" {
236 effective.HostName = alias
237 }
238 return effective, nil
239 }
240
241 func (s *SSHConfigSource) parserEffective(alias string) EffectiveSSHConfig {
242 if s == nil || s.cfg == nil {
243 return EffectiveSSHConfig{HostName: alias}
244 }
245 hostName := s.get(alias, "HostName")
246 if hostName == "" {
247 hostName = alias
248 }
249 var identities []string
250 identityFileNone := false
251 if vals, err := s.cfg.GetAll(alias, "IdentityFile"); err == nil {
252 for _, value := range vals {
253 value = strings.TrimSpace(value)
254 if strings.EqualFold(value, "none") {
255 identityFileNone = true
256 continue
257 }
258 if value == "" || value == ssh_config.Default("IdentityFile") {
259 continue
260 }
261 identities = append(identities, expandHome(value))
262 }
263 }
264 port := 0
265 if value := s.get(alias, "Port"); value != "" {
266 if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 && parsed <= 65535 {
267 port = parsed
268 }
269 }
270 return EffectiveSSHConfig{
271 HostName: hostName, User: s.get(alias, "User"), Port: port,
272 IdentityFiles: identities, IdentityFileNone: identityFileNone, ProxyJump: s.get(alias, "ProxyJump"),
273 IdentitiesOnly: strings.EqualFold(s.get(alias, "IdentitiesOnly"), "yes"),
274 }
275 }
276
277 func cloneEffectiveSSHConfig(in EffectiveSSHConfig) EffectiveSSHConfig {
278 in.IdentityFiles = append([]string(nil), in.IdentityFiles...)
279 return in
280 }
281
282 // HostName returns the ssh_config HostName for alias, or "" when it would
283 // just echo the default/alias back.
284 func (s *SSHConfigSource) HostName(alias string) string {
285 v := s.Effective(alias).HostName
286 if v == "" || v == alias {
287 return ""
288 }
289 return v
290 }
291
292 func (s *SSHConfigSource) User(alias string) string { return s.Effective(alias).User }
293
294 func (s *SSHConfigSource) Port(alias string) int {
295 p := s.Effective(alias).Port
296 if p == 22 {
297 return 0
298 }
299 return p
300 }
301
302 // IdentityFile returns the first non-default identity file, ~-expanded.
303 func (s *SSHConfigSource) IdentityFile(alias string) string {
304 identities := s.IdentityFiles(alias)
305 if len(identities) == 0 {
306 return ""
307 }
308 return identities[0]
309 }
310
311 func (s *SSHConfigSource) IdentityFiles(alias string) []string {
312 return append([]string(nil), s.Effective(alias).IdentityFiles...)
313 }
314
315 func (s *SSHConfigSource) IdentityFileNone(alias string) bool {
316 return s.Effective(alias).IdentityFileNone
317 }
318
319 func (s *SSHConfigSource) ProxyJump(alias string) string { return s.Effective(alias).ProxyJump }
320
321 func (s *SSHConfigSource) IdentitiesOnly(alias string) bool {
322 return s.Effective(alias).IdentitiesOnly
323 }
324
325 // ImportedHost is one concrete Host alias surfaced by `remote import`.
326 type ImportedHost struct {
327 Alias string
328 HostName string
329 User string
330 Port int
331 IdentityFile string
332 ProxyJump string
333 }
334
335 // Aliases lists concrete (non-wildcard, non-negated) Host aliases in file
336 // order without executing ssh -G or Match exec. Effective values are resolved
337 // only for a selected connection target.
338 func (s *SSHConfigSource) Aliases() []ImportedHost {
339 if s == nil {
340 return nil
341 }
342 seen := map[string]bool{}
343 // File order is meaningful to users, so it is preserved as-is.
344 out := make([]ImportedHost, 0, len(s.aliases))
345 for _, alias := range s.aliases {
346 if alias == "" || strings.ContainsAny(alias, "*?!") || seen[alias] {
347 continue
348 }
349 seen[alias] = true
350 out = append(out, ImportedHost{Alias: alias})
351 }
352 return out
353 }
354
355 // discoverSSHAliases walks Host and Include directives in file order. The
356 // upstream parser resolves values through Include nodes but does not expose
357 // included Host declarations, so import discovery needs this small read-only
358 // pass to avoid hiding the common ~/.ssh/config.d/* layout.
359 func discoverSSHAliases(filename string, depth int, seen map[string]bool) ([]string, error) {
360 if depth > 5 {
361 return nil, nil
362 }
363 abs, err := filepath.Abs(filename)
364 if err == nil {
365 filename = abs
366 }
367 if seen[filename] {
368 return nil, nil
369 }
370 seen[filename] = true
371 f, err := os.Open(filename)
372 if err != nil {
373 return nil, err
374 }
375 defer f.Close()
376 var out []string
377 scanner := bufio.NewScanner(f)
378 for scanner.Scan() {
379 line := strings.TrimSpace(scanner.Text())
380 if line == "" || strings.HasPrefix(line, "#") {
381 continue
382 }
383 line = strings.TrimSpace(stripSSHComment(line))
384 if eq := strings.IndexByte(line, '='); eq >= 0 {
385 if space := strings.IndexAny(line, " \t"); space < 0 || eq < space {
386 line = line[:eq] + " " + line[eq+1:]
387 }
388 }
389 fields := strings.Fields(line)
390 if len(fields) < 2 {
391 continue
392 }
393 switch strings.ToLower(fields[0]) {
394 case "host":
395 for _, alias := range fields[1:] {
396 out = append(out, strings.Trim(alias, `"'`))
397 }
398 case "include":
399 for _, directive := range fields[1:] {
400 directive = expandHome(strings.Trim(directive, `"'`))
401 if !filepath.IsAbs(directive) {
402 if home, homeErr := os.UserHomeDir(); homeErr == nil {
403 directive = filepath.Join(home, ".ssh", directive)
404 }
405 }
406 matches, _ := filepath.Glob(directive)
407 for _, match := range matches {
408 aliases, includeErr := discoverSSHAliases(match, depth+1, seen)
409 if includeErr == nil {
410 out = append(out, aliases...)
411 }
412 }
413 }
414 }
415 }
416 return out, scanner.Err()
417 }
418
419 func stripSSHComment(line string) string {
420 var quote rune
421 for i, r := range line {
422 switch {
423 case quote != 0 && r == quote:
424 quote = 0
425 case quote == 0 && (r == '\'' || r == '"'):
426 quote = r
427 case quote == 0 && r == '#':
428 return line[:i]
429 }
430 }
431 return line
432 }
433
434 func expandHome(p string) string {
435 if p == "~" || strings.HasPrefix(p, "~/") {
436 if home, err := os.UserHomeDir(); err == nil {
437 return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
438 }
439 }
440 return p
441 }
442
442 lines GO