From 67edb94b1df86d2c21d8a3a5f4b66da24278a7f4 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 9 Jul 2026 22:45:54 +0800 Subject: [PATCH] feat(sqlserver): support legacy TLS compatibility --- agents/drivers/sqlserver-legacy/build.gradle | 12 ++ .../sqlserverlegacy/SqlServerLegacyAgent.java | 202 ++++++++++++++++++ .../SqlServerLegacyAgentTest.java | 75 +++++++ agents/settings.gradle | 2 +- agents/versions.json | 3 +- .../connection/ConnectionDialog.vue | 74 ++++--- apps/desktop/src/i18n/locales/en.ts | 11 +- apps/desktop/src/i18n/locales/es.ts | 11 +- apps/desktop/src/i18n/locales/it.ts | 11 +- apps/desktop/src/i18n/locales/ja.ts | 11 +- apps/desktop/src/i18n/locales/pt-BR.ts | 11 +- apps/desktop/src/i18n/locales/zh-CN.ts | 10 +- apps/desktop/src/i18n/locales/zh-TW.ts | 10 +- .../sqlServerLegacyCompatibility.spec.ts | 50 +++++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 4 + apps/desktop/src/lib/backend/tauri.ts | 4 + .../sqlServerLegacyCompatibility.ts | 34 +++ .../connectionStore.sqlserverLegacy.spec.ts | 63 ++++++ apps/desktop/src/stores/connectionStore.ts | 8 + .../assets/database-drivers.manifest.json | 6 + crates/dbx-core/src/agent_catalog.rs | 12 +- crates/dbx-core/src/connection.rs | 151 +++++++++++-- crates/dbx-core/src/db/sqlserver.rs | 35 ++- .../dbx-core/tests/database_capabilities.rs | 4 + crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/agents.rs | 7 + src-tauri/src/commands/agents.rs | 5 + src-tauri/src/commands/connection.rs | 26 +-- src-tauri/src/lib.rs | 1 + 30 files changed, 738 insertions(+), 117 deletions(-) create mode 100644 agents/drivers/sqlserver-legacy/build.gradle create mode 100644 agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java create mode 100644 agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java create mode 100644 apps/desktop/src/lib/__tests__/connection/sqlServerLegacyCompatibility.spec.ts create mode 100644 apps/desktop/src/lib/connection/sqlServerLegacyCompatibility.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts diff --git a/agents/drivers/sqlserver-legacy/build.gradle b/agents/drivers/sqlserver-legacy/build.gradle new file mode 100644 index 000000000..69300226e --- /dev/null +++ b/agents/drivers/sqlserver-legacy/build.gradle @@ -0,0 +1,12 @@ +dependencies { + implementation 'com.microsoft.sqlserver:mssql-jdbc:13.2.0.jre11' +} + +tasks.named('shadowJar') { + manifest { + attributes( + 'Agent-Label': 'SQL Server legacy compatibility component', + 'Main-Class': 'com.dbx.agent.sqlserverlegacy.SqlServerLegacyAgent' + ) + } +} diff --git a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java new file mode 100644 index 000000000..6a390e094 --- /dev/null +++ b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java @@ -0,0 +1,202 @@ +package com.dbx.agent.sqlserverlegacy; + +import com.dbx.agent.ConfiguredJdbcAgent; +import com.dbx.agent.ConnectParams; +import com.dbx.agent.JdbcAgentProfile; +import com.dbx.agent.JsonRpcServer; + +import java.security.Security; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { + private static final String TLS_DISABLED_ALGORITHMS_KEY = "jdk.tls.disabledAlgorithms"; + private static final Set LEGACY_TLS_ALGORITHMS_TO_ALLOW = Set.of( + "TLSV1", + "TLSV1.1", + "DTLSV1.0", + "3DES_EDE_CBC", + "RC4", + "DES", + "MD5WITHRSA", + "DH KEYSIZE < 1024", + "RSA KEYSIZE < 1024" + ); + private static final Set INTERNAL_URL_PARAMS = Set.of( + "SQLSERVERENCRYPTION", + "ENCRYPT", + "TRUSTSERVERCERTIFICATE", + "SSLPROTOCOL" + ); + private static final JdbcAgentProfile PROFILE = new JdbcAgentProfile( + "com.microsoft.sqlserver.jdbc.SQLServerDriver", + "jdbc:sqlserver://{host}:{port};databaseName={database};", + 1433, + true, + Set.of("INFORMATION_SCHEMA", "SYS"), + Arrays.asList("TABLE", "VIEW", "SYSTEM TABLE") + ); + + public SqlServerLegacyAgent() { + super(PROFILE); + } + + @Override + protected String buildJdbcUrl(ConnectParams params) { + enableLegacyTlsAlgorithms(); + return legacyTlsUrl(params); + } + + static String legacyTlsUrl(ConnectParams params) { + Map properties = baseConnectionProperties(params); + properties.put("encrypt", "true"); + properties.put("trustServerCertificate", "true"); + properties.put("sslProtocol", "TLSv1"); + return appendProperties(baseJdbcUrl(params), properties); + } + + static String relaxedDisabledAlgorithms(String current) { + if (current == null || current.trim().isEmpty()) { + return ""; + } + + List kept = new ArrayList<>(); + for (String rawPart : current.split(",")) { + String part = rawPart.trim(); + if (part.isEmpty()) { + continue; + } + if (!LEGACY_TLS_ALGORITHMS_TO_ALLOW.contains(part.toUpperCase(Locale.ROOT))) { + kept.add(part); + } + } + return String.join(", ", kept); + } + + private static void enableLegacyTlsAlgorithms() { + String current = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY); + String relaxed = relaxedDisabledAlgorithms(current); + if (!Objects.equals(current, relaxed)) { + Security.setProperty(TLS_DISABLED_ALGORITHMS_KEY, relaxed); + } + } + + private static String baseJdbcUrl(ConnectParams params) { + String connectionString = params.getConnection_string(); + if (connectionString != null && !connectionString.trim().isEmpty()) { + return sanitizeSqlServerUrl(connectionString.trim()); + } + + String host = normalizedSqlServerHost(params.getHost()); + StringBuilder url = new StringBuilder("jdbc:sqlserver://") + .append(host); + if (!usesNamedInstance(host)) { + int port = params.getPort() > 0 ? params.getPort() : PROFILE.getDefaultPort(); + url.append(":").append(port); + } + if (params.getDatabase() != null && !params.getDatabase().trim().isEmpty()) { + url.append(";databaseName=").append(params.getDatabase().trim()); + } + return trimSqlServerUrl(url.toString()); + } + + private static String normalizedSqlServerHost(String value) { + String host = value == null ? "" : value.trim(); + int separator = host.indexOf('\\'); + if (separator <= 0 || separator >= host.length() - 1) { + return host; + } + + String server = host.substring(0, separator).trim(); + String instance = host.substring(separator + 1).trim(); + if (server.isEmpty() || instance.isEmpty()) { + return host; + } + return server + "\\" + instance; + } + + private static boolean usesNamedInstance(String host) { + int separator = host.indexOf('\\'); + return separator > 0 && separator < host.length() - 1; + } + + private static String sanitizeSqlServerUrl(String value) { + String trimmed = trimSqlServerUrl(value); + String[] parts = trimmed.split(";"); + if (parts.length <= 1) { + return trimmed; + } + StringBuilder result = new StringBuilder(parts[0].trim()); + for (int i = 1; i < parts.length; i++) { + String part = parts[i].trim(); + if (part.isEmpty()) { + continue; + } + int separator = part.indexOf('='); + if (separator <= 0) { + result.append(";").append(part); + continue; + } + String key = part.substring(0, separator).trim(); + if (!INTERNAL_URL_PARAMS.contains(key.toUpperCase(Locale.ROOT))) { + result.append(";").append(part); + } + } + return result.toString(); + } + + private static Map baseConnectionProperties(ConnectParams params) { + Map properties = new LinkedHashMap<>(); + String urlParams = params.getUrl_params(); + if (urlParams == null || urlParams.trim().isEmpty()) { + return properties; + } + + for (String pair : urlParams.trim().split("[&;]")) { + String value = pair.trim(); + while (value.startsWith("?") || value.startsWith("&") || value.startsWith(";")) { + value = value.substring(1).trim(); + } + if (value.isEmpty()) { + continue; + } + int separator = value.indexOf('='); + if (separator <= 0) { + continue; + } + String key = value.substring(0, separator).trim(); + String normalizedKey = key.toUpperCase(Locale.ROOT); + if (key.isEmpty() || INTERNAL_URL_PARAMS.contains(normalizedKey)) { + continue; + } + properties.put(key, value.substring(separator + 1).trim()); + } + return properties; + } + + private static String appendProperties(String base, Map properties) { + StringBuilder url = new StringBuilder(trimSqlServerUrl(base)); + for (Map.Entry entry : properties.entrySet()) { + url.append(";").append(entry.getKey()).append("=").append(entry.getValue()); + } + return url.toString(); + } + + private static String trimSqlServerUrl(String value) { + String trimmed = value.trim(); + while (trimmed.endsWith(";") || trimmed.endsWith("&") || trimmed.endsWith("?")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).trim(); + } + return trimmed; + } + + public static void main(String[] args) throws Exception { + new JsonRpcServer(new SqlServerLegacyAgent()).run(); + } +} diff --git a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java new file mode 100644 index 000000000..50eb1bd2e --- /dev/null +++ b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java @@ -0,0 +1,75 @@ +package com.dbx.agent.sqlserverlegacy; + +import com.dbx.agent.ConnectParams; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class SqlServerLegacyAgentTest { + @Test + void legacyTlsUrlUsesSqlServerTlsV1Properties() { + ConnectParams params = new ConnectParams( + "db.example.com", + 14330, + "appdb", + "sa", + "secret", + "applicationName=dbx;sqlserverEncryption=disabled;encrypt=false;trustServerCertificate=false;sslProtocol=TLSv1.2", + "", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com:14330;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void legacyTlsUrlKeepsNamedInstanceWithoutPort() { + ConnectParams params = new ConnectParams( + "db.example.com\\SQLEXPRESS", + 1433, + "appdb", + "sa", + "secret", + "applicationName=dbx", + "", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com\\SQLEXPRESS;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void legacyTlsUrlNormalizesExplicitConnectionString() { + ConnectParams params = new ConnectParams( + "ignored", + 0, + "", + "sa", + "secret", + "applicationName=dbx", + "jdbc:sqlserver://db.example.com:1433;encrypt=false;databaseName=custom;trustServerCertificate=false;sslProtocol=TLSv1.2;", + false + ); + + Assertions.assertEquals( + "jdbc:sqlserver://db.example.com:1433;databaseName=custom;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1", + SqlServerLegacyAgent.legacyTlsUrl(params) + ); + } + + @Test + void relaxedDisabledAlgorithmsRemovesOnlyLegacyTlsEntries() { + String current = + "SSLv3, TLSv1, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL"; + + Assertions.assertEquals( + "SSLv3, EC keySize < 224, anon, NULL", + SqlServerLegacyAgent.relaxedDisabledAlgorithms(current) + ); + } +} diff --git a/agents/settings.gradle b/agents/settings.gradle index 083223780..4b0678515 100644 --- a/agents/settings.gradle +++ b/agents/settings.gradle @@ -6,7 +6,7 @@ def driverModules = [ 'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s', 'bigquery', 'kylin', 'sundb', 'h2', 'snowflake', 'trino', 'hive', 'spark', 'db2', 'informix', 'neo4j', 'cassandra', 'mongodb', 'highgo', 'tdengine', 'yashandb', 'oscar', - 'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka' + 'iris', 'iotdb', 'etcd', 'zookeeper', 'kafka', 'sqlserver-legacy' ] include(*(infrastructureModules + driverModules)) diff --git a/agents/versions.json b/agents/versions.json index 9a922cf35..7d98de62d 100644 --- a/agents/versions.json +++ b/agents/versions.json @@ -37,5 +37,6 @@ "iotdb": "0.1.17", "etcd": "0.1.17", "zookeeper": "0.1.7", - "kafka": "0.1.3" + "kafka": "0.1.3", + "sqlserver-legacy": "0.1.0" } diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 21516bffa..ce2a7e667 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -42,6 +42,7 @@ import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHint import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapServers"; import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth"; import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi"; +import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue"; import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases"; import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases"; @@ -1070,6 +1071,10 @@ function formatInstallSize(bytes: number): string { } async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Promise { + if (requiresSqlServerLegacyCompatibilityComponent(config)) { + await installSqlServerLegacyCompatibilityComponentIfNeeded(); + } + const driverKey = agentDriverInstallKey(config.db_type, config.driver_profile); if (!driverKey) return; @@ -1094,29 +1099,42 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro } } -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()) { - const normalizedKey = key.trim().toLowerCase(); - if (normalizedKey === "sqlserverencryption" || normalizedKey === "encrypt") { - // Accept JDBC-style `encrypt=false` from imported SQL Server URLs as the same opt-in. - if (["disabled", "disable", "false", "0", "off", "no"].includes(value.trim().toLowerCase())) return true; - } +async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise { + if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return true; + + const label = t("connection.sqlServerLegacyCompatibilityComponent"); + testResult.value = { ok: true, message: t("connection.sqlServerLegacyCompatibilityComponentInstalling") }; + beginAgentDriverInstall(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, label); + try { + await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY); + await refreshLocalAgentDrivers(); + finishAgentDriverInstall(); + } catch (error) { + testResult.value = { ok: false, message: errorMessage(error) }; + failAgentDriverInstall(error); + throw error; } - return false; + return true; } -function setSqlServerLegacyUnencryptedMode(params: string | undefined, enabled: boolean): string { - const normalized = (params || "").trim().replace(/^\?/, "").replace(/;/g, "&"); - const parsed = new URLSearchParams(normalized); - for (const key of Array.from(parsed.keys())) { - const normalizedKey = key.trim().toLowerCase(); - if (normalizedKey === "sqlserverencryption" || normalizedKey === "encrypt") parsed.delete(key); +async function onSqlServerLegacyCompatibilityModeChange(event: Event) { + if (form.value.db_type !== "sqlserver") return; + const input = event.target instanceof HTMLInputElement ? event.target : null; + const enabled = input?.checked === true; + if (!enabled) { + form.value.url_params = setSqlServerLegacyCompatibilityMode(form.value.url_params, false); + return; + } + + if (input) input.checked = false; + try { + await installSqlServerLegacyCompatibilityComponentIfNeeded(); + form.value.url_params = setSqlServerLegacyCompatibilityMode(form.value.url_params, true); + testResult.value = { ok: true, message: t("connection.sqlServerLegacyCompatibilityModeEnabled") }; + } catch { + form.value.url_params = setSqlServerLegacyCompatibilityMode(form.value.url_params, false); + if (input) input.checked = false; } - if (enabled) parsed.set("sqlserverEncryption", "disabled"); - return parsed.toString(); } function isSqlServerTlsHandshakeFailure(message: string): boolean { @@ -1917,13 +1935,7 @@ const agentInstallProgressLabel = computed(() => { return `${label} ${formatInstallSize(progress.downloaded ?? 0)} / ${formatInstallSize(progress.total)} (${agentInstallPercent.value ?? 0}%)`; }); const canCloseAgentInstallDialog = computed(() => !agentInstallRunning.value || !!agentInstallError.value); -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 sqlServerLegacyCompatibilityModeEnabled = computed(() => form.value.db_type === "sqlserver" && isSqlServerLegacyCompatibilityMode(form.value.url_params)); const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "config" && (canChooseVisibleDatabases.value || (canChooseVisibleSchemas.value && !visibleFilterUsesSchemas.value))); const connectionDialogContentClass = computed(() => { if (dialogStep.value === "select") return "sm:max-w-[760px]"; @@ -2019,7 +2031,7 @@ async function testConnection() { const message = config ? connectionErrorWithDriverUpdateHint(config, rawMessage) : rawMessage; const fallbackMessage = config ? await tryNacosDockerConsoleFallback(config, message, runId) : null; if (runId !== testRunId) return; - const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyUnencryptedMode(config.url_params) && isSqlServerTlsHandshakeFailure(message); + const shouldShowSqlServerLegacyMode = !fallbackMessage && config?.db_type === "sqlserver" && !isSqlServerLegacyCompatibilityMode(config.url_params) && isSqlServerTlsHandshakeFailure(message); if (shouldShowSqlServerLegacyMode) { configTab.value = "advanced"; } @@ -5027,14 +5039,14 @@ function openExternalUrl(url: string) {
- +

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

diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8417cab6f..01ab202ca 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -323,10 +323,13 @@ export default { test: "Test", testing: "Testing...", copyTestResult: "Copy test result", - sqlServerLegacyUnencryptedMode: "Legacy compatibility mode", - sqlServerLegacyUnencryptedModeEnable: "Use SQL Server legacy 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 encrypted transport that the embedded driver cannot negotiate.\nUse it only on trusted networks, VPNs, or SSH tunnels", + sqlServerLegacyCompatibilityMode: "Legacy compatibility mode", + sqlServerLegacyCompatibilityModeEnable: "Use SQL Server legacy compatibility connection", + sqlServerLegacyCompatibilityModeEnabled: "SQL Server legacy compatibility mode is enabled.", + sqlServerLegacyCompatibilityModeHint: + "Applies to unencrypted, login-only encrypted, or TLS 1.0 legacy compatibility scenarios. If the compatibility component is not installed, DBX installs it before enabling this mode.\nSome native SQL Server features may be unavailable when fallback uses the compatibility component.\nUse it only on trusted networks, VPNs, or SSH tunnels", + sqlServerLegacyCompatibilityComponent: "SQL Server legacy compatibility component", + sqlServerLegacyCompatibilityComponentInstalling: "Installing SQL Server legacy compatibility component...", saveAndConnect: "Save & Connect", save: "Save", editTitle: "Edit Connection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f8d43dee1..cbfabc620 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -305,10 +305,13 @@ export default withEnglishFallback({ test: "Probar", testing: "Probando...", copyTestResult: "Copiar resultado de prueba", - sqlServerLegacyUnencryptedMode: "Modo de compatibilidad heredado", - sqlServerLegacyUnencryptedModeEnable: "Usar conexión SQL Server heredada sin cifrar", - sqlServerLegacyUnencryptedModeEnabled: "El modo de compatibilidad heredado de SQL Server está activado.", - sqlServerLegacyUnencryptedModeHint: "Aplica a transporte sin cifrar o cifrado solo en el login. Seguirá fallando si el servidor requiere transporte cifrado que el controlador integrado no puede negociar.\nÚsalo solo en redes de confianza, VPN o túneles SSH", + sqlServerLegacyCompatibilityMode: "Modo de compatibilidad heredado", + sqlServerLegacyCompatibilityModeEnable: "Usar conexión de compatibilidad heredada de SQL Server", + sqlServerLegacyCompatibilityModeEnabled: "El modo de compatibilidad heredado de SQL Server está activado.", + sqlServerLegacyCompatibilityModeHint: + "Aplica a escenarios sin cifrado, con cifrado solo en el login o con compatibilidad heredada TLS 1.0. Si el componente de compatibilidad no está instalado, DBX lo instala antes de activar este modo.\nAl usar el componente de compatibilidad como fallback, algunas funciones nativas de SQL Server pueden no estar disponibles.\nÚsalo solo en redes de confianza, VPN o túneles SSH", + sqlServerLegacyCompatibilityComponent: "Componente de compatibilidad heredada de SQL Server", + sqlServerLegacyCompatibilityComponentInstalling: "Instalando componente de compatibilidad heredada de SQL Server...", saveAndConnect: "Guardar y conectar", save: "Guardar", editTitle: "Editar conexión", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 92697c3fe..3fc0ac408 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -304,10 +304,13 @@ export default withEnglishFallback({ test: "Test", testing: "Verifica in corso...", copyTestResult: "Copia risultato test", - sqlServerLegacyUnencryptedMode: "Modalità compatibilità legacy", - sqlServerLegacyUnencryptedModeEnable: "Usa connessione non crittografata legacy SQL Server", - sqlServerLegacyUnencryptedModeEnabled: "La modalità compatibilità legacy di SQL Server è abilitata.", - sqlServerLegacyUnencryptedModeHint: "Si applica al trasporto non crittografato o alla crittografia solo per il login. Fallirà comunque se il server richiede un trasporto crittografato che il driver integrato non può negoziare.\nUsala solo su reti attendibili, VPN o tunnel SSH", + sqlServerLegacyCompatibilityMode: "Modalità compatibilità legacy", + sqlServerLegacyCompatibilityModeEnable: "Usa connessione di compatibilità legacy SQL Server", + sqlServerLegacyCompatibilityModeEnabled: "La modalità compatibilità legacy di SQL Server è abilitata.", + sqlServerLegacyCompatibilityModeHint: + "Si applica a scenari non crittografati, con crittografia solo al login o con compatibilità legacy TLS 1.0. Se il componente di compatibilità non è installato, DBX lo installa prima di abilitare questa modalità.\nQuando il fallback usa il componente di compatibilità, alcune funzionalità native di SQL Server potrebbero non essere disponibili.\nUsala solo su reti attendibili, VPN o tunnel SSH", + sqlServerLegacyCompatibilityComponent: "Componente di compatibilità legacy SQL Server", + sqlServerLegacyCompatibilityComponentInstalling: "Installazione componente di compatibilità legacy SQL Server...", saveAndConnect: "Salva & Connetti", save: "Salva", editTitle: "Modifica Connessione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 188484b1a..cb06daf08 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -298,10 +298,13 @@ export default withEnglishFallback({ test: "テスト", testing: "テスト中...", copyTestResult: "テスト結果をコピー", - sqlServerLegacyUnencryptedMode: "レガシー互換モード", - sqlServerLegacyUnencryptedModeEnable: "SQL Server レガシー非暗号化接続を使用", - sqlServerLegacyUnencryptedModeEnabled: "SQL Server レガシー互換モードが有効です。", - sqlServerLegacyUnencryptedModeHint: "非暗号化またはログイン時のみ暗号化する場合に適用されます。サーバーが内蔵ドライバーでネゴシエートできない暗号化通信を要求する場合は失敗します。\n信頼できるネットワーク、VPN、SSH トンネルでのみ使用してください", + sqlServerLegacyCompatibilityMode: "レガシー互換モード", + sqlServerLegacyCompatibilityModeEnable: "SQL Server レガシー互換接続を使用", + sqlServerLegacyCompatibilityModeEnabled: "SQL Server レガシー互換モードが有効です。", + sqlServerLegacyCompatibilityModeHint: + "非暗号化、ログイン時のみ暗号化、または TLS 1.0 レガシー互換シナリオに適用されます。互換コンポーネントが未インストールの場合、DBX はこのモードを有効にする前にインストールします。\n互換コンポーネントへフォールバックした場合、一部の SQL Server ネイティブ機能を利用できないことがあります。\n信頼できるネットワーク、VPN、SSH トンネルでのみ使用してください", + sqlServerLegacyCompatibilityComponent: "SQL Server レガシー互換コンポーネント", + sqlServerLegacyCompatibilityComponentInstalling: "SQL Server レガシー互換コンポーネントをインストールしています...", saveAndConnect: "保存して接続", save: "保存", editTitle: "接続を編集", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 931040497..6a2a343f0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -305,10 +305,13 @@ export default withEnglishFallback({ test: "Testar", testing: "Testando...", copyTestResult: "Copiar resultado do teste", - sqlServerLegacyUnencryptedMode: "Modo de compatibilidade legado", - sqlServerLegacyUnencryptedModeEnable: "Usar conexão SQL Server legada não criptografada", - sqlServerLegacyUnencryptedModeEnabled: "O modo de compatibilidade legado do SQL Server está ativado.", - sqlServerLegacyUnencryptedModeHint: "Aplica-se a transporte não criptografado ou criptografia apenas no login. Ainda falhará se o servidor exigir transporte criptografado que o driver integrado não consegue negociar.\nUse apenas em redes confiáveis, VPNs ou túneis SSH", + sqlServerLegacyCompatibilityMode: "Modo de compatibilidade legado", + sqlServerLegacyCompatibilityModeEnable: "Usar conexão de compatibilidade legada do SQL Server", + sqlServerLegacyCompatibilityModeEnabled: "O modo de compatibilidade legado do SQL Server está ativado.", + sqlServerLegacyCompatibilityModeHint: + "Aplica-se a cenários sem criptografia, com criptografia apenas no login ou compatibilidade legada TLS 1.0. Se o componente de compatibilidade não estiver instalado, o DBX o instala antes de ativar este modo.\nQuando o fallback usa o componente de compatibilidade, alguns recursos nativos do SQL Server podem ficar indisponíveis.\nUse apenas em redes confiáveis, VPNs ou túneis SSH", + sqlServerLegacyCompatibilityComponent: "Componente de compatibilidade legada do SQL Server", + sqlServerLegacyCompatibilityComponentInstalling: "Instalando componente de compatibilidade legada do SQL Server...", saveAndConnect: "Salvar e Conectar", save: "Salvar", editTitle: "Editar Conexão", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5a5af7e89..97d921cfb 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -325,10 +325,12 @@ export default withEnglishFallback({ test: "测试", testing: "测试中...", copyTestResult: "复制测试结果", - sqlServerLegacyUnencryptedMode: "旧版兼容模式", - sqlServerLegacyUnencryptedModeEnable: "使用 SQL Server 旧版非加密连接", - sqlServerLegacyUnencryptedModeEnabled: "已启用 SQL Server 旧版兼容模式。", - sqlServerLegacyUnencryptedModeHint: "适用于非加密或仅登录阶段加密的场景。若服务端强制使用内置驱动无法协商的加密传输,此模式仍会失败\n请仅在可信内网、VPN 或 SSH 隧道中使用", + sqlServerLegacyCompatibilityMode: "旧版兼容模式", + sqlServerLegacyCompatibilityModeEnable: "使用 SQL Server 旧版兼容连接", + sqlServerLegacyCompatibilityModeEnabled: "已启用 SQL Server 旧版兼容模式。", + sqlServerLegacyCompatibilityModeHint: "适用于非加密、仅登录阶段加密或 TLS 1.0 旧版兼容场景。若兼容组件未安装,DBX 会先安装再启用该模式\n若回退到兼容组件,部分 SQL Server 原生能力可能不可用\n请仅在可信内网、VPN 或 SSH 隧道中使用", + sqlServerLegacyCompatibilityComponent: "SQL Server 旧版兼容组件", + sqlServerLegacyCompatibilityComponentInstalling: "正在安装 SQL Server 旧版兼容组件...", saveAndConnect: "保存并连接", save: "保存", editTitle: "编辑连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0a55e773c..b0172375b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -305,10 +305,12 @@ export default withEnglishFallback({ test: "測試", testing: "測試中……", copyTestResult: "複製測試結果", - sqlServerLegacyUnencryptedMode: "舊版相容模式", - sqlServerLegacyUnencryptedModeEnable: "使用 SQL Server 舊版非加密連線", - sqlServerLegacyUnencryptedModeEnabled: "已啟用 SQL Server 舊版相容模式。", - sqlServerLegacyUnencryptedModeHint: "適用於非加密或僅登入階段加密的場景。若伺服器強制使用內建驅動無法協商的加密傳輸,此模式仍會失敗\n請僅在可信內網、VPN 或 SSH 隧道中使用", + sqlServerLegacyCompatibilityMode: "舊版相容模式", + sqlServerLegacyCompatibilityModeEnable: "使用 SQL Server 舊版相容連線", + sqlServerLegacyCompatibilityModeEnabled: "已啟用 SQL Server 舊版相容模式。", + sqlServerLegacyCompatibilityModeHint: "適用於非加密、僅登入階段加密或 TLS 1.0 舊版相容場景。若相容元件未安裝,DBX 會先安裝再啟用此模式\n若回退到相容元件,部分 SQL Server 原生能力可能無法使用\n請僅在可信內網、VPN 或 SSH 隧道中使用", + sqlServerLegacyCompatibilityComponent: "SQL Server 舊版相容元件", + sqlServerLegacyCompatibilityComponentInstalling: "正在安裝 SQL Server 舊版相容元件...", saveAndConnect: "儲存並連線", save: "儲存", editTitle: "編輯連線", diff --git a/apps/desktop/src/lib/__tests__/connection/sqlServerLegacyCompatibility.spec.ts b/apps/desktop/src/lib/__tests__/connection/sqlServerLegacyCompatibility.spec.ts new file mode 100644 index 000000000..e20b90e0a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/sqlServerLegacyCompatibility.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode } from "@/lib/connection/sqlServerLegacyCompatibility"; +import type { ConnectionConfig } from "@/types/database"; + +function connectionConfig(urlParams?: string): ConnectionConfig { + return { + id: "sqlserver", + name: "SQL Server", + db_type: "sqlserver", + driver_profile: "sqlserver", + driver_label: "SQL Server", + host: "127.0.0.1", + port: 1433, + username: "sa", + password: "secret", + database: "master", + url_params: urlParams, + ssl: false, + ssh_enabled: false, + read_only: false, + one_time: false, + transport_layers: [], + agent_java_options: [], + }; +} + +describe("SQL Server legacy compatibility", () => { + it("treats existing disabled encryption params as legacy compatibility opt-in", () => { + expect(isSqlServerLegacyCompatibilityMode("sqlserverEncryption=disabled")).toBe(true); + expect(isSqlServerLegacyCompatibilityMode("applicationName=dbx;encrypt=false")).toBe(true); + expect(isSqlServerLegacyCompatibilityMode("?Encrypt=0&applicationName=dbx")).toBe(true); + expect(isSqlServerLegacyCompatibilityMode("encrypt=true")).toBe(false); + }); + + it("updates URL params without keeping conflicting encryption params", () => { + expect(setSqlServerLegacyCompatibilityMode("applicationName=dbx;encrypt=true", true)).toBe("applicationName=dbx&sqlserverEncryption=disabled"); + expect(setSqlServerLegacyCompatibilityMode("applicationName=dbx;sqlserverEncryption=disabled", false)).toBe("applicationName=dbx"); + }); + + it("requires the hidden component only for SQL Server legacy compatibility connections", () => { + expect(requiresSqlServerLegacyCompatibilityComponent(connectionConfig("sqlserverEncryption=disabled"))).toBe(true); + expect(requiresSqlServerLegacyCompatibilityComponent(connectionConfig("encrypt=true"))).toBe(false); + expect( + requiresSqlServerLegacyCompatibilityComponent({ + ...connectionConfig("sqlserverEncryption=disabled"), + db_type: "mysql", + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 7cd521c87..a386424e0 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -75,6 +75,7 @@ export const installJdbcPluginLocal = forward("installJdbcPluginLocal"); export const uninstallJdbcPlugin = forward("uninstallJdbcPlugin"); export const listInstalledAgentsLocal = forward("listInstalledAgentsLocal"); export const listInstalledAgents = forward("listInstalledAgents"); +export const isAgentInstalled = forward("isAgentInstalled"); export const getDriverStoreUsage = forward("getDriverStoreUsage"); export const clearDriverDownloadCache = forward("clearDriverDownloadCache"); export const getDriverRuntimeSummary = forward("getDriverRuntimeSummary"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index d741e864c..53dd2404c 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -337,6 +337,10 @@ export async function listInstalledAgents(): Promise { return get("/api/agents/installed"); } +export async function isAgentInstalled(dbType: string): Promise { + return get(`/api/agents/installed/${encodeURIComponent(dbType)}`); +} + export async function getDriverStoreUsage(): Promise { return get("/api/agents/storage-usage"); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index bb212d765..08c793d6d 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1122,6 +1122,10 @@ export async function listInstalledAgents(): Promise { return invoke("list_installed_agents"); } +export async function isAgentInstalled(dbType: string): Promise { + return invoke("is_agent_installed", { dbType }); +} + export async function getDriverStoreUsage(): Promise { return invoke("get_driver_store_usage"); } diff --git a/apps/desktop/src/lib/connection/sqlServerLegacyCompatibility.ts b/apps/desktop/src/lib/connection/sqlServerLegacyCompatibility.ts new file mode 100644 index 000000000..36294512e --- /dev/null +++ b/apps/desktop/src/lib/connection/sqlServerLegacyCompatibility.ts @@ -0,0 +1,34 @@ +import type { ConnectionConfig } from "@/types/database"; + +export const SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY = "sqlserver-legacy"; + +const SQLSERVER_LEGACY_DISABLED_VALUES = new Set(["disabled", "disable", "false", "0", "off", "no"]); + +export function isSqlServerLegacyCompatibilityMode(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()) { + const normalizedKey = key.trim().toLowerCase(); + if (normalizedKey === "sqlserverencryption" || normalizedKey === "encrypt") { + // Accept JDBC-style `encrypt=false` from imported SQL Server URLs as the same opt-in. + if (SQLSERVER_LEGACY_DISABLED_VALUES.has(value.trim().toLowerCase())) return true; + } + } + return false; +} + +export function setSqlServerLegacyCompatibilityMode(params: string | undefined, enabled: boolean): string { + const normalized = (params || "").trim().replace(/^\?/, "").replace(/;/g, "&"); + const parsed = new URLSearchParams(normalized); + for (const key of Array.from(parsed.keys())) { + const normalizedKey = key.trim().toLowerCase(); + if (normalizedKey === "sqlserverencryption" || normalizedKey === "encrypt") parsed.delete(key); + } + if (enabled) parsed.set("sqlserverEncryption", "disabled"); + return parsed.toString(); +} + +export function requiresSqlServerLegacyCompatibilityComponent(config: ConnectionConfig): boolean { + return config.db_type === "sqlserver" && isSqlServerLegacyCompatibilityMode(config.url_params); +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts new file mode 100644 index 000000000..13f2f346d --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.sqlserverLegacy.spec.ts @@ -0,0 +1,63 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function sqlServerLegacyConnection(): ConnectionConfig { + return { + id: "sqlserver-1", + name: "SQL Server", + db_type: "sqlserver", + driver_profile: "sqlserver", + driver_label: "SQL Server", + host: "127.0.0.1", + port: 1433, + username: "sa", + password: "secret", + database: "master", + url_params: "sqlserverEncryption=disabled", + ssl: false, + ssh_enabled: false, + read_only: false, + one_time: false, + transport_layers: [], + agent_java_options: [], + }; +} + +describe("connectionStore SQL Server legacy compatibility", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("installs the legacy compatibility component before connecting saved legacy configs", async () => { + const connectDb = vi.fn().mockResolvedValue("sqlserver-1"); + const installAgent = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + connectDb, + installAgent, + isAgentInstalled: vi.fn().mockResolvedValue(false), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + await store.connect(sqlServerLegacyConnection()); + + expect(installAgent).toHaveBeenCalledWith("sqlserver-legacy"); + expect(connectDb).toHaveBeenCalledTimes(1); + expect(installAgent.mock.invocationCallOrder[0]).toBeLessThan(connectDb.mock.invocationCallOrder[0]); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 268599065..2707b0690 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -31,6 +31,7 @@ import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget"; import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth"; import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; +import { requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases"; import { buildObjectGroupPlaceholderNodes, @@ -1625,6 +1626,12 @@ export const useConnectionStore = defineStore("connection", () => { rebuildTreeNodes(); } + async function ensureSqlServerLegacyCompatibilityComponentInstalled(config: ConnectionConfig) { + if (!requiresSqlServerLegacyCompatibilityComponent(config)) return; + if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return; + await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY); + } + async function setDefaultDatabase(connectionId: string, database: string) { const config = getConfig(connectionId); if (!config || config.database === database) return; @@ -1771,6 +1778,7 @@ export const useConnectionStore = defineStore("connection", () => { const localAttempt = beginLocalConnectionAttempt(config.id); try { await beforeConnectHandler?.(config); + await ensureSqlServerLegacyCompatibilityComponentInstalled(config); ensureLocalConnectionAttemptActive(config.id, localAttempt); const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id); diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 5fdece8f6..ea7a63b53 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -212,6 +212,12 @@ "skipTcpProbe": false, "defaultPort": 1433, "supportLevel": "operate", + "driverProfiles": [ + { + "profile": "sqlserver-legacy", + "agentKey": "sqlserver-legacy" + } + ], "capabilities": { "queryExecution": true, "metadataBrowse": true, diff --git a/crates/dbx-core/src/agent_catalog.rs b/crates/dbx-core/src/agent_catalog.rs index cfe9e7e76..e9b9b100a 100644 --- a/crates/dbx-core/src/agent_catalog.rs +++ b/crates/dbx-core/src/agent_catalog.rs @@ -36,7 +36,10 @@ const MONGODB_PROFILES: &[AgentDriverProfile] = &[AgentDriverProfile { store_visible: false, }]; -const EXTRA_DRIVER_STORE_ENTRIES: &[(&str, &str)] = &[("kafka", "Apache Kafka")]; +const EXTRA_AGENT_LABELS: &[(&str, &str)] = + &[("kafka", "Apache Kafka"), ("sqlserver-legacy", "SQL Server legacy compatibility component")]; +const EXTRA_DRIVER_STORE_ENTRIES: &[(&str, &str)] = + &[("kafka", "Apache Kafka"), ("sqlserver-legacy", "SQL Server legacy compatibility component")]; const AGENT_CATALOG: &[AgentCatalogEntry] = &[ AgentCatalogEntry { @@ -297,6 +300,11 @@ pub fn agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option if *db_type == DatabaseType::MessageQueue { return (driver_profile == Some("kafka")).then_some("kafka"); } + if *db_type == DatabaseType::SqlServer { + return driver_profile + .is_some_and(|profile| profile.eq_ignore_ascii_case("sqlserver-legacy")) + .then_some("sqlserver-legacy"); + } let entry = entry_for_db_type(db_type)?; if let Some(driver_profile) = driver_profile { if let Some(profile) = entry.profiles.iter().find(|profile| profile.profile == driver_profile) { @@ -328,7 +336,7 @@ pub fn driver_store_entries() -> impl Iterator Option<&'static str> { - if let Some((_, label)) = EXTRA_DRIVER_STORE_ENTRIES.iter().find(|(key, _)| *key == agent_key) { + if let Some((_, label)) = EXTRA_AGENT_LABELS.iter().find(|(key, _)| *key == agent_key) { return Some(label); } for entry in entries() { diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 5e48c8625..6a5a59196 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -32,6 +32,8 @@ use crate::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORK pub const JDBC_PLUGIN_NOT_INSTALLED: &str = "JDBC plugin is not installed. Install the optional JDBC plugin to use this connection."; pub const PRESTOSQL_JDBC_DRIVER_CLASS: &str = "io.prestosql.jdbc.PrestoDriver"; +const SQLSERVER_LEGACY_DRIVER_INSTALL_HINT: &str = + "Install the SQL Server legacy compatibility component from Driver Manager, or open the connection settings and enable SQL Server legacy compatibility mode again."; const DEFAULT_AGENT_CONNECT_TIMEOUT_SECS: u64 = 30; const ACCESS_AGENT_CONNECT_TIMEOUT_SECS: u64 = 30; const POOL_CLOSE_TIMEOUT_SECS: u64 = 3; @@ -257,6 +259,24 @@ pub fn prestosql_jdbc_config_for_endpoint(config: &ConnectionConfig, host: &str, jdbc_config } +pub fn sqlserver_legacy_agent_config(config: &ConnectionConfig) -> ConnectionConfig { + let mut legacy_config = config.clone(); + legacy_config.driver_profile = Some(db::sqlserver::SQLSERVER_LEGACY_DRIVER_PROFILE.to_string()); + legacy_config.driver_label = Some(db::sqlserver::SQLSERVER_LEGACY_DRIVER_LABEL.to_string()); + legacy_config +} + +pub fn sqlserver_legacy_agent_error(native_error: &str, agent_error: &str) -> String { + let install_hint = if agent_error.contains("driver is not installed") { + format!("\n\n{SQLSERVER_LEGACY_DRIVER_INSTALL_HINT}") + } else { + String::new() + }; + format!( + "{native_error}\n\nFallback with SQL Server legacy compatibility component failed: {agent_error}{install_hint}" + ) +} + pub async fn connect_mysql_metadata_pool( config: &ConnectionConfig, db_config: &ConnectionConfig, @@ -607,6 +627,95 @@ impl AppState { Ok(PoolKind::ExternalDriver { driver_id: driver_id.to_string(), config: Arc::new(config.clone()), session }) } + pub async fn test_sqlserver_connection_with_legacy_fallback( + &self, + config: &ConnectionConfig, + host: &str, + port: u16, + connect_timeout: Duration, + ) -> Result { + match db::sqlserver::connect( + host, + port, + &config.username, + &config.password, + config.database.as_deref(), + config.url_params.as_deref(), + connect_timeout, + ) + .await + { + Ok(_) => Ok("Connection successful".to_string()), + Err(native_error) + if db::sqlserver::sqlserver_legacy_compatibility_enabled(config.url_params.as_deref()) => + { + let legacy_config = sqlserver_legacy_agent_config(config); + let connect_params = + agent_connect_params(&legacy_config, host, port, legacy_config.effective_database().unwrap_or("")); + let mut client = self + .agent_manager + .spawn(&legacy_config.db_type, legacy_config.driver_profile.as_deref()) + .await + .map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?; + client + .call_method_with_timeout::( + AgentMethod::TestConnection, + connect_params, + Some(agent_connect_timeout(&legacy_config)), + ) + .await + .map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?; + client.disconnect().await.ok(); + Ok("Connection successful (via SQL Server legacy compatibility driver)".to_string()) + } + Err(err) => Err(err), + } + } + + pub async fn connect_sqlserver_pool_with_legacy_fallback( + &self, + config: &ConnectionConfig, + host: &str, + port: u16, + connect_timeout: Duration, + ) -> Result { + match db::sqlserver::connect( + host, + port, + &config.username, + &config.password, + config.database.as_deref(), + config.url_params.as_deref(), + connect_timeout, + ) + .await + { + Ok(client) => Ok(PoolKind::SqlServer(Arc::new(tokio::sync::Mutex::new(client)))), + Err(native_error) + if db::sqlserver::sqlserver_legacy_compatibility_enabled(config.url_params.as_deref()) => + { + let legacy_config = sqlserver_legacy_agent_config(config); + let connect_params = + agent_connect_params(&legacy_config, host, port, legacy_config.effective_database().unwrap_or("")); + let mut client = self + .agent_manager + .spawn(&legacy_config.db_type, legacy_config.driver_profile.as_deref()) + .await + .map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?; + client + .call_method_with_timeout::( + AgentMethod::Connect, + connect_params, + Some(agent_connect_timeout(&legacy_config)), + ) + .await + .map_err(|err| sqlserver_legacy_agent_error(&native_error, &err))?; + Ok(PoolKind::Agent(Arc::new(tokio::sync::Mutex::new(client)))) + } + Err(err) => Err(err), + } + } + pub fn external_driver_runtime_env(&self, driver_id: &str) -> Result { if driver_id != "jdbc" { return Ok(PluginRuntimeEnv::default()); @@ -1130,17 +1239,7 @@ impl AppState { PoolKind::ClickHouse(client) } DatabaseType::SqlServer => { - let client = db::sqlserver::connect( - &host, - port, - &db_config.username, - &db_config.password, - db_config.database.as_deref(), - db_config.url_params.as_deref(), - connect_timeout, - ) - .await?; - PoolKind::SqlServer(Arc::new(tokio::sync::Mutex::new(client))) + self.connect_sqlserver_pool_with_legacy_fallback(&db_config, &host, port, connect_timeout).await? } DatabaseType::Elasticsearch => { let mut client = db::elasticsearch_driver::EsClient::from_config( @@ -2986,8 +3085,9 @@ mod tests { agent_connect_timeout, connection_remote_endpoint, connection_url_for_endpoint, database_connection_config, metadata_connection_config, mysql_metadata_fallback_url, oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, - redis_sentinel_transport_id, redis_sentinel_transport_prefix, uses_bare_mysql_pool, uses_tcp_probe, - validate_h2_database_path, AppState, PoolKind, PRESTOSQL_JDBC_DRIVER_CLASS, + redis_sentinel_transport_id, redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, + sqlserver_legacy_agent_error, uses_bare_mysql_pool, uses_tcp_probe, validate_h2_database_path, AppState, + PoolKind, PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, @@ -3102,6 +3202,31 @@ mod tests { assert_eq!(jdbc_config.jdbc_driver_paths, vec!["D:\\software\\jar\\presto-jdbc-350.jar"]); } + #[test] + fn sqlserver_legacy_agent_config_marks_hidden_profile() { + let mut config = mysql_config(Some("master")); + config.db_type = DatabaseType::SqlServer; + + let legacy = sqlserver_legacy_agent_config(&config); + + assert_eq!(legacy.db_type, DatabaseType::SqlServer); + assert_eq!(legacy.driver_profile.as_deref(), Some(crate::db::sqlserver::SQLSERVER_LEGACY_DRIVER_PROFILE)); + assert_eq!(legacy.driver_label.as_deref(), Some(crate::db::sqlserver::SQLSERVER_LEGACY_DRIVER_LABEL)); + } + + #[test] + fn sqlserver_legacy_agent_error_mentions_driver_manager_when_missing() { + let message = sqlserver_legacy_agent_error( + "native failed", + "sqlserver-legacy driver is not installed. Please install it from the Driver Manager.", + ); + + assert!(message.contains("native failed")); + assert!(message.contains("Fallback with SQL Server legacy compatibility component failed")); + assert!(message.contains("Driver Manager")); + assert!(message.contains("enable SQL Server legacy compatibility mode again")); + } + #[test] fn agent_connect_params_include_url_params() { let mut config = mysql_config(Some("testdb")); diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 52d462ba4..dc8d5da98 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -16,6 +16,8 @@ use tokio_util::sync::CancellationToken; pub type SqlServerClient = Client>; pub const SQLSERVER_DRIVER_PANIC_ERROR_PREFIX: &str = "SQL Server driver panic:"; +pub const SQLSERVER_LEGACY_DRIVER_PROFILE: &str = "sqlserver-legacy"; +pub const SQLSERVER_LEGACY_DRIVER_LABEL: &str = "SQL Server legacy compatibility component"; const SIMPLE_QUERY_MODULE_KEYWORDS: &[&str] = &["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"]; // Match JDBC/tiberius `encrypt=false`: encrypt only login, then drop back to raw TDS. const SQLSERVER_LEGACY_ENCRYPTION_LEVEL: tiberius::EncryptionLevel = tiberius::EncryptionLevel::Off; @@ -53,13 +55,9 @@ pub async fn connect( user: &str, pass: &str, database: Option<&str>, - url_params: Option<&str>, + _url_params: Option<&str>, timeout: Duration, ) -> Result { - if sqlserver_legacy_encryption_disabled(url_params) { - return try_connect_legacy_sqlserver_encryption(host, port, user, pass, database, timeout).await; - } - match try_connect(host, port, user, pass, database, tiberius::EncryptionLevel::Required, timeout).await { Ok(client) => Ok(client), Err(encrypted_error) => try_connect_legacy_sqlserver_encryption(host, port, user, pass, database, timeout) @@ -69,12 +67,11 @@ pub async fn connect( format!( "{encrypted_error}\n\nThis may be caused by an old SQL Server TLS/encryption configuration. \ If you are connecting to SQL Server 2008/2008 R2/2012 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 encrypted transport that the embedded driver cannot negotiate. \ - Only use this mode on trusted networks, VPNs, \ + try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, \ + when explicitly enabled, DBX can also fall back to the SQL Server legacy compatibility \ + driver for TLS 1.0 encrypted transport. Only use this mode on trusted networks, VPNs, \ or SSH tunnels.\n\n\ - Automatic legacy unencrypted fallback also failed: {plain_error}" + Automatic native legacy fallback also failed: {plain_error}" ) } else { plain_error @@ -102,7 +99,7 @@ async fn try_connect_legacy_sqlserver_encryption( Err(errors.join("\n")) } -fn sqlserver_legacy_encryption_disabled(url_params: Option<&str>) -> bool { +pub fn sqlserver_legacy_compatibility_enabled(url_params: Option<&str>) -> bool { let Some(params) = url_params.map(str::trim).filter(|params| !params.is_empty()) else { return false; }; @@ -1903,14 +1900,14 @@ mod tests { } #[test] - fn sqlserver_legacy_encryption_flag_accepts_dbx_and_jdbc_params() { - assert!(!super::sqlserver_legacy_encryption_disabled(None)); - assert!(!super::sqlserver_legacy_encryption_disabled(Some("encrypt=true"))); - 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"))); - assert!(super::sqlserver_legacy_encryption_disabled(Some("applicationName=dbx;encrypt=false"))); - assert!(super::sqlserver_legacy_encryption_disabled(Some("?Encrypt=0&applicationName=dbx"))); + fn sqlserver_legacy_compatibility_flag_accepts_dbx_and_jdbc_params() { + assert!(!super::sqlserver_legacy_compatibility_enabled(None)); + assert!(!super::sqlserver_legacy_compatibility_enabled(Some("encrypt=true"))); + assert!(super::sqlserver_legacy_compatibility_enabled(Some("sqlserverEncryption=disabled"))); + assert!(super::sqlserver_legacy_compatibility_enabled(Some("applicationName=dbx;sqlserverEncryption=off"))); + assert!(super::sqlserver_legacy_compatibility_enabled(Some("?sqlserverEncryption=false&applicationName=dbx"))); + assert!(super::sqlserver_legacy_compatibility_enabled(Some("applicationName=dbx;encrypt=false"))); + assert!(super::sqlserver_legacy_compatibility_enabled(Some("?Encrypt=0&applicationName=dbx"))); } #[test] diff --git a/crates/dbx-core/tests/database_capabilities.rs b/crates/dbx-core/tests/database_capabilities.rs index 3fe53d2de..32bf4dd46 100644 --- a/crates/dbx-core/tests/database_capabilities.rs +++ b/crates/dbx-core/tests/database_capabilities.rs @@ -115,6 +115,8 @@ fn maps_agent_database_types_to_driver_keys() { assert_eq!(agent_key(&DatabaseType::ZooKeeper, None), Some("zookeeper")); assert_eq!(agent_key(&DatabaseType::Oracle, Some("oracle-legacy")), Some("oracle")); assert_eq!(agent_key(&DatabaseType::Oracle, Some("oracle-10g")), Some("oracle")); + assert_eq!(agent_key(&DatabaseType::SqlServer, Some("sqlserver-legacy")), Some("sqlserver-legacy")); + assert_eq!(agent_key(&DatabaseType::SqlServer, None), None); assert_eq!(agent_key(&DatabaseType::Postgres, None), None); } @@ -127,6 +129,8 @@ fn driver_store_entries_do_not_repeat_agent_keys() { assert!(duplicate_keys.is_empty(), "driver store agent keys should be unique: {duplicate_keys:?}"); assert_eq!(entries.iter().filter(|(key, _)| *key == "gbase8a").count(), 1); assert_eq!(entries.iter().filter(|(key, _)| *key == "gbase8s").count(), 1); + assert_eq!(entries.iter().filter(|(key, _)| *key == "sqlserver-legacy").count(), 1); + assert_eq!(agent_catalog::label_for_key("sqlserver-legacy"), Some("SQL Server legacy compatibility component")); } #[test] diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 1b5de758f..b03219b23 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -250,6 +250,7 @@ async fn main() { // Agent drivers .route("/agents/installed-local", get(routes::agents::list_installed_agents_local)) .route("/agents/installed", get(routes::agents::list_installed_agents)) + .route("/agents/installed/{dbType}", get(routes::agents::is_agent_installed)) .route("/agents/storage-usage", get(routes::agents::get_driver_store_usage)) .route("/agents/download-cache", delete(routes::agents::clear_driver_download_cache)) .route("/agents/runtime", get(routes::agents::get_driver_runtime_summary)) diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index e9b9493f2..d110f51a5 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -49,6 +49,13 @@ pub async fn list_installed_agents(State(state): State>) -> Result Ok(Json(build_agent_list(&state.app.agent_manager, registry.as_ref()))) } +pub async fn is_agent_installed( + State(state): State>, + Path(db_type): Path, +) -> Result, AppError> { + Ok(Json(state.app.agent_manager.is_driver_installed(&db_type))) +} + pub async fn get_driver_store_usage(State(state): State>) -> Result, AppError> { Ok(Json(state.app.agent_manager.collect_driver_store_usage(state.app.plugins.root_dir()))) } diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 9890bbfbc..dd17eff0d 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -29,6 +29,11 @@ pub async fn list_installed_agents(state: State<'_, Arc>) -> Result>, db_type: String) -> Result { + Ok(state.agent_manager.is_driver_installed(&db_type)) +} + #[tauri::command] pub async fn get_driver_store_usage(state: State<'_, Arc>) -> Result { Ok(state.agent_manager.collect_driver_store_usage(state.plugins.root_dir())) diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index bbf49f5eb..2a53c4a24 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -747,17 +747,9 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection .await .map(|_| "Connection successful".to_string()) } - DatabaseType::SqlServer => db::sqlserver::connect( - &host, - port, - &config.username, - &config.password, - config.database.as_deref(), - config.url_params.as_deref(), - connect_timeout, - ) - .await - .map(|_| "Connection successful".to_string()), + DatabaseType::SqlServer => { + state.test_sqlserver_connection_with_legacy_fallback(&config, &host, port, connect_timeout).await + } DatabaseType::Elasticsearch => { let mut client = db::elasticsearch_driver::EsClient::from_config( &url, @@ -1047,17 +1039,7 @@ pub async fn connect_db( PoolKind::ClickHouse(client) } DatabaseType::SqlServer => { - let client = db::sqlserver::connect( - &host, - port, - &db_config.username, - &db_config.password, - db_config.database.as_deref(), - db_config.url_params.as_deref(), - connect_timeout, - ) - .await?; - PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) + state.connect_sqlserver_pool_with_legacy_fallback(&db_config, &host, port, connect_timeout).await? } DatabaseType::Elasticsearch => { let mut client = db::elasticsearch_driver::EsClient::from_config( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5a6098a9..64c93aa71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1136,6 +1136,7 @@ pub fn run() { commands::text_export::export_query_result_markdown, commands::agents::list_installed_agents, commands::agents::list_installed_agents_local, + commands::agents::is_agent_installed, commands::agents::get_driver_store_usage, commands::agents::clear_driver_download_cache, commands::agents::get_driver_runtime_summary,