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