Skip to content

Commit af8622c

Browse files
authored
Merge pull request #28 from ncode/juliano/fixes
2 parents bd06dd9 + f68dc89 commit af8622c

2 files changed

Lines changed: 534 additions & 10 deletions

File tree

internal/sshConn/config.go

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,64 @@
11
package sshConn
22

33
import (
4+
"context"
5+
"errors"
46
"fmt"
57
"os"
8+
"os/exec"
69
"os/user"
710
"path/filepath"
11+
"runtime"
812
"strconv"
913
"strings"
14+
"time"
1015

1116
sshconfig "github.com/ncode/ssh_config"
1217
"golang.org/x/crypto/ssh"
1318
)
1419

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+
1562
type SSHConfigPaths struct {
1663
User string
1764
System string
@@ -133,7 +180,10 @@ func (r *SSHConfigResolver) ResolveHost(spec HostSpec, fallbackUser string) (Res
133180
if err != nil {
134181
return ResolvedHost{}, err
135182
}
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") {
137187
resolved.ProxyJump = ParseProxyJump(proxyJump)
138188
}
139189

@@ -199,7 +249,18 @@ func (r *SSHConfigResolver) resolve(cfg *sshconfig.Config, alias string) (*sshco
199249
if cfg == nil {
200250
return nil, nil
201251
}
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+
}
203264
return cfg.Resolve(ctx)
204265
}
205266

@@ -230,31 +291,70 @@ func expandPath(path string) string {
230291
return path
231292
}
232293

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".
233298
func ParseProxyJump(value string) []string {
234299
parts := strings.Split(value, ",")
235300
jumps := make([]string, 0, len(parts))
236301
for _, part := range parts {
237302
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
240308
}
309+
jumps = append(jumps, trimmed)
241310
}
242311
return jumps
243312
}
244313

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.
245322
func LoadIdentityFiles(paths []string) ([]ssh.AuthMethod, error) {
246323
methods := make([]ssh.AuthMethod, 0, len(paths))
247324
for _, path := range paths {
248325
expanded := expandPath(path)
249326
key, err := os.ReadFile(expanded)
250327
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)
252332
}
253333
signer, err := ssh.ParsePrivateKey(key)
254334
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)
256339
}
257340
methods = append(methods, ssh.PublicKeys(signer))
258341
}
259342
return methods, nil
260343
}
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

Comments
 (0)