@@ -72,16 +72,16 @@ impl InteractiveCommand {
7272
7373 // Check if authentication failed and password fallback is allowed
7474 // This matches SSH key failures as well as SSH agent authentication failures
75+ // Also handles the case where russh disconnects during authentication failure
76+ // (which returns SshError(Disconnect) instead of KeyAuthFailed)
7577 let result = match result {
76- Err (
77- SshError :: KeyAuthFailed
78- | SshError :: AgentAuthenticationFailed
79- | SshError :: AgentNoIdentities
80- | SshError :: AgentConnectionFailed
81- | SshError :: AgentRequestIdentitiesFailed ,
82- ) if allow_password_fallback && atty:: is ( atty:: Stream :: Stdin ) => {
78+ Err ( ref err)
79+ if allow_password_fallback
80+ && atty:: is ( atty:: Stream :: Stdin )
81+ && is_auth_error_for_password_fallback ( err) =>
82+ {
8383 tracing:: debug!(
84- "SSH authentication failed for {username}@{host}:{port}, attempting password fallback"
84+ "SSH authentication failed for {username}@{host}:{port} ({err}) , attempting password fallback"
8585 ) ;
8686
8787 // Prompt for password (matching OpenSSH behavior)
@@ -473,18 +473,56 @@ impl InteractiveCommand {
473473/// - SSH agent has no identities loaded
474474/// - SSH agent connection fails
475475/// - SSH agent identity request fails
476+ /// - SSH server disconnects during authentication (russh::Error::Disconnect)
477+ /// This is a common behavior when the server rejects key authentication
478+ /// and the russh library drops the connection before returning the auth result.
476479///
477480/// These are all cases where falling back to password authentication makes sense,
478481/// matching OpenSSH's behavior.
482+ ///
483+ /// # Important
484+ /// The SshError(Disconnect) case is particularly important because russh may
485+ /// disconnect the connection before returning the authentication failure result.
486+ /// The log flow in this case is:
487+ /// ```text
488+ /// userauth_failure -> drop handle -> disconnected SshError(Disconnect)
489+ /// ```
490+ /// Without handling this case, password fallback would never be triggered when
491+ /// key authentication fails on servers that disconnect after auth failure.
479492pub fn is_auth_error_for_password_fallback ( error : & SshError ) -> bool {
480- matches ! (
481- error ,
493+ match error {
494+ // Explicit authentication failures
482495 SshError :: KeyAuthFailed
483- | SshError :: AgentAuthenticationFailed
484- | SshError :: AgentNoIdentities
485- | SshError :: AgentConnectionFailed
486- | SshError :: AgentRequestIdentitiesFailed
487- )
496+ | SshError :: AgentAuthenticationFailed
497+ | SshError :: AgentNoIdentities
498+ | SshError :: AgentConnectionFailed
499+ | SshError :: AgentRequestIdentitiesFailed => true ,
500+
501+ // russh may disconnect after auth failure, which manifests as these errors
502+ // This is a key fix for GitHub issue #113: the server may disconnect
503+ // during authentication, and we should treat this as an auth failure
504+ // that can be retried with password.
505+ SshError :: SshError ( russh:: Error :: Disconnect ) => {
506+ tracing:: debug!(
507+ "Treating SshError(Disconnect) as auth failure - server likely \
508+ disconnected after key authentication rejection"
509+ ) ;
510+ true
511+ }
512+
513+ // RecvError can occur when the server closes the channel during auth
514+ SshError :: SshError ( russh:: Error :: RecvError ) => {
515+ tracing:: debug!(
516+ "Treating SshError(RecvError) as auth failure - server likely \
517+ closed connection during authentication"
518+ ) ;
519+ true
520+ }
521+
522+ // All other errors should not trigger password fallback
523+ // This includes: PasswordWrong, ServerCheckFailed, IoError, etc.
524+ _ => false ,
525+ }
488526}
489527
490528#[ cfg( test) ]
@@ -574,4 +612,57 @@ mod tests {
574612 "KeyboardInteractiveAuthFailed should NOT trigger password fallback"
575613 ) ;
576614 }
615+
616+ // Tests for issue #113: Handle SshError(Disconnect) during authentication
617+ #[ test]
618+ fn test_ssh_disconnect_triggers_password_fallback ( ) {
619+ let error = SshError :: SshError ( russh:: Error :: Disconnect ) ;
620+ assert ! (
621+ is_auth_error_for_password_fallback( & error) ,
622+ "SshError(Disconnect) should trigger password fallback - \
623+ server may disconnect after key auth rejection"
624+ ) ;
625+ }
626+
627+ #[ test]
628+ fn test_ssh_recv_error_triggers_password_fallback ( ) {
629+ let error = SshError :: SshError ( russh:: Error :: RecvError ) ;
630+ assert ! (
631+ is_auth_error_for_password_fallback( & error) ,
632+ "SshError(RecvError) should trigger password fallback - \
633+ server may close connection during authentication"
634+ ) ;
635+ }
636+
637+ #[ test]
638+ fn test_ssh_hup_does_not_trigger_fallback ( ) {
639+ // HUP is a different type of disconnect that happens during normal operation
640+ let error = SshError :: SshError ( russh:: Error :: HUP ) ;
641+ assert ! (
642+ !is_auth_error_for_password_fallback( & error) ,
643+ "SshError(HUP) should NOT trigger password fallback - \
644+ this indicates remote closed connection, not auth failure"
645+ ) ;
646+ }
647+
648+ #[ test]
649+ fn test_ssh_connection_timeout_does_not_trigger_fallback ( ) {
650+ let error = SshError :: SshError ( russh:: Error :: ConnectionTimeout ) ;
651+ assert ! (
652+ !is_auth_error_for_password_fallback( & error) ,
653+ "SshError(ConnectionTimeout) should NOT trigger password fallback - \
654+ this is a network issue, not auth failure"
655+ ) ;
656+ }
657+
658+ #[ test]
659+ fn test_ssh_not_authenticated_does_not_trigger_fallback ( ) {
660+ // NotAuthenticated means we haven't tried auth yet, not that auth failed
661+ let error = SshError :: SshError ( russh:: Error :: NotAuthenticated ) ;
662+ assert ! (
663+ !is_auth_error_for_password_fallback( & error) ,
664+ "SshError(NotAuthenticated) should NOT trigger password fallback - \
665+ this means auth hasn't been attempted yet"
666+ ) ;
667+ }
577668}
0 commit comments