diff --git a/Taskfile.yml b/Taskfile.yml index cfefe98a6..923db2d69 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -97,7 +97,7 @@ tasks: vars: - ARCHS cmds: - - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} go build -tags "osusergo,netgo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} go build -tags "osusergo,netcgo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go for: var: ARCHS split: "," diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 4032519b1..e8c4c0f06 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -8,24 +8,26 @@ import ( "context" "crypto/rand" "crypto/rsa" - "crypto/x509" "encoding/base64" "fmt" "log" "net" "os" + "os/exec" "os/user" "path/filepath" "strconv" "strings" - "sync" "time" "github.com/kevinburke/ssh_config" "github.com/skeema/knownhosts" + "github.com/wavetermdev/waveterm/pkg/trimquotes" "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" xknownhosts "golang.org/x/crypto/ssh/knownhosts" ) @@ -66,7 +68,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, authSockSignersExt []ssh.Signer, agentClient agent.ExtendedAgent) func() ([]ssh.Signer, error) { var identityFiles []string existingKeys := make(map[string][]byte) @@ -84,7 +86,18 @@ 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 + } + if len(*identityFilesPtr) == 0 { return nil, fmt.Errorf("no identity files remaining") } @@ -96,22 +109,22 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, // skip this key and try with the next return createDummySigner() } - signer, err := ssh.ParsePrivateKey(privateKey) - if err == nil { - return []ssh.Signer{signer}, err - } + + unencryptedPrivateKey, err := ssh.ParseRawPrivateKey(privateKey) if _, ok := err.(*ssh.PassphraseMissingError); !ok { // skip this key and try with the next return createDummySigner() } - - signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase)) if err == nil { - return []ssh.Signer{signer}, err - } - if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" { - // skip this key and try with the next - return createDummySigner() + signer, err := ssh.NewSignerFromKey(unencryptedPrivateKey) + if err == nil { + if sshKeywords.AddKeysToAgent && agentClient != nil { + agentClient.Add(agent.AddedKey{ + PrivateKey: unencryptedPrivateKey, + }) + } + return []ssh.Signer{signer}, err + } } // batch mode deactivates user input @@ -133,24 +146,25 @@ 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 } } -func createDefaultPasswordCallbackPrompt(password string) func() (secret string, err error) { - return func() (secret string, err error) { - // this should be modified to return an error if no password is stored - // but an empty password is not sufficient because some systems allow - // empty passwords - return password, nil - } -} - func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) { return func() (secret string, err error) { ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) @@ -173,31 +187,6 @@ func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisp } } -func createCombinedPasswordCallbackPrompt(connCtx context.Context, password string, remoteDisplayName string) func() (secret string, err error) { - var once sync.Once - return func() (secret string, err error) { - var prompt func() (secret string, err error) - once.Do(func() { prompt = createDefaultPasswordCallbackPrompt(password) }) - if prompt == nil { - prompt = createInteractivePasswordCallbackPrompt(connCtx, remoteDisplayName) - } - return prompt() - } -} - -func createNaiveKbdInteractiveChallenge(password string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { - return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { - for _, q := range questions { - if strings.Contains(strings.ToLower(q), "password") { - answers = append(answers, password) - } else { - answers = append(answers, "") - } - } - return answers, nil - } -} - func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) != len(echos) { @@ -238,18 +227,6 @@ func promptChallengeQuestion(connCtx context.Context, question string, echo bool return response.Text, nil } -func createCombinedKbdInteractiveChallenge(connCtx context.Context, password string, remoteName string) ssh.KeyboardInteractiveChallenge { - var once sync.Once - return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) { - var challenge ssh.KeyboardInteractiveChallenge - once.Do(func() { challenge = createNaiveKbdInteractiveChallenge(password) }) - if challenge == nil { - challenge = createInteractiveKbdInteractiveChallenge(connCtx, remoteName) - } - return challenge(name, instruction, questions, echos) - } -} - func openKnownHostsForEdit(knownHostsFilename string) (*os.File, error) { path, _ := filepath.Split(knownHostsFilename) err := os.MkdirAll(path, 0700) @@ -543,30 +520,32 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts) (*ssh.Client, error } remoteName := sshKeywords.User + "@" + xknownhosts.Normalize(sshKeywords.HostName+":"+sshKeywords.Port) - publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, "")) - keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(connCtx, "", remoteName)) - passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(connCtx, "", remoteName)) - - // batch mode turns off interactive input. this means the number of - // attemtps must drop to 1 with this setup - var attemptsAllowed int - if sshKeywords.BatchMode { - attemptsAllowed = 1 + var authSockSigners []ssh.Signer + var agentClient agent.ExtendedAgent + conn, err := net.Dial("unix", sshKeywords.IdentityAgent) + if err != nil { + log.Printf("Failed to open Identity Agent Socket: %v", err) } else { - attemptsAllowed = 2 + agentClient = agent.NewClient(conn) + authSockSigners, _ = agentClient.Signers() } + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient)) + keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName)) + passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName)) + // exclude gssapi-with-mic and hostbased until implemented authMethodMap := map[string]ssh.AuthMethod{ - "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.IdentityFile)), - "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, attemptsAllowed), - "password": ssh.RetryableAuthMethod(passwordCallback, attemptsAllowed), + "publickey": ssh.RetryableAuthMethod(publicKeyCallback, len(sshKeywords.IdentityFile)+len(authSockSigners)), + "keyboard-interactive": ssh.RetryableAuthMethod(keyboardInteractive, 1), + "password": ssh.RetryableAuthMethod(passwordCallback, 1), } + // note: batch mode turns off interactive input authMethodActiveMap := map[string]bool{ "publickey": sshKeywords.PubkeyAuthentication, - "keyboard-interactive": sshKeywords.KbdInteractiveAuthentication, - "password": sshKeywords.PasswordAuthentication, + "keyboard-interactive": sshKeywords.KbdInteractiveAuthentication && !sshKeywords.BatchMode, + "password": sshKeywords.PasswordAuthentication && !sshKeywords.BatchMode, } var authMethods []ssh.AuthMethod @@ -607,6 +586,8 @@ type SshKeywords struct { PasswordAuthentication bool KbdInteractiveAuthentication bool PreferredAuthentications []string + AddKeysToAgent bool + IdentityAgent string } func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { @@ -649,6 +630,8 @@ func combineSshKeywords(opts *SSHOpts, configKeywords *SshKeywords) (*SshKeyword sshKeywords.PasswordAuthentication = configKeywords.PasswordAuthentication sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications + sshKeywords.AddKeysToAgent = configKeywords.AddKeysToAgent + sshKeywords.IdentityAgent = configKeywords.IdentityAgent return sshKeywords, nil } @@ -661,47 +644,54 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { sshKeywords := &SshKeywords{} var err error - sshKeywords.User, err = ssh_config.GetStrict(hostPattern, "User") + userRaw, err := ssh_config.GetStrict(hostPattern, "User") if err != nil { return nil, err } + sshKeywords.User = trimquotes.TryTrimQuotes(userRaw) - sshKeywords.HostName, err = ssh_config.GetStrict(hostPattern, "HostName") + hostNameRaw, err := ssh_config.GetStrict(hostPattern, "HostName") if err != nil { return nil, err } + sshKeywords.HostName = trimquotes.TryTrimQuotes(hostNameRaw) - sshKeywords.Port, err = ssh_config.GetStrict(hostPattern, "Port") + portRaw, err := ssh_config.GetStrict(hostPattern, "Port") if err != nil { return nil, err } + sshKeywords.Port = trimquotes.TryTrimQuotes(portRaw) - sshKeywords.IdentityFile = ssh_config.GetAll(hostPattern, "IdentityFile") + identityFileRaw := ssh_config.GetAll(hostPattern, "IdentityFile") + for i := 0; i < len(identityFileRaw); i++ { + identityFileRaw[i] = trimquotes.TryTrimQuotes(identityFileRaw[i]) + } + sshKeywords.IdentityFile = identityFileRaw batchModeRaw, err := ssh_config.GetStrict(hostPattern, "BatchMode") if err != nil { return nil, err } - sshKeywords.BatchMode = (strings.ToLower(batchModeRaw) == "yes") + sshKeywords.BatchMode = (strings.ToLower(trimquotes.TryTrimQuotes(batchModeRaw)) == "yes") // we currently do not support host-bound or unbound but will use yes when they are selected pubkeyAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "PubkeyAuthentication") if err != nil { return nil, err } - sshKeywords.PubkeyAuthentication = (strings.ToLower(pubkeyAuthenticationRaw) != "no") + sshKeywords.PubkeyAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(pubkeyAuthenticationRaw)) != "no") passwordAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "PasswordAuthentication") if err != nil { return nil, err } - sshKeywords.PasswordAuthentication = (strings.ToLower(passwordAuthenticationRaw) != "no") + sshKeywords.PasswordAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(passwordAuthenticationRaw)) != "no") kbdInteractiveAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "KbdInteractiveAuthentication") if err != nil { return nil, err } - sshKeywords.KbdInteractiveAuthentication = (strings.ToLower(kbdInteractiveAuthenticationRaw) != "no") + sshKeywords.KbdInteractiveAuthentication = (strings.ToLower(trimquotes.TryTrimQuotes(kbdInteractiveAuthenticationRaw)) != "no") // these are parsed as a single string and must be separated // these are case sensitive in openssh so they are here too @@ -709,7 +699,29 @@ func findSshConfigKeywords(hostPattern string) (*SshKeywords, error) { if err != nil { return nil, err } - sshKeywords.PreferredAuthentications = strings.Split(preferredAuthenticationsRaw, ",") + sshKeywords.PreferredAuthentications = strings.Split(trimquotes.TryTrimQuotes(preferredAuthenticationsRaw), ",") + addKeysToAgentRaw, err := ssh_config.GetStrict(hostPattern, "AddKeysToAgent") + if err != nil { + return nil, err + } + sshKeywords.AddKeysToAgent = (strings.ToLower(trimquotes.TryTrimQuotes(addKeysToAgentRaw)) == "yes") + + identityAgentRaw, err := ssh_config.GetStrict(hostPattern, "IdentityAgent") + if err != nil { + return nil, err + } + if identityAgentRaw == "" { + shellPath := shellutil.DetectLocalShellPath() + authSockCommand := exec.Command(shellPath, "-c", "echo ${SSH_AUTH_SOCK}") + sshAuthSock, err := authSockCommand.Output() + if err == nil { + sshKeywords.IdentityAgent = wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(strings.TrimSpace(string(sshAuthSock)))) + } else { + log.Printf("unable to find SSH_AUTH_SOCK: %v\n", err) + } + } else { + sshKeywords.IdentityAgent = wavebase.ExpandHomeDir(trimquotes.TryTrimQuotes(identityAgentRaw)) + } return sshKeywords, nil } diff --git a/pkg/trimquotes/trimquotes.go b/pkg/trimquotes/trimquotes.go new file mode 100644 index 000000000..9d7421ad7 --- /dev/null +++ b/pkg/trimquotes/trimquotes.go @@ -0,0 +1,28 @@ +package trimquotes + +import ( + "strconv" +) + +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/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index c3b2bccb8..cc87d08aa 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -214,6 +214,9 @@ func GetWshBaseName(version string, goos string, goarch string) string { if goarch == "amd64" { goarch = "x64" } + if goarch == "aarch64" { + goarch = "arm64" + } if goos == "windows" { ext = ".exe" }