diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7443ffd19..762f57e65 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -38,7 +38,10 @@ jobs: # ubuntu:25.10 is minimal — git for Swatinem/rust-cache key # generation, curl + ca-certificates for rust-toolchain's # rustup install, plus the GTK / libadwaita / sourceview / - # OpenSSL / libsecret -dev packages the workspace links. + # OpenSSL / libsecret -dev packages the workspace links, plus + # libkrb5-dev + clang for the MSSQL driver's integrated + # (Kerberos/GSSAPI) auth (libgssapi-sys links gssapi_krb5 and + # runs bindgen). # No sudo (container runs as root by default). run: | apt-get update @@ -52,7 +55,9 @@ jobs: libadwaita-1-dev \ libgtksourceview-5-dev \ libssl-dev \ - libsecret-1-dev + libsecret-1-dev \ + libkrb5-dev \ + clang - uses: dtolnay/rust-toolchain@1.93 with: components: rustfmt, clippy @@ -65,8 +70,10 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Build run: cargo build --workspace + # --bins matters: tablepro-app has no lib target, so --lib alone + # skips every test in the app crate. - name: Unit tests - run: cargo test --workspace --lib + run: cargo test --workspace --lib --bins integration: name: Driver integration tests (docker) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index a1270c316..486e0b8b8 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -336,6 +336,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -582,6 +602,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.20.7" @@ -651,6 +680,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1605,6 +1645,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "gobject-sys" version = "0.22.0" @@ -2214,6 +2260,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2369,6 +2424,38 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "libgssapi" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546" +dependencies = [ + "bitflags", + "bytes", + "lazy_static", + "libgssapi-sys", +] + +[[package]] +name = "libgssapi-sys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" +dependencies = [ + "bindgen", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2499,6 +2586,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2532,6 +2625,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3137,7 +3240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4639,7 +4742,7 @@ dependencies = [ "ferroid", "futures", "http", - "itertools", + "itertools 0.14.0", "log", "memchr", "parse-display", @@ -4715,8 +4818,7 @@ dependencies = [ [[package]] name = "tiberius" version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +source = "git+https://github.com/prisma/tiberius?rev=a6b4fcdae0de5702427290b89f8d05bc51f3bcfa#a6b4fcdae0de5702427290b89f8d05bc51f3bcfa" dependencies = [ "async-trait", "asynchronous-codec", @@ -4727,6 +4829,7 @@ dependencies = [ "encoding_rs", "enumflags2", "futures-util", + "libgssapi", "num-traits", "once_cell", "pin-project-lite", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 27aa71c5e..b444a8ec1 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -30,7 +30,9 @@ secrecy = { version = "0.10", features = ["serde"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "chrono", "rust_decimal", "uuid", "json"] } -tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal"] } +# `integrated-auth-gssapi` (Windows integrated auth) links MIT Kerberos +# (libkrb5) and runs bindgen (libclang) at build time. +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal", "integrated-auth-gssapi"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rust_decimal = { version = "1", default-features = false, features = ["serde", "std"] } futures = "0.3" @@ -49,3 +51,14 @@ relm4 = { version = "0.11", default-features = false, features = ["macros", "lib lto = "fat" codegen-units = 1 strip = "symbols" + +# tiberius 0.12.3 (its latest release) pins libgssapi ^0.4.5. libgssapi 0.4.6's +# `Buf::deref` calls `slice::from_raw_parts(NULL, 0)` on the empty token a +# successful Kerberos handshake returns -- undefined behavior that aborts every +# debug build (`cargo run`). tiberius already fixed this on `main` (merged PR +# prisma/tiberius#372: bump libgssapi 0.4.5 -> 0.8.x, whose deref is guarded) +# but has not cut a release; the bug is tracked open as prisma/tiberius#343. +# Pin the fixed commit until a release lands, then drop this and bump the +# `tiberius` version above to the release. +[patch.crates-io] +tiberius = { git = "https://github.com/prisma/tiberius", rev = "a6b4fcdae0de5702427290b89f8d05bc51f3bcfa" } diff --git a/linux/README.md b/linux/README.md index bd8467afd..79628c00f 100644 --- a/linux/README.md +++ b/linux/README.md @@ -33,13 +33,13 @@ System dependencies: ```bash # Ubuntu / Debian -sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libssl-dev libsecret-1-dev +sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libssl-dev libsecret-1-dev libkrb5-dev clang # Fedora -sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel openssl-devel libsecret-devel +sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel openssl-devel libsecret-devel krb5-devel clang # Arch -sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita openssl libsecret +sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita openssl libsecret krb5 clang ``` Verify the right versions are present: @@ -56,6 +56,31 @@ cd linux cargo run -p tablepro-app ``` +`libkrb5-dev` and `clang` are there for the SQL Server driver's Windows +integrated auth, which links MIT Kerberos and runs bindgen at build +time. + +## SQL Server with Windows integrated auth + +Pick **Method → Windows (Kerberos)** in the connect dialog. There is no +username or password to enter: the driver uses whatever ticket `klist` +shows, so get one first. + +```bash +kinit you@EXAMPLE.COM +``` + +The driver asks for `MSSQLSvc/:`, built from the host and +port you typed, not from an SSH tunnel's local forward. Two things are +worth knowing: + +- tiberius imports that SPN as a raw Kerberos principal, so it resolves + in your *default* realm. When the service lives in another realm, set + `default_realm` in `/etc/krb5.conf` and map the host with + `[domain_realm]` (plus `[capaths]` for a cross-realm trust). +- The host has to match the SPN registered on the server. An IP address + or a CNAME usually does not. + ## Documentation index | Topic | File | diff --git a/linux/crates/app/src/services/connection_service.rs b/linux/crates/app/src/services/connection_service.rs index 27645a594..c7578ffa4 100644 --- a/linux/crates/app/src/services/connection_service.rs +++ b/linux/crates/app/src/services/connection_service.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use secrecy::SecretString; -use tablepro_core::{ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, TableInfo}; +use tablepro_core::{AuthMode, ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, TableInfo}; use tablepro_ssh::{SshConfig, SshTunnel}; use tablepro_storage::{SavedConnection, SavedSshAuth, load_password, load_ssh_passphrase, load_ssh_password}; @@ -11,11 +11,16 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - let driver = registry .get(&saved.driver_id) .ok_or_else(|| format!("driver {} not registered", saved.driver_id))?; - let password = load_password(saved.id) - .await - .ok() - .flatten() - .unwrap_or_else(|| SecretString::new(String::new().into())); + // Kerberos authenticates from the ambient ticket cache, so there is + // no stored secret to read back from the keyring. + let password = match saved.auth_mode { + AuthMode::Kerberos => SecretString::new(String::new().into()), + AuthMode::Password => load_password(saved.id) + .await + .ok() + .flatten() + .unwrap_or_else(|| SecretString::new(String::new().into())), + }; let id = saved.id; let ssh_cfg = match &saved.ssh { @@ -30,6 +35,8 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - username: saved.username, password, use_tls: saved.use_tls, + auth_mode: saved.auth_mode, + service_endpoint: None, }; let (conn, tunnel) = establish(&*driver, opts.clone(), ssh_cfg.clone(), saved.read_only).await?; @@ -58,9 +65,13 @@ pub async fn establish( let tunnel = if let Some(cfg) = ssh { let remote_host = std::mem::take(&mut opts.host); let remote_port = opts.port; - let tun = SshTunnel::open(cfg, remote_host, remote_port) + let tun = SshTunnel::open(cfg, remote_host.clone(), remote_port) .await .map_err(|e| format!("ssh: {e}"))?; + // The socket now points at the local forward, so remember what + // the service is actually called; without this Kerberos would + // ask the KDC for MSSQLSvc/127.0.0.1:. + opts.service_endpoint = Some((remote_host, remote_port)); opts.host = tun.local_host().to_string(); opts.port = tun.local_port(); Some(tun) diff --git a/linux/crates/app/src/ui/connect_dialog.rs b/linux/crates/app/src/ui/connect_dialog.rs index c48f29f0f..8f75027a9 100644 --- a/linux/crates/app/src/ui/connect_dialog.rs +++ b/linux/crates/app/src/ui/connect_dialog.rs @@ -6,7 +6,7 @@ use relm4::{adw, gtk}; use secrecy::{ExposeSecret, SecretString}; use uuid::Uuid; -use tablepro_core::{ConnectOptions, DriverRegistry, TableInfo}; +use tablepro_core::{AuthMode, ConnectOptions, DriverRegistry, TableInfo}; use tablepro_storage::{ SavedConnection, SavedSshConfig, save_connections, store_password, store_ssh_passphrase, store_ssh_password, }; @@ -24,6 +24,7 @@ pub struct ConnectDialog { database: adw::EntryRow, username: adw::EntryRow, password: adw::PasswordEntryRow, + auth_combo: adw::ComboRow, use_tls: adw::SwitchRow, read_only: adw::SwitchRow, auth_group: adw::PreferencesGroup, @@ -31,6 +32,7 @@ pub struct ConnectDialog { test_button: gtk::Button, submit: gtk::Button, toast_overlay: adw::ToastOverlay, + form: AuthFormState, } #[derive(Debug, Clone)] @@ -39,6 +41,42 @@ struct DriverEntry { display_name: String, } +/// Row index of the Kerberos entry in the auth-method model. +const KERBEROS_ROW: u32 = 1; + +/// What the selected driver allows, kept beside the widgets so the form +/// never reads its own visibility flags back to work out the mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct AuthFormState { + file_based: bool, + supports_integrated: bool, + kerberos_selected: bool, +} + +impl AuthFormState { + /// A stale Kerberos selection left over from another driver + /// resolves back to password auth instead of leaking across the + /// switch. + fn mode(self) -> AuthMode { + if !self.file_based && self.supports_integrated && self.kerberos_selected { + AuthMode::Kerberos + } else { + AuthMode::Password + } + } + + /// The method selector is noise for a driver with only one method. + fn shows_method(self) -> bool { + !self.file_based && self.supports_integrated + } + + /// Kerberos reads the ambient ticket cache, so the credential rows + /// have nothing to collect. + fn shows_credentials(self) -> bool { + !self.file_based && self.mode() == AuthMode::Password + } +} + pub struct ConnectDialogInit { pub registry: Arc, } @@ -48,6 +86,7 @@ pub enum ConnectDialogInput { DriverChanged(u32), SshToggled, SshAuthChanged, + AuthModeChanged, Submit, TestConnection, InputChanged, @@ -187,9 +226,22 @@ impl Component for ConnectDialog { connection_group.add(&port); connection_group.add(&database); + let auth_password_label = crate::tr!("Password"); + let auth_kerberos_label = crate::tr!("Windows (Kerberos)"); + let auth_mode_model = gtk::StringList::new(&[auth_password_label.as_str(), auth_kerberos_label.as_str()]); + let auth_combo = adw::ComboRow::builder() + .title(crate::tr!("Method")) + .model(&auth_mode_model) + .build(); + let sender_for_authmode = sender.clone(); + auth_combo.connect_selected_notify(move |_| { + sender_for_authmode.input(ConnectDialogInput::AuthModeChanged); + }); + let auth_group = adw::PreferencesGroup::builder() .title(crate::tr!("Authentication")) .build(); + auth_group.add(&auth_combo); auth_group.add(&username); auth_group.add(&password); @@ -218,7 +270,7 @@ impl Component for ConnectDialog { let toast_overlay = adw::ToastOverlay::new(); toast_overlay.set_child(Some(&page)); - let model = ConnectDialog { + let mut model = ConnectDialog { registry: init.registry, drivers: drivers.clone(), driver_combo, @@ -227,6 +279,7 @@ impl Component for ConnectDialog { database, username, password, + auth_combo, use_tls, read_only, auth_group, @@ -234,6 +287,7 @@ impl Component for ConnectDialog { test_button, submit, toast_overlay, + form: AuthFormState::default(), }; let widgets = view_output!(); @@ -258,7 +312,7 @@ impl Component for ConnectDialog { fn update(&mut self, msg: Self::Input, sender: ComponentSender, root: &Self::Root) { match msg { ConnectDialogInput::DriverChanged(idx) => { - let Some(entry) = self.drivers.get(idx as usize) else { + let Some(entry) = self.drivers.get(idx as usize).cloned() else { return; }; if let Some(driver) = self.registry.get(&entry.id) { @@ -266,6 +320,7 @@ impl Component for ConnectDialog { self.port.set_value(driver.default_port() as f64); } root.set_title(&crate::tr!("Connect to {name}").replace("{name}", &entry.display_name)); + self.refresh_validity(); } ConnectDialogInput::SshToggled => { @@ -277,6 +332,12 @@ impl Component for ConnectDialog { self.refresh_validity(); } + ConnectDialogInput::AuthModeChanged => { + self.form.kerberos_selected = self.auth_combo.selected() == KERBEROS_ROW; + self.apply_form_state(); + self.refresh_validity(); + } + ConnectDialogInput::InputChanged => { self.refresh_validity(); } @@ -300,17 +361,13 @@ impl Component for ConnectDialog { } }; - let opts = ConnectOptions { - host: self.host.text().to_string(), - port: self.port.value() as u16, - database: self.database.text().to_string(), - username: self.username.text().to_string(), - password: SecretString::new(self.password.text().to_string().into()), - use_tls: self.use_tls.is_active(), - }; + let opts = self.collect_options(); + // A Kerberos entry has no username to name it after. let label = if entry.id == "sqlite" { opts.database.clone() + } else if opts.auth_mode == AuthMode::Kerberos { + opts.host.clone() } else { format!("{}@{}", opts.username, opts.host) }; @@ -355,14 +412,7 @@ impl Component for ConnectDialog { self.show_toast(&crate::tr!("Driver {id} not registered").replace("{id}", &entry.id)); return; }; - let opts = ConnectOptions { - host: self.host.text().to_string(), - port: self.port.value() as u16, - database: self.database.text().to_string(), - username: self.username.text().to_string(), - password: SecretString::new(self.password.text().to_string().into()), - use_tls: self.use_tls.is_active(), - }; + let opts = self.collect_options(); let ssh_inputs = if self.ssh.is_enabled() { match self.ssh.collect() { Ok(inputs) => Some(inputs.cfg), @@ -431,11 +481,11 @@ impl ConnectDialog { let database_empty = self.database.text().trim().is_empty(); toggle_error(&self.database, database_empty); - let host_required = self.host.is_visible(); + let host_required = !self.form.file_based; let host_empty = host_required && self.host.text().trim().is_empty(); toggle_error(&self.host, host_empty); - let username_required = self.username.is_visible(); + let username_required = self.form.shows_credentials(); let username_empty = username_required && self.username.text().trim().is_empty(); toggle_error(&self.username, username_empty); @@ -448,11 +498,11 @@ impl ConnectDialog { if self.database.text().trim().is_empty() { return false; } - if self.host.is_visible() { + if !self.form.file_based { if self.host.text().trim().is_empty() { return false; } - if self.username.text().trim().is_empty() { + if self.form.shows_credentials() && self.username.text().trim().is_empty() { return false; } } @@ -462,24 +512,53 @@ impl ConnectDialog { true } - fn apply_driver_form_visibility(&self, driver: &dyn tablepro_core::DatabaseDriver) { - let file_based = driver.is_file_based(); - self.host.set_visible(!file_based); - self.port.set_visible(!file_based); - self.username.set_visible(!file_based); - self.password.set_visible(!file_based); - self.use_tls.set_visible(!file_based); - // For file-based drivers (SQLite), only Connection + Options - // groups make sense; hide Authentication and SSH entirely. - self.auth_group.set_visible(!file_based); - self.ssh.set_visible(!file_based); - self.database.set_title(&if file_based { + fn apply_driver_form_visibility(&mut self, driver: &dyn tablepro_core::DatabaseDriver) { + self.form.file_based = driver.is_file_based(); + self.form.supports_integrated = driver.supports_integrated_auth(); + self.apply_form_state(); + self.database.set_title(&if self.form.file_based { crate::tr!("File path") } else { crate::tr!("Database") }); } + fn apply_form_state(&self) { + let network = !self.form.file_based; + self.host.set_visible(network); + self.port.set_visible(network); + self.use_tls.set_visible(network); + // For file-based drivers (SQLite), only Connection + Options + // groups make sense; hide Authentication and SSH entirely. + self.auth_group.set_visible(network); + self.ssh.set_visible(network); + self.auth_combo.set_visible(self.form.shows_method()); + let credentials = self.form.shows_credentials(); + self.username.set_visible(credentials); + self.password.set_visible(credentials); + } + + fn collect_options(&self) -> ConnectOptions { + // Kerberos ignores the credential rows, so whatever the user + // typed before switching modes never reaches the driver or the + // keyring. + let (username, password) = if self.form.shows_credentials() { + (self.username.text().to_string(), self.password.text().to_string()) + } else { + (String::new(), String::new()) + }; + ConnectOptions { + host: self.host.text().to_string(), + port: self.port.value() as u16, + database: self.database.text().to_string(), + username, + password: SecretString::new(password.into()), + use_tls: self.use_tls.is_active(), + auth_mode: self.form.mode(), + service_endpoint: None, + } + } + fn show_toast(&self, message: &str) { self.toast_overlay.add_toast(adw::Toast::new(message)); } @@ -551,6 +630,7 @@ async fn run_connect( username: opts_clone.username.clone(), use_tls: opts_clone.use_tls, read_only, + auth_mode: opts_clone.auth_mode, ssh: ssh.as_ref().map(|s| s.saved.clone()), // Stays None until `App::on_connected` stamps it. Save then // connect arrives in that order, so a freshly-saved entry is @@ -559,7 +639,11 @@ async fn run_connect( }; save_one(&saved).await.map_err(|e| format!("save: {e}"))?; - let _ = store_password(saved.id, stored_password.expose_secret(), &label).await; + // Kerberos has no secret of ours to keep; writing one would leave an + // unreachable credential in the keyring. + if saved.auth_mode == AuthMode::Password { + let _ = store_password(saved.id, stored_password.expose_secret(), &label).await; + } if let Some(s) = &ssh { match &s.secret_to_store { SshSecretToStore::Password(p) => { @@ -604,6 +688,7 @@ async fn find_existing_id(driver_id: &str, opts: &ConnectOptions, ssh: Option<&S && c.port == opts.port && c.database == opts.database && c.username == opts.username + && c.auth_mode == opts.auth_mode && saved_ssh_matches(&c.ssh, ssh) }) .map(|c| c.id) @@ -616,3 +701,64 @@ fn saved_ssh_matches(saved: &Option, current: Option<&SshInputs> _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// (state, mode, shows_method, shows_credentials) + #[test] + fn auth_form_state_drives_mode_and_visibility() { + let cases = [ + (AuthFormState::default(), AuthMode::Password, false, true), + // MSSQL: offers the selector, password until Kerberos is picked. + ( + AuthFormState { + file_based: false, + supports_integrated: true, + kerberos_selected: false, + }, + AuthMode::Password, + true, + true, + ), + ( + AuthFormState { + file_based: false, + supports_integrated: true, + kerberos_selected: true, + }, + AuthMode::Kerberos, + true, + false, + ), + // Postgres: a stale Kerberos selection does not survive the switch. + ( + AuthFormState { + file_based: false, + supports_integrated: false, + kerberos_selected: true, + }, + AuthMode::Password, + false, + true, + ), + // SQLite: no credentials at all. + ( + AuthFormState { + file_based: true, + supports_integrated: true, + kerberos_selected: true, + }, + AuthMode::Password, + false, + false, + ), + ]; + for (state, mode, method, credentials) in cases { + assert_eq!(state.mode(), mode, "{state:?}"); + assert_eq!(state.shows_method(), method, "{state:?}"); + assert_eq!(state.shows_credentials(), credentials, "{state:?}"); + } + } +} diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 6cf450e9e..432da9680 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -1,9 +1,25 @@ use async_trait::async_trait; use secrecy::SecretString; +use serde::{Deserialize, Serialize}; use crate::error::DriverError; use crate::query::{ColumnInfo, ExecResult, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, Value}; +/// How a driver authenticates to the database. Most drivers only +/// support [`AuthMode::Password`]; the SQL Server driver also supports +/// [`AuthMode::Kerberos`] (Windows integrated auth) using the current +/// user's Kerberos ticket cache obtained via `kinit`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMode { + /// Username + password authentication. + #[default] + Password, + /// Windows integrated authentication over Kerberos (GSSAPI), using + /// the ambient ticket cache. `username`/`password` are ignored. + Kerberos, +} + #[derive(Debug, Clone)] pub struct ConnectOptions { pub host: String, @@ -12,6 +28,20 @@ pub struct ConnectOptions { pub username: String, pub password: SecretString, pub use_tls: bool, + pub auth_mode: AuthMode, + /// Set only when `host`/`port` were replaced by a tunnel's local + /// forward. `None` means the socket already points at the service. + pub service_endpoint: Option<(String, u16)>, +} + +impl ConnectOptions { + /// Host and port the service answers to, which is `host`/`port` + /// unless a tunnel replaced them. + pub fn service_address(&self) -> (&str, u16) { + self.service_endpoint + .as_ref() + .map_or((self.host.as_str(), self.port), |(host, port)| (host.as_str(), *port)) + } } impl Default for ConnectOptions { @@ -23,6 +53,8 @@ impl Default for ConnectOptions { username: String::new(), password: SecretString::new(String::new().into()), use_tls: false, + auth_mode: AuthMode::Password, + service_endpoint: None, } } } @@ -82,3 +114,26 @@ pub trait Connection: Send + Sync { async fn ping(&self) -> Result<(), DriverError>; async fn close(self: Box) -> Result<(), DriverError>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_address_prefers_the_tunnelled_service_over_the_socket() { + let direct = ConnectOptions { + host: "sql.corp.example".into(), + port: 1433, + ..Default::default() + }; + assert_eq!(direct.service_address(), ("sql.corp.example", 1433)); + + let tunnelled = ConnectOptions { + host: "127.0.0.1".into(), + port: 54321, + service_endpoint: Some(("sql.corp.example".into(), 1433)), + ..Default::default() + }; + assert_eq!(tunnelled.service_address(), ("sql.corp.example", 1433)); + } +} diff --git a/linux/crates/core/src/driver.rs b/linux/crates/core/src/driver.rs index c98e26383..ee8c3b8e4 100644 --- a/linux/crates/core/src/driver.rs +++ b/linux/crates/core/src/driver.rs @@ -24,5 +24,13 @@ pub trait DatabaseDriver: Send + Sync { false } + /// Whether this driver supports Windows integrated / Kerberos + /// authentication (`ConnectOptions::auth_mode == AuthMode::Kerberos`). + /// The connect dialog only shows the auth-mode selector for drivers + /// returning `true`. Default `false`. + fn supports_integrated_auth(&self) -> bool { + false + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError>; } diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index e728b4577..c36bdd842 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -8,7 +8,7 @@ mod registry; pub mod sql_ddl; pub mod sql_dialect; -pub use connection::{ConnectOptions, Connection}; +pub use connection::{AuthMode, ConnectOptions, Connection}; pub use driver::DatabaseDriver; pub use error::DriverError; pub use filter::{BuildFilterError, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, build_filter_where}; diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index a518d5a9a..6cd00c059 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -12,8 +12,8 @@ use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tablepro_core::sql_dialect::build_order_and_pagination; use tablepro_core::{ - ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo, - MAX_QUERY_ROWS, QueryResult, TableInfo, Value, + AuthMode, ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, + IndexInfo, MAX_QUERY_ROWS, QueryResult, TableInfo, Value, }; type MssqlClient = Client>; @@ -42,12 +42,28 @@ impl DatabaseDriver for MssqlDriver { true } + fn supports_integrated_auth(&self) -> bool { + true + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { + // tiberius derives the Kerberos SPN and the TLS server name from + // the configured host/port, while the socket is opened here. An + // SSH tunnel replaces `opts.host`/`opts.port` with a local + // forward, so the two have to come from different places: + // `service_address()` names the server, `opts.host` reaches it. + let (service_host, service_port) = opts.service_address(); let mut config = Config::new(); - config.host(&opts.host); - config.port(opts.port); + config.host(service_host); + config.port(service_port); config.database(&opts.database); - config.authentication(AuthMethod::sql_server(&opts.username, opts.password.expose_secret())); + // `Integrated` reads the ambient Kerberos ticket cache (from + // `kinit`) and targets MSSQLSvc/:; username + // and password are ignored. + config.authentication(match opts.auth_mode { + AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), + AuthMode::Kerberos => AuthMethod::Integrated, + }); // SQL Server always encrypts the login exchange; `Off` keeps the // post-login stream in the clear, `Required` encrypts everything. // No cert-path UI exists, so the server certificate is trusted @@ -66,7 +82,9 @@ impl DatabaseDriver for MssqlDriver { // failure arrives on the same scale as the sqlx drivers' // acquire_timeout. tokio::time::timeout(CONNECT_TIMEOUT, async { - let tcp = TcpStream::connect(config.get_addr()).await.map_err(map_io_error)?; + let tcp = TcpStream::connect((opts.host.as_str(), opts.port)) + .await + .map_err(map_io_error)?; tcp.set_nodelay(true).map_err(map_io_error)?; Client::connect(config, tcp.compat_write()) .await @@ -681,6 +699,7 @@ mod tests { assert_eq!(d.display_name(), "SQL Server"); assert_eq!(d.default_port(), 1433); assert!(!d.is_file_based()); + assert!(d.supports_integrated_auth()); } #[test] diff --git a/linux/crates/drivers/mssql/tests/integration.rs b/linux/crates/drivers/mssql/tests/integration.rs index a919410aa..f505b25f0 100644 --- a/linux/crates/drivers/mssql/tests/integration.rs +++ b/linux/crates/drivers/mssql/tests/integration.rs @@ -25,6 +25,7 @@ async fn start_mssql() -> (ContainerAsync, ConnectOptions) { username: "sa".into(), password: SecretString::new(MssqlServer::DEFAULT_SA_PASSWORD.to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/mysql/tests/integration.rs b/linux/crates/drivers/mysql/tests/integration.rs index ac6cb3e1f..e19486b46 100644 --- a/linux/crates/drivers/mysql/tests/integration.rs +++ b/linux/crates/drivers/mysql/tests/integration.rs @@ -27,6 +27,7 @@ async fn start_mysql() -> (ContainerAsync, ConnectOptions) { username: "root".into(), password: secrecy::SecretString::new("tablepro_test".to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/postgres/tests/integration.rs b/linux/crates/drivers/postgres/tests/integration.rs index a47d08bc5..077bda999 100644 --- a/linux/crates/drivers/postgres/tests/integration.rs +++ b/linux/crates/drivers/postgres/tests/integration.rs @@ -32,6 +32,7 @@ async fn start_pg() -> (ContainerAsync, ConnectOptions) { username: "postgres".into(), password: secrecy::SecretString::new("postgres".to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index e2473483b..0da3a0e42 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use tablepro_core::AuthMode; use uuid::Uuid; use crate::error::StorageError; @@ -20,6 +21,8 @@ pub struct SavedConnection { pub use_tls: bool, #[serde(default)] pub read_only: bool, + #[serde(default)] + pub auth_mode: AuthMode, #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, /// Last successful open of this connection. Drives the welcome @@ -151,6 +154,7 @@ mod tests { username: "postgres".into(), use_tls: false, read_only: false, + auth_mode: AuthMode::Password, ssh: None, last_opened_at: None, } @@ -228,4 +232,20 @@ mod tests { let loaded = load_from(&path).await.unwrap(); assert_eq!(loaded, vec![conn]); } + + #[tokio::test] + async fn auth_mode_defaults_to_password_on_a_legacy_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("connections.json"); + let id = Uuid::new_v4(); + let legacy = format!( + r#"{{"version":1,"connections":[{{ + "id":"{id}","name":"Old","driver_id":"mssql", + "host":"localhost","port":1433,"database":"db", + "username":"sa","use_tls":false}}]}}"# + ); + tokio::fs::write(&path, legacy).await.unwrap(); + let loaded = load_from(&path).await.unwrap(); + assert_eq!(loaded[0].auth_mode, AuthMode::Password); + } } diff --git a/linux/flatpak/com.tablepro.linux.json b/linux/flatpak/com.tablepro.linux.json index 9dde351f2..bfa6b3ec8 100644 --- a/linux/flatpak/com.tablepro.linux.json +++ b/linux/flatpak/com.tablepro.linux.json @@ -4,7 +4,8 @@ "runtime-version": "47", "sdk": "org.gnome.Sdk", "sdk-extensions": [ - "org.freedesktop.Sdk.Extension.rust-stable" + "org.freedesktop.Sdk.Extension.rust-stable", + "org.freedesktop.Sdk.Extension.llvm18" ], "command": "tablepro-app", "finish-args": [ @@ -17,9 +18,11 @@ "--talk-name=org.freedesktop.secrets" ], "build-options": { - "append-path": "/usr/lib/sdk/rust-stable/bin", + "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm18/bin", + "prepend-ld-library-path": "/usr/lib/sdk/llvm18/lib", "env": { - "CARGO_HOME": "/run/build/tablepro-app/cargo" + "CARGO_HOME": "/run/build/tablepro-app/cargo", + "LIBCLANG_PATH": "/usr/lib/sdk/llvm18/lib" } }, "modules": [