diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5acb57659..ebee1595b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -214,10 +214,50 @@ fn build_tunnel_map_key( ssh_user: &str, ssh_host: &str, ssh_port: u16, - remote_host: &str, - remote_port: u16, + destination: &crate::ssh_tunnel::SshForwardDestination, ) -> String { - crate::ssh_tunnel::build_tunnel_key(ssh_user, ssh_host, ssh_port, remote_host, remote_port) + crate::ssh_tunnel::build_tunnel_key(ssh_user, ssh_host, ssh_port, destination) +} + +/// The SSH forward destination for a connection: the Unix socket path when one +/// is configured (with SSH enabled it names a socket on the SSH server, just +/// like host:port name the destination from the server's perspective), +/// otherwise the database host:port. +fn ssh_forward_destination( + params: &ConnectionParams, +) -> crate::ssh_tunnel::SshForwardDestination { + match params.unix_socket_path() { + Some(path) => crate::ssh_tunnel::SshForwardDestination::UnixSocket { + path: path.to_string(), + }, + None => crate::ssh_tunnel::SshForwardDestination::Tcp { + host: params.host.as_deref().unwrap_or("localhost").to_string(), + port: params.port.unwrap_or(DEFAULT_MYSQL_PORT), + }, + } +} + +/// Rewrite params to go through an established tunnel's local port. A socket +/// destination also disables database TLS: the server side of a Unix socket +/// cannot negotiate it, and the SSH tunnel already encrypts the whole path. +fn params_through_tunnel( + params: &ConnectionParams, + local_port: u16, + destination: &crate::ssh_tunnel::SshForwardDestination, +) -> ConnectionParams { + let mut new_params = params.clone(); + new_params.host = Some("127.0.0.1".to_string()); + new_params.port = Some(local_port); + // The tunnel owns the route: a leftover local socket path would make the + // drivers bypass the tunnel's local port and dial the socket instead. + new_params.unix_socket_path = None; + if matches!( + destination, + crate::ssh_tunnel::SshForwardDestination::UnixSocket { .. } + ) { + new_params.ssl_mode = Some("disable".to_string()); + } + new_params } /// Resolve K8s tunnel params synchronously (no saved-connection lookup; uses inline fields only). @@ -262,6 +302,9 @@ fn resolve_k8s_params(params: &ConnectionParams) -> Result Result Result Result Result( new_params.k8s_enabled = Some(false); new_params.host = Some("127.0.0.1".to_string()); new_params.port = Some(tunnel.local_port); + new_params.unix_socket_path = None; return Ok(new_params); } } @@ -1900,6 +1937,7 @@ pub async fn expand_k8s_connection_params( new_params.k8s_enabled = Some(false); new_params.host = Some("127.0.0.1".to_string()); new_params.port = Some(local_port); + new_params.unix_socket_path = None; Ok(new_params) } @@ -1994,6 +2032,84 @@ mod tests { } } + #[test] + fn forward_destination_defaults_to_tcp() { + let destination = ssh_forward_destination(&base_params()); + assert_eq!( + destination, + crate::ssh_tunnel::SshForwardDestination::Tcp { + host: "localhost".to_string(), + port: 3306, + } + ); + } + + #[test] + fn forward_destination_ignores_blank_socket_path() { + let params = ConnectionParams { + unix_socket_path: Some(" ".to_string()), + ..base_params() + }; + assert!(matches!( + ssh_forward_destination(¶ms), + crate::ssh_tunnel::SshForwardDestination::Tcp { .. } + )); + } + + #[test] + fn forward_destination_uses_trimmed_socket_path() { + let params = ConnectionParams { + unix_socket_path: Some(" /var/run/mysqld/mysqld.sock ".to_string()), + ..base_params() + }; + assert_eq!( + ssh_forward_destination(¶ms), + crate::ssh_tunnel::SshForwardDestination::UnixSocket { + path: "/var/run/mysqld/mysqld.sock".to_string(), + } + ); + } + + #[test] + fn tunnel_params_keep_ssl_mode_for_tcp_destination() { + let params = ConnectionParams { + ssl_mode: Some("require".to_string()), + ..base_params() + }; + let destination = ssh_forward_destination(¶ms); + let resolved = params_through_tunnel(¶ms, 15000, &destination); + assert_eq!(resolved.host.as_deref(), Some("127.0.0.1")); + assert_eq!(resolved.port, Some(15000)); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn tunnel_params_disable_ssl_for_socket_destination() { + let params = ConnectionParams { + ssl_mode: Some("require".to_string()), + unix_socket_path: Some("/tmp/db.sock".to_string()), + ..base_params() + }; + let destination = ssh_forward_destination(¶ms); + let resolved = params_through_tunnel(¶ms, 15000, &destination); + assert_eq!(resolved.host.as_deref(), Some("127.0.0.1")); + assert_eq!(resolved.port, Some(15000)); + assert_eq!(resolved.ssl_mode.as_deref(), Some("disable")); + } + + #[test] + fn tunnel_params_clear_local_socket_path() { + // A leftover local socket path would make the drivers dial the socket + // instead of the tunnel's forwarded local port. + let params = ConnectionParams { + unix_socket_path: Some("/tmp/db.sock".to_string()), + ..base_params() + }; + let destination = ssh_forward_destination(¶ms); + let resolved = params_through_tunnel(¶ms, 15000, &destination); + assert_eq!(resolved.unix_socket_path, None); + } + fn saved_conn(id: &str, password: Option<&str>, save_in_keychain: bool) -> SavedConnection { SavedConnection { id: id.to_string(), diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index de135c4f3..38585aa29 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -129,6 +129,12 @@ pub struct DriverCapabilities { /// their manifest. Defaults to `false`. #[serde(default, alias = "supportsSsl")] pub supports_ssl: bool, + /// Supports connecting through a Unix socket on this machine instead of + /// host:port (the connection modal shows the local Socket Path field). + /// Built-in network drivers (mysql, postgres) set this; plugins opt in + /// via their manifest. Defaults to `false`. + #[serde(default, alias = "unixSocket")] + pub unix_socket: bool, /// Supports EXPLAIN / query plan visualization (`explain_query`). /// When `false`, the Visual Explain UI is hidden for connections using /// this driver. Built-in drivers set this; plugins opt in via their diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 9b3f8abd0..b03d03571 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1580,6 +1580,7 @@ impl MysqlDriver { readonly: false, triggers: true, supports_ssl: true, + unix_socket: true, sql_dialect: SqlDialect::Mysql, }, is_builtin: true, diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 559e5ab38..5e822dff5 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1680,6 +1680,7 @@ impl PostgresDriver { readonly: false, triggers: true, supports_ssl: true, + unix_socket: true, sql_dialect: SqlDialect::Postgres, }, is_builtin: true, diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 381a4a2ce..2e0aaeb23 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -902,6 +902,7 @@ impl SqliteDriver { readonly: false, triggers: true, supports_ssl: false, + unix_socket: false, sql_dialect: SqlDialect::Sqlite, }, is_builtin: true, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 28e343b96..739bf4801 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -171,6 +171,15 @@ pub struct ConnectionParams { pub driver: String, pub host: Option, pub port: Option, + /// Absolute path of the Unix socket the database listens on, at the + /// connection's destination — like host/port, its perspective follows the + /// tunnel state. Without a tunnel, the drivers dial this socket on the + /// local machine instead of host:port. With SSH, the SSH server connects + /// to it (the tunnel forwards there instead of host:port). Ignored while + /// a Kubernetes tunnel is enabled. Either way database TLS is disabled: a + /// socket peer cannot negotiate it, and the path is local or SSH-encrypted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unix_socket_path: Option, pub username: Option, pub password: Option, pub database: DatabaseSelection, @@ -237,6 +246,27 @@ pub struct ConnectionParams { pub connection_id: Option, } +impl ConnectionParams { + /// The configured Unix socket path: trimmed and non-empty. Where it is + /// dialed from depends on the tunnel state — see `unix_socket_path`. + pub fn unix_socket_path(&self) -> Option<&str> { + self.unix_socket_path + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + } + + /// The Unix socket path the drivers dial *locally*: `None` while an SSH + /// or Kubernetes tunnel is enabled — the tunnel owns the route to the + /// database, so the socket must not override it at the driver level. + pub fn local_unix_socket_path(&self) -> Option<&str> { + if self.ssh_enabled.unwrap_or(false) || self.k8s_enabled.unwrap_or(false) { + return None; + } + self.unix_socket_path() + } +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(tag = "type", rename_all = "camelCase")] pub enum IconOverride { diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 402136911..761702ff3 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1004,6 +1004,7 @@ mod tests { ssh_key_file: None, ssh_key_passphrase: None, ssh_allow_passphrase_prompt: None, + unix_socket_path: None, save_in_keychain: None, k8s_enabled: None, k8s_connection_id: None, diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 0bfe34625..daf2ca08e 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -127,11 +127,20 @@ pub(crate) fn build_connection_key( // targets behind a single host:port and pick the backend from the // username, so without it two different targets would share one pool and // serve each other's databases. + // A local socket replaces host:port as the endpoint, so it must key the + // pool the same way host:port does. + let endpoint = match params.local_unix_socket_path() { + Some(socket) => format!("socket:{socket}"), + None => format!( + "{}:{}", + params.host.as_deref().unwrap_or("localhost"), + params.port.unwrap_or(0) + ), + }; format!( - "{}:{}:{}:{}:{}", + "{}:{}:{}:{}", params.driver, - params.host.as_deref().unwrap_or("localhost"), - params.port.unwrap_or(0), + endpoint, params.username.as_deref().unwrap_or(""), params.database ) @@ -170,19 +179,30 @@ pub(crate) fn build_mysql_options( let database = override_db.unwrap_or_else(|| params.database.primary()); let timezone = mysql_string_setting("timezone", DEFAULT_MYSQL_TIMEZONE); + let local_socket = params.local_unix_socket_path(); + let mut options = MySqlConnectOptions::new() - .host(host) - .port(port) .username(username) .database(database) .timezone(timezone); + // A local Unix socket replaces host:port entirely. + options = match local_socket { + Some(socket) => options.socket(socket), + None => options.host(host).port(port), + }; + if !password.is_empty() { options = options.password(password); } - // Configure SSL mode based on params.ssl_mode - let ssl_mode = match params.ssl_mode.as_deref().unwrap_or("required") { + // Configure SSL mode based on params.ssl_mode. TLS cannot be negotiated + // over a local Unix socket (and the traffic never leaves the machine), so + // a socket connection forces it off regardless of the configured mode. + let ssl_mode = match local_socket + .map(|_| "disabled") + .unwrap_or_else(|| params.ssl_mode.as_deref().unwrap_or("required")) + { "disabled" | "disable" => MySqlSslMode::Disabled, "preferred" | "prefer" => MySqlSslMode::Preferred, "required" | "require" => MySqlSslMode::Required, @@ -207,12 +227,15 @@ pub(crate) fn build_mysql_options( // bastions like Warpgate. Cleartext credentials must never be sent over an // unencrypted link, so require a TLS mode that actually guarantees // encryption. `Preferred` only attempts TLS and silently falls back to - // plaintext, so it is rejected alongside `Disabled`. + // plaintext, so it is rejected alongside `Disabled`. A local Unix socket is + // exempt: its traffic never crosses a network link. if params.enable_cleartext_plugin.unwrap_or(false) { - if !matches!( - ssl_mode, - MySqlSslMode::Required | MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity - ) { + if local_socket.is_none() + && !matches!( + ssl_mode, + MySqlSslMode::Required | MySqlSslMode::VerifyCa | MySqlSslMode::VerifyIdentity + ) + { return Err( "Cleartext password plugin requires an enforced TLS/SSL mode \ (Required, Verify CA, or Verify Identity). Preferred is not enough \ @@ -293,14 +316,43 @@ pub(crate) fn is_pipes_as_concat_unsupported(err: &str) -> bool { err.contains("pipes_as_concat") || err.contains("no_engine_substitution") } +/// Split a user-entered PostgreSQL socket path into what tokio_postgres wants: +/// the *directory* holding the socket plus the port that names it. Users point +/// at the socket file (`/var/run/postgresql/.s.PGSQL.5432`), matching the SSH +/// forward field; a path whose last component is not `.s.PGSQL.` is +/// taken as the directory itself, paired with `default_port`. +pub(crate) fn split_postgres_socket_path(path: &str, default_port: u16) -> (String, u16) { + if let Some((dir, file)) = path.rsplit_once('/') { + if let Some(port) = file + .strip_prefix(".s.PGSQL.") + .and_then(|p| p.parse::().ok()) + { + let dir = if dir.is_empty() { "/" } else { dir }; + return (dir.to_string(), port); + } + } + (path.to_string(), default_port) +} + pub(crate) fn build_postgres_configurations(params: &ConnectionParams) -> PgConfig { let mut cfg = PgConfig::new(); cfg.user(params.username.as_deref().unwrap_or_default()) .password(params.password.as_deref().unwrap_or_default()) - .port(params.port.unwrap_or(5432)) - .host(params.host.as_deref().unwrap_or_default()) .dbname(&format!("{}", params.database)); + if let Some(socket) = params.local_unix_socket_path() { + let (dir, port) = split_postgres_socket_path(socket, params.port.unwrap_or(5432)); + cfg.host_path(dir).port(port); + // TLS cannot be negotiated over a local Unix socket, and the traffic + // never leaves the machine — force it off so `prefer`-style defaults + // don't stall the handshake. + cfg.ssl_mode(PgSslMode::Disable); + return cfg; + } + + cfg.port(params.port.unwrap_or(5432)) + .host(params.host.as_deref().unwrap_or_default()); + if let Some(ssl_mode) = params.ssl_mode.as_deref() { match ssl_mode { "disable" => { diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 1b335db0a..95613b3c1 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -615,3 +615,140 @@ mod startup_script_tests { close_pool_with_id(¶ms, Some(&conn_id)).await; } } + +#[cfg(test)] +mod local_unix_socket_tests { + use crate::models::{ConnectionParams, DatabaseSelection}; + use crate::pool_manager::{ + build_connection_key, build_mysql_options, build_postgres_configurations, + split_postgres_socket_path, + }; + use sqlx::mysql::MySqlSslMode; + use tokio_postgres::config::Host as PgHost; + use tokio_postgres::config::SslMode as PgSslMode; + + fn socket_params(driver: &str, socket: &str) -> ConnectionParams { + ConnectionParams { + driver: driver.to_string(), + host: Some("localhost".to_string()), + port: Some(if driver == "postgres" { 5432 } else { 3306 }), + username: Some("dec".to_string()), + password: Some("secret".to_string()), + database: DatabaseSelection::Single("dec".to_string()), + unix_socket_path: Some(socket.to_string()), + ..Default::default() + } + } + + #[test] + fn local_socket_ignored_while_tunnel_enabled() { + let mut params = socket_params("mysql", "/tmp/mysql.sock"); + params.ssh_enabled = Some(true); + assert_eq!(params.local_unix_socket_path(), None); + + params.ssh_enabled = None; + params.k8s_enabled = Some(true); + assert_eq!(params.local_unix_socket_path(), None); + } + + #[test] + fn local_socket_trims_and_ignores_blank_path() { + let mut params = socket_params("mysql", " /tmp/mysql.sock "); + assert_eq!(params.local_unix_socket_path(), Some("/tmp/mysql.sock")); + + params.unix_socket_path = Some(" ".to_string()); + assert_eq!(params.local_unix_socket_path(), None); + } + + #[test] + fn mysql_options_use_socket_and_disable_tls() { + let mut params = socket_params("mysql", "/tmp/mysql.sock"); + // Even an enforced TLS mode must be overridden: a socket peer cannot + // negotiate TLS and the traffic never leaves the machine. + params.ssl_mode = Some("required".to_string()); + + let options = build_mysql_options(¶ms, None).unwrap(); + assert!(matches!(options.get_ssl_mode(), MySqlSslMode::Disabled)); + let dbg = format!("{options:?}"); + assert!( + dbg.contains("/tmp/mysql.sock"), + "expected socket path in options, got: {dbg}" + ); + } + + #[test] + fn mysql_cleartext_plugin_allowed_over_local_socket() { + let mut params = socket_params("mysql", "/tmp/mysql.sock"); + params.enable_cleartext_plugin = Some(true); + + assert!(build_mysql_options(¶ms, None).is_ok()); + } + + #[test] + fn postgres_config_uses_socket_dir_and_port_from_path() { + let params = socket_params("postgres", "/var/run/postgresql/.s.PGSQL.5433"); + let cfg = build_postgres_configurations(¶ms); + + assert_eq!(cfg.get_ports(), &[5433]); + assert_eq!(cfg.get_ssl_mode(), PgSslMode::Disable); + match cfg.get_hosts() { + [PgHost::Unix(dir)] => assert_eq!(dir.to_str(), Some("/var/run/postgresql")), + other => panic!("expected a single unix host, got: {other:?}"), + } + } + + #[test] + fn postgres_config_accepts_socket_directory() { + // A plain directory (no .s.PGSQL. component) pairs with the + // connection's configured port. + let params = socket_params("postgres", "/var/run/postgresql"); + let cfg = build_postgres_configurations(¶ms); + + assert_eq!(cfg.get_ports(), &[5432]); + match cfg.get_hosts() { + [PgHost::Unix(dir)] => assert_eq!(dir.to_str(), Some("/var/run/postgresql")), + other => panic!("expected a single unix host, got: {other:?}"), + } + } + + #[test] + fn split_postgres_socket_path_variants() { + assert_eq!( + split_postgres_socket_path("/var/run/postgresql/.s.PGSQL.5432", 1111), + ("/var/run/postgresql".to_string(), 5432) + ); + // Socket file directly under the root keeps "/" as the directory. + assert_eq!( + split_postgres_socket_path("/.s.PGSQL.5432", 1111), + ("/".to_string(), 5432) + ); + // Non-numeric suffix is not a socket file name: treat as a directory. + assert_eq!( + split_postgres_socket_path("/var/run/.s.PGSQL.abc", 1111), + ("/var/run/.s.PGSQL.abc".to_string(), 1111) + ); + assert_eq!( + split_postgres_socket_path("/var/run/postgresql", 1111), + ("/var/run/postgresql".to_string(), 1111) + ); + } + + #[test] + fn adhoc_pool_key_distinguishes_socket_from_tcp() { + let tcp = ConnectionParams { + unix_socket_path: None, + ..socket_params("mysql", "/tmp/mysql.sock") + }; + let socket = socket_params("mysql", "/tmp/mysql.sock"); + let other_socket = socket_params("mysql", "/tmp/other.sock"); + + assert_ne!( + build_connection_key(&tcp, None), + build_connection_key(&socket, None) + ); + assert_ne!( + build_connection_key(&socket, None), + build_connection_key(&other_socket, None) + ); + } +} diff --git a/src-tauri/src/ssh_tunnel.rs b/src-tauri/src/ssh_tunnel.rs index f905fac99..853ac0060 100644 --- a/src-tauri/src/ssh_tunnel.rs +++ b/src-tauri/src/ssh_tunnel.rs @@ -31,6 +31,34 @@ enum TunnelBackend { SystemSsh(Arc>), } +/// What the SSH server connects to on the far end of a local forward. +/// A TCP destination opens a `direct-tcpip` channel; a Unix socket destination +/// opens a `direct-streamlocal@openssh.com` channel, the equivalent of +/// `ssh -L :/path/to/socket`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshForwardDestination { + Tcp { host: String, port: u16 }, + UnixSocket { path: String }, +} + +impl SshForwardDestination { + /// The `-L` forward spec for system ssh: OpenSSH accepts either + /// `bind:port:host:hostport` or `bind:port:remote_socket_path`. + fn forward_spec(&self, local_port: u16) -> String { + match self { + Self::Tcp { host, port } => format!("127.0.0.1:{}:{}:{}", local_port, host, port), + Self::UnixSocket { path } => format!("127.0.0.1:{}:{}", local_port, path), + } + } + + fn log_description(&self) -> String { + match self { + Self::Tcp { host, port } => format!("{}:{}", host, port), + Self::UnixSocket { path } => path.clone(), + } + } +} + #[derive(Clone)] struct RusshClientHandler { ssh_host: String, @@ -107,13 +135,17 @@ impl SshTunnel { ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, ssh_allow_passphrase_prompt: bool, - remote_host: &str, - remote_port: u16, + destination: &SshForwardDestination, ) -> Result { let use_system_ssh = should_use_system_ssh(ssh_password); eprintln!( - "[SSH Tunnel] New Request: Host={}, Port={}, User={}, UseSystemSSH={}, AllowPrompt={}", - ssh_host, ssh_port, ssh_user, use_system_ssh, ssh_allow_passphrase_prompt + "[SSH Tunnel] New Request: Host={}, Port={}, User={}, Destination={}, UseSystemSSH={}, AllowPrompt={}", + ssh_host, + ssh_port, + ssh_user, + destination.log_description(), + use_system_ssh, + ssh_allow_passphrase_prompt ); let local_port = { @@ -133,8 +165,7 @@ impl SshTunnel { ssh_user, ssh_key_file, ssh_allow_passphrase_prompt, - remote_host, - remote_port, + destination, local_port, ) .map_err(|e| { @@ -149,8 +180,7 @@ impl SshTunnel { ssh_password, ssh_key_file, ssh_key_passphrase, - remote_host, - remote_port, + destination, local_port, ) .map_err(|e| { @@ -166,8 +196,7 @@ impl SshTunnel { ssh_user: &str, ssh_key_file: Option<&str>, ssh_allow_passphrase_prompt: bool, - remote_host: &str, - remote_port: u16, + destination: &SshForwardDestination, local_port: u16, ) -> Result { let mut args = Vec::with_capacity(16); // Pre-allocate for typical argument count @@ -178,10 +207,7 @@ impl SshTunnel { args.push("-N".to_string()); // No remote command args.push("-L".to_string()); // Explicitly bind to 127.0.0.1 to avoid ambiguity or public binding - args.push(format!( - "127.0.0.1:{}:{}:{}", - local_port, remote_host, remote_port - )); + args.push(destination.forward_spec(local_port)); let destination = if !ssh_user.trim().is_empty() { format!("{}@{}", ssh_user, ssh_host) @@ -337,8 +363,7 @@ impl SshTunnel { ssh_password: Option<&str>, ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, - remote_host: &str, - remote_port: u16, + destination: &SshForwardDestination, local_port: u16, ) -> Result { eprintln!("[SSH Tunnel] Russh connecting to {}:{}", ssh_host, ssh_port); @@ -361,7 +386,7 @@ impl SshTunnel { let ssh_password = ssh_password.map(|p| p.to_string()); let ssh_key_file = ssh_key_file.map(|p| p.to_string()); let ssh_key_passphrase = ssh_key_passphrase.map(|p| p.to_string()); - let remote_host = remote_host.to_string(); + let destination = destination.clone(); let (ready_tx, ready_rx) = mpsc::channel(); @@ -483,18 +508,25 @@ impl SshTunnel { }; let handle = handle.clone(); - let r_host = remote_host.clone(); + let dest = destination.clone(); tokio::spawn(async move { let handle = handle.lock().await; - let channel = match handle - .channel_open_direct_tcpip( - r_host, - u32::from(remote_port), - "127.0.0.1", - 0, - ) - .await - { + let channel_result = match &dest { + SshForwardDestination::Tcp { host, port } => { + handle + .channel_open_direct_tcpip( + host.clone(), + u32::from(*port), + "127.0.0.1", + 0, + ) + .await + } + SshForwardDestination::UnixSocket { path } => { + handle.channel_open_direct_streamlocal(path.clone()).await + } + }; + let channel = match channel_result { Ok(c) => c, Err(e) => { eprintln!("[SSH Tunnel Error] Failed to open SSH channel: {}", e); @@ -798,13 +830,16 @@ pub fn build_tunnel_key( ssh_user: &str, ssh_host: &str, ssh_port: u16, - remote_host: &str, - remote_port: u16, + destination: &SshForwardDestination, ) -> String { - format!( - "{}@{}:{}:{}->{}", - ssh_user, ssh_host, ssh_port, remote_host, remote_port - ) + match destination { + SshForwardDestination::Tcp { host, port } => { + format!("{}@{}:{}:{}->{}", ssh_user, ssh_host, ssh_port, host, port) + } + SshForwardDestination::UnixSocket { path } => { + format!("{}@{}:{}:socket->{}", ssh_user, ssh_host, ssh_port, path) + } + } } /// Check if a string is empty or contains only whitespace. @@ -827,23 +862,77 @@ mod tests { mod build_tunnel_key_tests { use super::*; + fn tcp(host: &str, port: u16) -> SshForwardDestination { + SshForwardDestination::Tcp { + host: host.to_string(), + port, + } + } + #[test] fn test_basic_key_format() { - let key = build_tunnel_key("user", "host.example.com", 22, "db.internal", 3306); + let key = build_tunnel_key("user", "host.example.com", 22, &tcp("db.internal", 3306)); assert_eq!(key, "user@host.example.com:22:db.internal->3306"); } #[test] fn test_non_standard_port() { - let key = build_tunnel_key("admin", "jump.server", 2222, "localhost", 5432); + let key = build_tunnel_key("admin", "jump.server", 2222, &tcp("localhost", 5432)); assert_eq!(key, "admin@jump.server:2222:localhost->5432"); } #[test] fn test_empty_user() { - let key = build_tunnel_key("", "host", 22, "remote", 80); + let key = build_tunnel_key("", "host", 22, &tcp("remote", 80)); assert_eq!(key, "@host:22:remote->80"); } + + #[test] + fn test_unix_socket_destination() { + let destination = SshForwardDestination::UnixSocket { + path: "/var/run/mysqld/mysqld.sock".to_string(), + }; + let key = build_tunnel_key("user", "host", 22, &destination); + assert_eq!(key, "user@host:22:socket->/var/run/mysqld/mysqld.sock"); + } + + #[test] + fn test_socket_and_tcp_keys_differ() { + let socket = SshForwardDestination::UnixSocket { + path: "/tmp/db.sock".to_string(), + }; + assert_ne!( + build_tunnel_key("user", "host", 22, &socket), + build_tunnel_key("user", "host", 22, &tcp("/tmp/db.sock", 0)) + ); + } + } + + mod forward_spec_tests { + use super::*; + + #[test] + fn test_tcp_spec() { + let destination = SshForwardDestination::Tcp { + host: "db.internal".to_string(), + port: 3306, + }; + assert_eq!( + destination.forward_spec(15000), + "127.0.0.1:15000:db.internal:3306" + ); + } + + #[test] + fn test_unix_socket_spec() { + let destination = SshForwardDestination::UnixSocket { + path: "/var/run/postgresql/.s.PGSQL.5432".to_string(), + }; + assert_eq!( + destination.forward_spec(15000), + "127.0.0.1:15000:/var/run/postgresql/.s.PGSQL.5432" + ); + } } mod should_use_system_ssh_tests { diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 0425cf765..84d47413a 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -49,6 +49,7 @@ import { K8sAdvancedSettings } from "../ui/K8sAdvancedSettings"; import { isMultiDatabaseCapable } from "../../utils/database"; import { toErrorMessage } from "../../utils/errors"; import { fetchConnectionWithCredentials } from "../../utils/credentials"; +import { unixSocketPathIssue } from "../../utils/connections"; import { getDriverIcon, getDriverColorStyle } from "../../utils/driverUI"; import { parseConnectionString, @@ -106,6 +107,9 @@ interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; + // Unix socket at the connection's destination, replacing host:port: + // dialed locally without a tunnel, by the SSH server with SSH enabled + unix_socket_path?: string; save_in_keychain?: boolean; // K8s k8s_enabled?: boolean; @@ -162,6 +166,7 @@ const FieldInput = ({ placeholder, autoFocus, className, + disabled, }: { label: string; value: string | number | undefined; @@ -170,6 +175,7 @@ const FieldInput = ({ placeholder?: string; autoFocus?: boolean; className?: string; + disabled?: boolean; }) => { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; @@ -186,13 +192,15 @@ const FieldInput = ({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoFocus={autoFocus} + disabled={disabled} autoCorrect="off" autoCapitalize="off" autoComplete="off" spellCheck={false} className={clsx( "w-full px-3 py-2 bg-base border border-strong rounded-md text-sm text-primary placeholder:text-muted placeholder:italic focus:border-blue-500 focus:outline-none transition-colors", - isPassword && "pr-10" + isPassword && "pr-10", + disabled && "opacity-50 cursor-not-allowed" )} /> {isPassword && ( @@ -516,6 +524,7 @@ export const NewConnectionModal = ({ !noConnectionRequired && activeDriver?.capabilities?.file_based === false && !activeDriver?.capabilities?.folder_based; + const supportsUnixSocket = activeDriver?.capabilities?.unix_socket === true; const k8sDefaultPort = activeDriver?.default_port ?? undefined; // Derive K8s ports instead of seeding formData so edit flows with no saved port are covered. const getK8sAutoPort = (params: Partial) => @@ -1223,6 +1232,23 @@ export const NewConnectionModal = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; + const tunnelEnabled = !!formData.ssh_enabled || !!formData.k8s_enabled; + const socketPathSet = !!formData.unix_socket_path?.trim(); + const canUseSocket = + (supportsUnixSocket || !!formData.ssh_enabled) && !formData.k8s_enabled; + const socketMode = + canUseSocket && formData.unix_socket_path !== undefined; + // The socket replaces host:port at the destination: dialed by the SSH + // server when SSH is enabled, locally otherwise (capability-gated). A K8s + // tunnel ignores it. + const usesForwardSocket = !!formData.ssh_enabled && socketPathSet; + const usesLocalSocket = + supportsUnixSocket && !tunnelEnabled && socketPathSet; + const usesSocket = usesForwardSocket || usesLocalSocket; + const socketPathIssue = unixSocketPathIssue( + formData.unix_socket_path ?? "", + ); + const loadDatabases = async ( overrides?: Partial, shouldApply: () => boolean = () => true, @@ -2026,28 +2052,117 @@ export const NewConnectionModal = ({ )} - {/* Host + Port */} -
- updateField("host", v)} - placeholder="localhost" - /> - updateField("port", v)} - type="number" - placeholder={driver === "mysql" ? "3306" : "5432"} - /> -
+ {/* DataGrip-style endpoint type selector. */} + {canUseSocket && ( +
+ + +
+ )} + + {/* A Unix socket replaces the database host and port entirely. */} + {!socketMode && ( +
+ updateField("host", v)} + placeholder="localhost" + /> + updateField("port", v)} + type="number" + placeholder={driver === "mysql" ? "3306" : "5432"} + /> +
+ )} + + {/* Unix socket instead of host:port — dialed locally (capability-gated) + or by the SSH server when the tunnel is enabled. */} + {socketMode && ( +
+ updateField("unix_socket_path", v)} + placeholder={ + driver === "postgres" + ? "/var/run/postgresql/.s.PGSQL.5432" + : formData.ssh_enabled + ? "/var/run/mysqld/mysqld.sock" + : "/tmp/mysql.sock" + } + /> + {socketPathIssue === "notAbsolute" && ( +

+ {" "} + {t("newConnection.socketPathNotAbsolute")} +

+ )} + {socketPathIssue === "looksLikeDirectory" && ( +

+ {" "} + {t("newConnection.socketPathDirectory")} +

+ )} + {!socketPathIssue && ( +

+ {formData.ssh_enabled + ? usesForwardSocket + ? t("newConnection.socketPathForwardActive") + : t("newConnection.socketPathForwardHint") + : usesLocalSocket + ? t("newConnection.socketPathActive") + : t("newConnection.socketPathHint")} +

+ )} +
+ )} {/* User + Password */}
@@ -2086,7 +2201,9 @@ export const NewConnectionModal = ({ void loadDatabases(); }} disabled={ - loadingDatabases || !formData.host || !formData.username + loadingDatabases || + (!formData.host && !usesSocket) || + !formData.username } className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 disabled:text-muted disabled:cursor-not-allowed transition-colors" > @@ -2262,7 +2379,11 @@ export const NewConnectionModal = ({ onClick={() => { void loadDatabases(); }} - disabled={loadingDatabases || !formData.host || !formData.username} + disabled={ + loadingDatabases || + (!formData.host && !usesSocket) || + !formData.username + } className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 disabled:text-muted disabled:cursor-not-allowed transition-colors shrink-0" > {loadingDatabases ? ( @@ -2824,6 +2945,7 @@ export const NewConnectionModal = ({
)} + )} diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index d8ee0286f..badadb2a9 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -43,6 +43,8 @@ export interface SavedConnection { host?: string; database: string | string[]; port?: number; + /** Local Unix socket the drivers dial instead of host:port (no tunnel). */ + unix_socket_path?: string; username?: string; password?: string; ssh_enabled?: boolean; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 74230b580..35480ed52 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -799,6 +799,14 @@ "sshKeyPassphrase": "Passphrase für SSH-Schlüssel (optional)", "sshKeyPassphrasePlaceholder": "Passphrase eingeben, falls der Schlüssel verschlüsselt ist", "allowSshPrompt": "SSH-Passwort/PIN-Eingabeaufforderung erlauben", + "socketPath": "Socket-Pfad (Optional)", + "socketPathHint": "Über einen Unix-Socket auf diesem Rechner verbinden statt über Host und Port.", + "socketPathActive": "Die Verbindung nutzt diesen Socket statt Host und Port. Datenbank-TLS ist deaktiviert — Verkehr über einen lokalen Socket verlässt diesen Rechner nie.", + "socketPathForwardHint": "Setzen, um eine Datenbank zu erreichen, die nur auf einem Unix-Socket lauscht.", + "socketPathForwardActive": "Der SSH-Server verbindet sich mit diesem Socket statt mit Host und Port. Eine Datenbank auf einem Socket kann kein TLS aushandeln, daher ist es deaktiviert; der SSH-Tunnel verschlüsselt weiterhin den gesamten Pfad.", + "socketPathNotAbsolute": "Absoluten Pfad eingeben, wie er auf dem Rechner erscheint, der sich mit dem Socket verbindet.", + "socketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", + "socketPathTunnel": "Wird nicht verwendet, solange ein Kubernetes-Tunnel aktiv ist.", "saveKeychain": "Passwörter im Keychain speichern", "testConnection": "Verbindung testen", "save": "Speichern", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 858967273..b2f47d92a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -838,6 +838,14 @@ "sshKeyPassphrase": "SSH Key Passphrase (Optional)", "sshKeyPassphrasePlaceholder": "Enter key passphrase if encrypted", "allowSshPrompt": "Allow SSH password/PIN prompt", + "socketPath": "Socket Path (Optional)", + "socketPathHint": "Connect through a Unix socket on this machine instead of Host and Port.", + "socketPathActive": "The connection uses this socket instead of Host and Port. Database TLS is turned off — local socket traffic never leaves this machine.", + "socketPathForwardHint": "Set this to reach a database that only listens on a Unix socket.", + "socketPathForwardActive": "The SSH server connects to this socket instead of Host and Port. A database on a socket cannot negotiate TLS, so it is turned off; the SSH tunnel still encrypts the whole path.", + "socketPathNotAbsolute": "Enter an absolute path, as it appears on the machine that connects to the socket.", + "socketPathDirectory": "Point at the socket file itself, not the directory holding it.", + "socketPathTunnel": "Not used while a Kubernetes tunnel is enabled.", "saveKeychain": "Save passwords in Keychain", "testConnection": "Test Connection", "save": "Save", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index dbf3b7caf..b0ac451fd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -824,6 +824,14 @@ "sshKeyPassphrase": "Frase de Paso de Clave SSH (Opcional)", "sshKeyPassphrasePlaceholder": "Ingresa la frase de paso si la clave está cifrada", "allowSshPrompt": "Permitir solicitud de contraseña/PIN de SSH", + "socketPath": "Ruta del Socket (Opcional)", + "socketPathHint": "Conéctate a través de un socket Unix en esta máquina en lugar de Host y Puerto.", + "socketPathActive": "La conexión usa este socket en lugar de Host y Puerto. El TLS de la base de datos está desactivado: el tráfico por un socket local nunca sale de esta máquina.", + "socketPathForwardHint": "Configúralo para acceder a una base de datos que solo escucha en un socket Unix.", + "socketPathForwardActive": "El servidor SSH se conecta a este socket en lugar de Host y Puerto. Una base de datos en un socket no puede negociar TLS, así que se desactiva; el túnel SSH sigue cifrando todo el trayecto.", + "socketPathNotAbsolute": "Introduce una ruta absoluta, tal como aparece en la máquina que se conecta al socket.", + "socketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", + "socketPathTunnel": "No se usa mientras hay un túnel Kubernetes activo.", "saveKeychain": "Guardar contraseñas en el Llavero", "testConnection": "Probar Conexión", "save": "Guardar", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 90236c9b5..50f8d0246 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -833,6 +833,14 @@ "sshKeyPassphrase": "Phrase secrète de clé SSH (optionnel)", "sshKeyPassphrasePlaceholder": "Saisissez la phrase secrète si la clé est chiffrée", "allowSshPrompt": "Autoriser l'invite de mot de passe/PIN SSH", + "socketPath": "Chemin du Socket (Optionnel)", + "socketPathHint": "Connectez-vous via un socket Unix sur cette machine au lieu de l'Hôte et du Port.", + "socketPathActive": "La connexion utilise ce socket au lieu de l'Hôte et du Port. Le TLS de la base de données est désactivé — le trafic d'un socket local ne quitte jamais cette machine.", + "socketPathForwardHint": "Définissez-le pour atteindre une base de données qui n'écoute que sur un socket Unix.", + "socketPathForwardActive": "Le serveur SSH se connecte à ce socket au lieu de l'Hôte et du Port. Une base de données sur un socket ne peut pas négocier le TLS, il est donc désactivé ; le tunnel SSH chiffre toujours l'ensemble du trajet.", + "socketPathNotAbsolute": "Saisissez un chemin absolu, tel qu'il apparaît sur la machine qui se connecte au socket.", + "socketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", + "socketPathTunnel": "Non utilisé lorsqu'un tunnel Kubernetes est actif.", "saveKeychain": "Enregistrer les mots de passe dans le trousseau", "testConnection": "Tester la connexion", "save": "Enregistrer", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d6193e178..469e94bf5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -824,6 +824,14 @@ "sshKeyPassphrase": "Passphrase Chiave SSH (Opzionale)", "sshKeyPassphrasePlaceholder": "Inserisci passphrase se la chiave è cifrata", "allowSshPrompt": "Consenti prompt password/PIN SSH", + "socketPath": "Percorso Socket (Opzionale)", + "socketPathHint": "Connettiti tramite un socket Unix su questa macchina invece di Host e Porta.", + "socketPathActive": "La connessione usa questo socket invece di Host e Porta. Il TLS del database è disattivato: il traffico su un socket locale non lascia mai questa macchina.", + "socketPathForwardHint": "Impostalo per raggiungere un database in ascolto solo su un socket Unix.", + "socketPathForwardActive": "Il server SSH si collega a questo socket invece che a Host e Porta. Un database su socket non può negoziare TLS, quindi viene disattivato; il tunnel SSH cifra comunque l'intero percorso.", + "socketPathNotAbsolute": "Inserisci un percorso assoluto, come appare sulla macchina che si collega al socket.", + "socketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", + "socketPathTunnel": "Non usato mentre è attivo un tunnel Kubernetes.", "saveKeychain": "Salva password nel Portachiavi", "testConnection": "Testa Connessione", "save": "Salva", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fb42fe058..6864ae75a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -813,6 +813,14 @@ "sshKeyPassphrase": "SSH 鍵のパスフレーズ (任意)", "sshKeyPassphrasePlaceholder": "鍵が暗号化されている場合はパスフレーズを入力", "allowSshPrompt": "SSHパスワード/PINプロンプトを許可する", + "socketPath": "ソケットパス(任意)", + "socketPathHint": "ホストとポートの代わりに、このマシン上のUnixソケット経由で接続します。", + "socketPathActive": "接続はホストとポートの代わりにこのソケットを使用します。データベースのTLSは無効になります。ローカルソケットの通信はこのマシンの外に出ません。", + "socketPathForwardHint": "Unixソケットのみで待ち受けるデータベースに接続する場合に設定します。", + "socketPathForwardActive": "SSHサーバーはホストとポートの代わりにこのソケットに接続します。ソケット上のデータベースはTLSをネゴシエートできないため無効になりますが、SSHトンネルが経路全体を暗号化します。", + "socketPathNotAbsolute": "ソケットに接続するマシン上でのパスとして、絶対パスを入力してください。", + "socketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", + "socketPathTunnel": "Kubernetesトンネルが有効な間は使用されません。", "saveKeychain": "パスワードをキーチェーンに保存", "testConnection": "接続テスト", "save": "保存", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 9d9aeb21c..bbad9c76c 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -797,6 +797,14 @@ "sshKeyPassphrase": "SSH 키 암호(선택)", "sshKeyPassphrasePlaceholder": "암호화된 경우 키 암호를 입력하세요", "allowSshPrompt": "SSH 비밀번호/PIN 입력창 허용", + "socketPath": "소켓 경로 (선택 사항)", + "socketPathHint": "호스트와 포트 대신 이 컴퓨터의 Unix 소켓을 통해 연결합니다.", + "socketPathActive": "연결은 호스트와 포트 대신 이 소켓을 사용합니다. 데이터베이스 TLS는 꺼집니다 — 로컬 소켓 트래픽은 이 컴퓨터를 벗어나지 않습니다.", + "socketPathForwardHint": "Unix 소켓에서만 수신하는 데이터베이스에 연결하려면 설정하세요.", + "socketPathForwardActive": "SSH 서버가 호스트와 포트 대신 이 소켓에 연결합니다. 소켓의 데이터베이스는 TLS를 협상할 수 없어 꺼지지만, SSH 터널이 전체 경로를 암호화합니다.", + "socketPathNotAbsolute": "소켓에 연결하는 컴퓨터에 있는 그대로의 절대 경로를 입력하세요.", + "socketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", + "socketPathTunnel": "Kubernetes 터널이 활성화된 동안에는 사용되지 않습니다.", "saveKeychain": "키체인에 비밀번호 저장", "testConnection": "연결 테스트", "save": "저장", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bc804890a..efb0a0553 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -791,6 +791,14 @@ "sshKeyPassphrase": "Пароль SSH-ключа (необязательно)", "sshKeyPassphrasePlaceholder": "Введите пароль, если ключ зашифрован", "allowSshPrompt": "Разрешить запрос пароля/PIN-кода SSH", + "socketPath": "Путь к сокету (необязательно)", + "socketPathHint": "Подключение через Unix-сокет на этой машине вместо хоста и порта.", + "socketPathActive": "Соединение использует этот сокет вместо хоста и порта. TLS базы данных отключён — трафик через локальный сокет не покидает эту машину.", + "socketPathForwardHint": "Укажите, чтобы подключиться к базе данных, доступной только через Unix-сокет.", + "socketPathForwardActive": "SSH-сервер подключается к этому сокету вместо хоста и порта. База данных на сокете не может согласовать TLS, поэтому он отключён; SSH-туннель по-прежнему шифрует весь путь.", + "socketPathNotAbsolute": "Введите абсолютный путь, как он выглядит на машине, которая подключается к сокету.", + "socketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", + "socketPathTunnel": "Не используется, пока включён туннель Kubernetes.", "saveKeychain": "Сохранять пароли в Keychain", "testConnection": "Проверить подключение", "save": "Сохранить", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index 9f5853253..381300f95 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -829,6 +829,14 @@ "sshKeyPassphrase": "SSH Key Passphrase (Opsyonal)", "sshKeyPassphrasePlaceholder": "Ilagay ang key passphrase kung naka-encrypt", "allowSshPrompt": "Payagan ang SSH password/PIN prompt", + "socketPath": "Socket Path (Opsyonal)", + "socketPathHint": "Kumonekta sa pamamagitan ng Unix socket sa makinang ito sa halip na Host at Port.", + "socketPathActive": "Ginagamit ng koneksyon ang socket na ito sa halip na Host at Port. Naka-off ang TLS ng database — hindi umaalis sa makinang ito ang trapiko ng local socket.", + "socketPathForwardHint": "Itakda ito para maabot ang database na nakikinig lang sa isang Unix socket.", + "socketPathForwardActive": "Kumokonekta ang SSH server sa socket na ito sa halip na Host at Port. Hindi maaaring mag-negotiate ng TLS ang database sa isang socket kaya naka-off ito; ineencrypt pa rin ng SSH tunnel ang buong daan.", + "socketPathNotAbsolute": "Maglagay ng absolute path, gaya ng makikita sa makinang kumokonekta sa socket.", + "socketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", + "socketPathTunnel": "Hindi ginagamit habang naka-enable ang Kubernetes tunnel.", "saveKeychain": "I-save ang mga password sa Keychain", "testConnection": "Subukan ang Connection", "save": "I-save", diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 90c966885..2a6c5bad2 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -43,6 +43,10 @@ export interface DriverCapabilities { /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ supports_ssl?: boolean; + /** Supports connecting through a Unix socket on this machine instead of host:port + * (shows the local Socket Path field in the connection modal). + * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ + unix_socket?: boolean; /** * SQL dialect for the statement splitter / classifier. Plugins that * omit the field fall back to "postgres" (the dialect everyone got diff --git a/src/utils/connections.ts b/src/utils/connections.ts index bfb7806bf..a818fc521 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -17,6 +17,11 @@ export interface ConnectionParams { host?: string; database: string; port?: number; + /** + * Unix socket at the connection's destination, replacing host:port — + * dialed locally without a tunnel, by the SSH server with SSH enabled. + */ + unix_socket_path?: string; username?: string; password?: string; ssh_enabled?: boolean; @@ -41,6 +46,50 @@ export interface ConnectionParams { startup_script?: string; } +export type UnixSocketPathIssue = "notAbsolute" | "looksLikeDirectory"; + +/** + * Advisory check for a Unix socket path (local or SSH forward destination). + * Both consumers need the socket file itself, as an absolute path on the + * machine that dials it (this one, or the SSH server). + * @param rawPath - The socket path as typed by the user + * @returns The detected issue, or null when the path looks plausible or is empty + */ +export function unixSocketPathIssue( + rawPath: string, +): UnixSocketPathIssue | null { + const path = rawPath.trim(); + if (!path) { + return null; + } + if (!path.startsWith("/")) { + return "notAbsolute"; + } + if (path.endsWith("/")) { + return "looksLikeDirectory"; + } + return null; +} + +/** + * The local Unix socket path in effect for a connection: set and non-empty, + * with no SSH or Kubernetes tunnel overriding it (the tunnel owns the route + * to the database, so the socket is ignored while one is enabled). + * @param params - Connection parameters (any object carrying the three fields) + * @returns The trimmed socket path, or null when none applies + */ +export function effectiveLocalSocketPath(params: { + unix_socket_path?: string; + ssh_enabled?: boolean; + k8s_enabled?: boolean; +}): string | null { + if (params.ssh_enabled || params.k8s_enabled) { + return null; + } + const path = params.unix_socket_path?.trim(); + return path ? path : null; +} + /** * Format a connection string for display. * When capabilities are provided, uses file_based/folder_based to determine local vs remote. @@ -60,6 +109,11 @@ export function formatConnectionString( return params.database; } + const socketPath = effectiveLocalSocketPath(params); + if (socketPath) { + return `${socketPath}/${params.database}`; + } + const host = params.host || "localhost"; const port = params.port || getDefaultPort(params.driver); @@ -112,7 +166,14 @@ export function validateConnectionParams( capabilities != null ? isLocalDriver(capabilities) : params.driver === "sqlite"; - if (!local && !params.host) { + const socketDestination = + !params.k8s_enabled && + !!params.unix_socket_path?.trim() && + (params.ssh_enabled || + capabilities?.unix_socket === true || + (capabilities == null && + (params.driver === "mysql" || params.driver === "postgres"))); + if (!local && !params.host && !socketDestination) { return { isValid: false, error: "Host is required for remote databases" }; } @@ -200,6 +261,10 @@ export function connectionSubtitle( } const db = conn.params.database; const dbStr = Array.isArray(db) ? `${db.length} databases` : db; + const socketPath = effectiveLocalSocketPath(conn.params); + if (socketPath) { + return `${socketPath} · ${dbStr}`; + } return `${conn.params.host ?? 'localhost'}:${conn.params.port ?? ''} · ${dbStr}`; } diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 41f057c25..bb191414e 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -17,6 +17,7 @@ interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; + unix_socket_path?: string; save_in_keychain?: boolean; } diff --git a/tests/components/modals/NewConnectionModal.test.tsx b/tests/components/modals/NewConnectionModal.test.tsx index 45f0cddc5..e57c72fbe 100644 --- a/tests/components/modals/NewConnectionModal.test.tsx +++ b/tests/components/modals/NewConnectionModal.test.tsx @@ -998,6 +998,51 @@ describe("NewConnectionModal advanced inline K8s paths", () => { }); }); + it("switches between host/port and Unix socket endpoint modes", async () => { + vi.mocked(invoke).mockImplementation((command) => + command === "get_connection_by_id" + ? Promise.reject(new Error("use initial params")) + : Promise.resolve("ok"), + ); + renderModal( + createInitialConnection({ + host: "db.internal", + port: 3306, + unix_socket_path: "/var/run/mysqld/mysqld.sock", + ssh_enabled: true, + }), + ); + + const socketInput = await screen.findByDisplayValue( + "/var/run/mysqld/mysqld.sock", + ); + expect(screen.queryByPlaceholderText("localhost")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("3306")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "newConnection.socketPath" }), + ).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click( + screen.getByRole("button", { + name: "newConnection.host / newConnection.port", + }), + ); + + expect(screen.getByPlaceholderText("localhost")).toHaveValue("db.internal"); + expect(screen.getByPlaceholderText("3306")).toHaveValue(3306); + expect(socketInput).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "newConnection.socketPath" }), + ); + + expect( + screen.getByDisplayValue("/var/run/mysqld/mysqld.sock"), + ).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("localhost")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("3306")).not.toBeInTheDocument(); + }); + it("does not clear an unrelated name validation error after a valid path blur", async () => { await openInlineK8s(); diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index f4bdd699c..dcfba1cc9 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -7,6 +7,8 @@ import { generateConnectionName, connectionSubtitle, getCardClass, + unixSocketPathIssue, + effectiveLocalSocketPath, type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; @@ -495,4 +497,148 @@ describe('connections', () => { expect(generateConnectionName(params, makeRemoteCaps())).toBe('analytics@localhost'); }); }); + + describe('unixSocketPathIssue', () => { + it('should accept an empty path', () => { + expect(unixSocketPathIssue('')).toBeNull(); + expect(unixSocketPathIssue(' ')).toBeNull(); + }); + + it('should accept an absolute socket file path', () => { + expect(unixSocketPathIssue('/var/run/mysqld/mysqld.sock')).toBeNull(); + expect(unixSocketPathIssue('/var/run/postgresql/.s.PGSQL.5432')).toBeNull(); + }); + + it('should flag a relative path', () => { + expect(unixSocketPathIssue('mysqld.sock')).toBe('notAbsolute'); + expect(unixSocketPathIssue('run/mysqld/mysqld.sock')).toBe('notAbsolute'); + }); + + it('should flag a directory-looking path', () => { + expect(unixSocketPathIssue('/var/run/mysqld/')).toBe('looksLikeDirectory'); + expect(unixSocketPathIssue('/')).toBe('looksLikeDirectory'); + }); + + it('should trim surrounding whitespace before checking', () => { + expect(unixSocketPathIssue(' /tmp/db.sock ')).toBeNull(); + expect(unixSocketPathIssue(' tmp/db.sock ')).toBe('notAbsolute'); + }); + }); + + describe('effectiveLocalSocketPath', () => { + it('should return the trimmed socket path when set', () => { + expect(effectiveLocalSocketPath({ unix_socket_path: ' /tmp/mysql.sock ' })).toBe( + '/tmp/mysql.sock', + ); + }); + + it('should return null when unset or blank', () => { + expect(effectiveLocalSocketPath({})).toBeNull(); + expect(effectiveLocalSocketPath({ unix_socket_path: '' })).toBeNull(); + expect(effectiveLocalSocketPath({ unix_socket_path: ' ' })).toBeNull(); + }); + + it('should return null while an SSH or K8s tunnel is enabled', () => { + expect( + effectiveLocalSocketPath({ unix_socket_path: '/tmp/mysql.sock', ssh_enabled: true }), + ).toBeNull(); + expect( + effectiveLocalSocketPath({ unix_socket_path: '/tmp/mysql.sock', k8s_enabled: true }), + ).toBeNull(); + }); + }); + + describe('local unix socket connections', () => { + it('formatConnectionString should show the socket path instead of host:port', () => { + const params: ConnectionParams = { + driver: 'mysql', + database: 'mydb', + unix_socket_path: '/tmp/mysql.sock', + }; + expect(formatConnectionString(params)).toBe('/tmp/mysql.sock/mydb'); + }); + + it('formatConnectionString should ignore the socket while a tunnel is enabled', () => { + const params: ConnectionParams = { + driver: 'mysql', + database: 'mydb', + host: 'db.host', + port: 3306, + unix_socket_path: '/tmp/mysql.sock', + ssh_enabled: true, + }; + expect(formatConnectionString(params)).toBe('db.host:3306/mydb'); + }); + + it('validateConnectionParams should not require host when a socket is set', () => { + const params: Partial = { + driver: 'mysql', + database: 'mydb', + unix_socket_path: '/tmp/mysql.sock', + }; + expect(validateConnectionParams(params).isValid).toBe(true); + }); + + it('validateConnectionParams should not require host for an SSH-forwarded socket', () => { + const params: Partial = { + driver: 'mysql', + database: 'mydb', + unix_socket_path: '/var/run/mysqld/mysqld.sock', + ssh_enabled: true, + ssh_host: 'bastion.example.com', + ssh_user: 'deploy', + ssh_password: 'secret', + }; + expect(validateConnectionParams(params).isValid).toBe(true); + }); + + it('validateConnectionParams should ignore a socket path for Kubernetes', () => { + const params: Partial = { + driver: 'mysql', + database: 'mydb', + unix_socket_path: '/tmp/mysql.sock', + k8s_enabled: true, + }; + const result = validateConnectionParams(params); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Host is required for remote databases'); + }); + + it('validateConnectionParams should capability-gate a local socket', () => { + const params: Partial = { + driver: 'custom', + database: 'mydb', + unix_socket_path: '/tmp/custom.sock', + }; + const result = validateConnectionParams(params, makeRemoteCaps()); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Host is required for remote databases'); + }); + + it('validateConnectionParams should still require host when the socket is blank', () => { + const params: Partial = { + driver: 'mysql', + database: 'mydb', + unix_socket_path: ' ', + }; + const result = validateConnectionParams(params); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Host is required for remote databases'); + }); + + it('connectionSubtitle should show the socket path instead of host:port', () => { + const conn: SavedConnection = { + id: '1', + name: 'test', + params: { + driver: 'postgres', + database: 'mydb', + unix_socket_path: '/var/run/postgresql/.s.PGSQL.5432', + }, + }; + expect(connectionSubtitle(conn, makeRemoteCaps())).toBe( + '/var/run/postgresql/.s.PGSQL.5432 · mydb', + ); + }); + }); });