feat(sqlserver): support legacy TLS compatibility

This commit is contained in:
zipg 2026-07-09 22:45:54 +08:00 committed by GitHub
parent 63ef26b25a
commit 67edb94b1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 738 additions and 117 deletions

View File

@ -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'
)
}
}

View File

@ -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<String> 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<String> 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<String, String> 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<String> 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<String, String> baseConnectionProperties(ConnectParams params) {
Map<String, String> 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<String, String> properties) {
StringBuilder url = new StringBuilder(trimSqlServerUrl(base));
for (Map.Entry<String, String> 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();
}
}

View File

@ -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)
);
}
}

View File

@ -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))

View File

@ -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"
}

View File

@ -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<void> {
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<boolean> {
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) {
</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>
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyCompatibilityMode") }}</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>
<input type="checkbox" :checked="sqlServerLegacyCompatibilityModeEnabled" :disabled="agentInstallRunning" class="mr-0" @change="onSqlServerLegacyCompatibilityModeChange" />
<span class="text-xs text-foreground">{{ t("connection.sqlServerLegacyCompatibilityModeEnable") }}</span>
</label>
<p class="m-0 whitespace-pre-line text-xs leading-5 text-muted-foreground">
{{ t("connection.sqlServerLegacyUnencryptedModeHint") }}
{{ t("connection.sqlServerLegacyCompatibilityModeHint") }}
</p>
</div>
</div>

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "接続を編集",

View File

@ -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",

View File

@ -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: "编辑连接",

View File

@ -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: "編輯連線",

View File

@ -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);
});
});

View File

@ -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");

View File

@ -337,6 +337,10 @@ export async function listInstalledAgents(): Promise<AgentDriverInfo[]> {
return get("/api/agents/installed");
}
export async function isAgentInstalled(dbType: string): Promise<boolean> {
return get(`/api/agents/installed/${encodeURIComponent(dbType)}`);
}
export async function getDriverStoreUsage(): Promise<DriverStoreUsage> {
return get("/api/agents/storage-usage");
}

View File

@ -1122,6 +1122,10 @@ export async function listInstalledAgents(): Promise<AgentDriverInfo[]> {
return invoke("list_installed_agents");
}
export async function isAgentInstalled(dbType: string): Promise<boolean> {
return invoke("is_agent_installed", { dbType });
}
export async function getDriverStoreUsage(): Promise<DriverStoreUsage> {
return invoke("get_driver_store_usage");
}

View File

@ -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);
}

View File

@ -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<string, string>();
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]);
});
});

View File

@ -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);

View File

@ -212,6 +212,12 @@
"skipTcpProbe": false,
"defaultPort": 1433,
"supportLevel": "operate",
"driverProfiles": [
{
"profile": "sqlserver-legacy",
"agentKey": "sqlserver-legacy"
}
],
"capabilities": {
"queryExecution": true,
"metadataBrowse": true,

View File

@ -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<Item = (&'static str, &'static st
}
pub fn label_for_key(agent_key: &str) -> 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() {

View File

@ -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<String, String> {
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::<serde_json::Value>(
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<PoolKind, String> {
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::<serde_json::Value>(
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<PluginRuntimeEnv, String> {
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"));

View File

@ -16,6 +16,8 @@ use tokio_util::sync::CancellationToken;
pub type SqlServerClient = Client<Compat<TcpStream>>;
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<SqlServerClient, String> {
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]

View File

@ -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]

View File

@ -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))

View File

@ -49,6 +49,13 @@ pub async fn list_installed_agents(State(state): State<Arc<WebState>>) -> Result
Ok(Json(build_agent_list(&state.app.agent_manager, registry.as_ref())))
}
pub async fn is_agent_installed(
State(state): State<Arc<WebState>>,
Path(db_type): Path<String>,
) -> Result<Json<bool>, AppError> {
Ok(Json(state.app.agent_manager.is_driver_installed(&db_type)))
}
pub async fn get_driver_store_usage(State(state): State<Arc<WebState>>) -> Result<Json<DriverStoreUsage>, AppError> {
Ok(Json(state.app.agent_manager.collect_driver_store_usage(state.app.plugins.root_dir())))
}

View File

@ -29,6 +29,11 @@ pub async fn list_installed_agents(state: State<'_, Arc<AppState>>) -> Result<Ve
Ok(build_agent_list(&state.agent_manager, registry.as_ref()))
}
#[tauri::command]
pub async fn is_agent_installed(state: State<'_, Arc<AppState>>, db_type: String) -> Result<bool, String> {
Ok(state.agent_manager.is_driver_installed(&db_type))
}
#[tauri::command]
pub async fn get_driver_store_usage(state: State<'_, Arc<AppState>>) -> Result<DriverStoreUsage, String> {
Ok(state.agent_manager.collect_driver_store_usage(state.plugins.root_dir()))

View File

@ -747,17 +747,9 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, 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(

View File

@ -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,