diff --git a/pkg/remote/connparse/connparse.go b/pkg/remote/connparse/connparse.go index 18c4e5e274..5472e6c3ee 100644 --- a/pkg/remote/connparse/connparse.go +++ b/pkg/remote/connparse/connparse.go @@ -92,26 +92,31 @@ func GetConnNameFromContext(ctx context.Context) (string, error) { return handler.GetRpcContext().Conn, nil } -// ParseURI parses a connection URI and returns the connection type, host/path, and parameters. +// It recognizes explicit schemes (scheme://...), shorthand forms starting with "//host/path" and WSL-style URIs (wsl://distro/path). When no scheme is provided the scheme defaults to "wsh" and the host may be set to the current connection marker or to the local connection name for local shorthand. For the "wsh" scheme: missing host defaults to the local connection name; paths beginning with "/~" are normalized by removing the leading slash; other paths may receive a prepended "/" except when they look like Windows drive paths, start with ".", "~", or already start with a slash. Trailing slashes in the original URI are preserved in the parsed Path. func ParseURI(uri string) (*Connection, error) { - split := strings.SplitN(uri, "://", 2) var scheme string var rest string - if len(split) > 1 { - scheme = split[0] - rest = strings.TrimPrefix(split[1], "//") + + if strings.HasPrefix(uri, "//") { + rest = strings.TrimPrefix(uri, "//") } else { - rest = split[0] + split := strings.SplitN(uri, "://", 2) + if len(split) > 1 { + scheme = split[0] + rest = strings.TrimPrefix(split[1], "//") + } else { + rest = split[0] + } } var host string var remotePath string parseGenericPath := func() { - split = strings.SplitN(rest, "/", 2) - host = split[0] - if len(split) > 1 && split[1] != "" { - remotePath = split[1] + parts := strings.SplitN(rest, "/", 2) + host = parts[0] + if len(parts) > 1 && parts[1] != "" { + remotePath = parts[1] } else if strings.HasSuffix(rest, "/") { // preserve trailing slash remotePath = "/" @@ -133,8 +138,9 @@ func ParseURI(uri string) (*Connection, error) { if scheme == "" { scheme = ConnectionTypeWsh addPrecedingSlash = false - if len(rest) != len(uri) { - // This accounts for when the uri starts with "//", which would get trimmed in the first split. + if strings.HasPrefix(uri, "//") { + rest = strings.TrimPrefix(uri, "//") + // Handles remote shorthand like //host/path and WSL URIs //wsl://distro/path parseWshPath() } else if strings.HasPrefix(rest, "/~") { host = wshrpc.LocalConnName @@ -166,4 +172,4 @@ func ParseURI(uri string) (*Connection, error) { Path: remotePath, } return conn, nil -} +} \ No newline at end of file diff --git a/pkg/remote/sshagent_windows.go b/pkg/remote/sshagent_windows.go new file mode 100644 index 0000000000..4f542158be --- /dev/null +++ b/pkg/remote/sshagent_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package remote + +import ( + "net" + "time" + + "github.com/Microsoft/go-winio" +) + +// dialIdentityAgent connects to the Windows OpenSSH agent named pipe at the given path and returns the established connection or an error. +func dialIdentityAgent(agentPath string) (net.Conn, error) { + timeout := 2 * time.Second + return winio.DialPipe(agentPath, &timeout) +} \ No newline at end of file diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index c7419fd940..7d29063b51 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -17,6 +17,7 @@ import ( "os/exec" "os/user" "path/filepath" + "runtime" "strings" "sync" "time" @@ -224,6 +225,14 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnK } } +// createPasswordCallbackPrompt returns a function that obtains a password for SSH authentication. +// +// The returned callback returns a password string or an error when password acquisition fails. +// If the optional `password` pointer is non-nil, its value is returned directly without prompting. +// Otherwise the callback prompts the user (with a 60 second timeout) for a password using the +// provided connection context and includes `remoteDisplayName` in the prompt. On prompt or input +// errors the callback returns a ConnectionError that wraps the underlying error and includes +// `debugInfo` for diagnostics. The callback also converts panics into errors. func createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, password *string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) { return func() (secret string, outErr error) { defer func() { @@ -233,12 +242,12 @@ func createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName str } }() blocklogger.Infof(connCtx, "[conndebug] Password Authentication requested from connection %s...\n", remoteDisplayName) - + if password != nil { blocklogger.Infof(connCtx, "[conndebug] using password from secret store, sending to ssh\n") return *password, nil } - + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() queryText := fmt.Sprintf( @@ -598,6 +607,22 @@ func createHostKeyCallback(ctx context.Context, sshKeywords *wconfig.ConnKeyword return waveHostKeyCallback, hostKeyAlgorithms, nil } +// createClientConfig builds an ssh.ClientConfig configured for the target described +// by sshKeywords and using connCtx for context-aware operations. +// +// The returned ClientConfig is populated with the selected user, authentication +// methods (publickey, keyboard-interactive, password) ordered by the host's +// PreferredAuthentications, a host key verification callback, and the host key +// algorithms appropriate for the destination. Batch mode, IdentitiesOnly, and +// preferred-authentication flags from sshKeywords are honored. +// +// This function may: +// - open and query an SSH identity agent socket if configured and allowed; +// - retrieve a password from the configured secret store when SshPasswordSecretName +// is set. +// +// It returns a non-nil error when required setup steps fail (for example, secret +// retrieval or host key callback construction). func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywords, debugInfo *ConnectionDebugInfo) (*ssh.ClientConfig, error) { chosenUser := utilfn.SafeDeref(sshKeywords.SshUser) chosenHostName := utilfn.SafeDeref(sshKeywords.SshHostName) @@ -612,10 +637,11 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor // IdentitiesOnly indicates that only the keys listed in the identity and certificate files or passed as arguments should be used, even if there are matches in the SSH Agent, PKCS11Provider, or SecurityKeyProvider. See https://man.openbsd.org/ssh_config#IdentitiesOnly // TODO: Update if we decide to support PKCS11Provider and SecurityKeyProvider - if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) { - conn, err := net.Dial("unix", utilfn.SafeDeref(sshKeywords.SshIdentityAgent)) + agentPath := strings.TrimSpace(utilfn.SafeDeref(sshKeywords.SshIdentityAgent)) + if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) && agentPath != "" { + conn, err := dialIdentityAgent(agentPath) if err != nil { - log.Printf("Failed to open Identity Agent Socket: %v", err) + log.Printf("Failed to open Identity Agent Socket %q: %v", agentPath, err) } else { agentClient = agent.NewClient(conn) authSockSigners, _ = agentClient.Signers() @@ -801,7 +827,19 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. // note that a `var == "yes"` will default to false // but `var != "no"` will default to true -// when given unexpected strings +// findSshConfigKeywords reads SSH configuration for the provided hostPattern and returns a populated +// wconfig.ConnKeywords describing the resolved connection parameters. +// +// The returned ConnKeywords includes resolved values for user, hostname, port, identity files, +// batch mode, publickey/password/keyboard-interactive authentication flags, preferred +// authentications, AddKeysToAgent, IdentitiesOnly, IdentityAgent (with home‑dir expansion and +// platform-aware fallbacks), ProxyJump entries, and user/global known_hosts files. Identity file +// paths are trimmed of surrounding quotes; boolean-style options are normalized from common SSH +// values (e.g., "yes"/"no"). ProxyJump entries are split on commas and empty/"none" values are +// ignored. Known-hosts file fields are split on whitespace. +// +// An error is returned if reading or expanding SSH configuration values fails. Panics are +// converted into errors and returned. func findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywords, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:find-ssh-config-keywords", recover()) @@ -900,17 +938,27 @@ func findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywor return nil, err } if identityAgentRaw == "" { - shellPath := shellutil.DetectLocalShellPath() - authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}") - sshAuthSock, err := authSockCommand.Output() - if err == nil { - agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock)))) + if envSock := os.Getenv("SSH_AUTH_SOCK"); envSock != "" { + agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(envSock)) if err != nil { return nil, err } sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath) + } else if runtime.GOOS == "windows" { + sshKeywords.SshIdentityAgent = utilfn.Ptr(`\\.\\pipe\\openssh-ssh-agent`) } else { - log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err) + shellPath := shellutil.DetectLocalShellPath() + authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}") + sshAuthSock, err := authSockCommand.Output() + if err == nil { + agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock)))) + if err != nil { + return nil, err + } + sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath) + } else { + log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err) + } } } else { agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw)) @@ -1040,4 +1088,4 @@ func mergeKeywords(oldKeywords *wconfig.ConnKeywords, newKeywords *wconfig.ConnK } return &outKeywords -} +} \ No newline at end of file