diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 67a98da853..94b8fd93ae 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -250,6 +250,7 @@ impl AppState { &config.ssh_password, &config.ssh_key_path, &config.ssh_key_passphrase, + config.effective_ssh_connect_timeout_secs(), &remote_host, remote_port, config.ssh_expose_lan, @@ -349,6 +350,7 @@ mod tests { ssh_key_path: String::new(), ssh_key_passphrase: String::new(), ssh_expose_lan: false, + ssh_connect_timeout_secs: crate::models::connection::default_ssh_connect_timeout_secs(), ssl: false, sysdba: false, connection_string: None, diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index b6a65c4b85..d3475cf712 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -285,6 +285,7 @@ mod tests { ssh_key_path: String::new(), ssh_key_passphrase: String::new(), ssh_expose_lan: false, + ssh_connect_timeout_secs: crate::models::connection::default_ssh_connect_timeout_secs(), ssl: false, sysdba: false, connection_string: None, diff --git a/crates/dbx-core/src/db/ssh_tunnel.rs b/crates/dbx-core/src/db/ssh_tunnel.rs index 780e8d002f..057e9dbf70 100644 --- a/crates/dbx-core/src/db/ssh_tunnel.rs +++ b/crates/dbx-core/src/db/ssh_tunnel.rs @@ -9,7 +9,7 @@ use tokio::net::TcpListener; use tokio::sync::Mutex; use tokio::task::JoinHandle; -use super::{connection_timeout, file_validator::validate_file_path, CONNECTION_TIMEOUT_SECS}; +use super::file_validator::validate_file_path; struct SshClient; @@ -31,13 +31,15 @@ async fn connect_and_authenticate( ssh_password: &str, ssh_key_path: &str, ssh_key_passphrase: &str, + connect_timeout_secs: u64, ) -> Result, String> { let config = Arc::new(Config { nodelay: true, ..Default::default() }); + let connect_timeout = std::time::Duration::from_secs(connect_timeout_secs); let mut session = - tokio::time::timeout(connection_timeout(), client::connect(config, (ssh_host, ssh_port), SshClient {})) + tokio::time::timeout(connect_timeout, client::connect(config, (ssh_host, ssh_port), SshClient {})) .await - .map_err(|_| format!("SSH connection timed out ({CONNECTION_TIMEOUT_SECS}s)"))? + .map_err(|_| format!("SSH connection timed out ({connect_timeout_secs}s)"))? .map_err(|e| format!("SSH connection failed: {e}"))?; if !ssh_key_path.is_empty() { @@ -47,7 +49,7 @@ async fn connect_and_authenticate( let passphrase = if ssh_key_passphrase.is_empty() { None } else { Some(ssh_key_passphrase) }; let key_pair = load_secret_key(ssh_key_path, passphrase).map_err(|e| format!("Failed to load SSH key: {e}"))?; let auth_res = tokio::time::timeout( - connection_timeout(), + connect_timeout, session.authenticate_publickey( ssh_user, PrivateKeyWithHashAlg::new( @@ -57,17 +59,16 @@ async fn connect_and_authenticate( ), ) .await - .map_err(|_| format!("SSH key auth timed out ({CONNECTION_TIMEOUT_SECS}s)"))? + .map_err(|_| format!("SSH key auth timed out ({connect_timeout_secs}s)"))? .map_err(|e| format!("SSH key auth failed: {e}"))?; if !auth_res.success() { return Err("SSH public key authentication failed".to_string()); } } else if !ssh_password.is_empty() { - let auth_res = - tokio::time::timeout(connection_timeout(), session.authenticate_password(ssh_user, ssh_password)) - .await - .map_err(|_| format!("SSH password auth timed out ({CONNECTION_TIMEOUT_SECS}s)"))? - .map_err(|e| format!("SSH password auth failed: {e}"))?; + let auth_res = tokio::time::timeout(connect_timeout, session.authenticate_password(ssh_user, ssh_password)) + .await + .map_err(|_| format!("SSH password auth timed out ({connect_timeout_secs}s)"))? + .map_err(|e| format!("SSH password auth failed: {e}"))?; if !auth_res.success() { return Err("SSH password authentication failed".to_string()); } @@ -156,15 +157,23 @@ impl TunnelManager { ssh_password: &str, ssh_key_path: &str, ssh_key_passphrase: &str, + connect_timeout_secs: u64, remote_host: &str, remote_port: u16, expose_to_lan: bool, ) -> Result { let local_port = portpicker::pick_unused_port().ok_or("No available port")?; - let session = - connect_and_authenticate(ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path, ssh_key_passphrase) - .await?; + let session = connect_and_authenticate( + ssh_host, + ssh_port, + ssh_user, + ssh_password, + ssh_key_path, + ssh_key_passphrase, + connect_timeout_secs, + ) + .await?; let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" }; let listener = diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index b621569ae1..6392ddc1f4 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -35,6 +35,8 @@ pub struct ConnectionConfig { pub ssh_key_passphrase: String, #[serde(default)] pub ssh_expose_lan: bool, + #[serde(default = "default_ssh_connect_timeout_secs")] + pub ssh_connect_timeout_secs: u64, #[serde(default)] pub ssl: bool, #[serde(default)] @@ -47,6 +49,10 @@ fn default_ssh_port() -> u16 { 22 } +pub fn default_ssh_connect_timeout_secs() -> u64 { + 5 +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum DatabaseType { @@ -75,6 +81,14 @@ pub enum DatabaseType { } impl ConnectionConfig { + pub fn effective_ssh_connect_timeout_secs(&self) -> u64 { + if self.ssh_connect_timeout_secs == 0 { + default_ssh_connect_timeout_secs() + } else { + self.ssh_connect_timeout_secs + } + } + pub fn effective_database(&self) -> Option<&str> { self.database .as_deref() @@ -346,7 +360,7 @@ fn bracket_ipv6(host: &str) -> String { #[cfg(test)] mod tests { - use super::{ConnectionConfig, DatabaseType}; + use super::{default_ssh_connect_timeout_secs, ConnectionConfig, DatabaseType}; fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig { ConnectionConfig { @@ -370,6 +384,7 @@ mod tests { ssh_key_path: String::new(), ssh_key_passphrase: String::new(), ssh_expose_lan: false, + ssh_connect_timeout_secs: default_ssh_connect_timeout_secs(), ssl: false, sysdba: false, connection_string: None, @@ -383,6 +398,32 @@ mod tests { config } + #[test] + fn ssh_connect_timeout_defaults_for_legacy_config() { + let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "id", + "name": "name", + "db_type": "mysql", + "host": "10.1.2.3", + "port": 3306, + "username": "root", + "password": "", + "database": null + })) + .unwrap(); + + assert_eq!(config.ssh_connect_timeout_secs, default_ssh_connect_timeout_secs()); + assert_eq!(config.effective_ssh_connect_timeout_secs(), default_ssh_connect_timeout_secs()); + } + + #[test] + fn ssh_connect_timeout_zero_uses_default() { + let mut config = mysql_config("root", "", None); + config.ssh_connect_timeout_secs = 0; + + assert_eq!(config.effective_ssh_connect_timeout_secs(), default_ssh_connect_timeout_secs()); + } + #[test] fn mysql_url_encodes_oceanbase_username() { let config = mysql_config("user@tenant#cluster", "secret", None); diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 7751851ee1..e1a0733235 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -63,6 +63,7 @@ const defaultForm = (): Omit => ({ ssh_key_path: "", ssh_key_passphrase: "", ssh_expose_lan: false, + ssh_connect_timeout_secs: 5, ssl: false, connection_string: undefined, }); @@ -245,6 +246,7 @@ watch( ssh_key_path: config.ssh_key_path || "", ssh_key_passphrase: config.ssh_key_passphrase || "", ssh_expose_lan: config.ssh_expose_lan || false, + ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, ssl: config.ssl || false, connection_string: config.connection_string, }; @@ -429,6 +431,8 @@ async function testConnection() { function connectionConfigForSubmit(id: string): ConnectionConfig { const config: ConnectionConfig = { ...form.value, id }; + const sshTimeout = Number(config.ssh_connect_timeout_secs); + config.ssh_connect_timeout_secs = Number.isFinite(sshTimeout) && sshTimeout > 0 ? sshTimeout : 5; if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; } @@ -965,6 +969,18 @@ async function browseDbFilePath() { {{ t("connection.sshExposeLan") }} +
+ + +
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b8b9176f0d..479ebe7a6b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -116,6 +116,7 @@ export default { sshKeyPassphrasePlaceholder: "Leave empty if key is not encrypted", sshKeyPathBrowse: "Browse", sshExposeLan: "Expose tunnel to LAN", + sshConnectTimeout: "SSH Timeout (seconds)", dmCompatHint: "Requires DM8 ODBC driver installed on your system.", dmDownload: "Download from Dameng", mongoLegacyHint: diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 17149b2f55..dc9e8c0818 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -115,6 +115,7 @@ export default { sshKeyPassphrasePlaceholder: "密钥未加密则留空", sshKeyPathBrowse: "浏览", sshExposeLan: "允许局域网访问隧道", + sshConnectTimeout: "SSH 超时时间(秒)", dmCompatHint: "需要在系统上安装达梦 DM8 ODBC 驱动程序。", dmDownload: "前往达梦官网下载", mongoLegacyHint: diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 59aebcc3d0..a8822f2ccd 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -163,6 +163,7 @@ export const useConnectionStore = defineStore("connection", () => { driver_profile: profile, driver_label: config.driver_label || labelMap[profile] || config.db_type, url_params: config.url_params || "", + ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, }; } diff --git a/src/types/database.ts b/src/types/database.ts index a5c0684e70..09131c7300 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -36,6 +36,7 @@ export interface ConnectionConfig { ssh_key_path?: string; ssh_key_passphrase?: string; ssh_expose_lan?: boolean; + ssh_connect_timeout_secs?: number; ssl?: boolean; sysdba?: boolean; connection_string?: string;