From 8bb989fc6ff5930f486176ba83595ec3a255352a Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:29:26 -0700 Subject: [PATCH] Add ssh-agent support to ssh client (#673) This will attempt to use the ssh agent before trying other ssh keys in case other integrations are being used through it. --------- Co-authored-by: Evan Simkowitz --- scripthaus.md | 4 +- waveshell/pkg/utilfn/utilfn.go | 24 ++++++++ wavesrv/pkg/remote/remote.go | 8 ++- wavesrv/pkg/remote/sshclient.go | 99 ++++++++++++++++++++++++++++++--- 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/scripthaus.md b/scripthaus.md index c47969557..2ad1bc11e 100644 --- a/scripthaus.md +++ b/scripthaus.md @@ -76,7 +76,7 @@ function buildWaveShell { } function buildWaveSrv { # adds -extldflags=-static, *only* on linux (macos does not support fully static binaries) to avoid a glibc dependency - (cd wavesrv; CGO_ENABLED=1 GOARCH=$1 go build -tags "osusergo,netgo,sqlite_omit_load_extension" -ldflags "-linkmode 'external' -extldflags=-static $GO_LDFLAGS -X main.WaveVersion=$WAVESRV_VERSION" -o ../bin/wavesrv.$1 ./cmd) + (cd wavesrv; CGO_ENABLED=1 GOARCH=$1 go build -tags "osusergo,netcgo,sqlite_omit_load_extension" -ldflags "-linkmode 'external' -extldflags=-static $GO_LDFLAGS -X main.WaveVersion=$WAVESRV_VERSION" -o ../bin/wavesrv.$1 ./cmd) } buildWaveShell darwin amd64 buildWaveShell darwin arm64 @@ -90,7 +90,7 @@ yarn run electron-builder -c electron-builder.config.js -l -p never # @scripthaus command build-wavesrv WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))') cd wavesrv -CGO_ENABLED=1 go build -tags "osusergo,netgo,sqlite_omit_load_extension" -ldflags "-X main.BuildTime=$(date +'%Y%m%d%H%M') -X main.WaveVersion=$WAVESRV_VERSION" -o ../bin/wavesrv ./cmd +CGO_ENABLED=1 go build -tags "osusergo,netcgo,sqlite_omit_load_extension" -ldflags "-X main.BuildTime=$(date +'%Y%m%d%H%M') -X main.WaveVersion=$WAVESRV_VERSION" -o ../bin/wavesrv ./cmd ``` ```bash diff --git a/waveshell/pkg/utilfn/utilfn.go b/waveshell/pkg/utilfn/utilfn.go index b9ef0e5c6..48f78e5b0 100644 --- a/waveshell/pkg/utilfn/utilfn.go +++ b/waveshell/pkg/utilfn/utilfn.go @@ -18,6 +18,7 @@ import ( "os/exec" "regexp" "sort" + "strconv" "strings" "syscall" "unicode/utf8" @@ -673,3 +674,26 @@ func GetFirstLine(s string) string { } return s[0:idx] } + +func TrimQuotes(s string) (string, bool) { + if len(s) > 2 && s[0] == '"' { + trimmed, err := strconv.Unquote(s) + if err != nil { + return s, false + } + return trimmed, true + } + return s, false +} + +func TryTrimQuotes(s string) string { + trimmed, _ := TrimQuotes(s) + return trimmed +} + +func ReplaceQuotes(s string, shouldReplace bool) string { + if shouldReplace { + return strconv.Quote(s) + } + return s +} diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index f738723af..fa557ebfc 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -1314,14 +1314,15 @@ func (wsh *WaveshellProc) RunInstall(autoInstall bool) { wsh.WriteToPtyBuffer("*error: cannot install on a local remote\n") return } - _, err = shellapi.MakeShellApi(packet.ShellType_bash) + sapi, err := shellapi.MakeShellApi(wsh.GetShellType()) if err != nil { wsh.WriteToPtyBuffer("*error: %v\n", err) return } if wsh.Client == nil { remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName) - client, err := ConnectToClient(makeClientCtx, remoteCopy.SSHOpts, remoteDisplayName) + sshAuthSock, _ := exec.CommandContext(makeClientCtx, sapi.GetLocalShellPath(), "-c", "echo \"${SSH_AUTH_SOCK}\"").CombinedOutput() + client, err := ConnectToClient(makeClientCtx, remoteCopy.SSHOpts, remoteDisplayName, strings.TrimSpace(string(sshAuthSock))) if err != nil { statusErr := fmt.Errorf("ssh cannot connect to client: %w", err) wsh.setInstallErrorStatus(statusErr) @@ -1614,7 +1615,8 @@ func (wsh *WaveshellProc) createWaveshellSession(clientCtx context.Context, remo wsSession = shexec.CmdWrap{Cmd: ecmd} } else if wsh.Client == nil { remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName) - client, err := ConnectToClient(clientCtx, remoteCopy.SSHOpts, remoteDisplayName) + sshAuthSock, _ := exec.CommandContext(clientCtx, sapi.GetLocalShellPath(), "-c", "echo \"${SSH_AUTH_SOCK}\"").CombinedOutput() + client, err := ConnectToClient(clientCtx, remoteCopy.SSHOpts, remoteDisplayName, strings.TrimSpace(string(sshAuthSock))) if err != nil { return nil, fmt.Errorf("ssh cannot connect to client: %w", err) } diff --git a/wavesrv/pkg/remote/sshclient.go b/wavesrv/pkg/remote/sshclient.go index 6d13446c1..3807f8643 100644 --- a/wavesrv/pkg/remote/sshclient.go +++ b/wavesrv/pkg/remote/sshclient.go @@ -24,10 +24,12 @@ import ( "github.com/kevinburke/ssh_config" "github.com/skeema/knownhosts" "github.com/wavetermdev/waveterm/waveshell/pkg/base" + "github.com/wavetermdev/waveterm/waveshell/pkg/utilfn" "github.com/wavetermdev/waveterm/wavesrv/pkg/scbus" "github.com/wavetermdev/waveterm/wavesrv/pkg/sstore" "github.com/wavetermdev/waveterm/wavesrv/pkg/userinput" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" xknownhosts "golang.org/x/crypto/ssh/knownhosts" ) @@ -68,7 +70,7 @@ func createDummySigner() ([]ssh.Signer, error) { // they were successes. An error in this function prevents any other // keys from being attempted. But if there's an error because of a dummy // file, the library can still try again with a new key. -func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, passphrase string) func() ([]ssh.Signer, error) { +func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, passphrase string, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent) func() ([]ssh.Signer, error) { var identityFiles []string existingKeys := make(map[string][]byte) @@ -86,7 +88,19 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, // require pointer to modify list in closure identityFilesPtr := &identityFiles + var authSockSigners []ssh.Signer + authSockSigners = append(authSockSigners, authSockSignersExt...) + authSockSignersPtr := &authSockSigners + return func() ([]ssh.Signer, error) { + // try auth sock + if len(*authSockSignersPtr) != 0 { + authSockSigner := (*authSockSignersPtr)[0] + *authSockSignersPtr = (*authSockSignersPtr)[1:] + return []ssh.Signer{authSockSigner}, nil + } + + // try manual identity files if len(*identityFilesPtr) == 0 { return nil, fmt.Errorf("no identity files remaining") } @@ -98,6 +112,24 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, // skip this key and try with the next return createDummySigner() } + + unencryptedPrivateKey, err := ssh.ParseRawPrivateKey(privateKey) + if err == nil { + signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) + if err == nil { + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } + return []ssh.Signer{signer}, err + } + } + if _, ok := err.(*ssh.PassphraseMissingError); !ok { + // skip this key and try with the next + return createDummySigner() + } + signer, err := ssh.ParsePrivateKey(privateKey) if err == nil { return []ssh.Signer{signer}, err @@ -107,9 +139,17 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, return createDummySigner() } - signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase)) + unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte(passphrase)) if err == nil { - return []ssh.Signer{signer}, err + signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) + if err == nil { + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } + return []ssh.Signer{signer}, err + } } if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" { // skip this key and try with the next @@ -135,11 +175,22 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, // trying keys return nil, UserInputCancelError{Err: err} } - signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text)) + + unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte([]byte(response.Text))) if err != nil { // skip this key and try with the next return createDummySigner() } + signer, err = ssh.NewSignerFromKey(unencryptedPrivateKey) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } return []ssh.Signer{signer}, err } } @@ -538,8 +589,8 @@ func DialContext(ctx context.Context, network string, addr string, config *ssh.C return ssh.NewClient(c, chans, reqs), nil } -func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Client, error) { - sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) +func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDisplayName string, sshAuthSock string) (*ssh.Client, error) { + sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost, sshAuthSock) if err != nil { return nil, err } @@ -549,7 +600,17 @@ func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDispla return nil, err } - publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, opts.SSHPassword)) + conn, err := net.Dial("unix", sshKeywords.IdentityAgent) + var authSockSigners []ssh.Signer + var agentClient agent.ExtendedAgent + if err != nil { + log.Printf("Failed to open Identity Agent Socket: %v", err) + } else { + agentClient = agent.NewClient(conn) + authSockSigners, _ = agentClient.Signers() + } + + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, opts.SSHPassword, authSockSigners, agentClient)) keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(connCtx, opts.SSHPassword, remoteDisplayName)) passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(connCtx, opts.SSHPassword, remoteDisplayName)) @@ -564,7 +625,7 @@ func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDispla // exclude gssapi-with-mic and hostbased until implemented authMethodMap := map[string]ssh.AuthMethod{ - "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.IdentityFile)), + "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.IdentityFile)+len(authSockSigners)), "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, attemptsAllowed), "password": ssh.RetryableAuthMethod(passwordCallback, attemptsAllowed), } @@ -613,6 +674,8 @@ type SshKeywords struct { PasswordAuthentication bool KbdInteractiveAuthentication bool PreferredAuthentications []string + AddKeysToAgent bool + IdentityAgent string } func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { @@ -662,6 +725,8 @@ func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*Ssh sshKeywords.PasswordAuthentication = configKeywords.PasswordAuthentication sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications + sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent + sshKeywords.IdentityAgent = configKeywords.IdentityAgent return sshKeywords, nil } @@ -669,7 +734,7 @@ func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*Ssh // note that a `var == "yes"` will default to false // but `var != "no"` will default to true // when given unexpected strings -func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { +func findSshConfigKeywords(hostPattern string, sshAuthSock string) (*SshKeywords, error) { ssh_config.ReloadConfigs() sshKeywords := &SshKeywords{} var err error @@ -724,5 +789,21 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { } sshKeywords.PreferredAuthentications = strings.Split(preferredAuthenticationsRaw, ",") + addKeysToAgentRaw, err := ssh_config.GetStrict(hostPattern, "AddKeysToAgent") + if err != nil { + return nil, err + } + sshKeywords.AddKeysToAgent = (strings.ToLower(addKeysToAgentRaw) == "yes") + + identityAgentRaw, err := ssh_config.GetStrict(hostPattern, "IdentityAgent") + if err != nil { + return nil, err + } + if identityAgentRaw == "" { + sshKeywords.IdentityAgent = base.ExpandHomeDir(utilfn.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock)))) + } else { + sshKeywords.IdentityAgent = base.ExpandHomeDir(utilfn.TryTrimQuotes(identityAgentRaw)) + } + return sshKeywords, nil }