From 9ec82c9c2dc6acb0f820b5b70798f0db767e425e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:43:03 +0300 Subject: [PATCH] fix(errors): SSH tunnel failures are not reported as a database authentication failure (#1305) SshAuthenticationException's text contains 'authentication failed', which the mapper turned into AuthFailed with the database hint and dropped the host. A wrong bastion password read as a wrong database password. - The mapper handles SshAuthenticationException, SshHostKeyMismatchException and the new SshConnectionException before any text matching, keeping the host and user and pointing at the SSH tunnel settings. - openTunnel tells a rejected login (SSHAuthError) from a transport or protocol failure, which is no longer 'SSH authentication failed', and the transport and jump host failures name the host and port. - describeDatabaseError keeps the hint of a mapped error. --- lib/core/database/database_error_mapper.dart | 37 ++++++++- lib/core/security/ssh_tunnel_manager.dart | 75 ++++++++++++++++--- .../database/database_error_mapper_test.dart | 51 +++++++++++++ .../security/ssh_tunnel_manager_test.dart | 43 ++++++++++- test/support/fake_ssh.dart | 6 ++ 5 files changed, 200 insertions(+), 12 deletions(-) diff --git a/lib/core/database/database_error_mapper.dart b/lib/core/database/database_error_mapper.dart index a140e7a3..47904e0b 100644 --- a/lib/core/database/database_error_mapper.dart +++ b/lib/core/database/database_error_mapper.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; /// Which engine produced an error; only used to tailor the wording. enum DatabaseDriver { postgres, mysql, sqlite, mongodb, redis, extension } @@ -22,6 +23,38 @@ QueryaDatabaseException mapDatabaseError( }) { if (error is QueryaDatabaseException) return error; + // The tunnel's own failures, before any database text matching: *SSH + // authentication failed for deploy@bastion* is not the database rejecting + // the credentials (#1305). + if (error is SshAuthenticationException) { + return UnknownDatabaseException( + error.message, + remediationHint: 'Check the SSH user, password or key and the bastion ' + 'host in the connection\'s SSH tunnel settings', + originalError: error, + stackTrace: stackTrace, + ); + } + if (error is SshHostKeyMismatchException) { + return UnknownDatabaseException( + error.message, + remediationHint: 'The server presented a different host key than the ' + 'pinned one. If the server was reinstalled, update the fingerprint ' + 'in the SSH tunnel settings; otherwise do not connect', + originalError: error, + stackTrace: stackTrace, + ); + } + if (error is SshConnectionException) { + return HostUnreachableException( + error.message, + remediationHint: 'Check the SSH host and port, the network and any ' + 'firewall', + originalError: error, + stackTrace: stackTrace, + ); + } + final raw = _fullText(error); final lower = raw.toLowerCase(); final engine = _engineName(driver); @@ -157,7 +190,9 @@ QueryaDatabaseException mapDatabaseError( /// place of `error.toString()` without losing information. String describeDatabaseError(Object error, {DatabaseDriver? driver}) { final mapped = mapDatabaseError(error, driver: driver); - if (mapped is UnknownDatabaseException) return error.toString(); + if (mapped is UnknownDatabaseException && mapped.remediationHint == null) { + return error.toString(); + } return mapped.displayText; } diff --git a/lib/core/security/ssh_tunnel_manager.dart b/lib/core/security/ssh_tunnel_manager.dart index 65ed80e8..54181b09 100644 --- a/lib/core/security/ssh_tunnel_manager.dart +++ b/lib/core/security/ssh_tunnel_manager.dart @@ -152,6 +152,40 @@ class SshTunnelManager { return sha256.convert(bytes).toString(); } + /// Opens the transport to [host]:[port]; a failure names the host, so it is + /// not mistaken for a database problem (#1305). + Future _dial( + String host, + int port, + Duration timeout, { + required String what, + }) async { + try { + return await _connect(host, port, timeout: timeout); + } catch (e) { + throw SshConnectionException('Cannot reach the $what $host:$port: $e'); + } + } + + /// What a failed SSH login or handshake is reported as: a rejected + /// credential is an authentication failure; anything else (a reset, a + /// timeout, a protocol error) is a connection failure, not a wrong password. + static Object _sshFailure(Object e, String user, String host, int port) { + if (e is SshAuthenticationException || + e is SshHostKeyMismatchException || + e is SshConnectionException) { + return e; + } + if (e is SSHAuthError) { + return SshAuthenticationException( + 'SSH authentication failed for ${user.trim()}@${host.trim()}: $e', + ); + } + return SshConnectionException( + 'The SSH connection to ${host.trim()}:$port failed: $e', + ); + } + /// Establishes or reuses an ephemeral local port forwarding tunnel. Future openTunnel({ required SshTunnelConfig config, @@ -192,10 +226,11 @@ class SshTunnelManager { final jumpPort = config.jumpPort ?? 22; final jumpUser = config.jumpUsername ?? config.username; - final rawJumpSocket = await _connect( + final rawJumpSocket = await _dial( jumpHost, jumpPort, - timeout: Duration(seconds: config.connectTimeoutSeconds), + Duration(seconds: config.connectTimeoutSeconds), + what: 'SSH jump host', ); jumpClient = _buildClient( @@ -204,15 +239,30 @@ class SshTunnelManager { onPasswordRequest: () => secrets.jumpPassword ?? secrets.password ?? '', ); - await jumpClient.authenticated; + try { + await jumpClient.authenticated; + } catch (e) { + unawaited(jumpClient.close()); + throw _sshFailure(e, jumpUser, jumpHost, jumpPort); + } // Forward through jump host to target bastion (returns SSHForwardChannel which implements SSHSocket) - bastionSocket = await jumpClient.forwardLocal(config.host.trim(), config.port); + try { + bastionSocket = + await jumpClient.forwardLocal(config.host.trim(), config.port); + } catch (e) { + unawaited(jumpClient.close()); + throw SshConnectionException( + 'The SSH jump host $jumpHost:$jumpPort could not reach the bastion ' + '${config.host.trim()}:${config.port}: $e', + ); + } } else { - bastionSocket = await _connect( + bastionSocket = await _dial( config.host.trim(), config.port, - timeout: Duration(seconds: config.connectTimeoutSeconds), + Duration(seconds: config.connectTimeoutSeconds), + what: 'SSH server', ); } @@ -284,9 +334,7 @@ class SshTunnelManager { 'Observed fingerprint: $observedFingerprint', ); } - throw SshAuthenticationException( - 'SSH authentication failed for ${config.username}@${config.host}: $e', - ); + throw _sshFailure(e, config.username, config.host, config.port); } // Zero sensitive in-memory credentials immediately after successful authentication @@ -496,6 +544,15 @@ class SshAuthenticationException implements Exception { String toString() => message; } +/// The SSH server could not be reached or the session broke before any +/// credential was judged: refused, timed out, reset, a protocol error. +class SshConnectionException implements Exception { + const SshConnectionException(this.message); + final String message; + @override + String toString() => message; +} + class SshHostKeyMismatchException implements Exception { const SshHostKeyMismatchException(this.message); final String message; diff --git a/test/core/database/database_error_mapper_test.dart b/test/core/database/database_error_mapper_test.dart index 1c510558..3348a8c1 100644 --- a/test/core/database/database_error_mapper_test.dart +++ b/test/core/database/database_error_mapper_test.dart @@ -8,8 +8,59 @@ import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; void main() { + group('SSH tunnel failures (#1305)', () { + test('a rejected SSH login is not the database refusing the credentials', + () { + final e = mapDatabaseError( + const SshAuthenticationException( + 'SSH authentication failed for deploy@bastion.example: rejected'), + driver: DatabaseDriver.postgres, + ); + expect(e, isNot(isA())); + expect(e.message, contains('deploy@bastion.example')); + expect(e.remediationHint, contains('SSH')); + }); + + test('the one-line text keeps the host and points at the tunnel settings', + () { + final text = describeDatabaseError( + const SshAuthenticationException( + 'SSH authentication failed for deploy@bastion.example: rejected'), + driver: DatabaseDriver.postgres, + ); + expect(text, contains('deploy@bastion.example')); + expect(text, contains('SSH tunnel settings')); + expect(text, isNot(contains('username and password in the connection'))); + }); + + test('a connection failure of the tunnel is unreachable, not authentication', + () { + final e = mapDatabaseError( + const SshConnectionException( + 'The SSH connection to bastion.example:22 failed: reset'), + ); + expect(e, isA()); + expect(e.message, contains('bastion.example:22')); + }); + + test('a host key mismatch says not to connect', () { + final e = mapDatabaseError(const SshHostKeyMismatchException( + 'SSH host key verification failed for bastion.example.')); + expect(e.remediationHint, contains('do not connect')); + }); + + test('a database authentication failure behind a tunnel is still one', () { + final e = mapDatabaseError( + Exception('password authentication failed for user "bob"'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + }); + }); + group('mapDatabaseError PostgreSQL', () { test('wrong password is AuthFailedException', () { final e = mapDatabaseError( diff --git a/test/core/security/ssh_tunnel_manager_test.dart b/test/core/security/ssh_tunnel_manager_test.dart index cc5d6693..9b950ebb 100644 --- a/test/core/security/ssh_tunnel_manager_test.dart +++ b/test/core/security/ssh_tunnel_manager_test.dart @@ -168,10 +168,49 @@ void main() { expect(server.clients.single.isClosed, isTrue); }); - test('an unreachable bastion surfaces the connect error', () async { + test('an unreachable bastion is a connection failure naming the host', + () async { server.failConnect = true; - await expectLater(open(), throwsA(isA())); + await expectLater( + open(), + throwsA(isA().having( + (e) => e.message, + 'message', + allOf(contains('bastion.example:2222'), contains('failed')), + )), + ); + expect(manager.activeSessionCount, 0); + }); + + test('a reset during the handshake is not reported as a wrong password', + () async { + server.failHandshake = const SocketException('Connection reset by peer'); + + await expectLater( + open(), + throwsA(isA().having( + (e) => e.message, + 'message', + allOf(contains('bastion.example:2222'), contains('reset by peer')), + )), + ); + }); + + test('a rejected jump host login names the jump host and user', () async { + server.userPasswords['jumper'] = 'right'; + + await expectLater( + open( + config: _config(jumpHost: 'jump.example', jumpUsername: 'jumper'), + secrets: SshTunnelSecrets(password: 'secret', jumpPassword: 'wrong'), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + allOf(contains('jumper@jump.example')), + )), + ); expect(manager.activeSessionCount, 0); }); diff --git a/test/support/fake_ssh.dart b/test/support/fake_ssh.dart index 4ef146fe..dea0b0fc 100644 --- a/test/support/fake_ssh.dart +++ b/test/support/fake_ssh.dart @@ -29,6 +29,10 @@ class FakeSshServer { /// Makes the TCP connect fail. bool failConnect; + /// When set, the handshake of every client fails with this (a reset, a + /// protocol error) before any credential is judged. + Object? failHandshake; + /// Every (host, port) the manager dialed, in order. final connects = <({String host, int port})>[]; @@ -142,6 +146,8 @@ class FakeSshClient implements SSHClient { Future _handshake() async { try { + final broken = server.failHandshake; + if (broken != null) throw broken; final trusted = await onVerifyHostKey?.call('ssh-ed25519', server.hostKey) ?? true; if (!trusted) throw SSHHostkeyError('host key rejected');