Skip to content

Commit f622c65

Browse files
authored
feat: implement SSH agent for Windows with tests (#2644)
#2643 ## Implementation 1. Add platform-aware identity-agent dialing: on Window, default to `//./pipe/openssh-ssh-agent`. 2. Keep Unix agent dialing via Unix sockets; unify agent resolution across platforms. 3. Add cross-platform unit tests covering dialer selection and failure paths.
1 parent 3d878bd commit f622c65

File tree

8 files changed

+119
-15
lines changed

8 files changed

+119
-15
lines changed

docs/docs/connections.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ In addition to the regular ssh config file, wave also has its own config file to
167167
| ssh:userknownhostsfile | A list containing the paths of any user host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
168168
| ssh:globalknownhostsfile | A list containing the paths of any global host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
169169

170+
### SSH Agent Detection
171+
172+
Wave resolves the identity agent path in this order:
173+
174+
- If `ssh:identityagent` (or `IdentityAgent` in SSH config) is set for the connection, that socket or pipe is used.
175+
- If not set on Windows, Wave falls back to the built-in OpenSSH agent pipe `\\.\pipe\openssh-ssh-agent`. Ensure the **OpenSSH Authentication Agent** service is running.
176+
- If not set on macOS/Linux, Wave queries your shell environment for `SSH_AUTH_SOCK` to detect the agent path automatically.
177+
170178
### Example Internal Configurations
171179

172180
Here are a couple examples of things you can do using the internal configuration file `connections.json`:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/wavetermdev/waveterm
33
go 1.24.6
44

55
require (
6+
github.com/Microsoft/go-winio v0.6.2
67
github.com/alexflint/go-filemutex v1.3.0
78
github.com/aws/aws-sdk-go-v2 v1.41.0
89
github.com/aws/aws-sdk-go-v2/config v1.32.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
1414
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
1515
github.com/0xrawsec/golang-utils v1.3.2 h1:ww4jrtHRSnX9xrGzJYbalx5nXoZewy4zPxiY+ubJgtg=
1616
github.com/0xrawsec/golang-utils v1.3.2/go.mod h1:m7AzHXgdSAkFCD9tWWsApxNVxMlyy7anpPVOyT/yM7E=
17+
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
18+
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
1719
github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM=
1820
github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A=
1921
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=

pkg/remote/sshagent_unix.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !windows
2+
3+
package remote
4+
5+
import "net"
6+
7+
// dialIdentityAgent connects to a Unix domain socket identity agent.
8+
func dialIdentityAgent(agentPath string) (net.Conn, error) {
9+
return net.Dial("unix", agentPath)
10+
}

pkg/remote/sshagent_unix_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//go:build !windows
2+
3+
package remote
4+
5+
import (
6+
"net"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func TestDialIdentityAgentUnix(t *testing.T) {
12+
socketPath := filepath.Join(t.TempDir(), "agent.sock")
13+
14+
ln, err := net.Listen("unix", socketPath)
15+
if err != nil {
16+
t.Fatalf("listen unix socket: %v", err)
17+
}
18+
defer ln.Close()
19+
20+
acceptDone := make(chan struct{})
21+
go func() {
22+
conn, _ := ln.Accept()
23+
if conn != nil {
24+
conn.Close()
25+
}
26+
close(acceptDone)
27+
}()
28+
29+
conn, err := dialIdentityAgent(socketPath)
30+
if err != nil {
31+
t.Fatalf("dialIdentityAgent: %v", err)
32+
}
33+
conn.Close()
34+
<-acceptDone
35+
}

pkg/remote/sshagent_windows.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build windows
2+
3+
package remote
4+
5+
import (
6+
"net"
7+
"time"
8+
9+
"github.com/Microsoft/go-winio"
10+
)
11+
12+
// dialIdentityAgent connects to the Windows OpenSSH agent named pipe.
13+
func dialIdentityAgent(agentPath string) (net.Conn, error) {
14+
timeout := 500 * time.Millisecond
15+
return winio.DialPipe(agentPath, &timeout)
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build windows
2+
3+
package remote
4+
5+
import (
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestDialIdentityAgentWindowsTimeout(t *testing.T) {
11+
start := time.Now()
12+
_, err := dialIdentityAgent(`\\.\\pipe\\waveterm-nonexistent-agent`)
13+
if err == nil {
14+
t.Skip("unexpectedly connected to a test pipe; skipping")
15+
}
16+
// Optionally verify error indicates connection/timeout failure
17+
t.Logf("dialIdentityAgent returned expected error: %v", err)
18+
if time.Since(start) > 3*time.Second {
19+
t.Fatalf("dialIdentityAgent exceeded expected timeout window")
20+
}
21+
}

pkg/remote/sshclient.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"os/exec"
1818
"os/user"
1919
"path/filepath"
20+
"runtime"
2021
"strings"
2122
"sync"
2223
"time"
@@ -233,12 +234,12 @@ func createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName str
233234
}
234235
}()
235236
blocklogger.Infof(connCtx, "[conndebug] Password Authentication requested from connection %s...\n", remoteDisplayName)
236-
237+
237238
if password != nil {
238239
blocklogger.Infof(connCtx, "[conndebug] using password from secret store, sending to ssh\n")
239240
return *password, nil
240241
}
241-
242+
242243
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
243244
defer cancelFn()
244245
queryText := fmt.Sprintf(
@@ -612,10 +613,11 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor
612613

613614
// 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
614615
// TODO: Update if we decide to support PKCS11Provider and SecurityKeyProvider
615-
if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) {
616-
conn, err := net.Dial("unix", utilfn.SafeDeref(sshKeywords.SshIdentityAgent))
616+
agentPath := strings.TrimSpace(utilfn.SafeDeref(sshKeywords.SshIdentityAgent))
617+
if !utilfn.SafeDeref(sshKeywords.SshIdentitiesOnly) && agentPath != "" {
618+
conn, err := dialIdentityAgent(agentPath)
617619
if err != nil {
618-
log.Printf("Failed to open Identity Agent Socket: %v", err)
620+
log.Printf("Failed to open Identity Agent Socket %q: %v", agentPath, err)
619621
} else {
620622
agentClient = agent.NewClient(conn)
621623
authSockSigners, _ = agentClient.Signers()
@@ -900,17 +902,26 @@ func findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywor
900902
return nil, err
901903
}
902904
if identityAgentRaw == "" {
903-
shellPath := shellutil.DetectLocalShellPath()
904-
authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}")
905-
sshAuthSock, err := authSockCommand.Output()
906-
if err == nil {
907-
agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock))))
908-
if err != nil {
909-
return nil, err
910-
}
911-
sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath)
905+
if runtime.GOOS == "windows" {
906+
sshKeywords.SshIdentityAgent = utilfn.Ptr(`\\.\pipe\openssh-ssh-agent`)
912907
} else {
913-
log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err)
908+
shellPath := shellutil.DetectLocalShellPath()
909+
authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}")
910+
sshAuthSock, err := authSockCommand.Output()
911+
if err == nil {
912+
trimmedSock := strings.TrimSpace(string(sshAuthSock))
913+
if trimmedSock == "" {
914+
log.Printf("SSH_AUTH_SOCK is empty in shell environment")
915+
} else {
916+
agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(trimmedSock))
917+
if err != nil {
918+
return nil, err
919+
}
920+
sshKeywords.SshIdentityAgent = utilfn.Ptr(agentPath)
921+
}
922+
} else {
923+
log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err)
924+
}
914925
}
915926
} else {
916927
agentPath, err := wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw))

0 commit comments

Comments
 (0)