diff --git a/src/app/common/modals/viewremoteconndetail.tsx b/src/app/common/modals/viewremoteconndetail.tsx index 6c654b3c..48536c60 100644 --- a/src/app/common/modals/viewremoteconndetail.tsx +++ b/src/app/common/modals/viewremoteconndetail.tsx @@ -259,8 +259,10 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { message = "Connected and ready to run commands."; } else if (remote.status == "connecting") { message = remote.waitingforpassword ? "Connecting, waiting for user-input..." : "Connecting..."; - let connectTimeout = remote.connecttimeout ?? 0; - message = message + " (" + connectTimeout + "s)"; + if (remote.countdownactive) { + let connectTimeout = remote.connecttimeout ?? 0; + message = message + " (" + connectTimeout + "s)"; + } } else if (remote.status == "disconnected") { message = "Disconnected"; } else if (remote.status == "error") { diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index dbe1df47..a1d15dd0 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -601,6 +601,9 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { if state.ConnectTimeout < 0 { state.ConnectTimeout = 0 } + state.CountdownActive = true + } else { + state.CountdownActive = false } } vars := msh.Remote.StateVars @@ -1318,23 +1321,22 @@ func (NewLauncher) Launch(msh *MShellProc, interactive bool) { if remoteCopy.ConnectMode != sstore.ConnectModeManual && remoteCopy.SSHOpts.SSHPassword == "" && !interactive { sshOpts.BatchMode = true } - makeClientCtx, makeClientCancelFn := context.WithCancel(context.Background()) - defer makeClientCancelFn() - msh.WithLock(func() { - msh.Err = nil - msh.ErrNoInitPk = false - msh.Status = StatusConnecting - msh.MakeClientCancelFn = makeClientCancelFn - deadlineTime := time.Now().Add(RemoteConnectTimeout) - msh.MakeClientDeadline = &deadlineTime - go msh.NotifyRemoteUpdate() - }) - go msh.watchClientDeadlineTime() - var cmdStr string var cproc *shexec.ClientProc var initPk *packet.InitPacketType if sshOpts.SSHHost == "" && remoteCopy.Local { - cmdStr, err = MakeLocalMShellCommandStr(remoteCopy.IsSudo()) + makeClientCtx, makeClientCancelFn := context.WithCancel(context.Background()) + defer makeClientCancelFn() + msh.WithLock(func() { + msh.Err = nil + msh.ErrNoInitPk = false + msh.Status = StatusConnecting + msh.MakeClientCancelFn = makeClientCancelFn + deadlineTime := time.Now().Add(RemoteConnectTimeout) + msh.MakeClientDeadline = &deadlineTime + go msh.NotifyRemoteUpdate() + }) + go msh.watchClientDeadlineTime() + cmdStr, err := MakeLocalMShellCommandStr(remoteCopy.IsSudo()) if err != nil { msh.WriteToPtyBuffer("*error, cannot find local mshell binary: %v\n", err) return @@ -1359,6 +1361,13 @@ func (NewLauncher) Launch(msh *MShellProc, interactive bool) { } cproc, initPk, err = shexec.MakeClientProc(makeClientCtx, shexec.CmdWrap{Cmd: ecmd}) } else { + msh.WithLock(func() { + msh.Err = nil + msh.ErrNoInitPk = false + msh.Status = StatusConnecting + msh.MakeClientDeadline = nil + go msh.NotifyRemoteUpdate() + }) var client *ssh.Client client, err = ConnectToClient(remoteCopy.SSHOpts) if err != nil { @@ -1375,6 +1384,15 @@ func (NewLauncher) Launch(msh *MShellProc, interactive bool) { msh.setErrorStatus(statusErr) return } + makeClientCtx, makeClientCancelFn := context.WithCancel(context.Background()) + defer makeClientCancelFn() + msh.WithLock(func() { + msh.MakeClientCancelFn = makeClientCancelFn + deadlineTime := time.Now().Add(RemoteConnectTimeout) + msh.MakeClientDeadline = &deadlineTime + go msh.NotifyRemoteUpdate() + }) + go msh.watchClientDeadlineTime() cproc, initPk, err = shexec.MakeClientProc(makeClientCtx, shexec.SessionWrap{Session: session, StartCmd: MakeServerRunOnlyCommandStr()}) } // TODO check if initPk.State is not nil diff --git a/wavesrv/pkg/remote/sshclient.go b/wavesrv/pkg/remote/sshclient.go index f664ac33..a9becca7 100644 --- a/wavesrv/pkg/remote/sshclient.go +++ b/wavesrv/pkg/remote/sshclient.go @@ -6,10 +6,11 @@ package remote import ( "bytes" "context" + "crypto/rand" + "crypto/rsa" "crypto/x509" "encoding/base64" "fmt" - "log" "net" "os" "os/user" @@ -35,39 +36,94 @@ func (uice UserInputCancelError) Error() string { return uice.Err.Error() } -func createPublicKeyAuth(identityFile string, passphrase string) (ssh.Signer, error) { - privateKey, err := os.ReadFile(base.ExpandHomeDir(identityFile)) +// This exists to trick the ssh library into continuing to try +// different public keys even when the current key cannot be +// properly parsed +func createDummySigner() ([]ssh.Signer, error) { + dummyKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return nil, fmt.Errorf("failed to read ssh key file. err: %+v", err) + return nil, err } - signer, err := ssh.ParsePrivateKey(privateKey) - if err == nil { - return signer, err - } - if _, ok := err.(*ssh.PassphraseMissingError); !ok { - return nil, fmt.Errorf("failed to parse private ssh key. err: %+v", err) + dummySigner, err := ssh.NewSignerFromKey(dummyKey) + if err != nil { + return nil, err } + return []ssh.Signer{dummySigner}, nil - signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(passphrase)) - if err == nil { - return signer, err +} + +// This is a workaround to only process one identity file at a time, +// even if they have passphrases. It must be combined with retryable +// authentication to work properly +// +// Despite returning an array of signers, we only ever provide one since +// it allows proper user interaction in between attempts +// +// A significant number of errors end up returning dummy values as if +// 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(sshKeywords *SshKeywords, passphrase string) func() ([]ssh.Signer, error) { + identityFiles := make([]string, len(sshKeywords.IdentityFile)) + copy(identityFiles, sshKeywords.IdentityFile) + identityFilesPtr := &identityFiles + + return func() ([]ssh.Signer, error) { + if len(*identityFilesPtr) == 0 { + // skip this key and try with the next + return createDummySigner() + } + identityFile := (*identityFilesPtr)[0] + *identityFilesPtr = (*identityFilesPtr)[1:] + privateKey, err := os.ReadFile(base.ExpandHomeDir(identityFile)) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + signer, err := ssh.ParsePrivateKey(privateKey) + if err == nil { + return []ssh.Signer{signer}, err + } + 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() + } + + // batch mode deactivates user input + if sshKeywords.BatchMode { + // skip this key and try with the next + return createDummySigner() + } + + request := &sstore.UserInputRequestType{ + ResponseType: "text", + QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), + Title: "Publickey Auth + Passphrase", + } + ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelFn() + response, err := sstore.MainBus.GetUserInput(ctx, request) + if err != nil { + // this is an error where we actually do want to stop + // trying keys + return nil, UserInputCancelError{Err: err} + } + signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text)) + if err != nil { + // skip this key and try with the next + return createDummySigner() + } + return []ssh.Signer{signer}, err } - if err != x509.IncorrectPasswordError && err.Error() != "bcrypt_pbkdf: empty password" { - log.Printf("qwerty: %+v", err) - return nil, fmt.Errorf("failed to parse private ssh key. err: %+v", err) - } - request := &sstore.UserInputRequestType{ - ResponseType: "text", - QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile), - Title: "Publickey Auth + Passphrase", - } - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelFn() - response, err := sstore.MainBus.GetUserInput(ctx, request) - if err != nil { - return nil, UserInputCancelError{Err: err} - } - return ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(response.Text)) } func createDefaultPasswordCallbackPrompt(password string) func() (secret string, err error) { @@ -83,7 +139,7 @@ func createInteractivePasswordCallbackPrompt() func() (secret string, err error) return func() (secret string, err error) { // limited to 15 seconds for some reason. this should be investigated more // in the future - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFn() request := &sstore.UserInputRequestType{ ResponseType: "text", @@ -143,7 +199,7 @@ func createInteractiveKbdInteractiveChallenge() func(name, instruction string, q func promptChallengeQuestion(question string, echo bool) (answer string, err error) { // limited to 15 seconds for some reason. this should be investigated more // in the future - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second) defer cancelFn() request := &sstore.UserInputRequestType{ ResponseType: "text", @@ -410,61 +466,180 @@ func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) { } func ConnectToClient(opts *sstore.SSHOpts) (*ssh.Client, error) { - ssh_config.ReloadConfigs() - configIdentity, _ := ssh_config.GetStrict(opts.SSHHost, "IdentityFile") - var identityFile string - if opts.SSHIdentity != "" { - identityFile = opts.SSHIdentity + sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost) + if err != nil { + return nil, err + } + + sshKeywords, err := combineSshKeywords(opts, sshConfigKeywords) + if err != nil { + return nil, err + } + + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(sshKeywords, opts.SSHPassword)) + keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(opts.SSHPassword)) + passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(opts.SSHPassword)) + + // 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 } else { - identityFile = configIdentity + attemptsAllowed = 2 + } + + // 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), + } + + authMethodActiveMap := map[string]bool{ + "publickey": sshKeywords.PubkeyAuthentication, + "keyboard-interactive": sshKeywords.KbdInteractiveAuthentication, + "password": sshKeywords.PasswordAuthentication, + } + + var authMethods []ssh.AuthMethod + for _, authMethodName := range sshKeywords.PreferredAuthentications { + authMethodActive, ok := authMethodActiveMap[authMethodName] + if !ok || !authMethodActive { + continue + } + authMethod, ok := authMethodMap[authMethodName] + if !ok { + continue + } + authMethods = append(authMethods, authMethod) } hostKeyCallback, err := createHostKeyCallback(opts) if err != nil { return nil, err } - var authMethods []ssh.AuthMethod - publicKeySigner, err := createPublicKeyAuth(identityFile, opts.SSHPassword) - if err == nil { - authMethods = append(authMethods, ssh.PublicKeys(publicKeySigner)) - } - authMethods = append(authMethods, ssh.RetryableAuthMethod(ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(opts.SSHPassword)), 2)) - authMethods = append(authMethods, ssh.RetryableAuthMethod(ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(opts.SSHPassword)), 2)) - configUser, _ := ssh_config.GetStrict(opts.SSHHost, "User") - configHostName, _ := ssh_config.GetStrict(opts.SSHHost, "HostName") - configPort, _ := ssh_config.GetStrict(opts.SSHHost, "Port") - var username string + clientConfig := &ssh.ClientConfig{ + User: sshKeywords.User, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + } + networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port + return ssh.Dial("tcp", networkAddr, clientConfig) +} + +type SshKeywords struct { + User string + HostName string + Port string + IdentityFile []string + BatchMode bool + PubkeyAuthentication bool + PasswordAuthentication bool + KbdInteractiveAuthentication bool + PreferredAuthentications []string +} + +func combineSshKeywords(opts *sstore.SSHOpts, configKeywords *SshKeywords) (*SshKeywords, error) { + sshKeywords := &SshKeywords{} + if opts.SSHUser != "" { - username = opts.SSHUser - } else if configUser != "" { - username = configUser + sshKeywords.User = opts.SSHUser + } else if configKeywords.User != "" { + sshKeywords.User = configKeywords.User } else { user, err := user.Current() if err != nil { return nil, fmt.Errorf("failed to get user for ssh: %+v", err) } - username = user.Username + sshKeywords.User = user.Username } - var hostName string - if configHostName != "" { - hostName = configHostName + + // we have to check the host value because of the weird way + // we store the pattern as the hostname for imported remotes + if configKeywords.HostName != "" { + sshKeywords.HostName = configKeywords.HostName } else { - hostName = opts.SSHHost + sshKeywords.HostName = opts.SSHHost } - clientConfig := &ssh.ClientConfig{ - User: username, - Auth: authMethods, - HostKeyCallback: hostKeyCallback, - } - var port string + if opts.SSHPort != 0 && opts.SSHPort != 22 { - port = strconv.Itoa(opts.SSHPort) - } else if configPort != "" && configPort != "22" { - port = configPort + sshKeywords.Port = strconv.Itoa(opts.SSHPort) + } else if configKeywords.Port != "" && configKeywords.Port != "22" { + sshKeywords.Port = configKeywords.Port } else { - port = "22" + sshKeywords.Port = "22" } - networkAddr := hostName + ":" + port - return ssh.Dial("tcp", networkAddr, clientConfig) + + sshKeywords.IdentityFile = []string{opts.SSHIdentity} + sshKeywords.IdentityFile = append(sshKeywords.IdentityFile, configKeywords.IdentityFile...) + + // these are not officially supported in the waveterm frontend but can be configured + // in ssh config files + sshKeywords.BatchMode = configKeywords.BatchMode + sshKeywords.PubkeyAuthentication = configKeywords.PubkeyAuthentication + sshKeywords.PasswordAuthentication = configKeywords.PasswordAuthentication + sshKeywords.KbdInteractiveAuthentication = configKeywords.KbdInteractiveAuthentication + sshKeywords.PreferredAuthentications = configKeywords.PreferredAuthentications + + return sshKeywords, nil +} + +// 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) { + ssh_config.ReloadConfigs() + sshKeywords := &SshKeywords{} + var err error + + sshKeywords.User, err = ssh_config.GetStrict(hostPattern, "User") + if err != nil { + return nil, err + } + + sshKeywords.HostName, err = ssh_config.GetStrict(hostPattern, "HostName") + if err != nil { + return nil, err + } + + sshKeywords.Port, err = ssh_config.GetStrict(hostPattern, "Port") + if err != nil { + return nil, err + } + + sshKeywords.IdentityFile = ssh_config.GetAll(hostPattern, "IdentityFile") + + batchModeRaw, err := ssh_config.GetStrict(hostPattern, "BatchMode") + if err != nil { + return nil, err + } + sshKeywords.BatchMode = (strings.ToLower(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") + + passwordAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "PasswordAuthentication") + if err != nil { + return nil, err + } + sshKeywords.PasswordAuthentication = (strings.ToLower(passwordAuthenticationRaw) != "no") + + kbdInteractiveAuthenticationRaw, err := ssh_config.GetStrict(hostPattern, "KbdInteractiveAuthentication") + if err != nil { + return nil, err + } + sshKeywords.KbdInteractiveAuthentication = (strings.ToLower(kbdInteractiveAuthenticationRaw) != "no") + + // these are parsed as a single string and must be separated + // these are case sensitive in openssh so they are here too + preferredAuthenticationsRaw, err := ssh_config.GetStrict(hostPattern, "PreferredAuthentications") + sshKeywords.PreferredAuthentications = strings.Split(preferredAuthenticationsRaw, ",") + + return sshKeywords, nil } diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index a4ef0f2d..386be8ab 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -1004,6 +1004,7 @@ type RemoteRuntimeState struct { DefaultFeState map[string]string `json:"defaultfestate"` Status string `json:"status"` ConnectTimeout int `json:"connecttimeout,omitempty"` + CountdownActive bool `json:"countdownactive"` ErrorStr string `json:"errorstr,omitempty"` InstallStatus string `json:"installstatus"` InstallErrorStr string `json:"installerrorstr,omitempty"` diff --git a/wavesrv/pkg/sstore/updatebus.go b/wavesrv/pkg/sstore/updatebus.go index aedcb756..b8789804 100644 --- a/wavesrv/pkg/sstore/updatebus.go +++ b/wavesrv/pkg/sstore/updatebus.go @@ -228,7 +228,6 @@ func (bus *UpdateBus) GetUserInput(ctx context.Context, userInputRequest *UserIn update := &ModelUpdate{} AddUpdate(update, *userInputRequest) bus.SendUpdate(update) - log.Printf("test: %+v", userInputRequest) var response *scpacket.UserInputResponsePacketType var err error