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 <esimkowitz@users.noreply.github.com>
This commit is contained in:
Sylvie Crowe 2024-09-17 16:29:26 -07:00 committed by GitHub
parent 0505205df5
commit 8bb989fc6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 14 deletions

View File

@ -76,7 +76,7 @@ function buildWaveShell {
} }
function buildWaveSrv { function buildWaveSrv {
# adds -extldflags=-static, *only* on linux (macos does not support fully static binaries) to avoid a glibc dependency # 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 amd64
buildWaveShell darwin arm64 buildWaveShell darwin arm64
@ -90,7 +90,7 @@ yarn run electron-builder -c electron-builder.config.js -l -p never
# @scripthaus command build-wavesrv # @scripthaus command build-wavesrv
WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))') WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))')
cd wavesrv 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 ```bash

View File

@ -18,6 +18,7 @@ import (
"os/exec" "os/exec"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"syscall" "syscall"
"unicode/utf8" "unicode/utf8"
@ -673,3 +674,26 @@ func GetFirstLine(s string) string {
} }
return s[0:idx] 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
}

View File

@ -1314,14 +1314,15 @@ func (wsh *WaveshellProc) RunInstall(autoInstall bool) {
wsh.WriteToPtyBuffer("*error: cannot install on a local remote\n") wsh.WriteToPtyBuffer("*error: cannot install on a local remote\n")
return return
} }
_, err = shellapi.MakeShellApi(packet.ShellType_bash) sapi, err := shellapi.MakeShellApi(wsh.GetShellType())
if err != nil { if err != nil {
wsh.WriteToPtyBuffer("*error: %v\n", err) wsh.WriteToPtyBuffer("*error: %v\n", err)
return return
} }
if wsh.Client == nil { if wsh.Client == nil {
remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName) 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 { if err != nil {
statusErr := fmt.Errorf("ssh cannot connect to client: %w", err) statusErr := fmt.Errorf("ssh cannot connect to client: %w", err)
wsh.setInstallErrorStatus(statusErr) wsh.setInstallErrorStatus(statusErr)
@ -1614,7 +1615,8 @@ func (wsh *WaveshellProc) createWaveshellSession(clientCtx context.Context, remo
wsSession = shexec.CmdWrap{Cmd: ecmd} wsSession = shexec.CmdWrap{Cmd: ecmd}
} else if wsh.Client == nil { } else if wsh.Client == nil {
remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName) 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 { if err != nil {
return nil, fmt.Errorf("ssh cannot connect to client: %w", err) return nil, fmt.Errorf("ssh cannot connect to client: %w", err)
} }

View File

@ -24,10 +24,12 @@ import (
"github.com/kevinburke/ssh_config" "github.com/kevinburke/ssh_config"
"github.com/skeema/knownhosts" "github.com/skeema/knownhosts"
"github.com/wavetermdev/waveterm/waveshell/pkg/base" "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/scbus"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore" "github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/userinput" "github.com/wavetermdev/waveterm/wavesrv/pkg/userinput"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
xknownhosts "golang.org/x/crypto/ssh/knownhosts" 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 // 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 // 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. // 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 var identityFiles []string
existingKeys := make(map[string][]byte) existingKeys := make(map[string][]byte)
@ -86,7 +88,19 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
// require pointer to modify list in closure // require pointer to modify list in closure
identityFilesPtr := &identityFiles identityFilesPtr := &identityFiles
var authSockSigners []ssh.Signer
authSockSigners = append(authSockSigners, authSockSignersExt...)
authSockSignersPtr := &authSockSigners
return func() ([]ssh.Signer, error) { 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 { if len(*identityFilesPtr) == 0 {
return nil, fmt.Errorf("no identity files remaining") 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 // skip this key and try with the next
return createDummySigner() 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) signer, err := ssh.ParsePrivateKey(privateKey)
if err == nil { if err == nil {
return []ssh.Signer{signer}, err return []ssh.Signer{signer}, err
@ -107,10 +139,18 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
return createDummySigner() return createDummySigner()
} }
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase)) unencryptedPrivateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(privateKey, []byte(passphrase))
if err == nil { 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 return []ssh.Signer{signer}, err
} }
}
if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" { if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" {
// skip this key and try with the next // skip this key and try with the next
return createDummySigner() return createDummySigner()
@ -135,11 +175,22 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords,
// trying keys // trying keys
return nil, UserInputCancelError{Err: err} 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 { if err != nil {
// skip this key and try with the next // skip this key and try with the next
return createDummySigner() 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 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 return ssh.NewClient(c, chans, reqs), nil
} }
func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Client, error) { func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDisplayName string, sshAuthSock string) (*ssh.Client, error) {
sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost, sshAuthSock)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -549,7 +600,17 @@ func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDispla
return nil, err 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)) keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(connCtx, opts.SSHPassword, remoteDisplayName))
passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(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 // exclude gssapi-with-mic and hostbased until implemented
authMethodMap := map[string]ssh.AuthMethod{ 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), "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, attemptsAllowed),
"password": ssh.RetryableAuthMethod(passwordCallback, attemptsAllowed), "password": ssh.RetryableAuthMethod(passwordCallback, attemptsAllowed),
} }
@ -613,6 +674,8 @@ type SshKeywords struct {
PasswordAuthentication bool PasswordAuthentication bool
KbdInteractiveAuthentication bool KbdInteractiveAuthentication bool
PreferredAuthentications []string PreferredAuthentications []string
AddKeysToAgent bool
IdentityAgent string
} }
func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { 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.PasswordAuthentication = configKeywords.PasswordAuthentication
sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication
sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications
sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent
sshKeywords.IdentityAgent = configKeywords.IdentityAgent
return sshKeywords, nil return sshKeywords, nil
} }
@ -669,7 +734,7 @@ func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*Ssh
// note that a `var == "yes"` will default to false // note that a `var == "yes"` will default to false
// but `var != "no"` will default to true // but `var != "no"` will default to true
// when given unexpected strings // when given unexpected strings
func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { func findSshConfigKeywords(hostPattern string, sshAuthSock string) (*SshKeywords, error) {
ssh_config.ReloadConfigs() ssh_config.ReloadConfigs()
sshKeywords := &SshKeywords{} sshKeywords := &SshKeywords{}
var err error var err error
@ -724,5 +789,21 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) {
} }
sshKeywords.PreferredAuthentications = strings.Split(preferredAuthenticationsRaw, ",") 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 return sshKeywords, nil
} }