diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 3bb10d01e3..4b3a342db5 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -508,6 +508,7 @@ function defaultSshTunnel(): SshTunnelConfig { ssh_agent_sock_path: "", auth_method: "password", allow_exec_channel_proxy: false, + proxy_command: "", }; } @@ -528,6 +529,7 @@ function normalizeSshTunnel(hop: Partial): SshTunnelConfig { ssh_agent_sock_path: hop.ssh_agent_sock_path || "", auth_method: hop.auth_method || inferSshAuthMethod(hop), allow_exec_channel_proxy: !!hop.allow_exec_channel_proxy, + proxy_command: hop.proxy_command || "", profile_id: hop.profile_id || undefined, }; } @@ -10533,6 +10535,13 @@ function openExternalUrl(url: string) { {{ t("connection.sshAllowExecChannelProxy") }} +
+ +
+ +

{{ t("connection.sshProxyCommandHint") }}

+
+
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 299ca1c49a..c15291356e 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1183,6 +1183,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Gözdən keçir", sshExposeLan: "Tuneli LAN üçün əlçatan et", sshAllowExecChannelProxy: "SSH exec-channel ehtiyat üsulunu aktivləşdir (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "Bu hosta çatmaq üçün istəyə bağlı OpenSSH ProxyCommand, məsələn nc %h %p və ya cloudflared access ssh --hostname %h. İcazə verilən köməkçilər: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "SSH vaxt limiti (saniyə)", sshHops: "Tunel / proksi qatları", sshHopAdd: "SSH qatı əlavə et", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 729241df2d..13d2b3c78b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1480,6 +1480,9 @@ export default { sshKeyPathBrowse: "Browse", sshExposeLan: "Expose tunnel to LAN", sshAllowExecChannelProxy: "Enable SSH exec-channel fallback (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "Optional OpenSSH ProxyCommand used to reach this host, for example nc %h %p or cloudflared access ssh --hostname %h. Accepted helpers: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "SSH Timeout (seconds)", sshHops: "Tunnel / Proxy Layers", sshHopAdd: "Add SSH layer", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b52d19ae72..fae5d5c54e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1274,6 +1274,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Examinar", sshExposeLan: "Exponer túnel a la red local", sshAllowExecChannelProxy: "Activar fallback del canal exec SSH (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "ProxyCommand OpenSSH opcional para llegar a este host, por ejemplo nc %h %p o cloudflared access ssh --hostname %h. Asistentes admitidos: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "Tiempo de espera SSH (segundos)", sshHops: "Capas de túnel SSH / proxy", sshHopAdd: "Agregar capa SSH", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 4f7ebbfaef..bd9008ab19 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -1418,6 +1418,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Jelajahi", sshExposeLan: "Ekspos tunnel ke LAN", sshAllowExecChannelProxy: "Aktifkan fallback exec-channel SSH (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "ProxyCommand OpenSSH opsional untuk menjangkau host ini, misalnya nc %h %p atau cloudflared access ssh --hostname %h. Helper yang diizinkan: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "Timeout SSH (detik)", sshHops: "Lapisan Tunnel / Proxy", sshHopAdd: "Tambah lapisan SSH", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0cfba2fb1a..f4f2082121 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1196,6 +1196,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Sfoglia", sshExposeLan: "Esponi tunnel alla LAN", sshAllowExecChannelProxy: "Abilita il fallback del canale exec SSH (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "ProxyCommand OpenSSH opzionale per raggiungere questo host, ad esempio nc %h %p o cloudflared access ssh --hostname %h. Helper consentiti: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "Timeout SSH (secondi)", sshHops: "Livelli tunnel SSH / proxy", sshHopAdd: "Aggiungi livello SSH", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index be8acdc31f..b5473185bc 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1166,6 +1166,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "参照", sshExposeLan: "トンネルをLANに公開", sshAllowExecChannelProxy: "SSH 実行チャネルのフォールバックを有効化(nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "このホストへの接続に使う任意の OpenSSH ProxyCommand(例: nc %h %p、cloudflared access ssh --hostname %h)。使用できるヘルパー: nc、ncat、netcat、cloudflared、socat、connect、corkscrew、ssh。", sshConnectTimeout: "SSHタイムアウト(秒)", sshHops: "SSHトンネル / プロキシ階層", sshHopAdd: "SSH階層を追加", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 05ca95f731..ebd2451299 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1301,6 +1301,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "찾아보기", sshExposeLan: "터널을 LAN에 노출", sshAllowExecChannelProxy: "SSH exec 채널 대체(nc) 활성화", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "이 호스트에 연결할 때 사용하는 선택적 OpenSSH ProxyCommand입니다(예: nc %h %p, cloudflared access ssh --hostname %h). 허용되는 도우미: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "SSH 제한 시간 (초)", sshHops: "터널 / 프록시 계층", sshHopAdd: "SSH 계층 추가", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c7bdb0a7e4..6900eac159 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1197,6 +1197,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Procurar", sshExposeLan: "Expor túnel à LAN", sshAllowExecChannelProxy: "Ativar fallback do canal exec SSH (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "ProxyCommand OpenSSH opcional usado para alcançar este host, por exemplo nc %h %p ou cloudflared access ssh --hostname %h. Auxiliares aceitos: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "Timeout SSH (segundos)", sshHops: "Camadas de túnel SSH / proxy", sshHopAdd: "Adicionar camada SSH", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index c7d009b254..72018a5974 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -1391,6 +1391,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Обзор", sshExposeLan: "Открыть туннель для локальной сети", sshAllowExecChannelProxy: "Включить резервный вариант SSH exec-channel (nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "Необязательный OpenSSH ProxyCommand для подключения к этому узлу, например nc %h %p или cloudflared access ssh --hostname %h. Допустимые команды: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "Тайм-аут SSH (секунды)", sshHops: "Слои туннеля / прокси", sshHopAdd: "Добавить слой SSH", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 952f6efa9a..49d58990fa 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1183,6 +1183,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "Gözat", sshExposeLan: "Tüneli yerel ağa aç", sshAllowExecChannelProxy: "SSH exec-channel yedeğini (nc) etkinleştir", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "Bu ana bilgisayara ulaşmak için isteğe bağlı OpenSSH ProxyCommand, örn. nc %h %p veya cloudflared access ssh --hostname %h. İzin verilen yardımcılar: nc, ncat, netcat, cloudflared, socat, connect, corkscrew, ssh.", sshConnectTimeout: "SSH Zaman Aşımı (saniye)", sshHops: "Tünel / Proxy Katmanları", sshHopAdd: "SSH katmanı ekle", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index aa6651c421..245d0277ac 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1419,6 +1419,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "浏览", sshExposeLan: "允许局域网访问隧道", sshAllowExecChannelProxy: "启用 SSH 执行通道回退(nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "可选的 OpenSSH ProxyCommand,用于通过代理命令连接该主机,例如 nc %h %p、cloudflared access ssh --hostname %h。支持的命令:nc、ncat、netcat、cloudflared、socat、connect、corkscrew、ssh。", sshConnectTimeout: "SSH 超时时间(秒)", sshHops: "隧道/代理层级", sshHopAdd: "添加 SSH 层", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 29fd54c473..df8ad39334 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1208,6 +1208,9 @@ export default withEnglishFallback({ sshKeyPathBrowse: "瀏覽", sshExposeLan: "允許區域網路存取隧道", sshAllowExecChannelProxy: "啟用 SSH 執行通道回退(nc)", + sshProxyCommand: "ProxyCommand", + sshProxyCommandPlaceholder: "nc %h %p", + sshProxyCommandHint: "可選的 OpenSSH ProxyCommand,用於透過代理指令連接此主機,例如 nc %h %p、cloudflared access ssh --hostname %h。支援的指令:nc、ncat、netcat、cloudflared、socat、connect、corkscrew、ssh。", sshConnectTimeout: "SSH 逾時時間(秒)", sshHops: "SSH 隧道/代理層級", sshHopAdd: "新增 SSH 層", diff --git a/apps/desktop/src/lib/connection/sshConfigHosts.ts b/apps/desktop/src/lib/connection/sshConfigHosts.ts index 97227aae60..c116c16fa0 100644 --- a/apps/desktop/src/lib/connection/sshConfigHosts.ts +++ b/apps/desktop/src/lib/connection/sshConfigHosts.ts @@ -20,4 +20,5 @@ export function applySshConfigHostAliasPrefill(target: SshTunnelConfig, hosts: r target.auth_method = "key"; } } + if (!target.proxy_command && entry.proxy_command) target.proxy_command = entry.proxy_command; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index afc72cd36f..a7d173f3b7 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -255,6 +255,15 @@ export interface SshTunnelConfig { auth_method?: "password" | "key" | "key+password" | "agent" | "none"; /** Allow `nc` through an SSH exec channel when direct-tcpip is prohibited. */ allow_exec_channel_proxy?: boolean; + /** + * OpenSSH-style `ProxyCommand` used to reach this host instead of a direct + * TCP connection, e.g. `nc %h %p` or + * `cloudflared access ssh --hostname %h`. `%h`/`%p`/`%r`/`%%` expand from + * the effective host, port and user. The executable must be one of the + * helpers the backend allowlists (`nc`, `ncat`, `netcat`, `cloudflared`, + * `socat`, `connect`, `corkscrew`, `ssh`). Empty means a direct connection. + */ + proxy_command?: string; /** * When set, this layer references a shared tunnel profile; the profile's * configuration replaces this layer's fields at connect time (only `id` @@ -269,6 +278,7 @@ export interface SshConfigHostEntry { port?: number; user?: string; identity_file?: string; + proxy_command?: string | null; } export interface ProxyTunnelConfig { diff --git a/crates/dbx-core/src/connection/connection_secrets.rs b/crates/dbx-core/src/connection/connection_secrets.rs index 22881fcbba..68ee52fefa 100644 --- a/crates/dbx-core/src/connection/connection_secrets.rs +++ b/crates/dbx-core/src/connection/connection_secrets.rs @@ -1116,6 +1116,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "key".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), } } diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index e795ecd112..91207b9bd0 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -3490,6 +3490,7 @@ impl AppState { 1, false, leaf.allow_exec_channel_proxy, + &leaf.proxy_command, ) .await .map(|_| ()) @@ -8536,6 +8537,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), profile_id: String::new(), }); assert!(state.test_tunnel_profile(&ssh).await.is_err()); @@ -11216,6 +11218,7 @@ for line in sys.stdin: ssh_agent_sock_path: String::new(), auth_method: String::new(), allow_exec_channel_proxy: false, + proxy_command: String::new(), profile_id: profile_id.to_string(), } } diff --git a/crates/dbx-core/src/persistence/cloud_sync.rs b/crates/dbx-core/src/persistence/cloud_sync.rs index f2b1b959d4..be33f98f92 100644 --- a/crates/dbx-core/src/persistence/cloud_sync.rs +++ b/crates/dbx-core/src/persistence/cloud_sync.rs @@ -3810,6 +3810,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), }), TransportLayerConfig::HttpTunnel(crate::models::connection::HttpTunnelConfig { profile_id: String::new(), @@ -5418,6 +5419,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), profile_id: String::new(), }); storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap(); diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index 1393222a32..5766ff2b92 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -10585,6 +10585,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), profile_id: String::new(), }) } diff --git a/crates/dbx-driver-support/src/ssh_config.rs b/crates/dbx-driver-support/src/ssh_config.rs index 0ac31b0dc5..9f24a080a9 100644 --- a/crates/dbx-driver-support/src/ssh_config.rs +++ b/crates/dbx-driver-support/src/ssh_config.rs @@ -26,6 +26,9 @@ pub struct SshConfigHostEntry { /// may declare its own `ProxyJump`, which `resolve_ssh_tunnel_chain` /// follows recursively. pub proxy_jump: Option, + /// The `ProxyCommand` line, verbatim, used to reach this host instead of + /// a direct TCP connection. `None` when the block declares none. + pub proxy_command: Option, } /// Reads and parses `~/.ssh/config`, following `Include` directives the way @@ -121,6 +124,7 @@ fn jump_hop_from_entry(leaf: &SshTunnelConfig, entry: SshConfigHostEntry) -> Ssh ssh_agent_sock_path: leaf.ssh_agent_sock_path.clone(), auth_method: leaf.auth_method.clone(), allow_exec_channel_proxy: leaf.allow_exec_channel_proxy, + proxy_command: entry.proxy_command.unwrap_or_default(), profile_id: String::new(), } } @@ -157,6 +161,13 @@ fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunn } } } + // A ProxyCommand typed into the connection form wins; otherwise inherit + // the one from the matched `~/.ssh/config` block. + if resolved.proxy_command.trim().is_empty() { + if let Some(proxy_command) = entry.proxy_command { + resolved.proxy_command = proxy_command; + } + } resolved } @@ -243,6 +254,7 @@ impl SshConfigLoader { user: None, identity_file: None, proxy_jump: None, + proxy_command: None, }); } self.current_aliases = aliases; @@ -263,6 +275,9 @@ impl SshConfigLoader { "identityfile" => self.set_current_field(|entry| { entry.identity_file = Some(value.to_string()); }), + "proxycommand" => self.set_current_field(|entry| { + entry.proxy_command = Some(value.to_string()); + }), "proxyjump" => { if let Some(first_hop) = value.split(',').next().map(str::trim).filter(|hop| !hop.is_empty()) { // A `user@host:port` jump target names an alias-incompatible @@ -365,6 +380,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: String::new(), allow_exec_channel_proxy: false, + proxy_command: String::new(), } } @@ -423,6 +439,39 @@ mod tests { assert_eq!(entries[0].proxy_jump, None); } + #[test] + fn parses_proxy_command_verbatim() { + let entries = parse_ssh_config("Host proxied\n HostName 10.0.0.5\n ProxyCommand nc %h %p\n"); + assert_eq!(entries[0].proxy_command, Some("nc %h %p".to_string())); + } + + #[test] + fn resolve_inherits_proxy_command_only_when_form_is_empty() { + let mut ssh = config("myserver"); + let mut source = entry("myserver"); + source.proxy_command = Some("nc %h %p".to_string()); + + let inherited = apply_host_entry(&ssh, source.clone()); + assert_eq!(inherited.proxy_command, "nc %h %p"); + + ssh.proxy_command = "cloudflared access ssh --hostname %h".to_string(); + let explicit = apply_host_entry(&ssh, source); + assert_eq!(explicit.proxy_command, "cloudflared access ssh --hostname %h"); + } + + #[test] + fn jump_hop_carries_its_own_proxy_command() { + let ssh = config("target"); + let mut gateway = jump_entry("gateway", "1.1.1.1", None); + gateway.proxy_command = Some("nc %h %p".to_string()); + let entries = [gateway, jump_entry("target", "1.1.1.2", Some("gateway"))]; + + let chain = resolve_chain_from_entries(&ssh, &entries); + + assert_eq!(chain[0].proxy_command, "nc %h %p"); + assert_eq!(chain[1].proxy_command, ""); + } + fn entry(alias: &str) -> SshConfigHostEntry { SshConfigHostEntry { alias: alias.to_string(), @@ -431,6 +480,7 @@ mod tests { user: Some("deploy".to_string()), identity_file: Some("~/.ssh/id_ed25519".to_string()), proxy_jump: None, + proxy_command: None, } } @@ -508,6 +558,7 @@ mod tests { user: None, identity_file: None, proxy_jump: proxy_jump.map(str::to_string), + proxy_command: None, } } diff --git a/crates/dbx-drivers/src/db/mod.rs b/crates/dbx-drivers/src/db/mod.rs index a89ffa6f73..f7ebfba6f3 100644 --- a/crates/dbx-drivers/src/db/mod.rs +++ b/crates/dbx-drivers/src/db/mod.rs @@ -40,6 +40,7 @@ pub mod sqlite_worker; pub use dbx_driver_sqlserver as sqlserver; pub use dbx_driver_support::ssh_host_key; pub use dbx_platform::ssh_prompt; +pub mod ssh_proxy_command; pub mod ssh_tunnel; pub use dbx_driver_mysql::starrocks; pub use dbx_driver_mysql::tidb; diff --git a/crates/dbx-drivers/src/db/sqlite_worker.rs b/crates/dbx-drivers/src/db/sqlite_worker.rs index 3c8b63bff6..2fdd9ca71c 100644 --- a/crates/dbx-drivers/src/db/sqlite_worker.rs +++ b/crates/dbx-drivers/src/db/sqlite_worker.rs @@ -568,6 +568,7 @@ async fn open_final_hop_session( &final_ssh.auth_method, ssh_tunnel::effective_hop_timeout(final_ssh), tunnels.known_hosts_path(), + &final_ssh.proxy_command, ) .await; } @@ -603,6 +604,7 @@ async fn open_final_hop_session( &final_ssh.auth_method, ssh_tunnel::effective_hop_timeout(final_ssh), tunnels.known_hosts_path(), + &final_ssh.proxy_command, ) .await { diff --git a/crates/dbx-drivers/src/db/ssh_proxy_command.rs b/crates/dbx-drivers/src/db/ssh_proxy_command.rs new file mode 100644 index 0000000000..e490da700f --- /dev/null +++ b/crates/dbx-drivers/src/db/ssh_proxy_command.rs @@ -0,0 +1,225 @@ +//! Executing an OpenSSH-style `ProxyCommand` for an SSH tunnel. +//! +//! Only a small allowlist of transport helpers may be spawned: the SSH config +//! (and a shared tunnel profile) is data that can come from a cloned repo or a +//! synced profile, so running an arbitrary shell command from it would be a +//! code-execution footgun. The command is split into arguments ourselves (no +//! shell), which also means shell metacharacters cannot be used to chain extra +//! commands onto an allowlisted binary. +//! +//! Tokens `%h` (host), `%p` (port), `%r` (remote user) and `%%` are expanded +//! from the effective SSH endpoint before the program starts. `%n` (the +//! original host alias) is not available once the host has been resolved, so +//! it expands to the same value as `%h` — prefer `%h` in new configs. + +use std::process::Stdio; + +use tokio::io::{AsyncReadExt, Join}; +use tokio::process::{ChildStdin, ChildStdout, Command}; + +/// Transport helpers that a `ProxyCommand` may invoke. Compared against the +/// basename of the first argv token, case-insensitively, with a trailing +/// `.exe` ignored. +pub const ALLOWED_PROXY_COMMAND_BINARIES: &[&str] = + &["nc", "ncat", "netcat", "cloudflared", "socat", "connect", "corkscrew", "ssh"]; + +/// Expands `%h`/`%p`/`%r`/`%n`/`%%` in a `ProxyCommand` template. +pub fn expand_proxy_command_tokens(command: &str, host: &str, port: u16, user: &str) -> String { + let mut expanded = String::with_capacity(command.len()); + let mut chars = command.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + expanded.push(ch); + continue; + } + match chars.next() { + Some('h') | Some('n') => expanded.push_str(host), + Some('p') => expanded.push_str(&port.to_string()), + Some('r') => expanded.push_str(user), + Some('%') => expanded.push('%'), + Some(other) => { + expanded.push('%'); + expanded.push(other); + } + None => expanded.push('%'), + } + } + expanded +} + +/// Splits a `ProxyCommand` into argv with POSIX-like quote handling: single +/// quotes are literal, double quotes allow `\` escapes, and a backslash +/// outside quotes escapes the next character. +pub fn parse_proxy_command_argv(command: &str) -> Result, String> { + let mut argv: Vec = Vec::new(); + let mut current = String::new(); + let mut has_token = false; + let mut quote: Option = None; + let mut chars = command.chars(); + + while let Some(ch) = chars.next() { + match quote { + Some(open) => { + if ch == open { + quote = None; + has_token = true; + } else if open == '"' && ch == '\\' { + if let Some(escaped) = chars.next() { + current.push(escaped); + } + has_token = true; + } else { + current.push(ch); + has_token = true; + } + } + None if ch == '\'' || ch == '"' => { + quote = Some(ch); + has_token = true; + } + None if ch == '\\' => { + if let Some(escaped) = chars.next() { + current.push(escaped); + has_token = true; + } + } + None if ch.is_whitespace() => { + if has_token { + argv.push(std::mem::take(&mut current)); + has_token = false; + } + } + None => { + current.push(ch); + has_token = true; + } + } + } + + if quote.is_some() { + return Err("ProxyCommand has an unterminated quote".to_string()); + } + if has_token { + argv.push(current); + } + if argv.is_empty() { + return Err("ProxyCommand is empty".to_string()); + } + Ok(argv) +} + +/// Returns `Ok(())` when the program is on the allowlist, otherwise a message +/// naming the allowed binaries. +pub fn validate_proxy_command_program(program: &str) -> Result<(), String> { + let basename = program.rsplit(['/', '\\']).next().unwrap_or(program); + let basename = basename.strip_suffix(".exe").unwrap_or(basename); + if ALLOWED_PROXY_COMMAND_BINARIES.iter().any(|allowed| allowed.eq_ignore_ascii_case(basename)) { + return Ok(()); + } + Err(format!( + "ProxyCommand program '{program}' is not allowed; supported helpers: {}", + ALLOWED_PROXY_COMMAND_BINARIES.join(", ") + )) +} + +/// Starts the `ProxyCommand` process and returns its stdin/stdout joined into +/// one duplex stream for the SSH handshake. The child is reaped in the +/// background; dropping the stream closes its stdin, which makes the usual +/// helpers (`nc`, `cloudflared`) exit. +pub fn spawn_proxy_command_stream( + command: &str, + host: &str, + port: u16, + user: &str, +) -> Result, String> { + let expanded = expand_proxy_command_tokens(command, host, port, user); + let argv = parse_proxy_command_argv(&expanded)?; + let (program, args) = argv.split_first().expect("parse_proxy_command_argv never returns empty"); + validate_proxy_command_program(program)?; + + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("Failed to start ProxyCommand '{program}': {error}"))?; + + let Some(stdout) = child.stdout.take() else { + return Err("ProxyCommand stdout pipe is unavailable".to_string()); + }; + let Some(stdin) = child.stdin.take() else { + return Err("ProxyCommand stdin pipe is unavailable".to_string()); + }; + let mut stderr = child.stderr.take(); + + tokio::spawn(async move { + if let Some(stderr) = stderr.as_mut() { + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + log::debug!("ProxyCommand stderr: {}", String::from_utf8_lossy(&buffer[..read]).trim_end()) + } + } + } + } + let _ = child.wait().await; + }); + + Ok(tokio::io::join(stdout, stdin)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expands_host_port_and_user_tokens() { + assert_eq!(expand_proxy_command_tokens("nc %h %p", "db.internal", 2222, "deploy"), "nc db.internal 2222"); + assert_eq!(expand_proxy_command_tokens("ssh -W %h:%p jump", "10.0.0.5", 22, "root"), "ssh -W 10.0.0.5:22 jump"); + assert_eq!(expand_proxy_command_tokens("run %% literal", "h", 1, "u"), "run % literal"); + assert_eq!(expand_proxy_command_tokens("user %r", "h", 1, "deploy"), "user deploy"); + } + + #[test] + fn keeps_unknown_tokens_verbatim() { + assert_eq!(expand_proxy_command_tokens("tool %z %h", "h", 1, "u"), "tool %z h"); + } + + #[test] + fn parses_quoted_arguments() { + assert_eq!( + parse_proxy_command_argv("cloudflared access ssh --hostname db.internal").unwrap(), + vec!["cloudflared", "access", "ssh", "--hostname", "db.internal"] + ); + assert_eq!(parse_proxy_command_argv("tool 'a b' \"c d\"").unwrap(), vec!["tool", "a b", "c d"]); + assert_eq!(parse_proxy_command_argv("tool a\\ b").unwrap(), vec!["tool", "a b"]); + } + + #[test] + fn rejects_unterminated_quotes() { + assert!(parse_proxy_command_argv("tool 'oops").is_err()); + } + + #[test] + fn allowlist_accepts_known_helpers_and_rejects_others() { + assert!(validate_proxy_command_program("nc").is_ok()); + assert!(validate_proxy_command_program("/usr/bin/nc").is_ok()); + assert!(validate_proxy_command_program("cloudflared.exe").is_ok()); + assert!(validate_proxy_command_program("ssh").is_ok()); + let error = validate_proxy_command_program("/bin/sh").unwrap_err(); + assert!(error.contains("not allowed"), "{error}"); + assert!(error.contains("nc"), "{error}"); + } + + #[test] + fn shell_metacharacters_are_not_executed() { + // `;` is a plain argument character because we never involve a shell; + // the allowlist still rejects non-helper programs. + let argv = parse_proxy_command_argv("nc %h %p; rm -rf /").unwrap(); + assert_eq!(argv, vec!["nc", "%h", "%p;", "rm", "-rf", "/"]); + assert!(validate_proxy_command_program(&argv[0]).is_ok()); + } +} diff --git a/crates/dbx-drivers/src/db/ssh_tunnel.rs b/crates/dbx-drivers/src/db/ssh_tunnel.rs index c5b599e121..ed5b16c971 100644 --- a/crates/dbx-drivers/src/db/ssh_tunnel.rs +++ b/crates/dbx-drivers/src/db/ssh_tunnel.rs @@ -411,6 +411,7 @@ pub async fn connect_and_authenticate( auth_method: &str, connect_timeout_secs: u64, known_hosts_path: &Path, + proxy_command: &str, ) -> Result, String> { let config = Arc::new(ssh_client_config()); let connect_timeout = Duration::from_secs(connect_timeout_secs); @@ -422,16 +423,33 @@ pub async fn connect_and_authenticate( let host_key_verifier = Arc::new(HostKeyVerifier::new(known_hosts_path.to_path_buf())); let (started_tx, mut started_rx) = mpsc::channel::(1); - let connect_fut = client::connect( - config, - (connect_host, connect_port), - SshClient { - host_key_verifier: host_key_verifier.clone(), - host: host_key_host.to_string(), - port: host_key_port, - prompt_started_tx: Some(started_tx), - }, - ); + let handler = SshClient { + host_key_verifier: host_key_verifier.clone(), + host: host_key_host.to_string(), + port: host_key_port, + prompt_started_tx: Some(started_tx), + }; + // A ProxyCommand swaps the direct TCP connection for a helper process + // (`nc`, `cloudflared`, ...) whose stdio carries the SSH handshake. Tokens + // expand from the logical host/port so a forwarded jump hop still names + // the host it is reaching rather than the previous hop's local endpoint. + let proxy_stream = if proxy_command.trim().is_empty() { + None + } else { + Some(crate::db::ssh_proxy_command::spawn_proxy_command_stream( + proxy_command, + host_key_host, + host_key_port, + ssh_user, + )?) + }; + let direct_host = connect_host.to_string(); + let connect_fut = async move { + match proxy_stream { + Some(stream) => client::connect_stream(config, stream, handler).await, + None => client::connect(config, (direct_host.as_str(), connect_port), handler).await, + } + }; tokio::pin!(connect_fut); // `connect_timeout` is meant to bound the *network* portion of the @@ -1251,6 +1269,7 @@ async fn tunnel_reconnect_loop( target: TunnelTarget, allow_exec_channel_proxy: bool, status: Arc, + proxy_command: String, ) { loop { // Reaching the top of the loop means a live session: either the initial @@ -1291,6 +1310,7 @@ async fn tunnel_reconnect_loop( &auth_method, connect_timeout_secs, &known_hosts_path, + &proxy_command, ) .await { @@ -1473,6 +1493,7 @@ impl TunnelManager { remote_port: u16, expose_to_lan: bool, allow_exec_channel_proxy: bool, + proxy_command: &str, ) -> Result { self.start_tunnel_on_local_port( connection_id, @@ -1493,6 +1514,7 @@ impl TunnelManager { expose_to_lan, allow_exec_channel_proxy, None, + proxy_command, ) .await } @@ -1518,6 +1540,7 @@ impl TunnelManager { expose_to_lan: bool, allow_exec_channel_proxy: bool, requested_local_port: Option, + proxy_command: &str, ) -> Result { { let mut tunnels = self.tunnels.lock().await; @@ -1564,6 +1587,7 @@ impl TunnelManager { expose_to_lan, allow_exec_channel_proxy, requested_local_port, + proxy_command, ) .await?; @@ -1591,6 +1615,7 @@ impl TunnelManager { auth_method: &str, connect_timeout_secs: u64, allow_exec_channel_proxy: bool, + proxy_command: &str, ) -> Result { { let mut tunnels = self.tunnels.lock().await; @@ -1628,6 +1653,7 @@ impl TunnelManager { connect_timeout_secs, &self.known_hosts_path, allow_exec_channel_proxy, + proxy_command, ) .await?; @@ -1741,6 +1767,7 @@ impl TunnelManager { is_last && hop.expose_lan, hop.allow_exec_channel_proxy, None, + &hop.proxy_command, ) .await .map_err(|err| format!("SSH hop {} failed: {err}", index + 1))?; @@ -1839,6 +1866,7 @@ async fn spawn_tunnel( expose_to_lan: bool, allow_exec_channel_proxy: bool, requested_local_port: Option, + proxy_command: &str, ) -> Result<(JoinHandle<()>, u16, Arc), String> { spawn_tunnel_target( connect_host, @@ -1858,6 +1886,7 @@ async fn spawn_tunnel( expose_to_lan, allow_exec_channel_proxy, requested_local_port, + proxy_command, ) .await } @@ -1878,6 +1907,7 @@ async fn spawn_socks5_proxy( connect_timeout_secs: u64, known_hosts_path: &Path, allow_exec_channel_proxy: bool, + proxy_command: &str, ) -> Result<(JoinHandle<()>, u16, Arc), String> { spawn_tunnel_target( connect_host, @@ -1897,6 +1927,7 @@ async fn spawn_socks5_proxy( false, allow_exec_channel_proxy, None, + proxy_command, ) .await } @@ -1920,6 +1951,7 @@ async fn spawn_tunnel_target( expose_to_lan: bool, allow_exec_channel_proxy: bool, requested_local_port: Option, + proxy_command: &str, ) -> Result<(JoinHandle<()>, u16, Arc), String> { let (listener, local_port) = bind_tunnel_listener(expose_to_lan, requested_local_port).await?; @@ -1938,6 +1970,7 @@ async fn spawn_tunnel_target( auth_method, connect_timeout_secs, known_hosts_path, + proxy_command, ) .await?; @@ -1961,6 +1994,7 @@ async fn spawn_tunnel_target( target, allow_exec_channel_proxy, status.clone(), + proxy_command.to_string(), )); Ok((handle, local_port, status)) @@ -2208,6 +2242,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), } } @@ -2969,6 +3004,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "password", 5, &known_hosts_path, + "", ) .await .expect("password + TOTP authentication should succeed"); @@ -3015,6 +3051,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "key", 5, &known_hosts_path, + "", ) .await .expect("public key + TOTP authentication should succeed"); @@ -3137,6 +3174,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "none", 5, false, + "", ) .await .unwrap(); @@ -3156,6 +3194,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "none", 5, false, + "", ) .await .unwrap(); @@ -3291,6 +3330,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 5432, false, true, + "", ) .await .expect("start fallback tunnel"); @@ -3346,6 +3386,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 5432, false, false, + "", ) .await .expect("start tunnel"); @@ -3389,6 +3430,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "none", 5, &known_hosts_path, + "", ) .await .expect("forwarded SSH connection should authenticate"); @@ -3438,6 +3480,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "none", 1, &known_hosts_path, + "", ) .await .expect("accepting the host key after the network timeout window should still succeed"); @@ -3487,6 +3530,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 5432, false, false, + "", ); let second = manager.start_tunnel( "shared-layer", @@ -3506,6 +3550,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 5432, false, false, + "", ); let (first_port, second_port) = tokio::join!(first, second); @@ -3716,6 +3761,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 3306, false, false, + "", ) .await .expect("initial tunnel start should succeed"); @@ -3760,6 +3806,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= 3306, false, false, + "", ) .await; @@ -3806,6 +3853,7 @@ uveF/dLmnVN1IriEyEvHAAAACGRieC10ZXN0AQIDBAU= "key", 5, &known_hosts_path, + "", ) .await { diff --git a/crates/dbx-drivers/src/db/transport_layer_tunnel.rs b/crates/dbx-drivers/src/db/transport_layer_tunnel.rs index 6180308a53..97a44d7d6d 100644 --- a/crates/dbx-drivers/src/db/transport_layer_tunnel.rs +++ b/crates/dbx-drivers/src/db/transport_layer_tunnel.rs @@ -200,6 +200,7 @@ async fn start_transport_layers_internal( &resolved.auth_method, effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs), resolved.allow_exec_channel_proxy, + &resolved.proxy_command, ) .await .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))? @@ -226,6 +227,7 @@ async fn start_transport_layers_internal( is_last && resolved.expose_lan, resolved.allow_exec_channel_proxy, if is_last { final_ssh_local_port } else { None }, + &resolved.proxy_command, ) .await .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))? @@ -411,6 +413,7 @@ mod tests { ssh_agent_sock_path: String::new(), auth_method: "password".to_string(), allow_exec_channel_proxy: false, + proxy_command: String::new(), }) } diff --git a/crates/dbx-types/src/models/connection.rs b/crates/dbx-types/src/models/connection.rs index ffb8946595..29a0de6839 100644 --- a/crates/dbx-types/src/models/connection.rs +++ b/crates/dbx-types/src/models/connection.rs @@ -468,6 +468,16 @@ pub struct SshTunnelConfig { /// TCP-forwarding policy; enable only for trusted JumpServer/Koko setups. #[serde(default, skip_serializing_if = "is_false")] pub allow_exec_channel_proxy: bool, + /// OpenSSH-style `ProxyCommand` used to reach this SSH host instead of a + /// direct TCP connection (e.g. `nc %h %p` or + /// `cloudflared access ssh --hostname %h`). Empty means a direct + /// connection. Resolved from `~/.ssh/config` when the host is an alias and + /// otherwise taken from the connection form. `%h`/`%p`/`%r`/`%%` are + /// expanded from the effective host, port, and user before the command + /// starts; the executable must be one of + /// `dbx_drivers::db::ssh_proxy_command::ALLOWED_PROXY_COMMAND_BINARIES`. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub proxy_command: String, /// When non-empty, this layer references a shared tunnel profile /// (Settings > Tunnels). The profile's configuration replaces this /// layer's own fields at connect time; only `id` and `enabled` are diff --git a/docs/screenshots/issue-10454-proxycommand-allowlist.png b/docs/screenshots/issue-10454-proxycommand-allowlist.png new file mode 100644 index 0000000000..95a9566067 Binary files /dev/null and b/docs/screenshots/issue-10454-proxycommand-allowlist.png differ diff --git a/docs/screenshots/issue-10454-proxycommand-field.png b/docs/screenshots/issue-10454-proxycommand-field.png new file mode 100644 index 0000000000..91b1fd2103 Binary files /dev/null and b/docs/screenshots/issue-10454-proxycommand-field.png differ diff --git a/docs/screenshots/issue-10454-proxycommand-test-success.png b/docs/screenshots/issue-10454-proxycommand-test-success.png new file mode 100644 index 0000000000..bb66425e71 Binary files /dev/null and b/docs/screenshots/issue-10454-proxycommand-test-success.png differ