mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat(ssh): support OpenSSH ProxyCommand for tunnel hosts
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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階層を追加",
|
||||
|
||||
@@ -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 계층 추가",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 层",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user