diff --git a/CHANGELOG.md b/CHANGELOG.md index 855e1aa6c0..b810b78372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.6.50 + - Added support for SQL Server named instances with automatic port discovery via SSRP (SQL Server Resolution Protocol). You can now connect using `mssql://user:pass@host/db?instance=SQLEXPRESS` and the port will be automatically discovered. + ## 0.6.49 - Added support for ODBC. SQLx-oldapi can now connect to Oracle, Db2, Snowflake, BigQuery, Databricks, and many other databases, using locally installed ODBC drivers. diff --git a/Cargo.lock b/Cargo.lock index 6fa3d9ddfe..661b294b6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3990,7 +3990,7 @@ dependencies = [ [[package]] name = "sqlx-cli" -version = "0.6.49" +version = "0.6.50" dependencies = [ "anyhow", "async-trait", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "sqlx-core-oldapi" -version = "0.6.49" +version = "0.6.50" dependencies = [ "ahash 0.8.12", "atoi", @@ -4177,7 +4177,7 @@ dependencies = [ [[package]] name = "sqlx-macros-oldapi" -version = "0.6.49" +version = "0.6.50" dependencies = [ "dotenvy", "either", @@ -4197,7 +4197,7 @@ dependencies = [ [[package]] name = "sqlx-oldapi" -version = "0.6.49" +version = "0.6.50" dependencies = [ "anyhow", "async-std", @@ -4225,7 +4225,7 @@ dependencies = [ [[package]] name = "sqlx-rt-oldapi" -version = "0.6.49" +version = "0.6.50" dependencies = [ "async-native-tls", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 8189ed9936..53dbe15d6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ [package] name = "sqlx-oldapi" -version = "0.6.49" +version = "0.6.50" license = "MIT OR Apache-2.0" readme = "README.md" repository = "https://github.com/lovasoa/sqlx" @@ -155,8 +155,8 @@ bstr = ["sqlx-core/bstr"] git2 = ["sqlx-core/git2"] [dependencies] -sqlx-core = { package = "sqlx-core-oldapi", version = "0.6.49", path = "sqlx-core", default-features = false } -sqlx-macros = { package = "sqlx-macros-oldapi", version = "0.6.49", path = "sqlx-macros", default-features = false, optional = true } +sqlx-core = { package = "sqlx-core-oldapi", version = "0.6.50", path = "sqlx-core", default-features = false } +sqlx-macros = { package = "sqlx-macros-oldapi", version = "0.6.50", path = "sqlx-macros", default-features = false, optional = true } [dev-dependencies] anyhow = "1.0.52" diff --git a/README.md b/README.md index 2683c4675c..bb846bb6d6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ > - Multiple bug fixes around string handling, including better support for long strings > - Support for packet chunking, which fixes a bug where large bound parameters or large queries would fail > - Support for TLS encrypted connections +> - Support for named instances with automatic port discovery via SSRP > > The main use case driving the development of sqlx-oldapi is the [SQLPage](https://sql.datapage.app/) SQL-only rapid application building tool. diff --git a/examples/postgres/axum-social-with-tests/Cargo.toml b/examples/postgres/axum-social-with-tests/Cargo.toml index b495844988..08f0e5615e 100644 --- a/examples/postgres/axum-social-with-tests/Cargo.toml +++ b/examples/postgres/axum-social-with-tests/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] # Primary crates axum = { version = "0.5.13", features = ["macros"] } -sqlx = { package = "sqlx-oldapi", version = "0.6.49", path = "../../../", features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] } +sqlx = { package = "sqlx-oldapi", version = "0.6.50", path = "../../../", features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] } tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"] } # Important secondary crates diff --git a/sqlx-bench/Cargo.toml b/sqlx-bench/Cargo.toml index b2ecbd9762..2742424ca0 100644 --- a/sqlx-bench/Cargo.toml +++ b/sqlx-bench/Cargo.toml @@ -33,8 +33,8 @@ sqlite = ["sqlx/sqlite"] criterion = "0.3.3" dotenvy = "0.15.0" once_cell = "1.4" -sqlx = { package = "sqlx-oldapi", version = "0.6.49", path = "../", default-features = false, features = ["macros"] } -sqlx-rt = { package = "sqlx-rt-oldapi", version = "0.6.49", path = "../sqlx-rt", default-features = false } +sqlx = { package = "sqlx-oldapi", version = "0.6.50", path = "../", default-features = false, features = ["macros"] } +sqlx-rt = { package = "sqlx-rt-oldapi", version = "0.6.50", path = "../sqlx-rt", default-features = false } chrono = "0.4.19" diff --git a/sqlx-cli/Cargo.toml b/sqlx-cli/Cargo.toml index 3af987b2eb..bc3c155acb 100644 --- a/sqlx-cli/Cargo.toml +++ b/sqlx-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-cli" -version = "0.6.49" +version = "0.6.50" description = "Command-line utility for SQLx, the Rust SQL toolkit." edition = "2021" readme = "README.md" @@ -28,7 +28,7 @@ path = "src/bin/cargo-sqlx.rs" [dependencies] dotenvy = "0.15.0" tokio = { version = "1.15.0", features = ["macros", "rt", "rt-multi-thread"] } -sqlx = { package = "sqlx-oldapi", version = "0.6.49", path = "..", default-features = false, features = [ +sqlx = { package = "sqlx-oldapi", version = "0.6.50", path = "..", default-features = false, features = [ "migrate", "any", "offline", diff --git a/sqlx-core/Cargo.toml b/sqlx-core/Cargo.toml index d29f2ca65f..709a265e6d 100644 --- a/sqlx-core/Cargo.toml +++ b/sqlx-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-core-oldapi" -version = "0.6.49" +version = "0.6.50" repository = "https://github.com/lovasoa/sqlx" description = "Core of SQLx, the rust SQL toolkit. Not intended to be used directly." license = "MIT OR Apache-2.0" @@ -104,7 +104,7 @@ offline = ["serde", "either/serde"] paste = "1.0.6" ahash = "0.8.3" atoi = "2.0.0" -sqlx-rt = { path = "../sqlx-rt", version = "0.6.49", package = "sqlx-rt-oldapi" } +sqlx-rt = { path = "../sqlx-rt", version = "0.6.50", package = "sqlx-rt-oldapi" } base64 = { version = "0.22", default-features = false, optional = true, features = ["std"] } bigdecimal_ = { version = "0.4.1", optional = true, package = "bigdecimal" } rust_decimal = { version = "1.19.0", optional = true } diff --git a/sqlx-core/src/mssql/connection/mod.rs b/sqlx-core/src/mssql/connection/mod.rs index a3aac85f88..4975fafc99 100644 --- a/sqlx-core/src/mssql/connection/mod.rs +++ b/sqlx-core/src/mssql/connection/mod.rs @@ -14,6 +14,7 @@ use std::sync::Arc; mod establish; mod executor; mod prepare; +mod ssrp; mod stream; mod tls_prelogin_stream_wrapper; diff --git a/sqlx-core/src/mssql/connection/ssrp.rs b/sqlx-core/src/mssql/connection/ssrp.rs new file mode 100644 index 0000000000..c9a9e413bc --- /dev/null +++ b/sqlx-core/src/mssql/connection/ssrp.rs @@ -0,0 +1,229 @@ +use crate::error::Error; +use encoding_rs::WINDOWS_1252; +use sqlx_rt::{timeout, UdpSocket}; +use std::time::Duration; + +const SSRP_PORT: u16 = 1434; +const CLNT_UCAST_INST: u8 = 0x04; +const SVR_RESP: u8 = 0x05; +const SSRP_TIMEOUT: Duration = Duration::from_secs(1); + +struct InstanceInfo<'a> { + server_name: Option<&'a str>, + instance_name: Option<&'a str>, + is_clustered: Option, + version: Option<&'a str>, + tcp_port: Option, +} + +pub(crate) async fn resolve_instance_port(server: &str, instance: &str) -> Result { + log::debug!( + "resolving SQL Server instance port for '{}' on server '{}'", + instance, + server + ); + + let mut request = Vec::with_capacity(1 + instance.len() + 1); + request.push(CLNT_UCAST_INST); + request.extend_from_slice(instance.as_bytes()); + request.push(0); + + let socket = UdpSocket::bind("0.0.0.0:0") + .await + .map_err(|e| err_protocol!("failed to bind UDP socket for SSRP: {}", e))?; + + log::debug!( + "sending SSRP CLNT_UCAST_INST request to {}:{} for instance '{}'", + server, + SSRP_PORT, + instance + ); + + socket + .send_to(&request, (server, SSRP_PORT)) + .await + .map_err(|e| { + err_protocol!( + "failed to send SSRP request to {}:{}: {}", + server, + SSRP_PORT, + e + ) + })?; + + let mut buffer = [0u8; 1024]; + let bytes_read = timeout(SSRP_TIMEOUT, socket.recv(&mut buffer)) + .await + .map_err(|_| { + err_protocol!( + "SSRP request to {} for instance {} timed out after {:?}", + server, + instance, + SSRP_TIMEOUT + ) + })? + .map_err(|e| { + err_protocol!( + "failed to receive SSRP response from {} for instance {}: {}", + server, + instance, + e + ) + })?; + + log::debug!( + "received SSRP response from {} ({} bytes)", + server, + bytes_read + ); + + if bytes_read < 3 { + return Err(err_protocol!( + "SSRP response too short: {} bytes", + bytes_read + )); + } + + if buffer[0] != SVR_RESP { + return Err(err_protocol!( + "invalid SSRP response type: expected 0x05, got 0x{:02x}", + buffer[0] + )); + } + + let response_size = u16::from_le_bytes([buffer[1], buffer[2]]) as usize; + if response_size + 3 > bytes_read { + return Err(err_protocol!( + "SSRP response size mismatch: expected {} bytes, got {}", + response_size + 3, + bytes_read + )); + } + + let response_bytes = &buffer[3..(3 + response_size)]; + let (response_str, _encoding_used, had_errors) = WINDOWS_1252.decode(response_bytes); + + if had_errors { + log::debug!("SSRP response had MBCS decoding errors, continuing anyway"); + } + + log::debug!("SSRP response data: {}", response_str); + + find_instance_tcp_port(&response_str, instance) +} + +fn find_instance_tcp_port(data: &str, instance_name: &str) -> Result { + for instance_data in data.split(";;") { + if instance_data.is_empty() { + continue; + } + + let info = parse_instance_info(instance_data); + + if let Some(name) = info.instance_name { + log::debug!("found instance '{}' in SSRP response", name); + + if name.eq_ignore_ascii_case(instance_name) { + log::debug!( + "instance '{}' matches requested instance '{}'", + name, + instance_name + ); + + if let Some(port) = info.tcp_port { + log::debug!("resolved instance '{}' to port {}", instance_name, port); + return Ok(port); + } else { + return Err(err_protocol!( + "instance '{}' found but no TCP port available", + instance_name + )); + } + } + } + } + + Err(err_protocol!( + "instance '{}' not found in SSRP response", + instance_name + )) +} + +fn parse_instance_info<'a>(data: &'a str) -> InstanceInfo<'a> { + let mut info = InstanceInfo { + server_name: None, + instance_name: None, + is_clustered: None, + version: None, + tcp_port: None, + }; + + let mut tokens = data.split(';'); + while let Some(key) = tokens.next() { + let value = tokens.next(); + + match key { + "ServerName" => info.server_name = value, + "InstanceName" => info.instance_name = value, + "IsClustered" => { + info.is_clustered = value.and_then(|v| match v { + "Yes" => Some(true), + "No" => Some(false), + _ => None, + }); + } + "Version" => info.version = value, + "tcp" => { + info.tcp_port = value.and_then(|v| v.parse::().ok()); + } + _ => { + if !key.is_empty() { + log::debug!("ignoring unknown SSRP key: '{}'", key); + } + } + } + } + + info +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_instance_tcp_port_single_instance() { + let data = "ServerName;MYSERVER;InstanceName;SQLEXPRESS;IsClustered;No;Version;15.0.2000.5;tcp;1433;;"; + let port = find_instance_tcp_port(data, "SQLEXPRESS").unwrap(); + assert_eq!(port, 1433); + } + + #[test] + fn test_find_instance_tcp_port_multiple_instances() { + let data = "ServerName;SRV1;InstanceName;INST1;IsClustered;No;Version;15.0.2000.5;tcp;1433;;ServerName;SRV1;InstanceName;INST2;IsClustered;No;Version;16.0.1000.6;tcp;1434;np;\\\\SRV1\\pipe\\MSSQL$INST2\\sql\\query;;"; + let port = find_instance_tcp_port(data, "INST2").unwrap(); + assert_eq!(port, 1434); + } + + #[test] + fn test_find_instance_tcp_port_case_insensitive() { + let data = "ServerName;MYSERVER;InstanceName;SQLExpress;IsClustered;No;Version;15.0.2000.5;tcp;1433;;"; + let port = find_instance_tcp_port(data, "sqlexpress").unwrap(); + assert_eq!(port, 1433); + } + + #[test] + fn test_find_instance_tcp_port_instance_not_found() { + let data = "ServerName;MYSERVER;InstanceName;SQLEXPRESS;IsClustered;No;Version;15.0.2000.5;tcp;1433;;"; + let result = find_instance_tcp_port(data, "NOTFOUND"); + assert!(result.is_err()); + } + + #[test] + fn test_find_instance_tcp_port_no_tcp_port() { + let data = + "ServerName;MYSERVER;InstanceName;SQLEXPRESS;IsClustered;No;Version;15.0.2000.5;;"; + let result = find_instance_tcp_port(data, "SQLEXPRESS"); + assert!(result.is_err()); + } +} diff --git a/sqlx-core/src/mssql/connection/stream.rs b/sqlx-core/src/mssql/connection/stream.rs index b19c26578d..0911d4d235 100644 --- a/sqlx-core/src/mssql/connection/stream.rs +++ b/sqlx-core/src/mssql/connection/stream.rs @@ -51,7 +51,33 @@ pub(crate) struct MssqlStream { impl MssqlStream { pub(super) async fn connect(options: &MssqlConnectOptions) -> Result { - let tcp_stream = TcpStream::connect((&*options.host, options.port)).await?; + let port = match (options.port, &options.instance) { + (Some(port), _) => { + log::debug!( + "using explicitly specified port {} for host '{}'", + port, + options.host + ); + port + } + (None, Some(instance)) => { + super::ssrp::resolve_instance_port(&options.host, instance).await? + } + (None, None) => { + const DEFAULT_PORT: u16 = 1433; + log::debug!( + "using default port {} for host '{}'", + DEFAULT_PORT, + options.host + ); + DEFAULT_PORT + } + }; + + log::debug!("establishing TCP connection to {}:{}", options.host, port); + let tcp_stream = TcpStream::connect((&*options.host, port)).await?; + log::debug!("TCP connection established to {}:{}", options.host, port); + let wrapped_stream = TlsPreloginWrapper::new(tcp_stream); let inner = BufStream::new(MaybeTlsStream::Raw(wrapped_stream)); diff --git a/sqlx-core/src/mssql/options/mod.rs b/sqlx-core/src/mssql/options/mod.rs index 3e90e070e1..4d7f97ba63 100644 --- a/sqlx-core/src/mssql/options/mod.rs +++ b/sqlx-core/src/mssql/options/mod.rs @@ -10,12 +10,27 @@ mod parse; /// /// Connection strings should be in the form: /// ```text -/// mssql://[username[:password]@]host/database[?instance=instance_name&packet_size=packet_size&client_program_version=client_program_version&client_pid=client_pid&hostname=hostname&app_name=app_name&server_name=server_name&client_interface_name=client_interface_name&language=language] +/// mssql://[username[:password]@]host[:port]/database[?param1=value1¶m2=value2...] +/// ``` +/// +/// Port resolution priority: +/// 1. If an explicit port is specified, it is always used +/// 2. If a named instance is specified via `?instance=NAME`, the port is discovered via SSRP +/// 3. Otherwise, the default port 1433 is used +/// +/// Example with named instance (port auto-discovered): +/// ```text +/// mssql://user:pass@localhost/mydb?instance=SQLEXPRESS +/// ``` +/// +/// Example with explicit port (SSRP not used): +/// ```text +/// mssql://user:pass@localhost:1434/mydb?instance=SQLEXPRESS /// ``` #[derive(Debug, Clone)] pub struct MssqlConnectOptions { pub(crate) host: String, - pub(crate) port: u16, + pub(crate) port: Option, pub(crate) username: String, pub(crate) database: String, pub(crate) password: Option, @@ -45,7 +60,7 @@ impl Default for MssqlConnectOptions { impl MssqlConnectOptions { pub fn new() -> Self { Self { - port: 1433, + port: None, host: String::from("localhost"), database: String::from("master"), username: String::from("sa"), @@ -73,7 +88,7 @@ impl MssqlConnectOptions { } pub fn port(mut self, port: u16) -> Self { - self.port = port; + self.port = Some(port); self } diff --git a/sqlx-core/src/mssql/options/parse.rs b/sqlx-core/src/mssql/options/parse.rs index fb6d921c98..b1c7cb79a7 100644 --- a/sqlx-core/src/mssql/options/parse.rs +++ b/sqlx-core/src/mssql/options/parse.rs @@ -19,11 +19,11 @@ impl FromStr for MssqlConnectOptions { /// - `username`: The username for SQL Server authentication. /// - `password`: The password for SQL Server authentication. /// - `host`: The hostname or IP address of the SQL Server. - /// - `port`: The port number (default is 1433). + /// - `port`: The port number. If not specified, defaults to 1433 or is discovered via SSRP when using named instances. /// - `database`: The name of the database to connect to. /// /// Supported query parameters: - /// - `instance`: SQL Server named instance. + /// - `instance`: SQL Server named instance. When specified without an explicit port, the port is automatically discovered using the SQL Server Resolution Protocol (SSRP). If a port is explicitly specified, SSRP is not used. /// - `encrypt`: Controls connection encryption: /// - `strict`: Requires encryption and validates the server certificate. /// - `mandatory` or `true` or `yes`: Requires encryption but doesn't validate the server certificate. @@ -41,9 +41,10 @@ impl FromStr for MssqlConnectOptions { /// - `client_interface_name`: Name of the client interface, sent to the server for logging purposes. /// - `language`: Sets the language for server messages. Affects date formats and system messages. /// - /// Example: + /// Examples: /// ```text /// mssql://user:pass@localhost:1433/mydb?encrypt=strict&app_name=MyApp&packet_size=4096 + /// mssql://user:pass@localhost/mydb?instance=SQLEXPRESS /// ``` fn from_str(s: &str) -> Result { let url: Url = s.parse().map_err(Error::config)?; diff --git a/sqlx-macros/Cargo.toml b/sqlx-macros/Cargo.toml index a41ae7ab5a..5b3ffc6307 100644 --- a/sqlx-macros/Cargo.toml +++ b/sqlx-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-macros-oldapi" -version = "0.6.49" +version = "0.6.50" repository = "https://github.com/lovasoa/sqlx" description = "Macros for SQLx, the rust SQL toolkit. Not intended to be used directly." license = "MIT OR Apache-2.0" @@ -75,8 +75,8 @@ heck = { version = "0.5" } either = "1.6.1" once_cell = "1.9.0" proc-macro2 = { version = "1.0.36", default-features = false } -sqlx-core = { package = "sqlx-core-oldapi", version = "0.6.49", default-features = false, features = ["any", "aws_lc_rs", "tls12"], path = "../sqlx-core" } -sqlx-rt = { version = "0.6.49", default-features = false, path = "../sqlx-rt", package = "sqlx-rt-oldapi", features = ["aws_lc_rs"] } +sqlx-core = { package = "sqlx-core-oldapi", version = "0.6.50", default-features = false, features = ["any", "aws_lc_rs", "tls12"], path = "../sqlx-core" } +sqlx-rt = { version = "0.6.50", default-features = false, path = "../sqlx-rt", package = "sqlx-rt-oldapi", features = ["aws_lc_rs"] } serde = { version = "1.0.132", features = ["derive"], optional = true } serde_json = { version = "1.0.73", optional = true } sha2 = { version = "0.10.0", optional = true } diff --git a/sqlx-rt/Cargo.toml b/sqlx-rt/Cargo.toml index 3aeea52084..1aaf3762a8 100644 --- a/sqlx-rt/Cargo.toml +++ b/sqlx-rt/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-rt-oldapi" -version = "0.6.49" +version = "0.6.50" repository = "https://github.com/launchbadge/sqlx" license = "MIT OR Apache-2.0" description = "Runtime abstraction used by SQLx, the Rust SQL toolkit. Not intended to be used directly." diff --git a/sqlx-rt/src/rt_async_std.rs b/sqlx-rt/src/rt_async_std.rs index e8ccb49849..dfced0520d 100644 --- a/sqlx-rt/src/rt_async_std.rs +++ b/sqlx-rt/src/rt_async_std.rs @@ -1,8 +1,8 @@ pub use async_std::{ self, fs, future::timeout, io::prelude::ReadExt as AsyncReadExt, io::prelude::WriteExt as AsyncWriteExt, io::Read as AsyncRead, io::Write as AsyncWrite, - net::TcpStream, sync::Mutex as AsyncMutex, task::sleep, task::spawn, task::spawn_blocking, - task::yield_now, + net::TcpStream, net::UdpSocket, sync::Mutex as AsyncMutex, task::sleep, task::spawn, + task::spawn_blocking, task::yield_now, }; #[cfg(unix)] diff --git a/sqlx-rt/src/rt_tokio.rs b/sqlx-rt/src/rt_tokio.rs index 72b2cbb27b..615cbc2d19 100644 --- a/sqlx-rt/src/rt_tokio.rs +++ b/sqlx-rt/src/rt_tokio.rs @@ -1,7 +1,7 @@ pub use tokio::{ self, fs, io::AsyncRead, io::AsyncReadExt, io::AsyncWrite, io::AsyncWriteExt, io::ReadBuf, - net::TcpStream, runtime::Handle, sync::Mutex as AsyncMutex, task::spawn, task::yield_now, - time::sleep, time::timeout, + net::TcpStream, net::UdpSocket, runtime::Handle, sync::Mutex as AsyncMutex, task::spawn, + task::yield_now, time::sleep, time::timeout, }; #[cfg(unix)]