feat(ssh): support OpenSSH ProxyCommand for tunnel hosts

This commit is contained in:
zipg
2026-10-04 09:51:35 +08:00
committed by GitHub
parent 53cd0c516e
commit a3a5f4f9bd
29 changed files with 413 additions and 10 deletions
@@ -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>): 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) {
<span class="text-xs text-muted-foreground">{{ t("connection.sshAllowExecChannelProxy") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshProxyCommand") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="selectedSshLayer.proxy_command" :placeholder="t('connection.sshProxyCommandPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
<p class="text-xs text-muted-foreground">{{ t("connection.sshProxyCommandHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshConnectTimeout") }}</Label>
<Input v-model.number="selectedSshLayer.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" :disabled="selectedSshLayer.enabled === false" />
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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階層を追加",
+3
View File
@@ -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 계층 추가",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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 层",
+3
View File
@@ -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 層",
@@ -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;
}
+10
View File
@@ -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 {
@@ -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(),
}
}
+3
View File
@@ -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(),
}
}
@@ -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();
@@ -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(),
})
}
@@ -26,6 +26,9 @@ pub struct SshConfigHostEntry {
/// may declare its own `ProxyJump`, which `resolve_ssh_tunnel_chain`
/// follows recursively.
pub proxy_jump: Option<String>,
/// 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<String>,
}
/// 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,
}
}
+1
View File
@@ -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;
@@ -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
{
@@ -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<Vec<String>, String> {
let mut argv: Vec<String> = Vec::new();
let mut current = String::new();
let mut has_token = false;
let mut quote: Option<char> = 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<Join<ChildStdout, ChildStdin>, 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());
}
}
+58 -10
View File
@@ -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<Handle<SshClient>, 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::<Instant>(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<TunnelStatus>,
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<u16, String> {
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<u16>,
proxy_command: &str,
) -> Result<u16, String> {
{
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<u16, String> {
{
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<u16>,
proxy_command: &str,
) -> Result<(JoinHandle<()>, u16, Arc<TunnelStatus>), 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<TunnelStatus>), 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<u16>,
proxy_command: &str,
) -> Result<(JoinHandle<()>, u16, Arc<TunnelStatus>), 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
{
@@ -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(),
})
}
+10
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB