Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 132 additions & 16 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -262,6 +302,9 @@ fn resolve_k8s_params(params: &ConnectionParams) -> Result<ConnectionParams, Str
new_params.k8s_enabled = Some(false);
new_params.host = Some("127.0.0.1".to_string());
new_params.port = Some(tunnel.local_port);
// See params_through_tunnel: the socket path must not survive past
// tunnel resolution, or drivers would bypass the tunnel.
new_params.unix_socket_path = None;
return Ok(new_params);
}
}
Expand Down Expand Up @@ -296,6 +339,7 @@ fn resolve_k8s_params(params: &ConnectionParams) -> Result<ConnectionParams, Str
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)
}

Expand All @@ -320,20 +364,16 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result<Connection
let ssh_host = params.ssh_host.as_deref().ok_or("Missing SSH Host")?;
let ssh_port = params.ssh_port.unwrap_or(22);
let ssh_user = params.ssh_user.as_deref().ok_or("Missing SSH User")?;
let remote_host = params.host.as_deref().unwrap_or("localhost");
let remote_port = params.port.unwrap_or(DEFAULT_MYSQL_PORT);
let destination = ssh_forward_destination(params);

let map_key = build_tunnel_map_key(ssh_user, ssh_host, ssh_port, remote_host, remote_port);
let map_key = build_tunnel_map_key(ssh_user, ssh_host, ssh_port, &destination);

// Check for existing tunnel
{
let tunnels = get_tunnels().lock().unwrap();
if let Some(tunnel) = tunnels.get(&map_key) {
log::debug!("Reusing existing SSH tunnel on port {}", tunnel.local_port);
let mut new_params = params.clone();
new_params.host = Some("127.0.0.1".to_string());
new_params.port = Some(tunnel.local_port);
return Ok(new_params);
return Ok(params_through_tunnel(params, tunnel.local_port, &destination));
}
}

Expand All @@ -352,8 +392,7 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result<Connection
params.ssh_key_file.as_deref(),
params.ssh_key_passphrase.as_deref(),
params.ssh_allow_passphrase_prompt.unwrap_or(false),
remote_host,
remote_port,
&destination,
)
.map_err(|e| {
eprintln!("[Connection Error] SSH Tunnel setup failed: {}", e);
Expand All @@ -368,10 +407,7 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result<Connection
tunnels.insert(map_key, tunnel);
}

let mut new_params = params.clone();
new_params.host = Some("127.0.0.1".to_string());
new_params.port = Some(local_port);
Ok(new_params)
Ok(params_through_tunnel(params, local_port, &destination))
}

/// Resolve connection params and set connection_id for stable pooling
Expand Down Expand Up @@ -1861,6 +1897,7 @@ pub async fn expand_k8s_connection_params<R: Runtime>(
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);
}
}
Expand Down Expand Up @@ -1900,6 +1937,7 @@ pub async fn expand_k8s_connection_params<R: Runtime>(
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)
}

Expand Down Expand Up @@ -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(&params),
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(&params),
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(&params);
let resolved = params_through_tunnel(&params, 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(&params);
let resolved = params_through_tunnel(&params, 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(&params);
let resolved = params_through_tunnel(&params, 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(),
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/drivers/driver_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/drivers/mysql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,7 @@ impl MysqlDriver {
readonly: false,
triggers: true,
supports_ssl: true,
unix_socket: true,
sql_dialect: SqlDialect::Mysql,
},
is_builtin: true,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/drivers/postgres/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,7 @@ impl PostgresDriver {
readonly: false,
triggers: true,
supports_ssl: true,
unix_socket: true,
sql_dialect: SqlDialect::Postgres,
},
is_builtin: true,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/drivers/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,7 @@ impl SqliteDriver {
readonly: false,
triggers: true,
supports_ssl: false,
unix_socket: false,
sql_dialect: SqlDialect::Sqlite,
},
is_builtin: true,
Expand Down
30 changes: 30 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ pub struct ConnectionParams {
pub driver: String,
pub host: Option<String>,
pub port: Option<u16>,
/// 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<String>,
pub username: Option<String>,
pub password: Option<String>,
pub database: DatabaseSelection,
Expand Down Expand Up @@ -237,6 +246,27 @@ pub struct ConnectionParams {
pub connection_id: Option<String>,
}

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 {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/plugins/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading