|
1 | 1 | package sshConn |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
| 5 | + "errors" |
4 | 6 | "fmt" |
5 | 7 | "os" |
| 8 | + "os/exec" |
6 | 9 | "os/user" |
7 | 10 | "path/filepath" |
| 11 | + "runtime" |
8 | 12 | "strconv" |
9 | 13 | "strings" |
| 14 | + "time" |
10 | 15 |
|
11 | 16 | sshconfig "github.com/ncode/ssh_config" |
12 | 17 | "golang.org/x/crypto/ssh" |
13 | 18 | ) |
14 | 19 |
|
| 20 | +// matchExecTimeout bounds how long a `Match ... exec "..."` probe may run. |
| 21 | +// OpenSSH itself does not impose a timeout, but pretty resolves many hosts in |
| 22 | +// a row (including per-host ProxyJump resolution) so we cap each probe to |
| 23 | +// avoid hanging the CLI if a user's exec command never returns. |
| 24 | +const matchExecTimeout = 10 * time.Second |
| 25 | + |
| 26 | +// matchExecFunc evaluates `Match ... exec "<cmd>"` directives. It is a package |
| 27 | +// variable so tests can substitute a deterministic stub without shelling out. |
| 28 | +// It must return (true, nil) if the command exits with status 0, (false, nil) |
| 29 | +// otherwise, and only return a non-nil error for conditions the caller should |
| 30 | +// surface (currently unused by the resolver because we do not opt into strict |
| 31 | +// mode). |
| 32 | +var matchExecFunc = shellMatchExec |
| 33 | + |
| 34 | +// shellMatchExec runs cmd via the local shell (sh -c / cmd /C on Windows) and |
| 35 | +// reports whether it succeeded. The command has already had its %-tokens |
| 36 | +// expanded by ssh_config before this function is invoked. |
| 37 | +func shellMatchExec(cmd string) (bool, error) { |
| 38 | + if strings.TrimSpace(cmd) == "" { |
| 39 | + return false, nil |
| 40 | + } |
| 41 | + ctx, cancel := context.WithTimeout(context.Background(), matchExecTimeout) |
| 42 | + defer cancel() |
| 43 | + |
| 44 | + var c *exec.Cmd |
| 45 | + if runtime.GOOS == "windows" { |
| 46 | + c = exec.CommandContext(ctx, "cmd", "/C", cmd) |
| 47 | + } else { |
| 48 | + c = exec.CommandContext(ctx, "/bin/sh", "-c", cmd) |
| 49 | + } |
| 50 | + c.Stdin = nil |
| 51 | + c.Stdout = nil |
| 52 | + c.Stderr = nil |
| 53 | + |
| 54 | + if err := c.Run(); err != nil { |
| 55 | + // Any non-zero exit or failure to launch counts as "did not match", |
| 56 | + // mirroring OpenSSH's treatment of Match exec probes. |
| 57 | + return false, nil |
| 58 | + } |
| 59 | + return true, nil |
| 60 | +} |
| 61 | + |
15 | 62 | type SSHConfigPaths struct { |
16 | 63 | User string |
17 | 64 | System string |
@@ -133,7 +180,10 @@ func (r *SSHConfigResolver) ResolveHost(spec HostSpec, fallbackUser string) (Res |
133 | 180 | if err != nil { |
134 | 181 | return ResolvedHost{}, err |
135 | 182 | } |
136 | | - if proxyJump != "" { |
| 183 | + // OpenSSH treats `ProxyJump none` as an explicit opt-out that cancels |
| 184 | + // ProxyJump inherited from broader-matching blocks. Skip parsing in that |
| 185 | + // case so we don't try to dial the literal host "none". |
| 186 | + if proxyJump != "" && !strings.EqualFold(strings.TrimSpace(proxyJump), "none") { |
137 | 187 | resolved.ProxyJump = ParseProxyJump(proxyJump) |
138 | 188 | } |
139 | 189 |
|
@@ -199,7 +249,18 @@ func (r *SSHConfigResolver) resolve(cfg *sshconfig.Config, alias string) (*sshco |
199 | 249 | if cfg == nil { |
200 | 250 | return nil, nil |
201 | 251 | } |
202 | | - ctx := sshconfig.Context{HostArg: alias, OriginalHost: alias, LocalUser: currentUser()} |
| 252 | + ctx := sshconfig.Context{ |
| 253 | + HostArg: alias, |
| 254 | + OriginalHost: alias, |
| 255 | + LocalUser: currentUser(), |
| 256 | + // Providing Exec enables `Match host X exec "..."` blocks to be |
| 257 | + // evaluated. Without it the library silently treats every exec |
| 258 | + // predicate as non-matching, which causes directives like |
| 259 | + // Match host jump-alias exec "nc -zG 1 primary.example.net 22" |
| 260 | + // HostName primary.example.net |
| 261 | + // to be skipped, leaving the alias unresolvable via DNS. |
| 262 | + Exec: matchExecFunc, |
| 263 | + } |
203 | 264 | return cfg.Resolve(ctx) |
204 | 265 | } |
205 | 266 |
|
@@ -230,31 +291,70 @@ func expandPath(path string) string { |
230 | 291 | return path |
231 | 292 | } |
232 | 293 |
|
| 294 | +// ParseProxyJump splits a ProxyJump value into individual jump hosts. Empty |
| 295 | +// components are dropped, and the OpenSSH "none" sentinel (which disables |
| 296 | +// ProxyJump) collapses the result to an empty slice so callers never try to |
| 297 | +// dial a literal host named "none". |
233 | 298 | func ParseProxyJump(value string) []string { |
234 | 299 | parts := strings.Split(value, ",") |
235 | 300 | jumps := make([]string, 0, len(parts)) |
236 | 301 | for _, part := range parts { |
237 | 302 | trimmed := strings.TrimSpace(part) |
238 | | - if trimmed != "" { |
239 | | - jumps = append(jumps, trimmed) |
| 303 | + if trimmed == "" { |
| 304 | + continue |
| 305 | + } |
| 306 | + if strings.EqualFold(trimmed, "none") { |
| 307 | + return nil |
240 | 308 | } |
| 309 | + jumps = append(jumps, trimmed) |
241 | 310 | } |
242 | 311 | return jumps |
243 | 312 | } |
244 | 313 |
|
| 314 | +// LoadIdentityFiles returns SSH auth methods for every identity file that |
| 315 | +// contains a usable private key locally. |
| 316 | +// |
| 317 | +// IdentityFile entries that the local process cannot use directly (missing |
| 318 | +// files, public-key-only files backing a hardware token such as yubikey-agent, |
| 319 | +// or passphrase-protected keys) are skipped so that authentication can still |
| 320 | +// proceed through the SSH agent. This mirrors OpenSSH's behaviour, which |
| 321 | +// silently tolerates these cases instead of aborting the connection. |
245 | 322 | func LoadIdentityFiles(paths []string) ([]ssh.AuthMethod, error) { |
246 | 323 | methods := make([]ssh.AuthMethod, 0, len(paths)) |
247 | 324 | for _, path := range paths { |
248 | 325 | expanded := expandPath(path) |
249 | 326 | key, err := os.ReadFile(expanded) |
250 | 327 | if err != nil { |
251 | | - return nil, err |
| 328 | + if errors.Is(err, os.ErrNotExist) { |
| 329 | + continue |
| 330 | + } |
| 331 | + return nil, fmt.Errorf("unable to read identity file %q: %w", expanded, err) |
252 | 332 | } |
253 | 333 | signer, err := ssh.ParsePrivateKey(key) |
254 | 334 | if err != nil { |
255 | | - return nil, err |
| 335 | + if isAgentCoveredIdentity(key, err) { |
| 336 | + continue |
| 337 | + } |
| 338 | + return nil, fmt.Errorf("unable to parse identity file %q: %w", expanded, err) |
256 | 339 | } |
257 | 340 | methods = append(methods, ssh.PublicKeys(signer)) |
258 | 341 | } |
259 | 342 | return methods, nil |
260 | 343 | } |
| 344 | + |
| 345 | +// isAgentCoveredIdentity reports whether an identity file that we failed to |
| 346 | +// parse as a private key should be delegated to the SSH agent. Typical cases: |
| 347 | +// |
| 348 | +// - The file stores only a public key (e.g. a hardware-token pub key in |
| 349 | +// `~/.ssh/...`) while the private half lives on the token itself and is |
| 350 | +// exposed exclusively through a signing agent. |
| 351 | +// - The key is encrypted and no passphrase is available to this process. |
| 352 | +func isAgentCoveredIdentity(data []byte, parseErr error) bool { |
| 353 | + if _, ok := parseErr.(*ssh.PassphraseMissingError); ok { |
| 354 | + return true |
| 355 | + } |
| 356 | + if _, _, _, _, err := ssh.ParseAuthorizedKey(data); err == nil { |
| 357 | + return true |
| 358 | + } |
| 359 | + return false |
| 360 | +} |
0 commit comments