From 7202256eff2fecfb469cf39c4e4571b1fed0e392 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sat, 18 Jul 2026 14:07:08 +0200 Subject: [PATCH 1/4] feat(ssh): forward the tunnel to a Unix socket instead of host:port --- src-tauri/src/commands.rs | 129 +++++++++++++-- src-tauri/src/models.rs | 5 + src-tauri/src/plugins/driver.rs | 1 + src-tauri/src/ssh_tunnel.rs | 161 ++++++++++++++----- src/components/modals/NewConnectionModal.tsx | 50 +++++- src/i18n/locales/de.json | 5 + src/i18n/locales/en.json | 5 + src/i18n/locales/es.json | 5 + src/i18n/locales/fr.json | 5 + src/i18n/locales/it.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/ko.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/tl.json | 5 + src/utils/connections.ts | 26 +++ src/utils/credentials.ts | 1 + tests/utils/connections.test.ts | 28 ++++ 17 files changed, 393 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5acb57659..5ca70f9d6 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, otherwise the database host:port. +fn ssh_forward_destination( + params: &ConnectionParams, +) -> crate::ssh_tunnel::SshForwardDestination { + let socket_path = params + .ssh_forward_unix_socket_path + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()); + match 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); + 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). @@ -320,20 +360,16 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result Result Result, save_in_keychain: bool) -> SavedConnection { SavedConnection { id: id.to_string(), diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 28e343b96..1c31e33f9 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -205,6 +205,11 @@ pub struct ConnectionParams { pub ssh_key_passphrase: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_allow_passphrase_prompt: Option, + /// Absolute path of a Unix socket on the SSH server. When set, the tunnel + /// forwards to this socket instead of host:port, and database TLS is + /// disabled (a socket peer cannot negotiate it; SSH encrypts the path). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_forward_unix_socket_path: Option, pub save_in_keychain: Option, // Kubernetes Tunnel (mutually exclusive with SSH) #[serde(default)] diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index b3e707430..6dbbe1190 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1002,6 +1002,7 @@ mod tests { ssh_key_file: None, ssh_key_passphrase: None, ssh_allow_passphrase_prompt: None, + ssh_forward_unix_socket_path: None, save_in_keychain: None, k8s_enabled: None, k8s_connection_id: 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 b5a3bf38d..cf17f4935 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -48,6 +48,7 @@ import { K8sAdvancedSettings } from "../ui/K8sAdvancedSettings"; import { isMultiDatabaseCapable } from "../../utils/database"; import { toErrorMessage } from "../../utils/errors"; import { fetchConnectionWithCredentials } from "../../utils/credentials"; +import { sshForwardSocketPathIssue } from "../../utils/connections"; import { getDriverIcon, getDriverColorStyle } from "../../utils/driverUI"; import { parseConnectionString, @@ -81,6 +82,7 @@ interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; + ssh_forward_unix_socket_path?: string; save_in_keychain?: boolean; // K8s k8s_enabled?: boolean; @@ -137,6 +139,7 @@ const FieldInput = ({ placeholder, autoFocus, className, + disabled, }: { label: string; value: string | number | undefined; @@ -145,6 +148,7 @@ const FieldInput = ({ placeholder?: string; autoFocus?: boolean; className?: string; + disabled?: boolean; }) => { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; @@ -161,13 +165,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 && ( @@ -1123,6 +1129,13 @@ export const NewConnectionModal = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; + const usesForwardSocket = + !!formData.ssh_enabled && + !!formData.ssh_forward_unix_socket_path?.trim(); + const forwardSocketPathIssue = sshForwardSocketPathIssue( + formData.ssh_forward_unix_socket_path ?? "", + ); + const loadDatabases = async ( overrides?: Partial, shouldApply: () => boolean = () => true, @@ -1866,6 +1879,7 @@ export const NewConnectionModal = ({ value={formData.host} onChange={(v) => updateField("host", v)} placeholder="localhost" + disabled={usesForwardSocket} /> updateField("port", v)} type="number" placeholder={driver === "mysql" ? "3306" : "5432"} + disabled={usesForwardSocket} /> @@ -2651,6 +2666,39 @@ export const NewConnectionModal = ({ )} + + {/* Forward destination: Unix socket instead of host:port */} +
+ updateField("ssh_forward_unix_socket_path", v)} + placeholder={ + driver === "postgres" + ? "/var/run/postgresql/.s.PGSQL.5432" + : "/var/run/mysqld/mysqld.sock" + } + /> + {forwardSocketPathIssue === "notAbsolute" && ( +

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

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

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

+ )} + {!forwardSocketPathIssue && ( +

+ {usesForwardSocket + ? t("newConnection.sshForwardSocketPathActive") + : t("newConnection.sshForwardSocketPathHint")} +

+ )} +
)} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f518b9d51..819483c8e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -797,6 +797,11 @@ "sshKeyPassphrase": "Passphrase für SSH-Schlüssel (optional)", "sshKeyPassphrasePlaceholder": "Passphrase eingeben, falls der Schlüssel verschlüsselt ist", "allowSshPrompt": "SSH-Passwort/PIN-Eingabeaufforderung erlauben", + "sshForwardSocketPath": "Socket-Pfad (Optional)", + "sshForwardSocketPathHint": "Setzen, um eine Datenbank zu erreichen, die nur auf einem Unix-Socket lauscht.", + "sshForwardSocketPathActive": "Der SSH-Server verbindet sich mit diesem Socket statt mit Host und Port. Eine Datenbank auf einem Socket kann kein TLS aushandeln, daher wird es deaktiviert; der SSH-Tunnel verschlüsselt weiterhin den gesamten Weg.", + "sshForwardSocketPathNotAbsolute": "Absoluten Pfad eingeben, wie er auf dem SSH-Server erscheint.", + "sshForwardSocketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", "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 40a5b40b3..7bc6fbf5f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -836,6 +836,11 @@ "sshKeyPassphrase": "SSH Key Passphrase (Optional)", "sshKeyPassphrasePlaceholder": "Enter key passphrase if encrypted", "allowSshPrompt": "Allow SSH password/PIN prompt", + "sshForwardSocketPath": "Socket Path (Optional)", + "sshForwardSocketPathHint": "Set this to reach a database that only listens on a Unix socket.", + "sshForwardSocketPathActive": "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.", + "sshForwardSocketPathNotAbsolute": "Enter an absolute path, as it appears on the SSH server.", + "sshForwardSocketPathDirectory": "Point at the socket file itself, not the directory holding it.", "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 dfa1c21e6..dd8b397b1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -822,6 +822,11 @@ "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", + "sshForwardSocketPath": "Ruta del Socket (Opcional)", + "sshForwardSocketPathHint": "Configúralo para acceder a una base de datos que solo escucha en un socket Unix.", + "sshForwardSocketPathActive": "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.", + "sshForwardSocketPathNotAbsolute": "Introduce una ruta absoluta, tal como aparece en el servidor SSH.", + "sshForwardSocketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", "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 6173ab38f..3cb770780 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -831,6 +831,11 @@ "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", + "sshForwardSocketPath": "Chemin du Socket (Optionnel)", + "sshForwardSocketPathHint": "Définissez-le pour atteindre une base de données qui n'écoute que sur un socket Unix.", + "sshForwardSocketPathActive": "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 TLS, il est donc désactivé ; le tunnel SSH chiffre tout le trajet.", + "sshForwardSocketPathNotAbsolute": "Saisissez un chemin absolu, tel qu'il apparaît sur le serveur SSH.", + "sshForwardSocketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", "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 2ef1e6ecd..1e236e02a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -822,6 +822,11 @@ "sshKeyPassphrase": "Passphrase Chiave SSH (Opzionale)", "sshKeyPassphrasePlaceholder": "Inserisci passphrase se la chiave è cifrata", "allowSshPrompt": "Consenti prompt password/PIN SSH", + "sshForwardSocketPath": "Percorso Socket (Opzionale)", + "sshForwardSocketPathHint": "Impostalo per raggiungere un database in ascolto solo su un socket Unix.", + "sshForwardSocketPathActive": "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.", + "sshForwardSocketPathNotAbsolute": "Inserisci un percorso assoluto, come appare sul server SSH.", + "sshForwardSocketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", "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 e51fb8833..289391a36 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -811,6 +811,11 @@ "sshKeyPassphrase": "SSH 鍵のパスフレーズ (任意)", "sshKeyPassphrasePlaceholder": "鍵が暗号化されている場合はパスフレーズを入力", "allowSshPrompt": "SSHパスワード/PINプロンプトを許可する", + "sshForwardSocketPath": "ソケットパス(任意)", + "sshForwardSocketPathHint": "Unixソケットのみで待ち受けるデータベースに接続する場合に設定します。", + "sshForwardSocketPathActive": "SSHサーバーはホストとポートの代わりにこのソケットへ接続します。ソケット上のデータベースはTLSをネゴシエートできないため無効になりますが、SSHトンネルが経路全体を暗号化します。", + "sshForwardSocketPathNotAbsolute": "SSHサーバー上に存在する絶対パスを入力してください。", + "sshForwardSocketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", "saveKeychain": "パスワードをキーチェーンに保存", "testConnection": "接続テスト", "save": "保存", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 9d9aeb21c..976d0e6d4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -797,6 +797,11 @@ "sshKeyPassphrase": "SSH 키 암호(선택)", "sshKeyPassphrasePlaceholder": "암호화된 경우 키 암호를 입력하세요", "allowSshPrompt": "SSH 비밀번호/PIN 입력창 허용", + "sshForwardSocketPath": "소켓 경로 (선택 사항)", + "sshForwardSocketPathHint": "Unix 소켓에서만 수신하는 데이터베이스에 연결하려면 설정하세요.", + "sshForwardSocketPathActive": "SSH 서버가 호스트와 포트 대신 이 소켓에 연결합니다. 소켓의 데이터베이스는 TLS를 협상할 수 없어 비활성화되며, SSH 터널이 전체 경로를 암호화합니다.", + "sshForwardSocketPathNotAbsolute": "SSH 서버에 있는 그대로의 절대 경로를 입력하세요.", + "sshForwardSocketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", "saveKeychain": "키체인에 비밀번호 저장", "testConnection": "연결 테스트", "save": "저장", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bc804890a..6ad9365f1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -791,6 +791,11 @@ "sshKeyPassphrase": "Пароль SSH-ключа (необязательно)", "sshKeyPassphrasePlaceholder": "Введите пароль, если ключ зашифрован", "allowSshPrompt": "Разрешить запрос пароля/PIN-кода SSH", + "sshForwardSocketPath": "Путь к сокету (необязательно)", + "sshForwardSocketPathHint": "Укажите, чтобы подключиться к базе данных, доступной только через Unix-сокет.", + "sshForwardSocketPathActive": "SSH-сервер подключается к этому сокету вместо хоста и порта. База данных на сокете не может согласовать TLS, поэтому он отключается; SSH-туннель всё равно шифрует весь путь.", + "sshForwardSocketPathNotAbsolute": "Введите абсолютный путь, как он выглядит на SSH-сервере.", + "sshForwardSocketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", "saveKeychain": "Сохранять пароли в Keychain", "testConnection": "Проверить подключение", "save": "Сохранить", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index 9f5853253..8d2df2458 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -829,6 +829,11 @@ "sshKeyPassphrase": "SSH Key Passphrase (Opsyonal)", "sshKeyPassphrasePlaceholder": "Ilagay ang key passphrase kung naka-encrypt", "allowSshPrompt": "Payagan ang SSH password/PIN prompt", + "sshForwardSocketPath": "Socket Path (Opsyonal)", + "sshForwardSocketPathHint": "Itakda ito para maabot ang database na nakikinig lang sa isang Unix socket.", + "sshForwardSocketPathActive": "Kumokonekta ang SSH server sa socket na ito sa halip na sa Host at Port. Hindi maaaring mag-negotiate ng TLS ang database sa socket kaya naka-off ito; ine-encrypt pa rin ng SSH tunnel ang buong daan.", + "sshForwardSocketPathNotAbsolute": "Maglagay ng absolute path, gaya ng makikita sa SSH server.", + "sshForwardSocketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", "saveKeychain": "I-save ang mga password sa Keychain", "testConnection": "Subukan ang Connection", "save": "I-save", diff --git a/src/utils/connections.ts b/src/utils/connections.ts index bfb7806bf..cff9257ac 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -29,6 +29,8 @@ export interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; + /** Unix socket on the SSH server the tunnel forwards to instead of host:port. */ + ssh_forward_unix_socket_path?: string; // K8s k8s_enabled?: boolean; k8s_connection_id?: string; @@ -41,6 +43,30 @@ export interface ConnectionParams { startup_script?: string; } +export type SshForwardSocketPathIssue = "notAbsolute" | "looksLikeDirectory"; + +/** + * Advisory check for the SSH forward socket path. `ssh -L` needs the socket + * file itself, as an absolute path on 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 sshForwardSocketPathIssue( + rawPath: string, +): SshForwardSocketPathIssue | null { + const path = rawPath.trim(); + if (!path) { + return null; + } + if (!path.startsWith("/")) { + return "notAbsolute"; + } + if (path.endsWith("/")) { + return "looksLikeDirectory"; + } + return null; +} + /** * Format a connection string for display. * When capabilities are provided, uses file_based/folder_based to determine local vs remote. diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 41f057c25..476acb4b3 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; + ssh_forward_unix_socket_path?: string; save_in_keychain?: boolean; } diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index f4bdd699c..7a00ee120 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -7,6 +7,7 @@ import { generateConnectionName, connectionSubtitle, getCardClass, + sshForwardSocketPathIssue, type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; @@ -495,4 +496,31 @@ describe('connections', () => { expect(generateConnectionName(params, makeRemoteCaps())).toBe('analytics@localhost'); }); }); + + describe('sshForwardSocketPathIssue', () => { + it('should accept an empty path', () => { + expect(sshForwardSocketPathIssue('')).toBeNull(); + expect(sshForwardSocketPathIssue(' ')).toBeNull(); + }); + + it('should accept an absolute socket file path', () => { + expect(sshForwardSocketPathIssue('/var/run/mysqld/mysqld.sock')).toBeNull(); + expect(sshForwardSocketPathIssue('/var/run/postgresql/.s.PGSQL.5432')).toBeNull(); + }); + + it('should flag a relative path', () => { + expect(sshForwardSocketPathIssue('mysqld.sock')).toBe('notAbsolute'); + expect(sshForwardSocketPathIssue('run/mysqld/mysqld.sock')).toBe('notAbsolute'); + }); + + it('should flag a directory-looking path', () => { + expect(sshForwardSocketPathIssue('/var/run/mysqld/')).toBe('looksLikeDirectory'); + expect(sshForwardSocketPathIssue('/')).toBe('looksLikeDirectory'); + }); + + it('should trim surrounding whitespace before checking', () => { + expect(sshForwardSocketPathIssue(' /tmp/db.sock ')).toBeNull(); + expect(sshForwardSocketPathIssue(' tmp/db.sock ')).toBe('notAbsolute'); + }); + }); }); From 4b2f54f9acf1c9b8a84ff3009c2d87547a2af1d4 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sat, 18 Jul 2026 15:40:44 +0200 Subject: [PATCH 2/4] feat(connections): connect to a local Unix socket without a tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unix_socket_path to ConnectionParams: when set and no SSH/K8s tunnel is enabled, MySQL connects via sqlx's socket option and PostgreSQL via host_path (the .s.PGSQL. file path is split into directory + port). Database TLS is forced off — a local socket peer cannot negotiate it and the traffic never leaves the machine. Tunnel resolution clears the field so a leftover path cannot bypass the tunnel's forwarded port. The field is gated by a new unix_socket driver capability (default false, true for the built-in mysql/postgres drivers); plugins can opt in via their manifest. The connection modal shows a Local Socket Path field that greys out Host/Port when active, reusing the SSH-forward advisory checks (helper renamed to unixSocketPathIssue). --- src-tauri/src/commands.rs | 22 +++ src-tauri/src/drivers/driver_trait.rs | 6 + src-tauri/src/drivers/mysql/mod.rs | 1 + src-tauri/src/drivers/postgres/mod.rs | 1 + src-tauri/src/drivers/sqlite/mod.rs | 1 + src-tauri/src/models.rs | 22 +++ src-tauri/src/plugins/driver.rs | 1 + src-tauri/src/pool_manager.rs | 80 +++++++++-- src-tauri/src/pool_manager_tests.rs | 137 +++++++++++++++++++ src/components/modals/NewConnectionModal.tsx | 70 +++++++++- src/contexts/DatabaseContext.ts | 2 + src/i18n/locales/de.json | 6 + src/i18n/locales/en.json | 6 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/ko.json | 6 + src/i18n/locales/ru.json | 6 + src/i18n/locales/tl.json | 6 + src/types/plugins.ts | 4 + src/utils/connections.ts | 43 +++++- src/utils/credentials.ts | 1 + tests/utils/connections.test.ts | 106 ++++++++++++-- 24 files changed, 513 insertions(+), 38 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5ca70f9d6..18859d920 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -251,6 +251,9 @@ fn params_through_tunnel( 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 { .. } @@ -302,6 +305,9 @@ fn resolve_k8s_params(params: &ConnectionParams) -> 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); } } @@ -1932,6 +1940,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) } @@ -2091,6 +2100,19 @@ mod tests { 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 af3fb4390..83cc8b2ae 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -123,6 +123,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 ec4564e07..05d4babff 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1579,6 +1579,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 63f783b98..14001af0c 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1679,6 +1679,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 fe856d8bb..c1ca0b121 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -901,6 +901,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 1c31e33f9..c072029b3 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -171,6 +171,13 @@ pub struct ConnectionParams { pub driver: String, pub host: Option, pub port: Option, + /// Absolute path of a Unix socket on this machine that the database + /// listens on. When set and no tunnel is active, drivers connect to the + /// socket directly instead of host:port, and database TLS is disabled + /// (TLS cannot be negotiated over a local socket; traffic never leaves + /// the machine). Ignored while an SSH or Kubernetes tunnel is enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unix_socket_path: Option, pub username: Option, pub password: Option, pub database: DatabaseSelection, @@ -242,6 +249,21 @@ pub struct ConnectionParams { pub connection_id: Option, } +impl ConnectionParams { + /// The local Unix socket path in effect: trimmed and non-empty. `None` + /// while an SSH or Kubernetes tunnel is enabled — the tunnel owns the + /// route to the database, so the local socket must not override it. + 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 + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + } +} + #[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 6dbbe1190..5c317f181 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1003,6 +1003,7 @@ mod tests { ssh_key_passphrase: None, ssh_allow_passphrase_prompt: None, ssh_forward_unix_socket_path: 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/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index cf17f4935..ad07ee75f 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -48,7 +48,7 @@ import { K8sAdvancedSettings } from "../ui/K8sAdvancedSettings"; import { isMultiDatabaseCapable } from "../../utils/database"; import { toErrorMessage } from "../../utils/errors"; import { fetchConnectionWithCredentials } from "../../utils/credentials"; -import { sshForwardSocketPathIssue } from "../../utils/connections"; +import { unixSocketPathIssue } from "../../utils/connections"; import { getDriverIcon, getDriverColorStyle } from "../../utils/driverUI"; import { parseConnectionString, @@ -83,6 +83,8 @@ interface ConnectionParams { ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; ssh_forward_unix_socket_path?: string; + // Local Unix socket the drivers dial instead of host:port (no tunnel) + unix_socket_path?: string; save_in_keychain?: boolean; // K8s k8s_enabled?: boolean; @@ -425,6 +427,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) => @@ -1132,9 +1135,17 @@ export const NewConnectionModal = ({ const usesForwardSocket = !!formData.ssh_enabled && !!formData.ssh_forward_unix_socket_path?.trim(); - const forwardSocketPathIssue = sshForwardSocketPathIssue( + const forwardSocketPathIssue = unixSocketPathIssue( formData.ssh_forward_unix_socket_path ?? "", ); + const tunnelEnabled = !!formData.ssh_enabled || !!formData.k8s_enabled; + const usesLocalSocket = + supportsUnixSocket && + !tunnelEnabled && + !!formData.unix_socket_path?.trim(); + const localSocketPathIssue = unixSocketPathIssue( + formData.unix_socket_path ?? "", + ); const loadDatabases = async ( overrides?: Partial, @@ -1879,7 +1890,7 @@ export const NewConnectionModal = ({ value={formData.host} onChange={(v) => updateField("host", v)} placeholder="localhost" - disabled={usesForwardSocket} + disabled={usesForwardSocket || usesLocalSocket} /> updateField("port", v)} type="number" placeholder={driver === "mysql" ? "3306" : "5432"} - disabled={usesForwardSocket} + disabled={usesForwardSocket || usesLocalSocket} /> + {/* Local Unix socket instead of host:port (drivers with the unix_socket capability, no tunnel) */} + {supportsUnixSocket && ( +
+ updateField("unix_socket_path", v)} + placeholder={ + driver === "postgres" + ? "/var/run/postgresql/.s.PGSQL.5432" + : "/tmp/mysql.sock" + } + disabled={tunnelEnabled} + /> + {tunnelEnabled && ( +

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

+ )} + {!tunnelEnabled && localSocketPathIssue === "notAbsolute" && ( +

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

+ )} + {!tunnelEnabled && localSocketPathIssue === "looksLikeDirectory" && ( +

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

+ )} + {!tunnelEnabled && !localSocketPathIssue && ( +

+ {usesLocalSocket + ? t("newConnection.localSocketPathActive") + : t("newConnection.localSocketPathHint")} +

+ )} +
+ )} + {/* User + Password */}
@@ -2104,7 +2158,11 @@ export const NewConnectionModal = ({ onClick={() => { void loadDatabases(); }} - disabled={loadingDatabases || !formData.host || !formData.username} + disabled={ + loadingDatabases || + (!formData.host && !usesLocalSocket) || + !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 ? ( 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 819483c8e..be81466ee 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -802,6 +802,12 @@ "sshForwardSocketPathActive": "Der SSH-Server verbindet sich mit diesem Socket statt mit Host und Port. Eine Datenbank auf einem Socket kann kein TLS aushandeln, daher wird es deaktiviert; der SSH-Tunnel verschlüsselt weiterhin den gesamten Weg.", "sshForwardSocketPathNotAbsolute": "Absoluten Pfad eingeben, wie er auf dem SSH-Server erscheint.", "sshForwardSocketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", + "localSocketPath": "Lokaler Socket-Pfad (Optional)", + "localSocketPathHint": "Über einen Unix-Socket auf diesem Rechner verbinden statt über Host und Port.", + "localSocketPathActive": "Die Verbindung nutzt diesen Socket statt Host und Port. Datenbank-TLS ist deaktiviert — Verkehr über einen lokalen Socket verlässt diesen Rechner nie.", + "localSocketPathNotAbsolute": "Absoluten Pfad eingeben.", + "localSocketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", + "localSocketPathTunnel": "Wird nicht verwendet, solange ein SSH- oder 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 7bc6fbf5f..0e170b2ae 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -841,6 +841,12 @@ "sshForwardSocketPathActive": "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.", "sshForwardSocketPathNotAbsolute": "Enter an absolute path, as it appears on the SSH server.", "sshForwardSocketPathDirectory": "Point at the socket file itself, not the directory holding it.", + "localSocketPath": "Local Socket Path (Optional)", + "localSocketPathHint": "Connect through a Unix socket on this machine instead of Host and Port.", + "localSocketPathActive": "The connection uses this socket instead of Host and Port. Database TLS is turned off — traffic over a local socket never leaves this machine.", + "localSocketPathNotAbsolute": "Enter an absolute path.", + "localSocketPathDirectory": "Point at the socket file itself, not the directory holding it.", + "localSocketPathTunnel": "Not used while an SSH or 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 dd8b397b1..8744d5e68 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -827,6 +827,12 @@ "sshForwardSocketPathActive": "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.", "sshForwardSocketPathNotAbsolute": "Introduce una ruta absoluta, tal como aparece en el servidor SSH.", "sshForwardSocketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", + "localSocketPath": "Ruta del Socket Local (Opcional)", + "localSocketPathHint": "Conéctate a través de un socket Unix en esta máquina en lugar de Host y Puerto.", + "localSocketPathActive": "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.", + "localSocketPathNotAbsolute": "Introduce una ruta absoluta.", + "localSocketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", + "localSocketPathTunnel": "No se usa mientras hay un túnel SSH o 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 3cb770780..095206c65 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -836,6 +836,12 @@ "sshForwardSocketPathActive": "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 TLS, il est donc désactivé ; le tunnel SSH chiffre tout le trajet.", "sshForwardSocketPathNotAbsolute": "Saisissez un chemin absolu, tel qu'il apparaît sur le serveur SSH.", "sshForwardSocketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", + "localSocketPath": "Chemin du Socket Local (Optionnel)", + "localSocketPathHint": "Connectez-vous via un socket Unix sur cette machine au lieu de l'Hôte et du Port.", + "localSocketPathActive": "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.", + "localSocketPathNotAbsolute": "Saisissez un chemin absolu.", + "localSocketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", + "localSocketPathTunnel": "Non utilisé lorsqu'un tunnel SSH ou 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 1e236e02a..8e71729b5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -827,6 +827,12 @@ "sshForwardSocketPathActive": "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.", "sshForwardSocketPathNotAbsolute": "Inserisci un percorso assoluto, come appare sul server SSH.", "sshForwardSocketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", + "localSocketPath": "Percorso Socket Locale (Opzionale)", + "localSocketPathHint": "Connettiti tramite un socket Unix su questa macchina invece di Host e Porta.", + "localSocketPathActive": "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.", + "localSocketPathNotAbsolute": "Inserisci un percorso assoluto.", + "localSocketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", + "localSocketPathTunnel": "Non usato mentre è attivo un tunnel SSH o 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 289391a36..5572f3308 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -816,6 +816,12 @@ "sshForwardSocketPathActive": "SSHサーバーはホストとポートの代わりにこのソケットへ接続します。ソケット上のデータベースはTLSをネゴシエートできないため無効になりますが、SSHトンネルが経路全体を暗号化します。", "sshForwardSocketPathNotAbsolute": "SSHサーバー上に存在する絶対パスを入力してください。", "sshForwardSocketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", + "localSocketPath": "ローカルソケットパス(任意)", + "localSocketPathHint": "ホストとポートの代わりに、このマシン上のUnixソケット経由で接続します。", + "localSocketPathActive": "接続はホストとポートの代わりにこのソケットを使用します。データベースのTLSは無効になります。ローカルソケットの通信はこのマシンの外に出ません。", + "localSocketPathNotAbsolute": "絶対パスを入力してください。", + "localSocketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", + "localSocketPathTunnel": "SSHまたはKubernetesトンネルが有効な間は使用されません。", "saveKeychain": "パスワードをキーチェーンに保存", "testConnection": "接続テスト", "save": "保存", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 976d0e6d4..652a436a0 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -802,6 +802,12 @@ "sshForwardSocketPathActive": "SSH 서버가 호스트와 포트 대신 이 소켓에 연결합니다. 소켓의 데이터베이스는 TLS를 협상할 수 없어 비활성화되며, SSH 터널이 전체 경로를 암호화합니다.", "sshForwardSocketPathNotAbsolute": "SSH 서버에 있는 그대로의 절대 경로를 입력하세요.", "sshForwardSocketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", + "localSocketPath": "로컬 소켓 경로 (선택 사항)", + "localSocketPathHint": "호스트와 포트 대신 이 컴퓨터의 Unix 소켓을 통해 연결합니다.", + "localSocketPathActive": "연결은 호스트와 포트 대신 이 소켓을 사용합니다. 데이터베이스 TLS는 꺼집니다 — 로컬 소켓 트래픽은 이 컴퓨터를 벗어나지 않습니다.", + "localSocketPathNotAbsolute": "절대 경로를 입력하세요.", + "localSocketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", + "localSocketPathTunnel": "SSH 또는 Kubernetes 터널이 활성화된 동안에는 사용되지 않습니다.", "saveKeychain": "키체인에 비밀번호 저장", "testConnection": "연결 테스트", "save": "저장", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6ad9365f1..f23f313cc 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -796,6 +796,12 @@ "sshForwardSocketPathActive": "SSH-сервер подключается к этому сокету вместо хоста и порта. База данных на сокете не может согласовать TLS, поэтому он отключается; SSH-туннель всё равно шифрует весь путь.", "sshForwardSocketPathNotAbsolute": "Введите абсолютный путь, как он выглядит на SSH-сервере.", "sshForwardSocketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", + "localSocketPath": "Путь к локальному сокету (необязательно)", + "localSocketPathHint": "Подключение через Unix-сокет на этой машине вместо хоста и порта.", + "localSocketPathActive": "Соединение использует этот сокет вместо хоста и порта. TLS базы данных отключён — трафик через локальный сокет не покидает эту машину.", + "localSocketPathNotAbsolute": "Введите абсолютный путь.", + "localSocketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", + "localSocketPathTunnel": "Не используется, пока включён туннель SSH или Kubernetes.", "saveKeychain": "Сохранять пароли в Keychain", "testConnection": "Проверить подключение", "save": "Сохранить", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index 8d2df2458..dc8fb2f1e 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -834,6 +834,12 @@ "sshForwardSocketPathActive": "Kumokonekta ang SSH server sa socket na ito sa halip na sa Host at Port. Hindi maaaring mag-negotiate ng TLS ang database sa socket kaya naka-off ito; ine-encrypt pa rin ng SSH tunnel ang buong daan.", "sshForwardSocketPathNotAbsolute": "Maglagay ng absolute path, gaya ng makikita sa SSH server.", "sshForwardSocketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", + "localSocketPath": "Local na Socket Path (Opsyonal)", + "localSocketPathHint": "Kumonekta sa pamamagitan ng Unix socket sa makinang ito sa halip na Host at Port.", + "localSocketPathActive": "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.", + "localSocketPathNotAbsolute": "Maglagay ng absolute path.", + "localSocketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", + "localSocketPathTunnel": "Hindi ginagamit habang naka-enable ang SSH o 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 6fd8c98ca..950723337 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -40,6 +40,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 cff9257ac..95f101795 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -17,6 +17,8 @@ export interface ConnectionParams { host?: string; database: string; port?: number; + /** Unix socket on this machine the drivers connect to instead of host:port (no tunnel). */ + unix_socket_path?: string; username?: string; password?: string; ssh_enabled?: boolean; @@ -43,17 +45,18 @@ export interface ConnectionParams { startup_script?: string; } -export type SshForwardSocketPathIssue = "notAbsolute" | "looksLikeDirectory"; +export type UnixSocketPathIssue = "notAbsolute" | "looksLikeDirectory"; /** - * Advisory check for the SSH forward socket path. `ssh -L` needs the socket - * file itself, as an absolute path on the SSH server. + * 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 sshForwardSocketPathIssue( +export function unixSocketPathIssue( rawPath: string, -): SshForwardSocketPathIssue | null { +): UnixSocketPathIssue | null { const path = rawPath.trim(); if (!path) { return null; @@ -67,6 +70,25 @@ export function sshForwardSocketPathIssue( 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. @@ -86,6 +108,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); @@ -138,7 +165,7 @@ export function validateConnectionParams( capabilities != null ? isLocalDriver(capabilities) : params.driver === "sqlite"; - if (!local && !params.host) { + if (!local && !params.host && !effectiveLocalSocketPath(params)) { return { isValid: false, error: "Host is required for remote databases" }; } @@ -226,6 +253,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 476acb4b3..7e5122c5f 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -18,6 +18,7 @@ interface ConnectionParams { ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; ssh_forward_unix_socket_path?: string; + unix_socket_path?: string; save_in_keychain?: boolean; } diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index 7a00ee120..72b1bdc48 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -7,7 +7,8 @@ import { generateConnectionName, connectionSubtitle, getCardClass, - sshForwardSocketPathIssue, + unixSocketPathIssue, + effectiveLocalSocketPath, type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; @@ -497,30 +498,111 @@ describe('connections', () => { }); }); - describe('sshForwardSocketPathIssue', () => { + describe('unixSocketPathIssue', () => { it('should accept an empty path', () => { - expect(sshForwardSocketPathIssue('')).toBeNull(); - expect(sshForwardSocketPathIssue(' ')).toBeNull(); + expect(unixSocketPathIssue('')).toBeNull(); + expect(unixSocketPathIssue(' ')).toBeNull(); }); it('should accept an absolute socket file path', () => { - expect(sshForwardSocketPathIssue('/var/run/mysqld/mysqld.sock')).toBeNull(); - expect(sshForwardSocketPathIssue('/var/run/postgresql/.s.PGSQL.5432')).toBeNull(); + expect(unixSocketPathIssue('/var/run/mysqld/mysqld.sock')).toBeNull(); + expect(unixSocketPathIssue('/var/run/postgresql/.s.PGSQL.5432')).toBeNull(); }); it('should flag a relative path', () => { - expect(sshForwardSocketPathIssue('mysqld.sock')).toBe('notAbsolute'); - expect(sshForwardSocketPathIssue('run/mysqld/mysqld.sock')).toBe('notAbsolute'); + expect(unixSocketPathIssue('mysqld.sock')).toBe('notAbsolute'); + expect(unixSocketPathIssue('run/mysqld/mysqld.sock')).toBe('notAbsolute'); }); it('should flag a directory-looking path', () => { - expect(sshForwardSocketPathIssue('/var/run/mysqld/')).toBe('looksLikeDirectory'); - expect(sshForwardSocketPathIssue('/')).toBe('looksLikeDirectory'); + expect(unixSocketPathIssue('/var/run/mysqld/')).toBe('looksLikeDirectory'); + expect(unixSocketPathIssue('/')).toBe('looksLikeDirectory'); }); it('should trim surrounding whitespace before checking', () => { - expect(sshForwardSocketPathIssue(' /tmp/db.sock ')).toBeNull(); - expect(sshForwardSocketPathIssue(' tmp/db.sock ')).toBe('notAbsolute'); + 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 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', + ); }); }); }); From dc8993001bade9a2052ae6b12e8de457c849e203 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sat, 18 Jul 2026 15:57:46 +0200 Subject: [PATCH 3/4] refactor(connections): unify the two socket fields into unix_socket_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSH-forward socket path and the local socket path were the same concept — the Unix socket the database listens on at the connection's destination — distinguished only by who dials it. Host/port already have exactly that tunnel-relative semantics, so fold both into the single unix_socket_path field: without a tunnel the drivers dial it locally (capability-gated), with SSH it becomes the forward destination, and a K8s tunnel ignores it. ssh_forward_unix_socket_path is gone (never shipped, so nothing saved references it). One Socket Path field now lives in the General tab, shown when the driver has the unix_socket capability or SSH is enabled, with hints that follow the tunnel state; the SSH-tab field is removed and the i18n keys collapse from 11 to 8 per locale. --- src-tauri/src/commands.rs | 17 ++- src-tauri/src/models.rs | 37 ++++--- src-tauri/src/plugins/driver.rs | 1 - src/components/modals/NewConnectionModal.tsx | 108 +++++++------------ src/i18n/locales/de.json | 19 ++-- src/i18n/locales/en.json | 19 ++-- src/i18n/locales/es.json | 19 ++-- src/i18n/locales/fr.json | 19 ++-- src/i18n/locales/it.json | 19 ++-- src/i18n/locales/ja.json | 19 ++-- src/i18n/locales/ko.json | 19 ++-- src/i18n/locales/ru.json | 19 ++-- src/i18n/locales/tl.json | 19 ++-- src/utils/connections.ts | 7 +- src/utils/credentials.ts | 1 - 15 files changed, 144 insertions(+), 198 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 18859d920..ebee1595b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -220,16 +220,13 @@ fn build_tunnel_map_key( } /// The SSH forward destination for a connection: the Unix socket path when one -/// is configured, otherwise the database host:port. +/// 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 { - let socket_path = params - .ssh_forward_unix_socket_path - .as_deref() - .map(str::trim) - .filter(|p| !p.is_empty()); - match socket_path { + match params.unix_socket_path() { Some(path) => crate::ssh_tunnel::SshForwardDestination::UnixSocket { path: path.to_string(), }, @@ -2050,7 +2047,7 @@ mod tests { #[test] fn forward_destination_ignores_blank_socket_path() { let params = ConnectionParams { - ssh_forward_unix_socket_path: Some(" ".to_string()), + unix_socket_path: Some(" ".to_string()), ..base_params() }; assert!(matches!( @@ -2062,7 +2059,7 @@ mod tests { #[test] fn forward_destination_uses_trimmed_socket_path() { let params = ConnectionParams { - ssh_forward_unix_socket_path: Some(" /var/run/mysqld/mysqld.sock ".to_string()), + unix_socket_path: Some(" /var/run/mysqld/mysqld.sock ".to_string()), ..base_params() }; assert_eq!( @@ -2090,7 +2087,7 @@ mod tests { fn tunnel_params_disable_ssl_for_socket_destination() { let params = ConnectionParams { ssl_mode: Some("require".to_string()), - ssh_forward_unix_socket_path: Some("/tmp/db.sock".to_string()), + unix_socket_path: Some("/tmp/db.sock".to_string()), ..base_params() }; let destination = ssh_forward_destination(¶ms); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index c072029b3..739bf4801 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -171,11 +171,13 @@ pub struct ConnectionParams { pub driver: String, pub host: Option, pub port: Option, - /// Absolute path of a Unix socket on this machine that the database - /// listens on. When set and no tunnel is active, drivers connect to the - /// socket directly instead of host:port, and database TLS is disabled - /// (TLS cannot be negotiated over a local socket; traffic never leaves - /// the machine). Ignored while an SSH or Kubernetes tunnel is enabled. + /// 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, @@ -212,11 +214,6 @@ pub struct ConnectionParams { pub ssh_key_passphrase: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_allow_passphrase_prompt: Option, - /// Absolute path of a Unix socket on the SSH server. When set, the tunnel - /// forwards to this socket instead of host:port, and database TLS is - /// disabled (a socket peer cannot negotiate it; SSH encrypts the path). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ssh_forward_unix_socket_path: Option, pub save_in_keychain: Option, // Kubernetes Tunnel (mutually exclusive with SSH) #[serde(default)] @@ -250,18 +247,24 @@ pub struct ConnectionParams { } impl ConnectionParams { - /// The local Unix socket path in effect: trimmed and non-empty. `None` - /// while an SSH or Kubernetes tunnel is enabled — the tunnel owns the - /// route to the database, so the local socket must not override it. - pub fn local_unix_socket_path(&self) -> Option<&str> { - if self.ssh_enabled.unwrap_or(false) || self.k8s_enabled.unwrap_or(false) { - return None; - } + /// 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)] diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 5c317f181..365265e4a 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1002,7 +1002,6 @@ mod tests { ssh_key_file: None, ssh_key_passphrase: None, ssh_allow_passphrase_prompt: None, - ssh_forward_unix_socket_path: None, unix_socket_path: None, save_in_keychain: None, k8s_enabled: None, diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index ad07ee75f..06815633e 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -82,8 +82,8 @@ interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; - ssh_forward_unix_socket_path?: string; - // Local Unix socket the drivers dial instead of host:port (no tunnel) + // 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 @@ -1132,18 +1132,16 @@ export const NewConnectionModal = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; - const usesForwardSocket = - !!formData.ssh_enabled && - !!formData.ssh_forward_unix_socket_path?.trim(); - const forwardSocketPathIssue = unixSocketPathIssue( - formData.ssh_forward_unix_socket_path ?? "", - ); const tunnelEnabled = !!formData.ssh_enabled || !!formData.k8s_enabled; + const socketPathSet = !!formData.unix_socket_path?.trim(); + // 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 && - !!formData.unix_socket_path?.trim(); - const localSocketPathIssue = unixSocketPathIssue( + supportsUnixSocket && !tunnelEnabled && socketPathSet; + const usesSocket = usesForwardSocket || usesLocalSocket; + const socketPathIssue = unixSocketPathIssue( formData.unix_socket_path ?? "", ); @@ -1890,7 +1888,7 @@ export const NewConnectionModal = ({ value={formData.host} onChange={(v) => updateField("host", v)} placeholder="localhost" - disabled={usesForwardSocket || usesLocalSocket} + disabled={usesSocket} /> updateField("port", v)} type="number" placeholder={driver === "mysql" ? "3306" : "5432"} - disabled={usesForwardSocket || usesLocalSocket} + disabled={usesSocket} />
- {/* Local Unix socket instead of host:port (drivers with the unix_socket capability, no tunnel) */} - {supportsUnixSocket && ( + {/* Unix socket instead of host:port — dialed locally (capability-gated) + or by the SSH server when the tunnel is enabled */} + {(supportsUnixSocket || !!formData.ssh_enabled) && (
updateField("unix_socket_path", v)} placeholder={ driver === "postgres" ? "/var/run/postgresql/.s.PGSQL.5432" - : "/tmp/mysql.sock" + : formData.ssh_enabled + ? "/var/run/mysqld/mysqld.sock" + : "/tmp/mysql.sock" } - disabled={tunnelEnabled} + disabled={!!formData.k8s_enabled} /> - {tunnelEnabled && ( + {formData.k8s_enabled && (

- {t("newConnection.localSocketPathTunnel")} -

- )} - {!tunnelEnabled && localSocketPathIssue === "notAbsolute" && ( -

- {" "} - {t("newConnection.localSocketPathNotAbsolute")} + {t("newConnection.socketPathTunnel")}

)} - {!tunnelEnabled && localSocketPathIssue === "looksLikeDirectory" && ( + {!formData.k8s_enabled && socketPathIssue === "notAbsolute" && (

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

)} - {!tunnelEnabled && !localSocketPathIssue && ( + {!formData.k8s_enabled && + socketPathIssue === "looksLikeDirectory" && ( +

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

+ )} + {!formData.k8s_enabled && !socketPathIssue && (

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

)}
@@ -1981,7 +1987,7 @@ export const NewConnectionModal = ({ }} disabled={ loadingDatabases || - (!formData.host && !usesLocalSocket) || + (!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" @@ -2160,7 +2166,7 @@ export const NewConnectionModal = ({ }} disabled={ loadingDatabases || - (!formData.host && !usesLocalSocket) || + (!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" @@ -2725,38 +2731,6 @@ export const NewConnectionModal = ({ )} - {/* Forward destination: Unix socket instead of host:port */} -
- updateField("ssh_forward_unix_socket_path", v)} - placeholder={ - driver === "postgres" - ? "/var/run/postgresql/.s.PGSQL.5432" - : "/var/run/mysqld/mysqld.sock" - } - /> - {forwardSocketPathIssue === "notAbsolute" && ( -

- {" "} - {t("newConnection.sshForwardSocketPathNotAbsolute")} -

- )} - {forwardSocketPathIssue === "looksLikeDirectory" && ( -

- {" "} - {t("newConnection.sshForwardSocketPathDirectory")} -

- )} - {!forwardSocketPathIssue && ( -

- {usesForwardSocket - ? t("newConnection.sshForwardSocketPathActive") - : t("newConnection.sshForwardSocketPathHint")} -

- )} -
)} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index be81466ee..20bd068de 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -797,17 +797,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", - "sshForwardSocketPath": "Socket-Pfad (Optional)", - "sshForwardSocketPathHint": "Setzen, um eine Datenbank zu erreichen, die nur auf einem Unix-Socket lauscht.", - "sshForwardSocketPathActive": "Der SSH-Server verbindet sich mit diesem Socket statt mit Host und Port. Eine Datenbank auf einem Socket kann kein TLS aushandeln, daher wird es deaktiviert; der SSH-Tunnel verschlüsselt weiterhin den gesamten Weg.", - "sshForwardSocketPathNotAbsolute": "Absoluten Pfad eingeben, wie er auf dem SSH-Server erscheint.", - "sshForwardSocketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", - "localSocketPath": "Lokaler Socket-Pfad (Optional)", - "localSocketPathHint": "Über einen Unix-Socket auf diesem Rechner verbinden statt über Host und Port.", - "localSocketPathActive": "Die Verbindung nutzt diesen Socket statt Host und Port. Datenbank-TLS ist deaktiviert — Verkehr über einen lokalen Socket verlässt diesen Rechner nie.", - "localSocketPathNotAbsolute": "Absoluten Pfad eingeben.", - "localSocketPathDirectory": "Die Socket-Datei selbst angeben, nicht das Verzeichnis, das sie enthält.", - "localSocketPathTunnel": "Wird nicht verwendet, solange ein SSH- oder Kubernetes-Tunnel aktiv ist.", + "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 0e170b2ae..05e356524 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -836,17 +836,14 @@ "sshKeyPassphrase": "SSH Key Passphrase (Optional)", "sshKeyPassphrasePlaceholder": "Enter key passphrase if encrypted", "allowSshPrompt": "Allow SSH password/PIN prompt", - "sshForwardSocketPath": "Socket Path (Optional)", - "sshForwardSocketPathHint": "Set this to reach a database that only listens on a Unix socket.", - "sshForwardSocketPathActive": "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.", - "sshForwardSocketPathNotAbsolute": "Enter an absolute path, as it appears on the SSH server.", - "sshForwardSocketPathDirectory": "Point at the socket file itself, not the directory holding it.", - "localSocketPath": "Local Socket Path (Optional)", - "localSocketPathHint": "Connect through a Unix socket on this machine instead of Host and Port.", - "localSocketPathActive": "The connection uses this socket instead of Host and Port. Database TLS is turned off — traffic over a local socket never leaves this machine.", - "localSocketPathNotAbsolute": "Enter an absolute path.", - "localSocketPathDirectory": "Point at the socket file itself, not the directory holding it.", - "localSocketPathTunnel": "Not used while an SSH or Kubernetes tunnel is enabled.", + "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 8744d5e68..1777f2350 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -822,17 +822,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", - "sshForwardSocketPath": "Ruta del Socket (Opcional)", - "sshForwardSocketPathHint": "Configúralo para acceder a una base de datos que solo escucha en un socket Unix.", - "sshForwardSocketPathActive": "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.", - "sshForwardSocketPathNotAbsolute": "Introduce una ruta absoluta, tal como aparece en el servidor SSH.", - "sshForwardSocketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", - "localSocketPath": "Ruta del Socket Local (Opcional)", - "localSocketPathHint": "Conéctate a través de un socket Unix en esta máquina en lugar de Host y Puerto.", - "localSocketPathActive": "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.", - "localSocketPathNotAbsolute": "Introduce una ruta absoluta.", - "localSocketPathDirectory": "Apunta al archivo del socket, no al directorio que lo contiene.", - "localSocketPathTunnel": "No se usa mientras hay un túnel SSH o Kubernetes activo.", + "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 095206c65..78ec84820 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -831,17 +831,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", - "sshForwardSocketPath": "Chemin du Socket (Optionnel)", - "sshForwardSocketPathHint": "Définissez-le pour atteindre une base de données qui n'écoute que sur un socket Unix.", - "sshForwardSocketPathActive": "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 TLS, il est donc désactivé ; le tunnel SSH chiffre tout le trajet.", - "sshForwardSocketPathNotAbsolute": "Saisissez un chemin absolu, tel qu'il apparaît sur le serveur SSH.", - "sshForwardSocketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", - "localSocketPath": "Chemin du Socket Local (Optionnel)", - "localSocketPathHint": "Connectez-vous via un socket Unix sur cette machine au lieu de l'Hôte et du Port.", - "localSocketPathActive": "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.", - "localSocketPathNotAbsolute": "Saisissez un chemin absolu.", - "localSocketPathDirectory": "Indiquez le fichier socket lui-même, pas le répertoire qui le contient.", - "localSocketPathTunnel": "Non utilisé lorsqu'un tunnel SSH ou Kubernetes est actif.", + "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 8e71729b5..8f4635154 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -822,17 +822,14 @@ "sshKeyPassphrase": "Passphrase Chiave SSH (Opzionale)", "sshKeyPassphrasePlaceholder": "Inserisci passphrase se la chiave è cifrata", "allowSshPrompt": "Consenti prompt password/PIN SSH", - "sshForwardSocketPath": "Percorso Socket (Opzionale)", - "sshForwardSocketPathHint": "Impostalo per raggiungere un database in ascolto solo su un socket Unix.", - "sshForwardSocketPathActive": "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.", - "sshForwardSocketPathNotAbsolute": "Inserisci un percorso assoluto, come appare sul server SSH.", - "sshForwardSocketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", - "localSocketPath": "Percorso Socket Locale (Opzionale)", - "localSocketPathHint": "Connettiti tramite un socket Unix su questa macchina invece di Host e Porta.", - "localSocketPathActive": "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.", - "localSocketPathNotAbsolute": "Inserisci un percorso assoluto.", - "localSocketPathDirectory": "Indica il file socket stesso, non la directory che lo contiene.", - "localSocketPathTunnel": "Non usato mentre è attivo un tunnel SSH o Kubernetes.", + "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 5572f3308..e9caa6b8f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -811,17 +811,14 @@ "sshKeyPassphrase": "SSH 鍵のパスフレーズ (任意)", "sshKeyPassphrasePlaceholder": "鍵が暗号化されている場合はパスフレーズを入力", "allowSshPrompt": "SSHパスワード/PINプロンプトを許可する", - "sshForwardSocketPath": "ソケットパス(任意)", - "sshForwardSocketPathHint": "Unixソケットのみで待ち受けるデータベースに接続する場合に設定します。", - "sshForwardSocketPathActive": "SSHサーバーはホストとポートの代わりにこのソケットへ接続します。ソケット上のデータベースはTLSをネゴシエートできないため無効になりますが、SSHトンネルが経路全体を暗号化します。", - "sshForwardSocketPathNotAbsolute": "SSHサーバー上に存在する絶対パスを入力してください。", - "sshForwardSocketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", - "localSocketPath": "ローカルソケットパス(任意)", - "localSocketPathHint": "ホストとポートの代わりに、このマシン上のUnixソケット経由で接続します。", - "localSocketPathActive": "接続はホストとポートの代わりにこのソケットを使用します。データベースのTLSは無効になります。ローカルソケットの通信はこのマシンの外に出ません。", - "localSocketPathNotAbsolute": "絶対パスを入力してください。", - "localSocketPathDirectory": "ソケットを含むディレクトリではなく、ソケットファイル自体を指定してください。", - "localSocketPathTunnel": "SSHまたはKubernetesトンネルが有効な間は使用されません。", + "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 652a436a0..bbad9c76c 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -797,17 +797,14 @@ "sshKeyPassphrase": "SSH 키 암호(선택)", "sshKeyPassphrasePlaceholder": "암호화된 경우 키 암호를 입력하세요", "allowSshPrompt": "SSH 비밀번호/PIN 입력창 허용", - "sshForwardSocketPath": "소켓 경로 (선택 사항)", - "sshForwardSocketPathHint": "Unix 소켓에서만 수신하는 데이터베이스에 연결하려면 설정하세요.", - "sshForwardSocketPathActive": "SSH 서버가 호스트와 포트 대신 이 소켓에 연결합니다. 소켓의 데이터베이스는 TLS를 협상할 수 없어 비활성화되며, SSH 터널이 전체 경로를 암호화합니다.", - "sshForwardSocketPathNotAbsolute": "SSH 서버에 있는 그대로의 절대 경로를 입력하세요.", - "sshForwardSocketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", - "localSocketPath": "로컬 소켓 경로 (선택 사항)", - "localSocketPathHint": "호스트와 포트 대신 이 컴퓨터의 Unix 소켓을 통해 연결합니다.", - "localSocketPathActive": "연결은 호스트와 포트 대신 이 소켓을 사용합니다. 데이터베이스 TLS는 꺼집니다 — 로컬 소켓 트래픽은 이 컴퓨터를 벗어나지 않습니다.", - "localSocketPathNotAbsolute": "절대 경로를 입력하세요.", - "localSocketPathDirectory": "소켓이 있는 디렉터리가 아니라 소켓 파일 자체를 지정하세요.", - "localSocketPathTunnel": "SSH 또는 Kubernetes 터널이 활성화된 동안에는 사용되지 않습니다.", + "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 f23f313cc..efb0a0553 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -791,17 +791,14 @@ "sshKeyPassphrase": "Пароль SSH-ключа (необязательно)", "sshKeyPassphrasePlaceholder": "Введите пароль, если ключ зашифрован", "allowSshPrompt": "Разрешить запрос пароля/PIN-кода SSH", - "sshForwardSocketPath": "Путь к сокету (необязательно)", - "sshForwardSocketPathHint": "Укажите, чтобы подключиться к базе данных, доступной только через Unix-сокет.", - "sshForwardSocketPathActive": "SSH-сервер подключается к этому сокету вместо хоста и порта. База данных на сокете не может согласовать TLS, поэтому он отключается; SSH-туннель всё равно шифрует весь путь.", - "sshForwardSocketPathNotAbsolute": "Введите абсолютный путь, как он выглядит на SSH-сервере.", - "sshForwardSocketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", - "localSocketPath": "Путь к локальному сокету (необязательно)", - "localSocketPathHint": "Подключение через Unix-сокет на этой машине вместо хоста и порта.", - "localSocketPathActive": "Соединение использует этот сокет вместо хоста и порта. TLS базы данных отключён — трафик через локальный сокет не покидает эту машину.", - "localSocketPathNotAbsolute": "Введите абсолютный путь.", - "localSocketPathDirectory": "Укажите сам файл сокета, а не каталог, в котором он находится.", - "localSocketPathTunnel": "Не используется, пока включён туннель SSH или Kubernetes.", + "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 dc8fb2f1e..381300f95 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -829,17 +829,14 @@ "sshKeyPassphrase": "SSH Key Passphrase (Opsyonal)", "sshKeyPassphrasePlaceholder": "Ilagay ang key passphrase kung naka-encrypt", "allowSshPrompt": "Payagan ang SSH password/PIN prompt", - "sshForwardSocketPath": "Socket Path (Opsyonal)", - "sshForwardSocketPathHint": "Itakda ito para maabot ang database na nakikinig lang sa isang Unix socket.", - "sshForwardSocketPathActive": "Kumokonekta ang SSH server sa socket na ito sa halip na sa Host at Port. Hindi maaaring mag-negotiate ng TLS ang database sa socket kaya naka-off ito; ine-encrypt pa rin ng SSH tunnel ang buong daan.", - "sshForwardSocketPathNotAbsolute": "Maglagay ng absolute path, gaya ng makikita sa SSH server.", - "sshForwardSocketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", - "localSocketPath": "Local na Socket Path (Opsyonal)", - "localSocketPathHint": "Kumonekta sa pamamagitan ng Unix socket sa makinang ito sa halip na Host at Port.", - "localSocketPathActive": "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.", - "localSocketPathNotAbsolute": "Maglagay ng absolute path.", - "localSocketPathDirectory": "Ituro ang mismong socket file, hindi ang directory na naglalaman nito.", - "localSocketPathTunnel": "Hindi ginagamit habang naka-enable ang SSH o Kubernetes tunnel.", + "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/utils/connections.ts b/src/utils/connections.ts index 95f101795..e4d53f3a8 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -17,7 +17,10 @@ export interface ConnectionParams { host?: string; database: string; port?: number; - /** Unix socket on this machine the drivers connect to instead of host:port (no tunnel). */ + /** + * 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; @@ -31,8 +34,6 @@ export interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; - /** Unix socket on the SSH server the tunnel forwards to instead of host:port. */ - ssh_forward_unix_socket_path?: string; // K8s k8s_enabled?: boolean; k8s_connection_id?: string; diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 7e5122c5f..bb191414e 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -17,7 +17,6 @@ interface ConnectionParams { ssh_key_file?: string; ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; - ssh_forward_unix_socket_path?: string; unix_socket_path?: string; save_in_keychain?: boolean; } From 60b459ab9cca1e4dc5fc63df3d723403b2b80d30 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Mon, 20 Jul 2026 23:15:27 +0200 Subject: [PATCH 4/4] fix(connections): polish Unix socket endpoint flow --- src/components/modals/NewConnectionModal.tsx | 114 ++++++++++++------ src/utils/connections.ts | 9 +- .../modals/NewConnectionModal.test.tsx | 45 +++++++ tests/utils/connections.test.ts | 36 ++++++ 4 files changed, 167 insertions(+), 37 deletions(-) diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 7467e1179..84d47413a 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -1234,6 +1234,10 @@ export const NewConnectionModal = ({ 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. @@ -2048,34 +2052,79 @@ export const NewConnectionModal = ({ )} - {/* Host + Port */} -
- updateField("host", v)} - placeholder="localhost" - disabled={usesSocket} - /> - updateField("port", v)} - type="number" - placeholder={driver === "mysql" ? "3306" : "5432"} - disabled={usesSocket} - /> -
+ {/* 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 */} - {(supportsUnixSocket || !!formData.ssh_enabled) && ( + or by the SSH server when the tunnel is enabled. */} + {socketMode && (
- {formData.k8s_enabled && ( -

- {t("newConnection.socketPathTunnel")} -

- )} - {!formData.k8s_enabled && socketPathIssue === "notAbsolute" && ( + {socketPathIssue === "notAbsolute" && (

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

)} - {!formData.k8s_enabled && - socketPathIssue === "looksLikeDirectory" && ( + {socketPathIssue === "looksLikeDirectory" && (

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

)} - {!formData.k8s_enabled && !socketPathIssue && ( + {!socketPathIssue && (

{formData.ssh_enabled ? usesForwardSocket diff --git a/src/utils/connections.ts b/src/utils/connections.ts index e4d53f3a8..a818fc521 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -166,7 +166,14 @@ export function validateConnectionParams( capabilities != null ? isLocalDriver(capabilities) : params.driver === "sqlite"; - if (!local && !params.host && !effectiveLocalSocketPath(params)) { + 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" }; } 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 72b1bdc48..dcfba1cc9 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -579,6 +579,42 @@ describe('connections', () => { 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',