diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 5f99de02..395cc6b0 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.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 { @@ -19,9 +22,30 @@ class MongoConnection { this.useSSL = false, this.replicaSet, String? connectionString, + this.sshConfig, + this.sshSecrets, }) : _password = password, _connectionString = connectionString; + factory MongoConnection.fromConnectionRow( + ConnectionRow row, { + String? database, + }) { + return MongoConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 27017, + username: row.username, + password: row.password, + database: database ?? row.databaseName, + authSource: row.authSource, + useSSL: row.useSSL, + connectionString: row.connectionString, + sshConfig: row.sshTunnelConfig, + ); + } + final int id; final String name; final String host; @@ -34,6 +58,12 @@ class MongoConnection { final String? replicaSet; String? _connectionString; + final SshTunnelConfig? sshConfig; + 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]. String? _sessionUri; @@ -79,12 +109,24 @@ class MongoConnection { } /// Builds MongoDB connection URI from configuration. - String buildConnectionUri({String? pass, String? connStr}) { + String buildConnectionUri({ + String? pass, + String? connStr, + String? hostOverride, + int? portOverride, + }) { final effectiveConnStr = connStr ?? _connectionString; if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { + if (hostOverride != null) { + final parsed = Uri.parse(effectiveConnStr); + return parsed + .replace(host: hostOverride, port: portOverride) + .toString(); + } return effectiveConnStr; } if (pass == null && + hostOverride == null && _sessionUri != null && _sessionUri!.isNotEmpty && (_password == null || _password!.isEmpty)) { @@ -104,9 +146,10 @@ class MongoConnection { } // Add host and port - buffer.write(host); - if (port != 27017) { - buffer.write(':$port'); + buffer.write(hostOverride ?? host); + final effectivePort = portOverride ?? port; + if (effectivePort != 27017 || hostOverride != null) { + buffer.write(':$effectivePort'); } // Add database @@ -187,9 +230,41 @@ 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 && + effectiveConnectionString.trim().isNotEmpty) { + final uri = Uri.tryParse(effectiveConnectionString.trim()); + if (uri != null && uri.host.isNotEmpty) { + targetHost = uri.host; + targetPort = uri.hasPort ? uri.port : 27017; + } + } + _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( + config: sshConfig!, + secrets: sec ?? SshTunnelSecrets(), + remoteHost: targetHost, + remotePort: targetPort, + ); + } + final uri = await _effectiveMongoUri( pass: effectivePassword, connStr: effectiveConnectionString, + hostOverride: _sshTunnelHandle?.localHost, + portOverride: _sshTunnelHandle?.localPort, ); _db = await Db.create(uri); await _db!.open(); @@ -204,12 +279,26 @@ class MongoConnection { _isConnected = false; _db = null; await _cleanupTempTlsKey(); + try { + await _sshTunnelHandle?.release(); + } catch (_) {} + _sshTunnelHandle = null; rethrow; } } - Future _effectiveMongoUri({String? pass, String? connStr}) async { - final base = buildConnectionUri(pass: pass, connStr: connStr); + Future _effectiveMongoUri({ + String? pass, + String? connStr, + String? hostOverride, + int? portOverride, + }) async { + final base = buildConnectionUri( + pass: pass, + connStr: connStr, + hostOverride: hostOverride, + portOverride: portOverride, + ); final parsed = Uri.parse(base); final paths = extractSslCertificatePaths(parsed); final params = Map.from(parsed.queryParameters); @@ -261,6 +350,12 @@ class MongoConnection { } } await _cleanupTempTlsKey(); + try { + await _sshTunnelHandle?.release(); + } catch (e) { + debugPrint('MongoConnection.sshRelease: $e'); + } + _sshTunnelHandle = null; } Future _cleanupTempTlsKey() async { diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 85f89a45..d534f32b 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -78,18 +78,7 @@ class MongoService { existing.disconnect(); // fire-and-forget; disconnect is safe } - final connection = MongoConnection( - id: id, - name: row.name, - host: row.host ?? 'localhost', - port: row.port ?? 27017, - username: row.username, - password: row.password, - database: row.databaseName, - authSource: row.authSource, - useSSL: row.useSSL, - connectionString: row.connectionString, - ); + final connection = MongoConnection.fromConnectionRow(row); _connections[connection.id] = connection; return connection; diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 16c905e4..6b74d914 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.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'; @@ -99,6 +101,8 @@ class MysqlConnection { this.database, this.useSSL = true, String? connectionString, + this.sshConfig, + this.sshSecrets, }) : _password = password, _connectionString = connectionString; @@ -116,6 +120,7 @@ class MysqlConnection { database: database ?? row.databaseName, useSSL: row.useSSL, connectionString: row.connectionString, + sshConfig: row.sshTunnelConfig, ); } @@ -129,6 +134,12 @@ class MysqlConnection { final bool useSSL; String? _connectionString; + final SshTunnelConfig? sshConfig; + final SshTunnelSecrets? sshSecrets; + SshTunnelHandle? _sshTunnelHandle; + + SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; + String? get password => _password; String? get connectionString => _connectionString; @@ -170,6 +181,26 @@ 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, + ); + } + _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( + config: sshConfig!, + secrets: sec ?? SshTunnelSecrets(), + remoteHost: host, + remotePort: port, + ); + } + final user = username ?? ''; final pass = effectivePassword ?? ''; if (_usesConnectionString(effectiveConnectionString)) { @@ -188,26 +219,29 @@ class MysqlConnection { } validateMysqlSslMode(sslMode, sslPaths); final securityContext = buildSecurityContext(sslPaths); - final host = parsed.host; + final effectiveHost = _sshTunnelHandle?.localHost ?? parsed.host; + final effectivePort = _sshTunnelHandle?.localPort ?? parsed.port; _conn = await MySQLConnection.createConnection( - host: host, - port: parsed.port, + host: effectiveHost, + port: effectivePort, userName: parsed.userName, password: parsed.password, secure: sslMode.secure, databaseName: parsed.databaseName, securityContext: securityContext, sslVerifyCertificates: sslMode.verifyCertificates, - sslServerName: sslMode.verifyIdentity && host is String ? host : null, + sslServerName: sslMode.verifyIdentity && parsed.host is String ? parsed.host : null, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); + final effectiveHost = _sshTunnelHandle?.localHost ?? host; + final effectivePort = _sshTunnelHandle?.localPort ?? port; _conn = await MySQLConnection.createConnection( - host: host, - port: port, + host: effectiveHost, + port: effectivePort, userName: user, password: pass, secure: useSSL || sslPaths.hasAny, @@ -221,6 +255,10 @@ class MysqlConnection { } catch (e) { _isConnected = false; _conn = null; + try { + await _sshTunnelHandle?.release(); + } catch (_) {} + _sshTunnelHandle = null; rethrow; } } @@ -315,6 +353,12 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.disconnect: $e'); } + try { + await _sshTunnelHandle?.release(); + } catch (e) { + debugPrint('MysqlConnection.sshRelease: $e'); + } + _sshTunnelHandle = null; } /// Best-effort close. The `mysql_client` driver may not allow graceful [close] @@ -332,6 +376,12 @@ class MysqlConnection { } catch (e) { debugPrint('MysqlConnection.forceClose: $e'); } + try { + await _sshTunnelHandle?.release(); + } catch (e) { + debugPrint('MysqlConnection.forceClose ssh: $e'); + } + _sshTunnelHandle = null; } /// 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 e008370a..a68e2964 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -3,6 +3,8 @@ import 'dart:io' show SecurityContext; import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.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'; @@ -78,6 +80,8 @@ class PostgresConnection { this.sslRootCert, this.sslCert, this.sslKey, + this.sshConfig, + this.sshSecrets, }) : _password = password, _connectionString = connectionString; @@ -111,6 +115,7 @@ class PostgresConnection { sslRootCert: rootCert, sslCert: clientCert, sslKey: clientKey, + sshConfig: row.sshTunnelConfig, ); } @@ -127,6 +132,12 @@ class PostgresConnection { final String? sslCert; final String? sslKey; + final SshTunnelConfig? sshConfig; + final SshTunnelSecrets? sshSecrets; + SshTunnelHandle? _sshTunnelHandle; + + SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; + String? get password => _password; String? get connectionString => _connectionString; @@ -145,10 +156,14 @@ class PostgresConnection { bool _usesConnectionString(String? connStr) => connStr != null && connStr.trim().isNotEmpty; - Endpoint _buildEndpoint({String? pass}) { + Endpoint _buildEndpoint({ + String? pass, + String? hostOverride, + int? portOverride, + }) { return Endpoint( - host: host, - port: port, + host: hostOverride ?? host, + port: portOverride ?? port, database: database ?? 'postgres', username: username, password: pass ?? _password, @@ -202,6 +217,26 @@ 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, + ); + } + if (_usesConnectionString(effectiveConnectionString)) { // Pool passes target catalog via [database]; URI alone would always open // the DB embedded in the string — every tree branch then queried the @@ -220,8 +255,17 @@ class PostgresConnection { (sslKey != null && sslKey!.trim().isNotEmpty), hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, ); + final endpoint = _sshTunnelHandle != null + ? Endpoint( + host: _sshTunnelHandle!.localHost, + port: _sshTunnelHandle!.localPort, + database: parsed.endpoints.first.database, + username: parsed.endpoints.first.username, + password: parsed.endpoints.first.password, + ) + : parsed.endpoints.first; _conn = await Connection.open( - parsed.endpoints.first, + endpoint, settings: ConnectionSettings( applicationName: parsed.applicationName, connectTimeout: @@ -235,7 +279,11 @@ class PostgresConnection { ); } else { _conn = await Connection.open( - _buildEndpoint(pass: effectivePassword), + _buildEndpoint( + pass: effectivePassword, + hostOverride: _sshTunnelHandle?.localHost, + portOverride: _sshTunnelHandle?.localPort, + ), settings: _buildSettings(), ); } @@ -245,6 +293,10 @@ class PostgresConnection { } catch (e, st) { _isConnected = false; _conn = null; + try { + await _sshTunnelHandle?.release(); + } catch (_) {} + _sshTunnelHandle = null; Error.throwWithStackTrace( PostgresConnectionException( 'Failed to connect to PostgreSQL${name.isNotEmpty ? ' ($name)' : ''}: $e', @@ -266,6 +318,12 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.disconnect: $e'); } + try { + await _sshTunnelHandle?.release(); + } catch (e) { + debugPrint('PostgresConnection.sshRelease: $e'); + } + _sshTunnelHandle = null; } /// Drops the TCP session immediately (kills pending client I/O). Used when @@ -280,6 +338,12 @@ class PostgresConnection { } catch (e) { debugPrint('PostgresConnection.forceClose: $e'); } + try { + await _sshTunnelHandle?.release(); + } catch (e) { + debugPrint('PostgresConnection.forceClose ssh: $e'); + } + _sshTunnelHandle = null; } /// 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 0fa4c7f8..b200f06a 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,6 +1,8 @@ import 'dart:io'; 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/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/database/redis_bulk.dart'; @@ -24,6 +26,8 @@ class RedisConnection { String? password, this.useSSL = false, String? connectionString, + this.sshConfig, + this.sshSecrets, }) : _password = password, _connectionString = connectionString; @@ -59,6 +63,8 @@ class RedisConnection { password: pass ?? row.password, useSSL: row.useSSL || parsed.scheme == 'rediss', connectionString: uriText, + sshConfig: row.sshTunnelConfig, + sshSecrets: row.sshSecrets, ); } return RedisConnection( @@ -70,6 +76,8 @@ class RedisConnection { password: row.password, useSSL: row.useSSL, connectionString: row.connectionString, + sshConfig: row.sshTunnelConfig, + sshSecrets: row.sshSecrets, ); } @@ -82,6 +90,12 @@ class RedisConnection { final bool useSSL; String? _connectionString; + final SshTunnelConfig? sshConfig; + final SshTunnelSecrets? sshSecrets; + SshTunnelHandle? _sshTunnelHandle; + + SshTunnelHandle? get sshTunnelHandle => _sshTunnelHandle; + String? get password => _password; String? get connectionString => _connectionString; @@ -118,44 +132,79 @@ class RedisConnection { final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); final secure = useSSL || sslPaths.hasAny; - if (secure) { - final context = buildSecurityContext(sslPaths); - final socket = await SecureSocket.connect( - host, - port, - context: context, - ); - _command = await _conn!.connectWithSocket(socket); - } else { - _command = await _conn!.connect(host, port); - } - _command!.setParser(redis.RedisParserBulkBinary()); - if (effectivePassword != null && effectivePassword.isNotEmpty) { - if (username != null && username!.trim().isNotEmpty) { - await _command! - .send_object(['AUTH', username!.trim(), effectivePassword]); + + 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, + ); + } + + final effectiveHost = _sshTunnelHandle?.localHost ?? host; + final effectivePort = _sshTunnelHandle?.localPort ?? port; + + if (secure) { + final context = buildSecurityContext(sslPaths); + final socket = await SecureSocket.connect( + effectiveHost, + effectivePort, + context: context, + ); + _command = await _conn!.connectWithSocket(socket); } else { - await _command!.send_object(['AUTH', effectivePassword]); + _command = await _conn!.connect(effectiveHost, effectivePort); } - } - final result = await _command!.send_object(['PING']); - if (result == null || result.toString().toUpperCase() != 'PONG') { - try { - await _conn?.close(); - } catch (_) {} + _command!.setParser(redis.RedisParserBulkBinary()); + if (effectivePassword != null && effectivePassword.isNotEmpty) { + if (username != null && username!.trim().isNotEmpty) { + await _command! + .send_object(['AUTH', username!.trim(), effectivePassword]); + } else { + await _command!.send_object(['AUTH', effectivePassword]); + } + } + final result = await _command!.send_object(['PING']); + if (result == null || result.toString().toUpperCase() != 'PONG') { + try { + await _conn?.close(); + } catch (_) {} + _conn = null; + _command = null; + throw RedisConnectionException('PING failed'); + } + _isConnected = true; + if (_clientReadOnly) { + try { + await _command!.send_object(['READONLY']); + } catch (_) { + // Standalone / older servers: READONLY is cluster-replica only. + } + } + scrubCredentials(); + } catch (e) { + _isConnected = false; _conn = null; _command = null; - throw RedisConnectionException('PING failed'); - } - _isConnected = true; - if (_clientReadOnly) { try { - await _command!.send_object(['READONLY']); - } catch (_) { - // Standalone / older servers: READONLY is cluster-replica only. - } + await _sshTunnelHandle?.release(); + } catch (_) {} + _sshTunnelHandle = null; + rethrow; } - scrubCredentials(); } Future disconnect() async { @@ -173,6 +222,10 @@ class RedisConnection { } } } + try { + await _sshTunnelHandle?.release(); + } catch (_) {} + _sshTunnelHandle = null; } Future forceClose() => disconnect(); diff --git a/lib/core/security/ssh_tunnel_config.dart b/lib/core/security/ssh_tunnel_config.dart new file mode 100644 index 00000000..e4865d4b --- /dev/null +++ b/lib/core/security/ssh_tunnel_config.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +/// Authentication method for the SSH Bastion host. +enum SshAuthType { + password, + privateKey, + sshAgent, +} + +/// Non-secret configuration for connecting through an SSH Bastion / Jump Host. +@immutable +class SshTunnelConfig { + const SshTunnelConfig({ + this.enabled = false, + this.host = '', + this.port = 22, + this.username = '', + this.authType = SshAuthType.password, + this.privateKeyPath, + this.knownHostFingerprint, + this.keepAliveIntervalSeconds = 30, + this.connectTimeoutSeconds = 15, + this.jumpHost, + this.jumpPort, + this.jumpUsername, + }); + + /// Whether SSH tunneling is enabled for this connection. + final bool enabled; + + /// Bastion / SSH server hostname or IP address. + final String host; + + /// Bastion SSH port (defaults to 22). + final int port; + + /// SSH login username (e.g. 'ubuntu', 'root', 'ec2-user'). + final String username; + + /// Authentication method (Password, Private Key, or SSH Agent). + final SshAuthType authType; + + /// Optional path to an SSH private key file on the local disk. + final String? privateKeyPath; + + /// Expected SHA-256 host key fingerprint for host verification (MitM prevention). + /// If null or empty, host key is accepted upon first use (TOFU). + final String? knownHostFingerprint; + + /// Keep-alive ping interval in seconds to keep NAT/firewall sessions alive. + final int keepAliveIntervalSeconds; + + /// Connection timeout in seconds. + final int connectTimeoutSeconds; + + /// Optional second-hop Jump Host / ProxyJump host. + final String? jumpHost; + final int? jumpPort; + final String? jumpUsername; + + /// True if minimal required fields to connect are filled out. + bool get isValid => + !enabled || (host.trim().isNotEmpty && username.trim().isNotEmpty && port > 0); + + SshTunnelConfig copyWith({ + bool? enabled, + String? host, + int? port, + String? username, + SshAuthType? authType, + String? privateKeyPath, + String? knownHostFingerprint, + int? keepAliveIntervalSeconds, + int? connectTimeoutSeconds, + String? jumpHost, + int? jumpPort, + String? jumpUsername, + }) { + return SshTunnelConfig( + enabled: enabled ?? this.enabled, + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + authType: authType ?? this.authType, + privateKeyPath: privateKeyPath ?? this.privateKeyPath, + knownHostFingerprint: knownHostFingerprint ?? this.knownHostFingerprint, + keepAliveIntervalSeconds: + keepAliveIntervalSeconds ?? this.keepAliveIntervalSeconds, + connectTimeoutSeconds: + connectTimeoutSeconds ?? this.connectTimeoutSeconds, + jumpHost: jumpHost ?? this.jumpHost, + jumpPort: jumpPort ?? this.jumpPort, + jumpUsername: jumpUsername ?? this.jumpUsername, + ); + } + + Map toMap() => { + 'enabled': enabled, + 'host': host, + 'port': port, + 'username': username, + 'auth_type': authType.name, + 'private_key_path': privateKeyPath, + 'known_host_fingerprint': knownHostFingerprint, + 'keep_alive_seconds': keepAliveIntervalSeconds, + 'connect_timeout_seconds': connectTimeoutSeconds, + if (jumpHost != null) 'jump_host': jumpHost, + if (jumpPort != null) 'jump_port': jumpPort, + if (jumpUsername != null) 'jump_username': jumpUsername, + }; + + String toJson() => jsonEncode(toMap()); + + static SshTunnelConfig fromMap(Map? m) { + if (m == null) return const SshTunnelConfig(); + return SshTunnelConfig( + enabled: m['enabled'] as bool? ?? false, + host: m['host'] as String? ?? '', + port: (m['port'] as num?)?.toInt() ?? 22, + username: m['username'] as String? ?? '', + authType: SshAuthType.values.firstWhere( + (e) => e.name == (m['auth_type'] as String?), + orElse: () => SshAuthType.password, + ), + privateKeyPath: m['private_key_path'] as String?, + knownHostFingerprint: m['known_host_fingerprint'] as String?, + keepAliveIntervalSeconds: + (m['keep_alive_seconds'] as num?)?.toInt() ?? 30, + connectTimeoutSeconds: + (m['connect_timeout_seconds'] as num?)?.toInt() ?? 15, + jumpHost: m['jump_host'] as String?, + jumpPort: (m['jump_port'] as num?)?.toInt(), + jumpUsername: m['jump_username'] as String?, + ); + } + + static SshTunnelConfig? fromJson(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return fromMap(decoded); + } + } catch (_) {} + return null; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SshTunnelConfig && + runtimeType == other.runtimeType && + enabled == other.enabled && + host == other.host && + port == other.port && + username == other.username && + authType == other.authType && + privateKeyPath == other.privateKeyPath && + knownHostFingerprint == other.knownHostFingerprint && + keepAliveIntervalSeconds == other.keepAliveIntervalSeconds && + connectTimeoutSeconds == other.connectTimeoutSeconds && + jumpHost == other.jumpHost && + jumpPort == other.jumpPort && + jumpUsername == other.jumpUsername; + + @override + int get hashCode => Object.hash( + enabled, + host, + port, + username, + authType, + privateKeyPath, + knownHostFingerprint, + keepAliveIntervalSeconds, + connectTimeoutSeconds, + jumpHost, + jumpPort, + jumpUsername, + ); +} + +/// Sensitive credentials for SSH authentication. +/// Stored in OS secure store (Keychain / Credential Manager / libsecret) +/// and NEVER stored in the plain SQLite database. +class SshTunnelSecrets { + SshTunnelSecrets({ + this.password, + this.privateKey, + this.passphrase, + this.jumpPassword, + }); + + String? password; + String? privateKey; + String? passphrase; + String? jumpPassword; + + bool get isEmpty => + (password == null || password!.isEmpty) && + (privateKey == null || privateKey!.isEmpty) && + (passphrase == null || passphrase!.isEmpty) && + (jumpPassword == null || jumpPassword!.isEmpty); + + bool get isNotEmpty => !isEmpty; + + bool get hasAny => !isEmpty; + + /// Overwrites in-memory secret buffers once authentication completes. + void zero() { + password = null; + privateKey = null; + passphrase = null; + jumpPassword = null; + } +} diff --git a/lib/core/security/ssh_tunnel_manager.dart b/lib/core/security/ssh_tunnel_manager.dart new file mode 100644 index 00000000..e91c5290 --- /dev/null +++ b/lib/core/security/ssh_tunnel_manager.dart @@ -0,0 +1,446 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; + +/// Result of an SSH connection diagnostic test. +class SshTestResult { + const SshTestResult({ + required this.ok, + this.message, + this.error, + this.serverFingerprint, + }); + + final bool ok; + final String? message; + final String? error; + final String? serverFingerprint; +} + +/// Handle to an active ephemeral local port forwarding tunnel. +class SshTunnelHandle { + SshTunnelHandle({ + required this.localHost, + required this.localPort, + required this.remoteHost, + required this.remotePort, + required Future Function() onRelease, + }) : _onRelease = onRelease; + + /// Always '127.0.0.1' (strictly loopback). + final String localHost; + + /// Dynamic local port bound on 127.0.0.1. + final int localPort; + + /// Destination target database host behind the bastion. + final String remoteHost; + + /// Destination target database port behind the bastion. + final int remotePort; + + final Future Function() _onRelease; + bool _released = false; + + /// Decrements ref-count and tears down socket/SSH session when no callers remain. + Future release() async { + if (_released) return; + _released = true; + await _onRelease(); + } +} + +/// Active pooled SSH tunnel session. +class _PooledTunnelSession { + _PooledTunnelSession({ + required this.poolKey, + required this.client, + required this.serverSocket, + required this.localPort, + this.jumpClient, + this.keepAliveTimer, + }); + + final String poolKey; + final SSHClient client; + final ServerSocket serverSocket; + final int localPort; + final SSHClient? jumpClient; + Timer? keepAliveTimer; + int refCount = 1; + + Future close() async { + keepAliveTimer?.cancel(); + keepAliveTimer = null; + try { + await serverSocket.close(); + } catch (_) {} + try { + client.close(); + } catch (_) {} + try { + jumpClient?.close(); + } catch (_) {} + } +} + +/// Singleton manager for production SSH tunnels (Bastion / Jump Hosts). +/// Supports ephemeral local port forwarding, TLS over SSH, ref-counting, +/// host key fingerprint verification, and zero-leak credential hygiene. +class SshTunnelManager { + SshTunnelManager._(); + + static SshTunnelManager instance = SshTunnelManager._(); + + final Map _sessions = {}; + + /// Formats SHA-256 fingerprint as standard hex string (`aa:bb:cc...` or raw hex). + static String formatFingerprint(Uint8List bytes) { + return sha256.convert(bytes).toString(); + } + + /// Establishes or reuses an ephemeral local port forwarding tunnel. + Future openTunnel({ + required SshTunnelConfig config, + required SshTunnelSecrets secrets, + required String remoteHost, + required int remotePort, + }) async { + final poolKey = '${config.host}:${config.port}:${config.username}:' + '${config.jumpHost ?? ""}:${config.jumpPort ?? ""}' + '@$remoteHost:$remotePort'; + + // 1. Check if an active session can be reused (ref-counting) + final existing = _sessions[poolKey]; + if (existing != null && !existing.client.isClosed) { + existing.refCount++; + return SshTunnelHandle( + localHost: '127.0.0.1', + localPort: existing.localPort, + remoteHost: remoteHost, + remotePort: remotePort, + onRelease: () => _releaseSession(poolKey), + ); + } + + // 2. Connect to SSH Bastion (with optional Jump Host) + SSHClient? jumpClient; + final SSHSocket bastionSocket; + + if (config.jumpHost != null && config.jumpHost!.trim().isNotEmpty) { + final jumpHost = config.jumpHost!.trim(); + final jumpPort = config.jumpPort ?? 22; + final jumpUser = config.jumpUsername ?? config.username; + + final rawJumpSocket = await SSHSocket.connect( + jumpHost, + jumpPort, + timeout: Duration(seconds: config.connectTimeoutSeconds), + ); + + jumpClient = SSHClient( + rawJumpSocket, + username: jumpUser, + onPasswordRequest: () => + secrets.jumpPassword ?? secrets.password ?? '', + ); + await jumpClient.authenticated; + + // Forward through jump host to target bastion (returns SSHForwardChannel which implements SSHSocket) + bastionSocket = await jumpClient.forwardLocal(config.host.trim(), config.port); + } else { + bastionSocket = await SSHSocket.connect( + config.host.trim(), + config.port, + timeout: Duration(seconds: config.connectTimeoutSeconds), + ); + } + + // Prepare identities for private key auth + List identities = []; + if (config.authType == SshAuthType.privateKey) { + String? keyContent = secrets.privateKey; + if ((keyContent == null || keyContent.isEmpty) && + config.privateKeyPath != null && + config.privateKeyPath!.trim().isNotEmpty) { + final file = File(config.privateKeyPath!.trim()); + if (await file.exists()) { + keyContent = await file.readAsString(); + } + } + + if (keyContent != null && keyContent.isNotEmpty) { + try { + identities = SSHKeyPair.fromPem( + keyContent, + secrets.passphrase, + ); + } catch (e) { + throw SshAuthenticationException( + 'Failed to parse private key: $e', + ); + } + } + } + + // Host key verification (MitM protection) + String? observedFingerprint; + Future handleVerifyHostKey(String type, Uint8List fingerprint) async { + observedFingerprint = formatFingerprint(fingerprint); + if (config.knownHostFingerprint == null || + config.knownHostFingerprint!.trim().isEmpty) { + return true; // TOFU / accept-new + } + final expected = config.knownHostFingerprint!.trim().toLowerCase(); + final actual = observedFingerprint!.toLowerCase(); + if (actual != expected && + actual.replaceAll(':', '') != expected.replaceAll(':', '')) { + debugPrint( + 'SSH Host Key Mismatch! Expected: $expected, Actual: $actual', + ); + return false; + } + return true; + } + + final client = SSHClient( + bastionSocket, + username: config.username.trim(), + onPasswordRequest: () => secrets.password ?? '', + identities: identities.isNotEmpty ? identities : null, + onVerifyHostKey: handleVerifyHostKey, + ); + + try { + await client.authenticated; + } catch (e) { + client.close(); + jumpClient?.close(); + if (observedFingerprint != null && + config.knownHostFingerprint != null && + config.knownHostFingerprint!.isNotEmpty) { + throw SshHostKeyMismatchException( + 'SSH host key verification failed for ${config.host}. ' + 'Observed fingerprint: $observedFingerprint', + ); + } + throw SshAuthenticationException( + 'SSH authentication failed for ${config.username}@${config.host}: $e', + ); + } + + // Zero sensitive in-memory credentials immediately after successful authentication + secrets.zero(); + + // 3. Start local ephemeral port forwarding on 127.0.0.1:0 + final serverSocket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final localPort = serverSocket.port; + + serverSocket.listen( + (clientSocket) async { + try { + final forward = await client.forwardLocal(remoteHost, remotePort); + forward.stream.cast>().pipe(clientSocket).catchError((_) {}); + clientSocket.cast>().pipe(forward.sink).catchError((_) {}); + } catch (e) { + clientSocket.destroy(); + } + }, + onError: (_) {}, + ); + + // 4. Setup keep-alive ping timer + Timer? keepAliveTimer; + if (config.keepAliveIntervalSeconds > 0) { + keepAliveTimer = Timer.periodic( + Duration(seconds: config.keepAliveIntervalSeconds), + (_) { + if (!client.isClosed) { + client.ping().catchError((_) {}); + } + }, + ); + } + + final session = _PooledTunnelSession( + poolKey: poolKey, + client: client, + serverSocket: serverSocket, + localPort: localPort, + jumpClient: jumpClient, + keepAliveTimer: keepAliveTimer, + ); + _sessions[poolKey] = session; + + return SshTunnelHandle( + localHost: '127.0.0.1', + localPort: localPort, + remoteHost: remoteHost, + remotePort: remotePort, + onRelease: () => _releaseSession(poolKey), + ); + } + + Future _releaseSession(String poolKey) async { + final session = _sessions[poolKey]; + if (session == null) return; + session.refCount--; + if (session.refCount <= 0) { + _sessions.remove(poolKey); + await session.close(); + } + } + + /// Closes all active tunnels and cleans up all sockets. + Future closeAll() async { + final active = List<_PooledTunnelSession>.from(_sessions.values); + _sessions.clear(); + for (final s in active) { + await s.close(); + } + } + + /// Tests the Bastion connection and remote target reachability. + Future testSshConnection({ + required SshTunnelConfig config, + required SshTunnelSecrets secrets, + String? testRemoteHost, + int? testRemotePort, + }) async { + if (!config.enabled) { + return const SshTestResult(ok: true, message: 'SSH Tunneling disabled'); + } + if (config.host.trim().isEmpty) { + return const SshTestResult( + ok: false, + error: 'SSH Bastion host cannot be empty', + ); + } + if (config.username.trim().isEmpty) { + return const SshTestResult( + ok: false, + error: 'SSH username cannot be empty', + ); + } + + final SSHSocket bastionSocket; + SSHClient? jumpClient; + SSHClient? client; + + try { + if (config.jumpHost != null && config.jumpHost!.trim().isNotEmpty) { + final jumpSocket = await SSHSocket.connect( + config.jumpHost!.trim(), + config.jumpPort ?? 22, + timeout: Duration(seconds: config.connectTimeoutSeconds), + ); + jumpClient = SSHClient( + jumpSocket, + username: config.jumpUsername ?? config.username, + onPasswordRequest: () => + secrets.jumpPassword ?? secrets.password ?? '', + ); + await jumpClient.authenticated; + bastionSocket = await jumpClient.forwardLocal( + config.host.trim(), + config.port, + ); + } else { + bastionSocket = await SSHSocket.connect( + config.host.trim(), + config.port, + timeout: Duration(seconds: config.connectTimeoutSeconds), + ); + } + + List identities = []; + if (config.authType == SshAuthType.privateKey) { + String? keyContent = secrets.privateKey; + if ((keyContent == null || keyContent.isEmpty) && + config.privateKeyPath != null && + config.privateKeyPath!.trim().isNotEmpty) { + final file = File(config.privateKeyPath!.trim()); + if (await file.exists()) { + keyContent = await file.readAsString(); + } + } + if (keyContent != null && keyContent.isNotEmpty) { + identities = SSHKeyPair.fromPem( + keyContent, + secrets.passphrase, + ); + } + } + + String? observedFingerprint; + client = SSHClient( + bastionSocket, + username: config.username.trim(), + onPasswordRequest: () => secrets.password ?? '', + identities: identities.isNotEmpty ? identities : null, + onVerifyHostKey: (type, fingerprint) async { + observedFingerprint = formatFingerprint(fingerprint); + return true; + }, + ); + + await client.authenticated; + + // If remote host/port provided, test port forward channel + if (testRemoteHost != null && + testRemoteHost.trim().isNotEmpty && + testRemotePort != null && + testRemotePort > 0) { + try { + final forward = await client.forwardLocal( + testRemoteHost.trim(), + testRemotePort, + ); + forward.close(); + } catch (e) { + return SshTestResult( + ok: false, + error: + 'Bastion authenticated, but cannot reach target $testRemoteHost:$testRemotePort: $e', + serverFingerprint: observedFingerprint, + ); + } + } + + return SshTestResult( + ok: true, + message: 'SSH Bastion connection successful!', + serverFingerprint: observedFingerprint, + ); + } catch (e) { + return SshTestResult( + ok: false, + error: 'SSH Connection failed: $e', + ); + } finally { + client?.close(); + jumpClient?.close(); + } + } +} + +class SshAuthenticationException implements Exception { + const SshAuthenticationException(this.message); + final String message; + @override + String toString() => message; +} + +class SshHostKeyMismatchException implements Exception { + const SshHostKeyMismatchException(this.message); + final String message; + @override + String toString() => message; +} diff --git a/lib/core/storage/connection_secrets_store.dart b/lib/core/storage/connection_secrets_store.dart index 8d1592b9..07f00e7c 100644 --- a/lib/core/storage/connection_secrets_store.dart +++ b/lib/core/storage/connection_secrets_store.dart @@ -61,6 +61,14 @@ class ConnectionSecretsStore { 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.password'; static String _connectionStringKey(int connectionId) => 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.connection_string'; + static String _sshPasswordKey(int connectionId) => + 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.ssh_password'; + static String _sshPrivateKeyKey(int connectionId) => + 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.ssh_private_key'; + static String _sshPassphraseKey(int connectionId) => + 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.ssh_passphrase'; + static String _sshJumpPasswordKey(int connectionId) => + 'querya.v1.profile.${_requireProfileId()}.conn.$connectionId.ssh_jump_password'; /// Pre-#986 unnamespaced key format, kept only for [adoptLegacyKeysForConnection]. static String _legacyPasswordKey(int connectionId) => @@ -72,9 +80,63 @@ class ConnectionSecretsStore { int connectionId, { String? password, String? connectionString, + String? sshPassword, + String? sshPrivateKey, + String? sshPassphrase, + String? jumpPassword, }) async { await backend.write(_passwordKey(connectionId), password); await backend.write(_connectionStringKey(connectionId), connectionString); + if (sshPassword != null || sshPrivateKey != null || sshPassphrase != null || jumpPassword != null) { + await writeSshSecretsForConnection( + connectionId, + password: sshPassword, + privateKey: sshPrivateKey, + passphrase: sshPassphrase, + jumpPassword: jumpPassword, + ); + } + } + + static Future writeSshSecretsForConnection( + int connectionId, { + String? password, + String? privateKey, + String? passphrase, + String? jumpPassword, + }) async { + await backend.write(_sshPasswordKey(connectionId), password); + await backend.write(_sshPrivateKeyKey(connectionId), privateKey); + await backend.write(_sshPassphraseKey(connectionId), passphrase); + await backend.write(_sshJumpPasswordKey(connectionId), jumpPassword); + } + + static Future<({ + String? password, + String? privateKey, + String? passphrase, + String? jumpPassword, + })> readSshSecretsForConnection(int connectionId) async { + try { + final password = await backend.read(_sshPasswordKey(connectionId)); + final privateKey = await backend.read(_sshPrivateKeyKey(connectionId)); + final passphrase = await backend.read(_sshPassphraseKey(connectionId)); + final jumpPassword = + await backend.read(_sshJumpPasswordKey(connectionId)); + return ( + password: password, + privateKey: privateKey, + passphrase: passphrase, + jumpPassword: jumpPassword, + ); + } catch (_) { + return ( + password: null, + privateKey: null, + passphrase: null, + jumpPassword: null, + ); + } } static Future<({String? password, String? connectionString})> @@ -138,6 +200,10 @@ class ConnectionSecretsStore { static Future deleteForConnection(int connectionId) async { await backend.delete(_passwordKey(connectionId)); await backend.delete(_connectionStringKey(connectionId)); + await backend.delete(_sshPasswordKey(connectionId)); + await backend.delete(_sshPrivateKeyKey(connectionId)); + await backend.delete(_sshPassphraseKey(connectionId)); + await backend.delete(_sshJumpPasswordKey(connectionId)); try { await backend.delete(_legacyPasswordKey(connectionId)); await backend.delete(_legacyConnectionStringKey(connectionId)); diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 6a05e446..4d1c9e2a 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -1,8 +1,10 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -542,6 +544,15 @@ class LocalDb { password: row.password, connectionString: row.connectionString, ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + id, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } } catch (e) { try { await ConnectionSecretsStore.deleteForConnection(id); @@ -594,6 +605,15 @@ class LocalDb { password: row.password, connectionString: row.connectionString, ); + if (row.sshSecrets != null) { + await ConnectionSecretsStore.writeSshSecretsForConnection( + row.id!, + password: row.sshSecrets!.password, + privateKey: row.sshSecrets!.privateKey, + passphrase: row.sshSecrets!.passphrase, + jumpPassword: row.sshSecrets!.jumpPassword, + ); + } } catch (e) { await db.update( 'connections', @@ -679,6 +699,7 @@ class ConnectionRow { this.folderId, this.sortOrder = 0, required this.createdAt, + this.sshSecrets, }); final int? id; @@ -692,6 +713,7 @@ class ConnectionRow { final String? authSource; final bool useSSL; final String? connectionString; + final SshTunnelSecrets? sshSecrets; /// Package id of an installed extension driver (null for built-ins). final String? extensionId; @@ -707,6 +729,43 @@ class ConnectionRow { bool get isExtensionDriver => extensionId != null && extensionId!.trim().isNotEmpty; + /// Returns the SSH tunnel config stored in [driverOptions], if any. + SshTunnelConfig? get sshTunnelConfig { + if (driverOptions == null || driverOptions!.trim().isEmpty) return null; + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map && decoded.containsKey('ssh_tunnel')) { + final sshMap = decoded['ssh_tunnel']; + if (sshMap is Map) { + return SshTunnelConfig.fromMap(sshMap); + } + } + } catch (_) {} + return null; + } + + /// Returns a copy of this row with the given [sshTunnelConfig] encoded in [driverOptions]. + ConnectionRow withSshTunnelConfig(SshTunnelConfig? config) { + Map opts = {}; + if (driverOptions != null && driverOptions!.trim().isNotEmpty) { + try { + final decoded = jsonDecode(driverOptions!); + if (decoded is Map) { + opts = Map.from(decoded); + } + } catch (_) {} + } + if (config == null || !config.enabled) { + opts.remove('ssh_tunnel'); + } else { + opts['ssh_tunnel'] = config.toMap(); + } + return copyWith( + driverOptions: opts.isEmpty ? null : jsonEncode(opts), + clearDriverOptions: opts.isEmpty, + ); + } + Map toMap() => { 'type': type, 'name': name, @@ -780,8 +839,11 @@ class ConnectionRow { int? folderId, int? sortOrder, String? createdAt, + SshTunnelSecrets? sshSecrets, bool clearPassword = false, bool clearConnectionString = false, + bool clearSshSecrets = false, + bool clearDriverOptions = false, }) { return ConnectionRow( id: id ?? this.id, @@ -798,10 +860,13 @@ class ConnectionRow { ? null : (connectionString ?? this.connectionString), extensionId: extensionId ?? this.extensionId, - driverOptions: driverOptions ?? this.driverOptions, + driverOptions: clearDriverOptions + ? null + : (driverOptions ?? this.driverOptions), folderId: folderId ?? this.folderId, sortOrder: sortOrder ?? this.sortOrder, createdAt: createdAt ?? this.createdAt, + sshSecrets: clearSshSecrets ? null : (sshSecrets ?? this.sshSecrets), ); } @@ -813,12 +878,14 @@ class ConnectionRow { connectionString != null && connectionString!.isNotEmpty; /// Whether any in-memory secret credentials are held. - bool get hasSecrets => hasPassword || hasConnectionString; + bool get hasSecrets => + hasPassword || hasConnectionString || (sshSecrets != null && sshSecrets!.hasAny); /// Returns a clean copy of this [ConnectionRow] with all secret credentials - /// ([password] and [connectionString]) scrubbed to null. + /// ([password], [connectionString], and [sshSecrets]) scrubbed to null. ConnectionRow withoutSecrets() => copyWith( clearPassword: true, clearConnectionString: true, + clearSshSecrets: true, ); } diff --git a/lib/features/connections/connection_edit_secrets.dart b/lib/features/connections/connection_edit_secrets.dart index 700f4732..616d0f0b 100644 --- a/lib/features/connections/connection_edit_secrets.dart +++ b/lib/features/connections/connection_edit_secrets.dart @@ -1,7 +1,35 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +/// Merges existing SSH secrets from secure store when edit form fields are left blank. +Future mergeSshSecretsForConnectionUpdate({ + required int connectionId, + required SshTunnelSecrets editedSecrets, +}) async { + final prev = + await ConnectionSecretsStore.readSshSecretsForConnection(connectionId); + return SshTunnelSecrets( + password: (editedSecrets.password != null && + editedSecrets.password!.isNotEmpty) + ? editedSecrets.password + : prev.password, + privateKey: (editedSecrets.privateKey != null && + editedSecrets.privateKey!.isNotEmpty) + ? editedSecrets.privateKey + : prev.privateKey, + passphrase: (editedSecrets.passphrase != null && + editedSecrets.passphrase!.isNotEmpty) + ? editedSecrets.passphrase + : prev.passphrase, + jumpPassword: (editedSecrets.jumpPassword != null && + editedSecrets.jumpPassword!.isNotEmpty) + ? editedSecrets.jumpPassword + : prev.jumpPassword, + ); +} + /// Keeps previous secure-store secrets when edit form fields are left blank. /// /// [ConnectionSecretsStore.writeForConnection] deletes empty values — callers @@ -27,9 +55,18 @@ Future mergeSecretsForConnectionUpdate( connectionString = injectUriPasswordIfMissing(connectionString, password); } + SshTunnelSecrets? sshSecrets = edited.sshSecrets; + if (sshSecrets != null) { + sshSecrets = await mergeSshSecretsForConnectionUpdate( + connectionId: id, + editedSecrets: sshSecrets, + ); + } + return edited.copyWith( password: password, connectionString: connectionString, + sshSecrets: sshSecrets, clearPassword: password == null, clearConnectionString: connectionString == null, ); diff --git a/lib/features/connections/ssh_tunnel_section.dart b/lib/features/connections/ssh_tunnel_section.dart new file mode 100644 index 00000000..84ec2cfe --- /dev/null +++ b/lib/features/connections/ssh_tunnel_section.dart @@ -0,0 +1,537 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Reusable UI section for configuring SSH Bastion / Jump Host tunneling +/// in connection dialogs. +class SshTunnelSection extends material.StatefulWidget { + const SshTunnelSection({ + super.key, + required this.config, + required this.secrets, + required this.onChanged, + this.targetHost, + this.targetPort, + }); + + final SshTunnelConfig config; + final SshTunnelSecrets secrets; + final material.ValueChanged onChanged; + final String? targetHost; + final int? targetPort; + + @override + material.State createState() => _SshTunnelSectionState(); +} + +class _SshTunnelSectionState extends material.State { + late final material.TextEditingController _hostController; + late final material.TextEditingController _portController; + late final material.TextEditingController _usernameController; + late final material.TextEditingController _passwordController; + late final material.TextEditingController _keyPathController; + late final material.TextEditingController _passphraseController; + late final material.TextEditingController _fingerprintController; + + bool _obscurePassword = true; + bool _obscurePassphrase = true; + bool _showAdvanced = false; + bool _testing = false; + String? _testMessage; + bool _testSuccess = false; + + @override + void initState() { + super.initState(); + _hostController = material.TextEditingController(text: widget.config.host); + _portController = + material.TextEditingController(text: widget.config.port.toString()); + _usernameController = + material.TextEditingController(text: widget.config.username); + _passwordController = + material.TextEditingController(text: widget.secrets.password ?? ''); + _keyPathController = material.TextEditingController( + text: widget.config.privateKeyPath ?? ''); + _passphraseController = + material.TextEditingController(text: widget.secrets.passphrase ?? ''); + _fingerprintController = material.TextEditingController( + text: widget.config.knownHostFingerprint ?? ''); + + _hostController.addListener(_notify); + _portController.addListener(_notify); + _usernameController.addListener(_notify); + _passwordController.addListener(() { + widget.secrets.password = _passwordController.text; + }); + _keyPathController.addListener(_notify); + _passphraseController.addListener(() { + widget.secrets.passphrase = _passphraseController.text; + }); + _fingerprintController.addListener(_notify); + } + + @override + void didUpdateWidget(SshTunnelSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.config != widget.config) { + if (_hostController.text != widget.config.host) { + _hostController.text = widget.config.host; + } + if (_portController.text != widget.config.port.toString()) { + _portController.text = widget.config.port.toString(); + } + if (_usernameController.text != widget.config.username) { + _usernameController.text = widget.config.username; + } + if (_keyPathController.text != (widget.config.privateKeyPath ?? '')) { + _keyPathController.text = widget.config.privateKeyPath ?? ''; + } + if (_fingerprintController.text != + (widget.config.knownHostFingerprint ?? '')) { + _fingerprintController.text = widget.config.knownHostFingerprint ?? ''; + } + } + if (widget.secrets.password != null && + widget.secrets.password!.isNotEmpty && + _passwordController.text.isEmpty) { + _passwordController.text = widget.secrets.password!; + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _keyPathController.dispose(); + _passphraseController.dispose(); + _fingerprintController.dispose(); + super.dispose(); + } + + void _notify() { + final updated = widget.config.copyWith( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 22, + username: _usernameController.text.trim(), + privateKeyPath: _keyPathController.text.trim().isEmpty + ? null + : _keyPathController.text.trim(), + knownHostFingerprint: _fingerprintController.text.trim().isEmpty + ? null + : _fingerprintController.text.trim(), + ); + widget.onChanged(updated); + } + + Future _pickPrivateKeyFile() async { + const typeGroup = XTypeGroup( + label: 'SSH Keys', + extensions: ['pem', 'key', 'id_rsa', 'id_ed25519', 'id_ecdsa'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file != null) { + _keyPathController.text = file.path; + _notify(); + } + } + + Future _testSsh() async { + setState(() { + _testing = true; + _testMessage = null; + }); + + final currentConfig = widget.config.copyWith( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 22, + username: _usernameController.text.trim(), + privateKeyPath: _keyPathController.text.trim().isEmpty + ? null + : _keyPathController.text.trim(), + knownHostFingerprint: _fingerprintController.text.trim().isEmpty + ? null + : _fingerprintController.text.trim(), + ); + + final result = await SshTunnelManager.instance.testSshConnection( + config: currentConfig, + secrets: widget.secrets, + testRemoteHost: widget.targetHost, + testRemotePort: widget.targetPort, + ); + + if (!mounted) return; + setState(() { + _testing = false; + _testSuccess = result.ok; + if (result.ok) { + _testMessage = result.serverFingerprint != null + ? 'Connected! Host fingerprint: ${result.serverFingerprint}' + : 'SSH Bastion connection verified successfully!'; + } else { + _testMessage = result.error ?? 'SSH Connection failed'; + } + }); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + return material.Container( + margin: const material.EdgeInsets.only(top: 16), + padding: const material.EdgeInsets.all(14), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: widget.config.enabled + ? cs.primary.withValues(alpha: 0.5) + : cs.border.withValues(alpha: 0.4), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Checkbox( + value: widget.config.enabled, + onChanged: (v) { + widget.onChanged(widget.config.copyWith(enabled: v ?? false)); + }, + ), + const Gap(4), + material.Icon( + material.Icons.security_rounded, + size: 16, + color: widget.config.enabled ? cs.primary : cs.mutedForeground, + ), + const Gap(6), + material.Expanded( + child: const Text( + 'Connect via SSH Tunnel (Bastion / Jump Host)', + ).semiBold().small(), + ), + ], + ), + if (widget.config.enabled) ...[ + const Gap(14), + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Bastion Host').small().muted(), + const Gap(4), + TextField( + controller: _hostController, + placeholder: const Text('bastion.corp.example.com'), + ), + ], + ), + ), + const Gap(8), + material.Expanded( + flex: 1, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SSH Port').small().muted(), + const Gap(4), + TextField( + controller: _portController, + placeholder: const Text('22'), + ), + ], + ), + ), + ], + ), + const Gap(12), + material.Row( + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SSH Username').small().muted(), + const Gap(4), + TextField( + controller: _usernameController, + placeholder: const Text('ubuntu / root / ec2-user'), + ), + ], + ), + ), + const Gap(12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Authentication Method').small().muted(), + const Gap(4), + material.Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _AuthTypeChip( + label: 'Password', + selected: + widget.config.authType == SshAuthType.password, + onTap: () { + widget.onChanged(widget.config.copyWith( + authType: SshAuthType.password, + )); + }, + ), + _AuthTypeChip( + label: 'Private Key', + selected: widget.config.authType == + SshAuthType.privateKey, + onTap: () { + widget.onChanged(widget.config.copyWith( + authType: SshAuthType.privateKey, + )); + }, + ), + _AuthTypeChip( + label: 'Agent', + selected: + widget.config.authType == SshAuthType.sshAgent, + onTap: () { + widget.onChanged(widget.config.copyWith( + authType: SshAuthType.sshAgent, + )); + }, + ), + ], + ), + ], + ), + ), + ], + ), + const Gap(12), + if (widget.config.authType == SshAuthType.password) ...[ + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SSH Password').small().muted(), + const Gap(4), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + placeholder: const Text('••••••••'), + features: [ + InputFeature.trailing( + IconButton.ghost( + size: ButtonSize.small, + icon: material.Icon( + _obscurePassword + ? material.Icons.visibility_off_outlined + : material.Icons.visibility_outlined, + size: 16, + ), + onPressed: () { + setState( + () => _obscurePassword = !_obscurePassword, + ); + }, + ), + ), + ], + ), + ], + ), + ] else if (widget.config.authType == SshAuthType.privateKey) ...[ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.end, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Private Key Path (PEM / OpenSSH / RSA)') + .small() + .muted(), + const Gap(4), + TextField( + controller: _keyPathController, + placeholder: const Text('~/.ssh/id_rsa or click Browse'), + ), + ], + ), + ), + const Gap(8), + OutlineButton( + onPressed: _pickPrivateKeyFile, + child: const Text('Browse…'), + ), + ], + ), + const Gap(10), + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Key Passphrase (optional)').small().muted(), + const Gap(4), + TextField( + controller: _passphraseController, + obscureText: _obscurePassphrase, + placeholder: const Text('Passphrase if key is encrypted'), + features: [ + InputFeature.trailing( + IconButton.ghost( + size: ButtonSize.small, + icon: material.Icon( + _obscurePassphrase + ? material.Icons.visibility_off_outlined + : material.Icons.visibility_outlined, + size: 16, + ), + onPressed: () { + setState( + () => _obscurePassphrase = !_obscurePassphrase, + ); + }, + ), + ), + ], + ), + ], + ), + ] else ...[ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(6), + ), + child: const Text( + 'Authenticates using the local SSH Agent via \$SSH_AUTH_SOCK (Linux/macOS) or Named Pipe (Windows).', + ).muted().xSmall(), + ), + ], + const Gap(12), + // Actions: Test SSH Connection and Advanced Options toggle + material.Wrap( + spacing: 10, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + OutlineButton( + size: ButtonSize.small, + onPressed: _testing ? null : _testSsh, + leading: _testing + ? const QueryaSpinner(size: QueryaSpinnerSize.sm) + : const material.Icon( + material.Icons.network_check_rounded, + size: 15, + ), + child: const Text('Test SSH Connection'), + ), + GhostButton( + size: ButtonSize.small, + onPressed: () => + setState(() => _showAdvanced = !_showAdvanced), + child: Text(_showAdvanced + ? 'Hide Advanced ▲' + : 'Advanced (Host Key & Keep-Alive) ▼'), + ), + ], + ), + if (_testMessage != null) ...[ + const Gap(8), + material.SelectableText( + _testMessage!, + style: material.TextStyle( + fontSize: 12, + color: _testSuccess + ? material.Colors.green + : cs.destructive, + ), + ), + ], + if (_showAdvanced) ...[ + const Gap(12), + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Known Host SHA-256 Fingerprint (optional)') + .small() + .muted(), + const Gap(4), + TextField( + controller: _fingerprintController, + placeholder: const Text( + 'e.g. 7f8a12... (leave blank to trust on first connect)', + ), + ), + const Gap(6), + const Text( + 'Protects against Man-in-the-Middle (MitM) attacks by rejecting mismatched host keys.', + ).muted().xSmall(), + ], + ), + ), + ], + ], + ], + ), + ); + } +} + +class _AuthTypeChip extends material.StatelessWidget { + const _AuthTypeChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final material.VoidCallback onTap; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + color: selected + ? cs.primary.withValues(alpha: 0.18) + : cs.muted.withValues(alpha: 0.25), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: selected ? cs.primary : cs.border.withValues(alpha: 0.4), + ), + ), + child: Text(label) + .xSmall() + .semiBold(), + ), + ); + } +} diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 13b6ee90..e067f1ce 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/ssh_tunnel_section.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -87,6 +89,9 @@ class _MongoConnectionFormContentState final _sslCertController = material.TextEditingController(); final _sslKeyController = material.TextEditingController(); + SshTunnelConfig _sshConfig = const SshTunnelConfig(); + final SshTunnelSecrets _sshSecrets = SshTunnelSecrets(); + bool _useConnectionString = false; bool _useSSL = false; bool _showPassword = false; @@ -128,6 +133,9 @@ class _MongoConnectionFormContentState if (redacted.isNotEmpty) { _useConnectionString = true; } + if (initial.sshTunnelConfig != null) { + _sshConfig = initial.sshTunnelConfig!; + } } _formValidNotifier.seed(); @@ -285,6 +293,8 @@ class _MongoConnectionFormContentState authSource: data.authSource, useSSL: data.useSSL, connectionString: data.connectionString, + sshConfig: _sshConfig.enabled ? _sshConfig : null, + sshSecrets: _sshSecrets, ); final success = await connection.testConnection(); @@ -305,7 +315,7 @@ class _MongoConnectionFormContentState data.name.isNotEmpty ? data.name : 'MongoDB ${data.host}:${data.port}'; final initial = widget.initial; - final row = ConnectionRow( + var row = ConnectionRow( id: initial?.id, type: initial?.type ?? 'mongodb', name: displayName, @@ -322,7 +332,9 @@ class _MongoConnectionFormContentState folderId: initial?.folderId ?? widget.folderId, sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + sshSecrets: _sshConfig.enabled ? _sshSecrets : null, ); + row = row.withSshTunnelConfig(_sshConfig.enabled ? _sshConfig : null); material.Navigator.of(context).pop(row); } @@ -555,7 +567,6 @@ class _MongoConnectionFormContentState ), ], ), - const Gap(16), // SSL material.Row( children: [ @@ -577,6 +588,18 @@ class _MongoConnectionFormContentState onChanged: _syncUriSslParams, ), ], + const Gap(16), + // SSH Tunneling Section + SshTunnelSection( + config: _sshConfig, + secrets: _sshSecrets, + onChanged: (cfg) => setState(() => _sshConfig = cfg), + targetHost: _hostController.text.trim().isNotEmpty + ? _hostController.text.trim() + : 'localhost', + targetPort: + int.tryParse(_portController.text.trim()) ?? 27017, + ), ], ], ), diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 4994c57f..5d4a727d 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/ssh_tunnel_section.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -54,6 +56,9 @@ class _MysqlConnectionFormContentState final _sslCertController = material.TextEditingController(); final _sslKeyController = material.TextEditingController(); + SshTunnelConfig _sshConfig = const SshTunnelConfig(); + final SshTunnelSecrets _sshSecrets = SshTunnelSecrets(); + bool _useSSL = true; bool _showPassword = false; bool _isTesting = false; @@ -92,6 +97,9 @@ class _MysqlConnectionFormContentState _useSSL = initial.useSSL; _connectionStringController.text = redactUriPassword(initial.connectionString) ?? ''; + if (initial.sshTunnelConfig != null) { + _sshConfig = initial.sshTunnelConfig!; + } } _formValidNotifier.seed(); @@ -215,6 +223,8 @@ class _MysqlConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, + sshConfig: _sshConfig.enabled ? _sshConfig : null, + sshSecrets: _sshSecrets, ); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); @@ -237,7 +247,7 @@ class _MysqlConnectionFormContentState ? 'MySQL (URI)' : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); final initial = widget.initial; - final row = ConnectionRow( + var row = ConnectionRow( id: initial?.id, type: initial?.type ?? 'mysql', name: displayName, @@ -257,7 +267,9 @@ class _MysqlConnectionFormContentState folderId: initial?.folderId ?? widget.folderId, sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + sshSecrets: _sshConfig.enabled ? _sshSecrets : null, ); + row = row.withSshTunnelConfig(_sshConfig.enabled ? _sshConfig : null); material.Navigator.of(context).pop(row); } @@ -478,15 +490,27 @@ class _MysqlConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), - if (_useSSL) ...[ - const Gap(16), - SslCertificateFields( - rootCertController: _sslRootCertController, - clientCertController: _sslCertController, - clientKeyController: _sslKeyController, - onChanged: _syncUriSslParams, - ), - ], + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], + const Gap(16), + // SSH Tunneling Section + SshTunnelSection( + config: _sshConfig, + secrets: _sshSecrets, + onChanged: (cfg) => setState(() => _sshConfig = cfg), + targetHost: _hostController.text.trim().isNotEmpty + ? _hostController.text.trim() + : 'localhost', + targetPort: + int.tryParse(_portController.text.trim()) ?? 3306, + ), ], ), ), diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4ddea4f6..772bce7f 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -4,8 +4,10 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/ssh_tunnel_section.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -53,6 +55,9 @@ class _PostgresConnectionFormContentState final _sslCertController = material.TextEditingController(); final _sslKeyController = material.TextEditingController(); + SshTunnelConfig _sshConfig = const SshTunnelConfig(); + final SshTunnelSecrets _sshSecrets = SshTunnelSecrets(); + bool _useSSL = false; bool _showPassword = false; bool _isTesting = false; @@ -91,6 +96,9 @@ class _PostgresConnectionFormContentState _useSSL = initial.useSSL; _connectionStringController.text = redactUriPassword(initial.connectionString) ?? ''; + if (initial.sshTunnelConfig != null) { + _sshConfig = initial.sshTunnelConfig!; + } // Password left empty — mergeSecretsForConnectionUpdate keeps existing. } @@ -287,6 +295,8 @@ class _PostgresConnectionFormContentState sslKey: _sslKeyController.text.trim().isEmpty ? null : _sslKeyController.text.trim(), + sshConfig: _sshConfig.enabled ? _sshConfig : null, + sshSecrets: _sshSecrets, ); final result = await conn.testConnection(); if (mounted) { @@ -329,7 +339,7 @@ class _PostgresConnectionFormContentState ? 'PostgreSQL: $effectiveHost:$effectivePort' : 'PostgreSQL $host:$port/$database'); final initial = widget.initial; - final row = ConnectionRow( + var row = ConnectionRow( id: initial?.id, type: initial?.type ?? 'postgresql', name: displayName, @@ -349,7 +359,9 @@ class _PostgresConnectionFormContentState folderId: initial?.folderId ?? widget.folderId, sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + sshSecrets: _sshConfig.enabled ? _sshSecrets : null, ); + row = row.withSshTunnelConfig(_sshConfig.enabled ? _sshConfig : null); material.Navigator.of(context).pop(row); } @@ -615,7 +627,6 @@ class _PostgresConnectionFormContentState ), ], ), - const Gap(16), // SSL/TLS Toggle material.Row( children: [ @@ -670,6 +681,18 @@ class _PostgresConnectionFormContentState ), ), ], + const Gap(16), + // SSH Tunneling Section + SshTunnelSection( + config: _sshConfig, + secrets: _sshSecrets, + onChanged: (cfg) => setState(() => _sshConfig = cfg), + targetHost: _hostController.text.trim().isNotEmpty + ? _hostController.text.trim() + : 'localhost', + targetPort: + int.tryParse(_portController.text.trim()) ?? 5432, + ), ], ), ), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 79bfb495..60cef124 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/ssh_tunnel_section.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; @@ -52,6 +54,9 @@ class _RedisConnectionFormContentState final _sslCertController = material.TextEditingController(); final _sslKeyController = material.TextEditingController(); + SshTunnelConfig _sshConfig = const SshTunnelConfig(); + final SshTunnelSecrets _sshSecrets = SshTunnelSecrets(); + bool _useSSL = false; bool _showPassword = false; bool _isTesting = false; @@ -87,6 +92,9 @@ class _RedisConnectionFormContentState _useSSL = initial.useSSL; _connectionStringController.text = redactUriPassword(initial.connectionString) ?? ''; + if (initial.sshTunnelConfig != null) { + _sshConfig = initial.sshTunnelConfig!; + } } _formValidNotifier.seed(); @@ -200,7 +208,7 @@ class _RedisConnectionFormContentState final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; final initial = widget.initial; - return ConnectionRow( + var row = ConnectionRow( id: id, type: initial?.type ?? 'redis', name: displayName.isEmpty ? 'test' : displayName, @@ -218,7 +226,9 @@ class _RedisConnectionFormContentState folderId: initial?.folderId ?? widget.folderId, sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), + sshSecrets: _sshConfig.enabled ? _sshSecrets : null, ); + return row.withSshTunnelConfig(_sshConfig.enabled ? _sshConfig : null); } void _save() { @@ -402,7 +412,6 @@ class _RedisConnectionFormContentState ), ], ), - const Gap(16), material.Row( children: [ material.Checkbox( @@ -423,6 +432,18 @@ class _RedisConnectionFormContentState onChanged: _syncUriSslParams, ), ], + const Gap(16), + // SSH Tunneling Section + SshTunnelSection( + config: _sshConfig, + secrets: _sshSecrets, + onChanged: (cfg) => setState(() => _sshConfig = cfg), + targetHost: _hostController.text.trim().isNotEmpty + ? _hostController.text.trim() + : 'localhost', + targetPort: + int.tryParse(_portController.text.trim()) ?? 6379, + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index cc1b8844..b11bc3d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: url_launcher: ^6.3.1 package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 + dartssh2: ^4.1.0 dev_dependencies: flutter_test: diff --git a/test/core/security/ssh_tunnel_test.dart b/test/core/security/ssh_tunnel_test.dart new file mode 100644 index 00000000..3a8ab0a2 --- /dev/null +++ b/test/core/security/ssh_tunnel_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +void main() { + group('SshTunnelConfig', () { + test('default configuration has enabled=false and port=22', () { + const config = SshTunnelConfig(); + expect(config.enabled, isFalse); + expect(config.port, equals(22)); + expect(config.authType, equals(SshAuthType.password)); + expect(config.keepAliveIntervalSeconds, equals(30)); + }); + + test('serializes to and from Map correctly', () { + const config = SshTunnelConfig( + enabled: true, + host: 'bastion.example.com', + port: 2222, + username: 'jumpuser', + authType: SshAuthType.privateKey, + privateKeyPath: '/home/user/.ssh/id_ed25519', + knownHostFingerprint: 'SHA256:abc123xyz', + keepAliveIntervalSeconds: 15, + connectTimeoutSeconds: 10, + jumpHost: 'proxy.internal', + jumpPort: 22, + jumpUsername: 'admin', + ); + + final map = config.toMap(); + final restored = SshTunnelConfig.fromMap(map); + + expect(restored, equals(config)); + expect(restored.enabled, isTrue); + expect(restored.host, equals('bastion.example.com')); + expect(restored.port, equals(2222)); + expect(restored.username, equals('jumpuser')); + expect(restored.authType, equals(SshAuthType.privateKey)); + expect(restored.privateKeyPath, equals('/home/user/.ssh/id_ed25519')); + expect(restored.knownHostFingerprint, equals('SHA256:abc123xyz')); + expect(restored.keepAliveIntervalSeconds, equals(15)); + expect(restored.jumpHost, equals('proxy.internal')); + expect(restored.jumpPort, equals(22)); + expect(restored.jumpUsername, equals('admin')); + }); + + test('JSON serialization round-trip', () { + const config = SshTunnelConfig( + enabled: true, + host: 'ssh.server.net', + port: 22, + username: 'dev', + authType: SshAuthType.sshAgent, + ); + + final json = config.toJson(); + final restored = SshTunnelConfig.fromJson(json); + + expect(restored, equals(config)); + }); + + test('copyWith updates properties correctly', () { + const config = SshTunnelConfig(host: 'old.host', port: 22); + final updated = config.copyWith(host: 'new.host', port: 2222); + + expect(updated.host, equals('new.host')); + expect(updated.port, equals(2222)); + expect(updated.enabled, isFalse); + }); + }); + + group('SshTunnelSecrets', () { + test('reports empty / non-empty status accurately', () { + final secrets = SshTunnelSecrets(); + expect(secrets.isEmpty, isTrue); + expect(secrets.hasAny, isFalse); + + secrets.password = 'supersecret'; + expect(secrets.isEmpty, isFalse); + expect(secrets.hasAny, isTrue); + + secrets.password = ''; + secrets.privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----'; + expect(secrets.isEmpty, isFalse); + expect(secrets.hasAny, isTrue); + }); + + test('zero() clears all in-memory buffers', () { + final secrets = SshTunnelSecrets( + password: 'pass', + privateKey: 'key', + passphrase: 'phrase', + jumpPassword: 'jumppass', + ); + + expect(secrets.hasAny, isTrue); + + secrets.zero(); + + expect(secrets.password, isNull); + expect(secrets.privateKey, isNull); + expect(secrets.passphrase, isNull); + expect(secrets.jumpPassword, isNull); + expect(secrets.isEmpty, isTrue); + }); + }); + + group('ConnectionRow SSH integration', () { + test('encodes and decodes SshTunnelConfig in driverOptions', () { + const row = ConnectionRow( + type: 'postgresql', + name: 'Remote PG via Bastion', + createdAt: '2026-10-05T00:00:00Z', + ); + + expect(row.sshTunnelConfig, isNull); + + const ssh = SshTunnelConfig( + enabled: true, + host: 'bastion.company.org', + port: 22, + username: 'ops', + ); + + final updated = row.withSshTunnelConfig(ssh); + expect(updated.sshTunnelConfig, equals(ssh)); + expect(updated.driverOptions, contains('"ssh_tunnel"')); + + // Disabling SSH tunnel removes the key from driverOptions + final disabled = updated.withSshTunnelConfig(const SshTunnelConfig(enabled: false)); + expect(disabled.sshTunnelConfig, isNull); + expect(disabled.driverOptions, isNot(contains('"ssh_tunnel"'))); + }); + + test('withoutSecrets wipes transient sshSecrets', () { + final secrets = SshTunnelSecrets(password: 'secret'); + final row = ConnectionRow( + type: 'postgresql', + name: 'Test', + password: 'dbpass', + createdAt: '2026-10-05T00:00:00Z', + sshSecrets: secrets, + ); + + expect(row.hasSecrets, isTrue); + expect(row.sshSecrets, isNotNull); + + final cleaned = row.withoutSecrets(); + expect(cleaned.password, isNull); + expect(cleaned.sshSecrets, isNull); + expect(cleaned.hasSecrets, isFalse); + }); + + test('toPersistenceMap never leaks password, connectionString, or sshSecrets', () { + final secrets = SshTunnelSecrets(password: 'secret'); + final row = ConnectionRow( + type: 'postgresql', + name: 'Test', + password: 'dbpass', + connectionString: 'postgresql://user:pass@host/db', + createdAt: '2026-10-05T00:00:00Z', + sshSecrets: secrets, + ); + + final map = row.toPersistenceMap(); + expect(map['password'], isNull); + expect(map['connection_string'], isNull); + expect(map.containsKey('ssh_secrets'), isFalse); + expect(map.containsKey('sshSecrets'), isFalse); + }); + }); +} diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index 1aae290f..1103bbf9 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; @@ -287,6 +288,59 @@ void main() { expect(loaded.name, 'PG Renamed'); }); + test( + 'SSH secrets are saved to secure store and merged during update', + () async { + final initialRow = ConnectionRow( + type: 'postgresql', + name: 'PG with SSH', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'db-password', + createdAt: '2026-01-01T00:00:00Z', + sshSecrets: SshTunnelSecrets( + password: 'ssh-pass-123', + privateKey: 'ssh-key-data', + passphrase: 'key-passphrase', + ), + ); + final id = await LocalDb.instance.addConnection(initialRow); + + final readSsh = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + expect(readSsh.password, 'ssh-pass-123'); + expect(readSsh.privateKey, 'ssh-key-data'); + expect(readSsh.passphrase, 'key-passphrase'); + + // Edit connection without re-entering SSH secrets (empty/blank) + final edited = ConnectionRow( + id: id, + type: 'postgresql', + name: 'PG with SSH Renamed', + createdAt: '2026-01-01T00:00:00Z', + sshSecrets: SshTunnelSecrets(), + ); + + final merged = await mergeSecretsForConnectionUpdate(edited); + expect(merged.sshSecrets?.password, 'ssh-pass-123'); + expect(merged.sshSecrets?.privateKey, 'ssh-key-data'); + expect(merged.sshSecrets?.passphrase, 'key-passphrase'); + + await LocalDb.instance.updateConnection(merged); + final afterUpdate = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + expect(afterUpdate.password, 'ssh-pass-123'); + expect(afterUpdate.privateKey, 'ssh-key-data'); + + // Deleting connection also deletes SSH secrets + await LocalDb.instance.removeConnection(id); + final afterDelete = + await ConnectionSecretsStore.readSshSecretsForConnection(id); + expect(afterDelete.password, isNull); + expect(afterDelete.privateKey, isNull); + }); + test('getConnections does not read secure store by default', () async { const row = ConnectionRow( type: 'mysql',