diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index e2917b75b9..80e6e46d86 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -867,6 +867,28 @@ function connectionErrorWithDriverUpdateHint(config: ConnectionConfig, message: return appendAgentDriverUpdateHint(message, t("connection.agentDriverUpdateConnectionHint")); } +function isSqlServerLegacyUnencryptedMode(params: string | undefined): boolean { + const normalized = (params || "").trim().replace(/^\?/, "").replace(/;/g, "&"); + if (!normalized) return false; + const parsed = new URLSearchParams(normalized); + for (const [key, value] of parsed.entries()) { + if (key.trim().toLowerCase() === "sqlserverencryption") { + return ["disabled", "disable", "false", "0", "off"].includes(value.trim().toLowerCase()); + } + } + return false; +} + +function setSqlServerLegacyUnencryptedMode(params: string | undefined, enabled: boolean): string { + const normalized = (params || "").trim().replace(/^\?/, "").replace(/;/g, "&"); + return setUrlParam(normalized, "sqlserverEncryption", enabled ? "disabled" : ""); +} + +function isSqlServerTlsHandshakeFailure(message: string): boolean { + const text = message.toLowerCase(); + return text.includes("sql server") && text.includes("tls") && (text.includes("handshake") || text.includes("eof") || text.includes("performing i/o")); +} + async function testConnectionWithTimeout(config: ConnectionConfig, runId: number): Promise { const timeoutMs = connectionAttemptTimeoutMs(config); const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); @@ -1608,6 +1630,13 @@ const testResultMessage = computed(() => { if (!testResult.value) return ""; return testResult.value.ok ? t("connection.testSuccess") : translateBackendError(t, testResult.value.message); }); +const sqlServerLegacyUnencryptedModeEnabled = computed({ + get: () => form.value.db_type === "sqlserver" && isSqlServerLegacyUnencryptedMode(form.value.url_params), + set: (enabled: boolean) => { + if (form.value.db_type !== "sqlserver") return; + form.value.url_params = setSqlServerLegacyUnencryptedMode(form.value.url_params, enabled); + }, +}); const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value))); const connectionDialogContentClass = computed(() => { if (dialogStep.value === "select") return "sm:max-w-[760px]"; @@ -1685,6 +1714,10 @@ async function testConnection() { const message = connectionErrorWithDriverUpdateHint(config, mongodbAuthFailureHint(String(e))); const fallbackMessage = await tryNacosDockerConsoleFallback(config, message, runId); if (runId !== testRunId) return; + const shouldShowSqlServerLegacyMode = !fallbackMessage && config.db_type === "sqlserver" && !isSqlServerLegacyUnencryptedMode(config.url_params) && isSqlServerTlsHandshakeFailure(message); + if (shouldShowSqlServerLegacyMode) { + configTab.value = "advanced"; + } testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message }; } finally { if (runId === testRunId) { @@ -4297,6 +4330,18 @@ function openExternalUrl(url: string) { {{ t("connection.readOnlyHint") }} +
+ +
+ +

+ {{ t("connection.sqlServerLegacyUnencryptedModeHint") }} +

+
+
@@ -4318,7 +4363,7 @@ function openExternalUrl(url: string) {
-
+
@@ -4629,4 +4674,8 @@ function openExternalUrl(url: string) { width: min(100%, 36rem); margin-inline: auto; } + +.connection-dialog-content .grid.grid-cols-4.connection-label-wide-grid { + grid-template-columns: 7.75rem repeat(3, minmax(0, 1fr)); +} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5b8ebb0faa..fa61a2106f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -283,6 +283,10 @@ export default { test: "Test", testing: "Testing...", copyTestResult: "Copy test result", + sqlServerLegacyUnencryptedMode: "Legacy compatibility mode", + sqlServerLegacyUnencryptedModeEnable: "Use SQL Server legacy TLS 1.0 unencrypted connection", + sqlServerLegacyUnencryptedModeEnabled: "SQL Server legacy compatibility mode is enabled.", + sqlServerLegacyUnencryptedModeHint: "Applies to unencrypted transport or login-only encryption. It will still fail if the server requires TLS 1.0 encryption.\nUse it only on trusted networks, VPNs, or SSH tunnels", saveAndConnect: "Save & Connect", save: "Save", editTitle: "Edit Connection", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b32f2b82cf..33a3c793bc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -286,6 +286,10 @@ export default withEnglishFallback({ test: "测试", testing: "测试中...", copyTestResult: "复制测试结果", + sqlServerLegacyUnencryptedMode: "旧版兼容模式", + sqlServerLegacyUnencryptedModeEnable: "使用 SQL Server 旧版TLS 1.0非加密连接", + sqlServerLegacyUnencryptedModeEnabled: "已启用 SQL Server 旧版兼容模式。", + sqlServerLegacyUnencryptedModeHint: "适用于非加密或仅登录阶段加密的场景。若服务端强制 TLS 1.0 加密,此模式仍会失败\n请仅在可信内网、VPN 或 SSH 隧道中使用", saveAndConnect: "保存并连接", save: "保存", editTitle: "编辑连接", diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 20c646e635..ae9b95a59b 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -881,6 +881,7 @@ impl AppState { &db_config.username, &db_config.password, db_config.database.as_deref(), + db_config.url_params.as_deref(), connect_timeout, ) .await?; diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index b53e5e86b4..8fda8fe43c 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken; pub type SqlServerClient = Client>; pub const SQLSERVER_DRIVER_PANIC_ERROR_PREFIX: &str = "SQL Server driver panic:"; const SIMPLE_QUERY_MODULE_KEYWORDS: &[&str] = &["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"]; +const SQLSERVER_LEGACY_ENCRYPTION_LEVEL: tiberius::EncryptionLevel = tiberius::EncryptionLevel::Off; #[derive(Debug, PartialEq, Eq)] struct SqlServerEndpoint<'a> { @@ -44,12 +45,51 @@ pub async fn connect( user: &str, pass: &str, database: Option<&str>, + url_params: Option<&str>, timeout: Duration, ) -> Result { - match try_connect(host, port, user, pass, database, true, timeout).await { - Ok(client) => Ok(client), - Err(_) => try_connect(host, port, user, pass, database, false, timeout).await, + if sqlserver_legacy_encryption_disabled(url_params) { + return try_connect(host, port, user, pass, database, SQLSERVER_LEGACY_ENCRYPTION_LEVEL, timeout).await; } + + match try_connect(host, port, user, pass, database, tiberius::EncryptionLevel::Required, timeout).await { + Ok(client) => Ok(client), + Err(encrypted_error) => { + try_connect(host, port, user, pass, database, tiberius::EncryptionLevel::NotSupported, timeout) + .await + .map_err(|plain_error| { + if is_sqlserver_tls_handshake_error(&encrypted_error) { + format!( + "{encrypted_error}\n\nThis may be caused by an old SQL Server TLS configuration. \ + If you are connecting to SQL Server 2008/2008 R2 or another legacy instance, \ + try SQL Server legacy unencrypted mode. It behaves like encrypt=false and only helps \ + when the server allows unencrypted transport or login-only encryption. It will still fail \ + if the server requires TLS 1.0 encryption. Only use this mode on trusted networks, VPNs, \ + or SSH tunnels.\n\n\ + Automatic unencrypted fallback also failed: {plain_error}" + ) + } else { + plain_error + } + }) + } + } +} + +fn sqlserver_legacy_encryption_disabled(url_params: Option<&str>) -> bool { + let Some(params) = url_params.map(str::trim).filter(|params| !params.is_empty()) else { + return false; + }; + + params.trim_start_matches('?').split(['&', ';']).filter_map(|pair| pair.split_once('=')).any(|(key, value)| { + key.trim().eq_ignore_ascii_case("sqlserverEncryption") + && matches!(value.trim().to_ascii_lowercase().as_str(), "disabled" | "disable" | "false" | "0" | "off") + }) +} + +fn is_sqlserver_tls_handshake_error(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("tls") && (error.contains("handshake") || error.contains("eof") || error.contains("performing i/o")) } async fn try_connect( @@ -58,7 +98,7 @@ async fn try_connect( user: &str, pass: &str, database: Option<&str>, - use_encryption: bool, + encryption: tiberius::EncryptionLevel, timeout: Duration, ) -> Result { let mut config = Config::new(); @@ -74,9 +114,7 @@ async fn try_connect( config.database(db); } config.trust_cert(); - if !use_encryption { - config.encryption(tiberius::EncryptionLevel::NotSupported); - } + config.encryption(encryption); let tcp = if endpoint.instance_name.is_some() { tokio::time::timeout(timeout, TcpStream::connect_named(&config)) @@ -1817,6 +1855,29 @@ mod tests { assert!(try_connect.contains("connect_named(&config)")); } + #[test] + fn sqlserver_legacy_encryption_flag_is_opt_in() { + assert!(!super::sqlserver_legacy_encryption_disabled(None)); + assert!(!super::sqlserver_legacy_encryption_disabled(Some("encrypt=false"))); + assert!(super::sqlserver_legacy_encryption_disabled(Some("sqlserverEncryption=disabled"))); + assert!(super::sqlserver_legacy_encryption_disabled(Some("applicationName=dbx;sqlserverEncryption=off"))); + assert!(super::sqlserver_legacy_encryption_disabled(Some("?sqlserverEncryption=false&applicationName=dbx"))); + } + + #[test] + fn sqlserver_legacy_encryption_mode_matches_jdbc_encrypt_false_semantics() { + assert_eq!(super::SQLSERVER_LEGACY_ENCRYPTION_LEVEL, tiberius::EncryptionLevel::Off); + } + + #[test] + fn sqlserver_tls_handshake_error_detection_matches_legacy_hint_cases() { + assert!(super::is_sqlserver_tls_handshake_error( + "SQL Server connection failed: An error occured during the attempt of performing I/O: tls handshake eof" + )); + assert!(super::is_sqlserver_tls_handshake_error("TLS handshake failed: unexpected EOF")); + assert!(!super::is_sqlserver_tls_handshake_error("SQL Server connection failed: Login failed for user")); + } + #[test] fn sqlserver_module_definitions_require_simple_query_batch() { assert!(requires_simple_query_batch("CREATE SCHEMA [analytics];")); diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 351b60027d..e13be8bde9 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -63,7 +63,7 @@ async fn live_sqlserver_execute_query_creates_schema() { let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); let mut client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect SQL Server"); @@ -94,7 +94,7 @@ async fn live_sqlserver_stream_first_result_set_exports_cte_query_rows() { let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); let mut client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect SQL Server"); @@ -152,11 +152,11 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() { let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); let mut setup_client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect SQL Server"); let export_client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect export SQL Server"); @@ -240,7 +240,7 @@ async fn live_sqlserver_transfer_table_skips_rowversion_insert_column() { let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); let mut setup_client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect SQL Server"); let mut target_client = dbx_core::db::sqlserver::connect( @@ -249,6 +249,7 @@ async fn live_sqlserver_transfer_table_skips_rowversion_insert_column() { &user, &password, Some(&target_database), + None, Duration::from_secs(10), ) .await @@ -329,7 +330,7 @@ async fn live_sqlserver_completion_assistant_searches_metadata_before_limiting() let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); let mut client = - dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), Duration::from_secs(10)) + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) .await .expect("connect SQL Server"); diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 8dc223ba27..c7f93ac2a6 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -636,6 +636,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection &config.username, &config.password, config.database.as_deref(), + config.url_params.as_deref(), connect_timeout, ) .await @@ -924,6 +925,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi &db_config.username, &db_config.password, db_config.database.as_deref(), + db_config.url_params.as_deref(), connect_timeout, ) .await?;