fix(sqlserver): preserve explicit instance ports

This commit is contained in:
zipg 2026-07-14 01:27:40 +08:00 committed by GitHub
parent 8e87ab2e39
commit 8020fe5886
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 237 additions and 26 deletions

View File

@ -12,6 +12,7 @@ public final class ConnectParams {
private String password;
private String url_params;
private String connection_string;
private boolean port_explicit;
private boolean mysql_compat_mode;
private String jdbc_driver_class;
private List<String> jdbc_driver_paths;
@ -91,6 +92,10 @@ public final class ConnectParams {
return connection_string;
}
public boolean isPort_explicit() {
return port_explicit;
}
public void setHost(String host) {
this.host = host;
}
@ -119,6 +124,10 @@ public final class ConnectParams {
this.connection_string = connection_string;
}
public void setPort_explicit(boolean port_explicit) {
this.port_explicit = port_explicit;
}
public boolean isMysql_compat_mode() {
return mysql_compat_mode;
}
@ -203,6 +212,7 @@ public final class ConnectParams {
&& Objects.equals(password, that.password)
&& Objects.equals(url_params, that.url_params)
&& Objects.equals(connection_string, that.connection_string)
&& port_explicit == that.port_explicit
&& mysql_compat_mode == that.mysql_compat_mode
&& Objects.equals(jdbc_driver_class, that.jdbc_driver_class)
&& Objects.equals(jdbc_driver_paths, that.jdbc_driver_paths)
@ -217,7 +227,7 @@ public final class ConnectParams {
@Override
public int hashCode() {
return Objects.hash(host, port, database, username, password, url_params, connection_string,
mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server);
port_explicit, mysql_compat_mode, jdbc_driver_class, jdbc_driver_paths, ssl, ca_cert_path, client_cert_path, client_key_path, gbase_server, informix_server);
}
@Override
@ -229,6 +239,7 @@ public final class ConnectParams {
+ ", password=" + password
+ ", url_params=" + url_params
+ ", connection_string=" + connection_string
+ ", port_explicit=" + port_explicit
+ ", mysql_compat_mode=" + mysql_compat_mode
+ ", jdbc_driver_class=" + jdbc_driver_class
+ ", jdbc_driver_paths=" + jdbc_driver_paths

View File

@ -246,9 +246,10 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
}
String host = normalizedSqlServerHost(params.getHost());
boolean usesNamedInstance = usesNamedInstance(host, params.getPort(), params.isPort_explicit());
StringBuilder url = new StringBuilder("jdbc:sqlserver://")
.append(host);
if (!usesNamedInstance(host)) {
.append(usesNamedInstance ? host : serverHost(host));
if (!usesNamedInstance) {
int port = params.getPort() > 0 ? params.getPort() : PROFILE.getDefaultPort();
url.append(":").append(port);
}
@ -273,9 +274,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
return server + "\\" + instance;
}
private static boolean usesNamedInstance(String host) {
private static boolean usesNamedInstance(String host, int port, boolean portExplicit) {
int separator = host.indexOf('\\');
return separator > 0 && separator < host.length() - 1;
return separator > 0 && separator < host.length() - 1 && (port <= 0 || (port == PROFILE.getDefaultPort() && !portExplicit));
}
private static String serverHost(String host) {
int separator = host.indexOf('\\');
if (separator > 0 && separator < host.length() - 1) {
return host.substring(0, separator).trim();
}
return host;
}
private static String sanitizeSqlServerUrl(String value) {

View File

@ -84,6 +84,45 @@ class SqlServerLegacyAgentTest {
);
}
@Test
void legacyTlsUrlUsesExplicitPortInsteadOfNamedInstanceResolution() {
ConnectParams params = new ConnectParams(
"db.example.com\\SQLEXPRESS",
40030,
"appdb",
"sa",
"secret",
"applicationName=dbx",
"",
false
);
Assertions.assertEquals(
"jdbc:sqlserver://db.example.com:40030;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
SqlServerLegacyAgent.legacyTlsUrl(params)
);
}
@Test
void legacyTlsUrlUsesExplicitDefaultPortInsteadOfNamedInstanceResolution() {
ConnectParams params = new ConnectParams(
"db.example.com\\SQLEXPRESS",
1433,
"appdb",
"sa",
"secret",
"applicationName=dbx",
"",
false
);
params.setPort_explicit(true);
Assertions.assertEquals(
"jdbc:sqlserver://db.example.com:1433;databaseName=appdb;applicationName=dbx;encrypt=true;trustServerCertificate=true;sslProtocol=TLSv1",
SqlServerLegacyAgent.legacyTlsUrl(params)
);
}
@Test
void legacyTlsUrlNormalizesExplicitConnectionString() {
ConnectParams params = new ConnectParams(

View File

@ -370,6 +370,33 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
}
const form = ref(defaultForm());
function externalConfigRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
}
function sqlServerPortExplicitFromConfig(config: Pick<ConnectionConfig, "db_type" | "external_config">): boolean {
if (config.db_type !== "sqlserver") return false;
const external = externalConfigRecord(config.external_config);
return external.portExplicit === true || external.port_explicit === true;
}
function setSqlServerPortExplicit(config: Pick<ConnectionConfig, "db_type"> & { external_config?: unknown }, explicit: boolean) {
if (config.db_type !== "sqlserver") return;
const next = externalConfigRecord(config.external_config);
delete next.port_explicit;
if (explicit) {
next.portExplicit = true;
} else {
delete next.portExplicit;
}
config.external_config = Object.keys(next).length > 0 ? next : undefined;
}
function markSqlServerPortExplicit() {
setSqlServerPortExplicit(form.value, true);
}
const keepaliveEnabled = computed({
get: () => Number(form.value.keepalive_interval_secs) > 0,
set: (enabled: boolean) => {
@ -1319,9 +1346,13 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.db_type = profile.type;
form.value.driver_profile = val;
form.value.driver_label = isCustomCompatibleProfile() ? customDriverName.value.trim() || profile.label : profile.label;
if (profile.type !== "sqlserver") {
form.value.external_config = undefined;
}
if (!preserveConnectionFields) {
form.value.port = profile.port;
setSqlServerPortExplicit(form.value, false);
form.value.username = profile.user;
form.value.url_params = profile.urlParams || "";
form.value.agent_java_options = [];
@ -2394,6 +2425,8 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
config.password = config.password.trim();
config.database = config.database?.trim() || undefined;
}
} else if (config.db_type === "sqlserver") {
config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
} else {
config.external_config = undefined;
}
@ -3112,7 +3145,7 @@ function resetForm() {
const submittedOneTimePrefillKey = ref<string | null>(null);
function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) {
return JSON.stringify([draft.name, draft.dbType, draft.driverProfile, draft.driverLabel, draft.host, draft.port, draft.username, draft.password, draft.database, draft.urlParams, draft.ssl, draft.connectionString, draft.oracleConnectionType, draft.useMongoUrl]);
return JSON.stringify([draft.name, draft.dbType, draft.driverProfile, draft.driverLabel, draft.host, draft.port, draft.portExplicit, draft.username, draft.password, draft.database, draft.urlParams, draft.ssl, draft.connectionString, draft.oracleConnectionType, draft.useMongoUrl]);
}
function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) {
@ -3124,7 +3157,7 @@ function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) {
}
function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draft: ConnectionDeepLinkDraft): Omit<ConnectionConfig, "id"> {
return {
const next = {
...config,
db_type: draft.dbType,
driver_profile: draft.driverProfile,
@ -3140,6 +3173,8 @@ function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draf
oracle_connection_type: draft.oracleConnectionType ?? config.oracle_connection_type,
one_time: draft.oneTime || undefined,
};
setSqlServerPortExplicit(next, draft.portExplicit === true);
return next;
}
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
@ -4807,7 +4842,7 @@ function openExternalUrl(url: string) {
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
</div>
<div v-if="form.driver_profile === 'gbase8s'" class="grid grid-cols-4 items-center gap-4">

View File

@ -8,6 +8,7 @@ export interface ConnectionDeepLinkDraft {
driverLabel: string;
host?: string;
port?: number;
portExplicit?: boolean;
username?: string;
password?: string;
database?: string;
@ -54,6 +55,7 @@ function draftFromConnectionUrl(value: string, preferredProfile?: string): Conne
driverLabel: parsed.driverLabel,
host: parsed.host,
port: parsed.port,
portExplicit: parsed.portExplicit,
username: parsed.username,
password: parsed.password,
database: parsed.database,
@ -94,12 +96,14 @@ export function parseConnectionDeepLink(value: string): ConnectionDeepLinkDraft
})();
const oneTime = optionalBooleanParam(params, "one_time");
const explicitPort = optionalNumberParam(params, "port");
return {
...draft,
name: optionalParam(params, "name") ?? draft.name,
host: optionalParam(params, "host") ?? draft.host,
port: optionalNumberParam(params, "port") ?? draft.port,
port: explicitPort ?? draft.port,
...((explicitPort !== undefined && draft.dbType === "sqlserver") || draft.portExplicit ? { portExplicit: true } : {}),
username: optionalParam(params, "user") ?? draft.username,
password: optionalParam(params, "password") ?? draft.password,
database: optionalParam(params, "database") ?? draft.database,

View File

@ -16,6 +16,7 @@ export interface ParsedConnectionUrl {
connectionString?: string;
oracleConnectionType?: "service_name" | "sid";
useMongoUrl?: boolean;
portExplicit?: boolean;
}
export type ConnectionProfile = {
@ -276,6 +277,7 @@ function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null {
driverLabel: profile.label,
host: match[1],
port: match[2] ? Number(match[2]) : profile.defaultPort,
...(match[2] ? { portExplicit: true } : {}),
username: decodeUrlPart(props.get("user") || ""),
password: decodeUrlPart(props.get("password") || ""),
database: decodeUrlPart(props.get("databasename") || props.get("database") || "") || undefined,
@ -568,6 +570,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
driverLabel: profile.label,
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : profile.defaultPort,
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: mysqlCredentials?.username ?? decodeUrlPart(parsed.username),
password: mysqlCredentials?.password ?? decodeUrlPart(parsed.password),
database: databaseFromPath(parsed.pathname),
@ -598,6 +601,23 @@ function applyParsedPassword(config: Omit<ConnectionConfig, "id">, parsed: Parse
return parsed.password;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function sqlServerExternalConfig(existing: unknown, parsed: ParsedConnectionUrl): unknown {
if (parsed.dbType !== "sqlserver") return existing;
const next = isRecord(existing) ? { ...existing } : {};
delete next.port_explicit;
if (parsed.portExplicit) {
next.portExplicit = true;
} else {
delete next.portExplicit;
}
return Object.keys(next).length > 0 ? next : undefined;
}
export function applyParsedConnectionUrl(config: Omit<ConnectionConfig, "id">, parsed: ParsedConnectionUrl): Omit<ConnectionConfig, "id"> {
return {
...config,
@ -614,5 +634,6 @@ export function applyParsedConnectionUrl(config: Omit<ConnectionConfig, "id">, p
ssl: parsed.ssl,
connection_string: parsed.connectionString,
oracle_connection_type: parsed.oracleConnectionType,
external_config: sqlServerExternalConfig(config.external_config, parsed),
};
}

View File

@ -47,6 +47,7 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
serde_json::json!({
"host": agent_host,
"port": agent_port,
"port_explicit": config.sqlserver_port_explicit(),
"database": agent_database,
"username": config.username,
"password": config.password,

View File

@ -685,13 +685,13 @@ impl AppState {
port: u16,
connect_timeout: Duration,
) -> Result<String, String> {
match db::sqlserver::connect(
match db::sqlserver::connect_with_port_explicit(
host,
port,
config.sqlserver_port_explicit(),
&config.username,
&config.password,
config.database.as_deref(),
config.url_params.as_deref(),
connect_timeout,
)
.await
@ -730,13 +730,13 @@ impl AppState {
port: u16,
connect_timeout: Duration,
) -> Result<PoolKind, String> {
match db::sqlserver::connect(
match db::sqlserver::connect_with_port_explicit(
host,
port,
config.sqlserver_port_explicit(),
&config.username,
&config.password,
config.database.as_deref(),
config.url_params.as_deref(),
connect_timeout,
)
.await
@ -3482,6 +3482,17 @@ mod tests {
assert_eq!(params["url_params"], "INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8");
}
#[test]
fn agent_connect_params_include_sqlserver_explicit_port_state() {
let mut config = mysql_config(Some("master"));
config.db_type = DatabaseType::SqlServer;
config.external_config = Some(serde_json::json!({ "portExplicit": true }));
let params = agent_connect_params(&config, r"db.example.com\SQLEXPRESS", 1433, "master");
assert_eq!(params["port_explicit"], true);
}
#[test]
fn databend_uses_agent_pool_not_bare_mysql_pool() {
assert!(uses_bare_mysql_pool(&DatabaseType::Doris));

View File

@ -18,6 +18,7 @@ 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 SQLSERVER_DEFAULT_PORT: u16 = 1433;
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;
@ -45,6 +46,10 @@ fn sqlserver_endpoint(host: &str) -> SqlServerEndpoint<'_> {
SqlServerEndpoint { host: host.trim(), instance_name: None }
}
fn sqlserver_uses_named_instance_resolution(endpoint: &SqlServerEndpoint<'_>, port: u16, port_explicit: bool) -> bool {
endpoint.instance_name.is_some() && (port == 0 || (port == SQLSERVER_DEFAULT_PORT && !port_explicit))
}
fn query_result_row_limit(max_rows: Option<usize>) -> usize {
max_rows.unwrap_or(MAX_ROWS).max(1)
}
@ -58,13 +63,28 @@ pub async fn connect(
_url_params: Option<&str>,
timeout: Duration,
) -> Result<SqlServerClient, String> {
match try_connect(host, port, user, pass, database, tiberius::EncryptionLevel::Required, timeout).await {
connect_with_port_explicit(host, port, false, user, pass, database, timeout).await
}
pub async fn connect_with_port_explicit(
host: &str,
port: u16,
port_explicit: bool,
user: &str,
pass: &str,
database: Option<&str>,
timeout: Duration,
) -> Result<SqlServerClient, String> {
match try_connect(host, port, port_explicit, 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)
.await
.map_err(|plain_error| {
if is_sqlserver_tls_handshake_error(&encrypted_error) {
format!(
Err(encrypted_error) => {
try_connect_legacy_sqlserver_encryption(host, port, port_explicit, user, pass, database, timeout)
.await
.map_err(|plain_error| {
if is_sqlserver_tls_handshake_error(&encrypted_error) {
format!(
"{encrypted_error}\n\nThis may be caused by an old SQL Server TLS/encryption configuration. \
If you are connecting to SQL Server 2008/2008 R2/2012 or another legacy instance, \
try SQL Server legacy compatibility mode. It first behaves like encrypt=false and, \
@ -73,16 +93,18 @@ pub async fn connect(
or SSH tunnels.\n\n\
Automatic native legacy fallback also failed: {plain_error}"
)
} else {
plain_error
}
}),
} else {
plain_error
}
})
}
}
}
async fn try_connect_legacy_sqlserver_encryption(
host: &str,
port: u16,
port_explicit: bool,
user: &str,
pass: &str,
database: Option<&str>,
@ -90,7 +112,7 @@ async fn try_connect_legacy_sqlserver_encryption(
) -> Result<SqlServerClient, String> {
let mut errors = Vec::new();
for (label, encryption) in SQLSERVER_LEGACY_ENCRYPTION_FALLBACKS {
match try_connect(host, port, user, pass, database, encryption, timeout).await {
match try_connect(host, port, port_explicit, user, pass, database, encryption, timeout).await {
Ok(client) => return Ok(client),
Err(error) => errors.push(format!("{label} failed: {error}")),
}
@ -120,6 +142,7 @@ fn is_sqlserver_tls_handshake_error(error: &str) -> bool {
async fn try_connect(
host: &str,
port: u16,
port_explicit: bool,
user: &str,
pass: &str,
database: Option<&str>,
@ -128,8 +151,9 @@ async fn try_connect(
) -> Result<SqlServerClient, String> {
let mut config = Config::new();
let endpoint = sqlserver_endpoint(host);
let uses_named_instance_resolution = sqlserver_uses_named_instance_resolution(&endpoint, port, port_explicit);
config.host(endpoint.host);
if let Some(instance_name) = endpoint.instance_name {
if let Some(instance_name) = endpoint.instance_name.filter(|_| uses_named_instance_resolution) {
config.instance_name(instance_name);
} else {
config.port(port);
@ -141,7 +165,7 @@ async fn try_connect(
config.trust_cert();
config.encryption(encryption);
let tcp = if endpoint.instance_name.is_some() {
let tcp = if uses_named_instance_resolution {
tokio::time::timeout(timeout, TcpStream::connect_named(&config))
.await
.map_err(|_| format!("SQL Server connection timed out ({}s)", timeout.as_secs()))?
@ -1893,6 +1917,15 @@ mod tests {
);
}
#[test]
fn sqlserver_named_instance_resolution_yields_to_explicit_port() {
let endpoint = super::sqlserver_endpoint(r"db.example.com\SQLEXPRESS");
assert!(super::sqlserver_uses_named_instance_resolution(&endpoint, 0, false));
assert!(super::sqlserver_uses_named_instance_resolution(&endpoint, 1433, false));
assert!(!super::sqlserver_uses_named_instance_resolution(&endpoint, 1433, true));
assert!(!super::sqlserver_uses_named_instance_resolution(&endpoint, 40030, false));
}
#[test]
fn sqlserver_connect_uses_named_instance_resolution() {
let source = include_str!("sqlserver.rs");

View File

@ -1205,6 +1205,17 @@ impl ConnectionConfig {
}
}
pub fn sqlserver_port_explicit(&self) -> bool {
if self.db_type != DatabaseType::SqlServer {
return false;
}
self.external_config
.as_ref()
.and_then(|value| value.get("portExplicit").or_else(|| value.get("port_explicit")))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn message_queue_admin_url(&self) -> String {
self.external_config
.as_ref()

View File

@ -33,6 +33,23 @@ test("parses encoded database URL with password", () => {
assert.equal(draft?.urlParams, "sslmode=require");
});
test("preserves explicit SQL Server default port from nested URLs", () => {
const nested = encodeURIComponent("sqlserver://sa:secret@db.internal:1433/erp");
const draft = parseConnectionDeepLink(`dbx://connection/new?url=${nested}`);
assert.equal(draft?.dbType, "sqlserver");
assert.equal(draft?.port, 1433);
assert.equal(draft?.portExplicit, true);
});
test("marks top-level SQL Server ports explicit for one-time links", () => {
const draft = parseConnectionDeepLink("dbx://connection/new?type=sqlserver&host=db\\instance&port=1433&one_time=1");
assert.equal(draft?.port, 1433);
assert.equal(draft?.portExplicit, true);
assert.equal(draft?.oneTime, true);
});
test("uses the nested database URL name as connection name", () => {
const nested = encodeURIComponent("mysql://root:123456@localhost/?name=%E5%85%AC%E5%8F%B8+-+%E6%9C%AC%E5%9C%B0Docker&charset=utf8mb4");
const draft = parseConnectionDeepLink(`dbx://connection/new?url=${nested}`);

View File

@ -274,6 +274,25 @@ test("parses SQL Server JDBC URLs with semicolon properties", () => {
assert.equal(parsed.password, "s@cret");
assert.equal(parsed.database, "erp");
assert.equal(parsed.urlParams, "encrypt=true");
assert.equal(parsed.portExplicit, true);
});
test("marks explicit SQL Server default port when applying connection URLs", () => {
const parsed = parseConnectionUrl("jdbc:sqlserver://sql.example.com\\SQLEXPRESS:1433;databaseName=erp;user=sa;password=secret");
const applied = applyParsedConnectionUrl({ name: "", db_type: "sqlserver", username: "", password: "" } as any, parsed);
assert.equal(parsed.port, 1433);
assert.equal(parsed.portExplicit, true);
assert.deepEqual(applied.external_config, { portExplicit: true });
});
test("does not mark SQL Server default port explicit when connection URL omits it", () => {
const parsed = parseConnectionUrl("jdbc:sqlserver://sql.example.com\\SQLEXPRESS;databaseName=erp;user=sa;password=secret");
const applied = applyParsedConnectionUrl({ name: "", db_type: "sqlserver", username: "", password: "" } as any, parsed);
assert.equal(parsed.port, 1433);
assert.equal(parsed.portExplicit, undefined);
assert.equal(applied.external_config, undefined);
});
test("parses H2 split JDBC URLs as file connections", () => {