From 6b6d0944cf7c53c65413a6549687ad2b604f6c1f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 14:21:34 +0300 Subject: [PATCH] refactor(database): the SSH tunnel steps of the four drivers live in one mixin (#1508) --- lib/core/database/mongodb_connection.dart | 44 +++--------- lib/core/database/mysql_connection.dart | 61 +++++------------ lib/core/database/postgres_connection.dart | 58 ++++------------ lib/core/database/redis_connection.dart | 43 +++--------- lib/core/database/ssh_tunnel_host.dart | 70 ++++++++++++++++++++ test/core/database/ssh_tunnel_host_test.dart | 29 ++++++++ 6 files changed, 146 insertions(+), 159 deletions(-) create mode 100644 lib/core/database/ssh_tunnel_host.dart create mode 100644 test/core/database/ssh_tunnel_host_test.dart diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 2bbc4a4e..8b3205dc 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -3,15 +3,15 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/mongodb_uri.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; /// MongoDB connection configuration and state. -class MongoConnection { +class MongoConnection with SshTunnelHost { MongoConnection({ required this.id, required this.name, @@ -60,11 +60,10 @@ class MongoConnection { final String? replicaSet; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; /// Handshake URI for this live session (includes auth). Not persisted; not /// exposed via [password] / [connectionString] after [scrubCredentials]. @@ -234,17 +233,6 @@ class MongoConnection { try { if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } String targetHost = host; int targetPort = port; if (effectiveConnectionString != null && @@ -258,19 +246,15 @@ class MongoConnection { targetPort = seed.port ?? 27017; } } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: targetHost, - remotePort: targetPort, - ); + adoptSshTunnel( + await openSshTunnel(id: id, host: targetHost, port: targetPort)); } final uri = await _effectiveMongoUri( pass: effectivePassword, connStr: effectiveConnectionString, - hostOverride: _sshTunnelHandle?.localHost, - portOverride: _sshTunnelHandle?.localPort, + hostOverride: sshTunnelHandle?.localHost, + portOverride: sshTunnelHandle?.localPort, ); _db = await Db.create(uri); await _db!.open(); @@ -285,10 +269,7 @@ class MongoConnection { _isConnected = false; _db = null; await _cleanupTempTlsKey(); - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mongodb); } } @@ -361,12 +342,7 @@ class MongoConnection { } } await _cleanupTempTlsKey(); - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MongoConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MongoConnection.sshRelease'); } Future _cleanupTempTlsKey() async { diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index e269fa8d..06f3c0d0 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; @@ -93,7 +94,7 @@ String replaceDatabaseInMysqlConnectionString( } /// Pure-Dart MySQL client wrapper around [MySQLConnection] (`mysql_client`). -class MysqlConnection { +class MysqlConnection with SshTunnelHost { MysqlConnection({ required this.id, required this.name, @@ -137,11 +138,10 @@ class MysqlConnection { final bool useSSL; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -234,27 +234,9 @@ class MysqlConnection { } try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - openedHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - if (generation != _generation) throw _ConnectSuperseded(); - _sshTunnelHandle = openedHandle; - } + openedHandle = await openSshTunnel(id: id, host: host, port: port); + if (generation != _generation) throw _ConnectSuperseded(); + adoptSshTunnel(openedHandle); final user = username ?? ''; final pass = effectivePassword ?? ''; @@ -274,8 +256,8 @@ class MysqlConnection { } validateMysqlSslMode(sslMode, sslPaths); final securityContext = buildSecurityContext(sslPaths); - final effectiveHost = _sshTunnelHandle?.localHost ?? parsed.host; - final effectivePort = _sshTunnelHandle?.localPort ?? parsed.port; + final effectiveHost = sshTunnelHandle?.localHost ?? parsed.host; + final effectivePort = sshTunnelHandle?.localPort ?? parsed.port; opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, @@ -293,8 +275,8 @@ class MysqlConnection { final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); - final effectiveHost = _sshTunnelHandle?.localHost ?? host; - final effectivePort = _sshTunnelHandle?.localPort ?? port; + final effectiveHost = sshTunnelHandle?.localHost ?? host; + final effectivePort = sshTunnelHandle?.localPort ?? port; opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, @@ -327,15 +309,12 @@ class MysqlConnection { try { await openedHandle?.release(); } catch (_) {} - if (identical(_sshTunnelHandle, openedHandle)) _sshTunnelHandle = null; + forgetSshTunnel(openedHandle); throw StateError('The connection was closed while it was being opened'); } _isConnected = false; _conn = null; - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrowMappedDatabaseError(e, st, driver: DatabaseDriver.mysql); } } @@ -432,12 +411,7 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.disconnect: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MysqlConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MysqlConnection.sshRelease'); } /// Best-effort close. The `mysql_client` driver may not allow graceful [close] @@ -459,12 +433,7 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.forceClose: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('MysqlConnection.forceClose ssh: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'MysqlConnection.forceClose ssh'); } /// Session hint for Table Browser / tree (`MysqlSessionMode.readOnly`). diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 875d6ea7..b324ed04 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -5,9 +5,9 @@ import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -69,7 +69,7 @@ int? postgresReltuplesEstimate(Object? value) { } /// PostgreSQL connection using the pure-Dart `postgres` package. -class PostgresConnection { +class PostgresConnection with SshTunnelHost { PostgresConnection({ required this.id, required this.name, @@ -135,11 +135,10 @@ class PostgresConnection { final String? sslCert; final String? sslKey; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -262,25 +261,7 @@ class PostgresConnection { } try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - } + adoptSshTunnel(await openSshTunnel(id: id, host: host, port: port)); if (_usesConnectionString(effectiveConnectionString)) { // Pool passes target catalog via [database]; URI alone would always open @@ -300,10 +281,10 @@ class PostgresConnection { (sslKey != null && sslKey!.trim().isNotEmpty), hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, ); - final endpoint = _sshTunnelHandle != null + final endpoint = sshTunnelHandle != null ? Endpoint( - host: _sshTunnelHandle!.localHost, - port: _sshTunnelHandle!.localPort, + host: sshTunnelHandle!.localHost, + port: sshTunnelHandle!.localPort, database: parsed.endpoints.first.database, username: parsed.endpoints.first.username, password: parsed.endpoints.first.password, @@ -330,8 +311,8 @@ class PostgresConnection { _conn = await Connection.open( _buildEndpoint( pass: effectivePassword, - hostOverride: _sshTunnelHandle?.localHost, - portOverride: _sshTunnelHandle?.localPort, + hostOverride: sshTunnelHandle?.localHost, + portOverride: sshTunnelHandle?.localPort, ), settings: _buildSettings(), ); @@ -342,10 +323,7 @@ class PostgresConnection { } catch (e, st) { _isConnected = false; _conn = null; - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); Error.throwWithStackTrace( PostgresConnectionException( 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: ' @@ -368,12 +346,7 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.disconnect: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('PostgresConnection.sshRelease: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'PostgresConnection.sshRelease'); } /// Drops the TCP session immediately (kills pending client I/O). Used when @@ -388,12 +361,7 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.forceClose: $e'); } - try { - await _sshTunnelHandle?.release(); - } catch (e) { - debugPrint('PostgresConnection.forceClose ssh: $e'); - } - _sshTunnelHandle = null; + await releaseSshTunnel(logLabel: 'PostgresConnection.forceClose ssh'); } /// Session-level default for transactions (browse vs SQL editor). diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 120483e6..316d52c3 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; -import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; @@ -20,7 +20,7 @@ const redisCollectionPageSize = 200; const redisLargeCollectionWarnAt = 10000; /// Redis connection using the Dart redis package (no Java/JRE). -class RedisConnection { +class RedisConnection with SshTunnelHost { RedisConnection({ required this.id, required this.name, @@ -110,11 +110,10 @@ class RedisConnection { final bool useSSL; String? _connectionString; + @override final SshTunnelConfig? sshConfig; + @override final SshTunnelSecrets? sshSecrets; - SshTunnelHandle? _sshTunnelHandle; - - SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; String? get password => _password; String? get connectionString => _connectionString; @@ -212,28 +211,10 @@ class RedisConnection { final limit = connectTimeout > Duration.zero ? connectTimeout : null; try { - if (sshConfig != null && sshConfig!.enabled) { - var sec = sshSecrets; - if (sec == null && id > 0) { - final stored = - await ConnectionSecretsStore.readSshSecretsForConnection(id); - sec = SshTunnelSecrets( - password: stored.password, - privateKey: stored.privateKey, - passphrase: stored.passphrase, - jumpPassword: stored.jumpPassword, - ); - } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( - config: sshConfig!, - secrets: sec ?? SshTunnelSecrets(), - remoteHost: host, - remotePort: port, - ); - } + adoptSshTunnel(await openSshTunnel(id: id, host: host, port: port)); - final effectiveHost = _sshTunnelHandle?.localHost ?? host; - final effectivePort = _sshTunnelHandle?.localPort ?? port; + final effectiveHost = sshTunnelHandle?.localHost ?? host; + final effectivePort = sshTunnelHandle?.localPort ?? port; // The socket is opened here, with a timeout, for plain and TLS alike. socket = secure @@ -296,10 +277,7 @@ class RedisConnection { try { socket?.destroy(); } catch (_) {} - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); rethrow; } } @@ -340,10 +318,7 @@ class RedisConnection { } } } - try { - await _sshTunnelHandle?.release(); - } catch (_) {} - _sshTunnelHandle = null; + await releaseSshTunnel(); } Future forceClose() => disconnect(); diff --git a/lib/core/database/ssh_tunnel_host.dart b/lib/core/database/ssh_tunnel_host.dart new file mode 100644 index 00000000..13f3c1c6 --- /dev/null +++ b/lib/core/database/ssh_tunnel_host.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +/// The SSH tunnel of a database connection: the same steps for every driver. +/// +/// The class gives [sshConfig] and [sshSecrets]; the mixin holds the open +/// tunnel, opens it with the stored secrets when none were passed in, and +/// releases it. +mixin SshTunnelHost { + SshTunnelConfig? get sshConfig; + SshTunnelSecrets? get sshSecrets; + + SshTunnelHandle? _sshTunnelHandle; + + SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; + + /// Opens the tunnel to [host]:[port] when the connection has one enabled, + /// else null. The handle is not kept: pass it to [adoptSshTunnel] once the + /// connect attempt is still the current one. + @protected + Future openSshTunnel({ + required int id, + required String host, + required int port, + }) async { + final config = sshConfig; + if (config == null || !config.enabled) return null; + var secrets = sshSecrets; + if (secrets == null && id > 0) { + final stored = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + secrets = SshTunnelSecrets( + password: stored.password, + privateKey: stored.privateKey, + passphrase: stored.passphrase, + jumpPassword: stored.jumpPassword, + ); + } + return SshTunnelManager.instance.openTunnel( + config: config, + secrets: secrets ?? SshTunnelSecrets(), + remoteHost: host, + remotePort: port, + ); + } + + @protected + void adoptSshTunnel(SshTunnelHandle? handle) => _sshTunnelHandle = handle; + + /// Releases the open tunnel, if any. A failing release is logged under + /// [logLabel] when given and ignored otherwise. + @protected + Future releaseSshTunnel({String? logLabel}) async { + final handle = _sshTunnelHandle; + _sshTunnelHandle = null; + try { + await handle?.release(); + } catch (e) { + if (logLabel != null) debugPrint('$logLabel: $e'); + } + } + + /// Forgets [handle] when it is the one kept (a superseded connect attempt). + @protected + void forgetSshTunnel(SshTunnelHandle? handle) { + if (identical(_sshTunnelHandle, handle)) _sshTunnelHandle = null; + } +} diff --git a/test/core/database/ssh_tunnel_host_test.dart b/test/core/database/ssh_tunnel_host_test.dart new file mode 100644 index 00000000..a70f42a1 --- /dev/null +++ b/test/core/database/ssh_tunnel_host_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/ssh_tunnel_host.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; + +class _Host with SshTunnelHost { + _Host(this.sshConfig); + + @override + final SshTunnelConfig? sshConfig; + @override + final SshTunnelSecrets? sshSecrets = null; + + Future open() => openSshTunnel(id: 0, host: 'db', port: 5432); + Future release() => releaseSshTunnel(); +} + +void main() { + test('no tunnel is opened without a config or when it is disabled', () async { + expect(await _Host(null).open(), isNull); + expect(await _Host(const SshTunnelConfig(enabled: false)).open(), isNull); + }); + + test('releasing with no tunnel open is harmless and leaves no handle', () async { + final host = _Host(null); + await host.release(); + expect(host.sshTunnelHandle, isNull); + }); +}