Feature:支持 SQL Server 旧版兼容模式 (#2181)

* fix: 支持 SQL Server 旧版非加密重试

* fix: 优化 SQL Server 旧版兼容模式入口

---------

Co-authored-by: staff <staff@qimaos-MacBook-Pro.local>
This commit is contained in:
zipg
2026-06-30 19:52:07 +08:00
committed by GitHub
co-authored by staff
parent 8747a0e8f9
commit d04463ebeb
7 changed files with 136 additions and 14 deletions
@@ -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<string> {
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) {
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
</label>
</div>
<div v-show="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyUnencryptedMode") }}</Label>
<div class="col-span-3 flex flex-col gap-1">
<label class="flex h-5 cursor-pointer items-center gap-2">
<input type="checkbox" v-model="sqlServerLegacyUnencryptedModeEnabled" class="mr-0" />
<span class="text-xs text-foreground">{{ t("connection.sqlServerLegacyUnencryptedModeEnable") }}</span>
</label>
<p class="m-0 whitespace-pre-line text-xs leading-5 text-muted-foreground">
{{ t("connection.sqlServerLegacyUnencryptedModeHint") }}
</p>
</div>
</div>
<div v-show="form.db_type === 'redis'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("settings.redisScanPageSize") }}</Label>
<div class="col-span-3 flex flex-col gap-1">
@@ -4318,7 +4363,7 @@ function openExternalUrl(url: string) {
<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0">
<div class="connection-form-body grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
<div class="grid grid-cols-4 items-start gap-4">
<div class="connection-label-wide-grid grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.sshHops") }}</Label>
<div class="col-span-3 grid gap-3">
<div class="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground">
@@ -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));
}
</style>
+4
View File
@@ -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",
+4
View File
@@ -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: "编辑连接",
+1
View File
@@ -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?;
+68 -7
View File
@@ -17,6 +17,7 @@ use tokio_util::sync::CancellationToken;
pub type SqlServerClient = Client<Compat<TcpStream>>;
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<SqlServerClient, String> {
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<SqlServerClient, String> {
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];"));
@@ -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");
+2
View File
@@ -636,6 +636,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, 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<AppState>>, config: ConnectionConfi
&db_config.username,
&db_config.password,
db_config.database.as_deref(),
db_config.url_params.as_deref(),
connect_timeout,
)
.await?;