diff --git a/Cargo.lock b/Cargo.lock index ce96f23..d31e7e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", + "tokio-socks", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index ef6da91..5daa5a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ rust-version = "1.85.0" [dependencies] anyhow = "1" +tokio-socks = "0.5" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" } async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } diff --git a/locales/en.yml b/locales/en.yml index eaabd81..90e5459 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -62,6 +62,12 @@ add_ssh: "+ ssh" open_local_or_ssh: "Open a local terminal or pick an SSH session." open_session: "Open Session" settings: "Settings" +proxy: "Proxy" +proxy_none: "Follow Global" +proxy_host: "Proxy Host" +proxy_port: "Proxy Port" +proxy_user: "Proxy Username (optional)" +proxy_password: "Proxy Password (optional)" ui_font_size: "UI Font Size" terminal_font_size: "Terminal Font Size" system_default: "System Default" @@ -191,3 +197,15 @@ sync_upload_complete: "Configuration uploaded" sync_download_complete: "Configuration downloaded" sync_failed: "Synchronization failed" keyboard_interactive: "Keyboard Interactive" +settings_proxy: "Proxy" +enable_proxy: "Enable Proxy" +read_env_proxy: "Read environment variables on startup" +read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup." +global_proxy_settings: "Global Proxy Configuration" +global_proxy_type: "Proxy Type" +global_proxy_host: "Proxy Host" +global_proxy_port: "Proxy Port" +global_proxy_user: "Proxy Username" +global_proxy_password: "Proxy Password" +save_proxy: "Save Proxy" + diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index a0b6de7..148ae3b 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -63,6 +63,12 @@ add_ssh: "+ SSH" open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。" open_session: "打开会话" settings: "设置" +proxy: "代理" +proxy_none: "跟随全局" +proxy_host: "代理服务器地址" +proxy_port: "端口" +proxy_user: "用户名 (可选)" +proxy_password: "密码 (可选)" ui_font_size: "界面字体大小" terminal_font_size: "终端字体大小" system_default: "系统默认" @@ -193,3 +199,15 @@ sync_upload_complete: "配置上传完成" sync_download_complete: "配置下载完成" sync_failed: "同步失败" keyboard_interactive: "键盘交互" +settings_proxy: "代理设置" +enable_proxy: "启用代理功能" +read_env_proxy: "启动时读取环境变量" +read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。" +global_proxy_settings: "全局代理配置" +global_proxy_type: "代理类型" +global_proxy_host: "代理地址" +global_proxy_port: "代理端口" +global_proxy_user: "代理用户名" +global_proxy_password: "代理密码" +save_proxy: "保存代理设置" + diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 94bc48c..db3c656 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -36,6 +36,10 @@ impl Ashell { let key_path_input = self.key_path_input.clone(); let key_inline_input = self.key_inline_input.clone(); let passphrase_input = self.passphrase_input.clone(); + let proxy_host_input = self.proxy_host_input.clone(); + let proxy_port_input = self.proxy_port_input.clone(); + let proxy_user_input = self.proxy_user_input.clone(); + let proxy_password_input = self.proxy_password_input.clone(); window.open_dialog(cx, move |dialog: Dialog, _window, _cx| { dialog @@ -61,12 +65,18 @@ impl Ashell { let key_path_input = key_path_input.clone(); let key_inline_input = key_inline_input.clone(); let passphrase_input = passphrase_input.clone(); + let proxy_host_input = proxy_host_input.clone(); + let proxy_port_input = proxy_port_input.clone(); + let proxy_user_input = proxy_user_input.clone(); + let proxy_password_input = proxy_password_input.clone(); move |content, window, cx| { let method = view.read(cx).ssh_auth_method; let is_password = method == AuthMethod::Password; let is_key = method == AuthMethod::Key; let is_kb = method == AuthMethod::KeyboardInteractive; let is_editing = view.read(cx).editing_session_id.is_some(); + let proxy_type = view.read(cx).ssh_proxy_type.clone(); + let show_proxy_fields = proxy_type != "none"; content.child( v_flex() .gap_3() @@ -170,6 +180,60 @@ impl Ashell { .child(Input::new(&key_inline_input).h(px(128.)).tab_index(5)) .child(Input::new(&passphrase_input).mask_toggle().tab_index(6)) }) + .child( + div().text_sm().font_weight(FontWeight::BOLD).child(t!("proxy").to_string()) + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("proxy-none") + .label(t!("proxy_none").to_string()) + .when(proxy_type == "none", |button| button.primary()) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.set_ssh_proxy_type("none".to_string(), cx) + }, + )), + ) + .child( + Button::new("proxy-socks5") + .label("SOCKS5") + .when(proxy_type == "socks5", |button| button.primary()) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.set_ssh_proxy_type("socks5".to_string(), cx) + }, + )), + ) + .child( + Button::new("proxy-http") + .label("HTTP") + .when(proxy_type == "http", |button| button.primary()) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.set_ssh_proxy_type("http".to_string(), cx) + }, + )), + ) + ) + .when(show_proxy_fields, |this| { + this.child( + h_flex() + .gap_2() + .child(Input::new(&proxy_host_input).flex_1()) + .child(Input::new(&proxy_port_input).w(px(96.))) + ) + .child( + h_flex() + .gap_2() + .child(Input::new(&proxy_user_input).flex_1()) + .child(Input::new(&proxy_password_input).flex_1()) + ) + }) .child( h_flex() .justify_end() @@ -1817,6 +1881,119 @@ impl Ashell { })) ) ) + .page( + SettingPage::new(t!("settings_proxy").to_string()) + .icon(IconName::Network) + .group( + SettingGroup::new() + .title(t!("settings_proxy").to_string()) + .item( + SettingItem::new( + t!("enable_proxy").to_string(), + SettingField::render({ + let view = view.clone(); + move |_, window, cx| { + Switch::new("use-proxy") + .small() + .checked(view.read(cx).config.use_proxy()) + .on_click(window.listener_for(&view, |this, checked, _, cx| { + this.config.set_use_proxy(*checked); + let _ = this.config.save(); + cx.notify(); + })) + .into_any_element() + } + }) + ) + ) + .item( + SettingItem::new( + t!("read_env_proxy").to_string(), + SettingField::render({ + let view = view.clone(); + move |_, window, cx| { + Switch::new("read-env-proxy") + .small() + .checked(view.read(cx).config.read_env_proxy()) + .on_click(window.listener_for(&view, |this, checked, _, cx| { + this.config.set_read_env_proxy(*checked); + let _ = this.config.save(); + cx.notify(); + })) + .into_any_element() + } + }) + ).description(t!("read_env_proxy_desc").to_string()) + ) + .item(SettingItem::render({ + let view = view.clone(); + let global_proxy_host_input = view.read(cx).global_proxy_host_input.clone(); + let global_proxy_port_input = view.read(cx).global_proxy_port_input.clone(); + let global_proxy_user_input = view.read(cx).global_proxy_user_input.clone(); + let global_proxy_password_input = view.read(cx).global_proxy_password_input.clone(); + move |_, window, cx| { + let proxy_type = view.read(cx).global_proxy_type.clone(); + v_flex() + .w_full() + .gap_3() + .child(div().text_sm().font_weight(FontWeight::BOLD).child(t!("global_proxy_settings").to_string())) + .child( + h_flex() + .gap_2() + .child( + Button::new("global-proxy-type-socks5") + .small() + .label("SOCKS5") + .when(proxy_type == "socks5", |b| b.primary()) + .on_click(window.listener_for(&view, |this, _, _, cx| { + this.global_proxy_type = "socks5".to_string(); + cx.notify(); + })) + ) + .child( + Button::new("global-proxy-type-http") + .small() + .label("HTTP") + .when(proxy_type == "http", |b| b.primary()) + .on_click(window.listener_for(&view, |this, _, _, cx| { + this.global_proxy_type = "http".to_string(); + cx.notify(); + })) + ) + ) + .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_host").to_string())).child(Input::new(&global_proxy_host_input).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_port").to_string())).child(Input::new(&global_proxy_port_input).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_user").to_string())).child(Input::new(&global_proxy_user_input).w_full())) + .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_password").to_string())).child(Input::new(&global_proxy_password_input).w_full())) + .child( + Button::new("save-global-proxy") + .small() + .primary() + .label(t!("save_proxy").to_string()) + .on_click(window.listener_for(&view, |this, _, _, cx| { + let host = this.global_proxy_host_input.read(cx).value().trim().to_string(); + let port_str = this.global_proxy_port_input.read(cx).value(); + let port = port_str.trim().parse::().ok(); + let user = this.global_proxy_user_input.read(cx).value().trim().to_string(); + let password = this.global_proxy_password_input.read(cx).value().to_string(); + + if host.is_empty() || port.is_none() { + return; + } + + this.config.set_global_proxy_type(this.global_proxy_type.clone()); + this.config.set_global_proxy_host(host); + this.config.set_global_proxy_port(port); + this.config.set_global_proxy_user(user); + this.config.set_global_proxy_password(password); + let _ = this.config.save(); + cx.notify(); + })) + ) + } + })) + ) + ) .page({ let mut page = SettingPage::new(t!("settings_key_bindings").to_string()) .icon(IconName::SquareTerminal) diff --git a/src/app/mod.rs b/src/app/mod.rs index 64aa652..e41ee14 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -211,6 +211,16 @@ pub(crate) struct Ashell { pub(crate) key_path_input: Entity, pub(crate) key_inline_input: Entity, pub(crate) passphrase_input: Entity, + pub(crate) ssh_proxy_type: String, + pub(crate) proxy_host_input: Entity, + pub(crate) proxy_port_input: Entity, + pub(crate) proxy_user_input: Entity, + pub(crate) proxy_password_input: Entity, + pub(crate) global_proxy_type: String, + pub(crate) global_proxy_host_input: Entity, + pub(crate) global_proxy_port_input: Entity, + pub(crate) global_proxy_user_input: Entity, + pub(crate) global_proxy_password_input: Entity, pub(crate) sync_endpoint_input: Entity, pub(crate) sync_username_input: Entity, pub(crate) sync_webdav_password_input: Entity, @@ -377,6 +387,10 @@ impl Ashell { .placeholder("SSH private key passphrase (optional)") .masked(true) }); + let proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string())); + let proxy_port_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string())); + let proxy_user_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string())); + let proxy_password_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_password").to_string()).masked(true)); let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/")); let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); @@ -387,6 +401,27 @@ impl Ashell { tracing::warn!("failed to load config: {err:#}"); ConfigStore::in_memory() }); + let global_proxy_host_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("proxy_host").to_string()) + .default_value(config.global_proxy_host()) + }); + let global_proxy_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("proxy_port").to_string()) + .default_value(config.global_proxy_port().map(|p| p.to_string()).unwrap_or_default()) + }); + let global_proxy_user_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("proxy_user").to_string()) + .default_value(config.global_proxy_user()) + }); + let global_proxy_password_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(t!("proxy_password").to_string()) + .masked(true) + .default_value(config.global_proxy_password()) + }); let sync_endpoint_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("https://dav.example.com/ashell/") @@ -450,6 +485,10 @@ impl Ashell { cx.subscribe_in(&key_path_input, window, Self::on_input_event), cx.subscribe_in(&key_inline_input, window, Self::on_input_event), cx.subscribe_in(&passphrase_input, window, Self::on_input_event), + cx.subscribe_in(&proxy_host_input, window, Self::on_input_event), + cx.subscribe_in(&proxy_port_input, window, Self::on_input_event), + cx.subscribe_in(&proxy_user_input, window, Self::on_input_event), + cx.subscribe_in(&proxy_password_input, window, Self::on_input_event), cx.subscribe_in(&sftp_path_input, window, Self::on_input_event), cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event), cx.subscribe_in(&search_input, window, Self::on_input_event), @@ -524,6 +563,16 @@ impl Ashell { key_path_input, key_inline_input, passphrase_input, + ssh_proxy_type: "none".to_string(), + proxy_host_input, + proxy_port_input, + proxy_user_input, + proxy_password_input, + global_proxy_type: config.global_proxy_type().to_string(), + global_proxy_host_input, + global_proxy_port_input, + global_proxy_user_input, + global_proxy_password_input, sync_endpoint_input, sync_username_input, sync_webdav_password_input, diff --git a/src/app/startup.rs b/src/app/startup.rs index ab2a2e3..3e71162 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -162,6 +162,12 @@ pub(crate) fn sync_macos_launch_environment() { | "HOMEBREW_PREFIX" | "HOMEBREW_CELLAR" | "HOMEBREW_REPOSITORY" + | "HTTP_PROXY" + | "HTTPS_PROXY" + | "ALL_PROXY" + | "http_proxy" + | "https_proxy" + | "all_proxy" ) || key.starts_with("LC_"); if should_import { @@ -172,12 +178,56 @@ pub(crate) fn sync_macos_launch_environment() { } } +fn read_proxy_from_env() -> Option<(String, String, Option, String, String)> { + let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; + for var in vars { + if let Ok(val) = std::env::var(var) { + if val.is_empty() { + continue; + } + if let Ok(url) = reqwest::Url::parse(&val) { + let scheme = url.scheme(); + let proxy_type = match scheme { + "socks5" | "socks5h" => "socks5".to_string(), + "http" | "https" => "http".to_string(), + _ => "socks5".to_string(), + }; + let host = url.host_str().unwrap_or("").to_string(); + let port = url.port(); + let user = url.username().to_string(); + let password = url.password().unwrap_or("").to_string(); + return Some((proxy_type, host, port, user, password)); + } + } + } + None +} + #[cfg(not(target_os = "macos"))] pub(crate) fn sync_macos_launch_environment() {} pub(crate) fn open_main_window(cx: &mut App) { let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); + let _ = crate::session::config::ENV_PROXY.get_or_init(|| { + read_proxy_from_env().map(|(proxy_type, host, port, user, password)| { + tracing::info!( + "[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}", + proxy_type, + host, + port, + user + ); + crate::session::config::EnvProxy { + proxy_type, + host, + port, + user, + pass: password, + } + }) + }); + let mut window_options = WindowOptions::default(); if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated { diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index 0133eb0..baa2851 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -265,11 +265,18 @@ async fn connect_and_authenticate( addr, session.user ); + let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) { + let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 }); + format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val) + } else { + format!("opening tcp connection to {addr}") + }; let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), - text: format!("opening tcp connection to {addr}"), + text: status_text, }); - let mut handle = client::connect(config, addr.as_str(), ClientHandler) + let stream = crate::session::config::connect_proxy(session).await?; + let mut handle = client::connect_stream(config, stream, ClientHandler) .await .with_context(|| format!("connect {addr} failed"))?; diff --git a/src/session/config.rs b/src/session/config.rs index caa7491..504bc3f 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -31,6 +31,16 @@ pub struct Session { pub passphrase: String, #[serde(default)] pub last_used: Option, + #[serde(default)] + pub proxy_type: String, // "none", "socks5", "http" + #[serde(default)] + pub proxy_host: String, + #[serde(default)] + pub proxy_port: Option, + #[serde(default)] + pub proxy_user: String, + #[serde(default)] + pub proxy_password: String, } impl Session { @@ -48,6 +58,11 @@ impl Session { private_key_inline: String::new(), passphrase: String::new(), last_used: None, + proxy_type: "none".to_string(), + proxy_host: String::new(), + proxy_port: None, + proxy_user: String::new(), + proxy_password: String::new(), } } @@ -72,6 +87,11 @@ impl Session { private_key_inline, passphrase, last_used: None, + proxy_type: "none".to_string(), + proxy_host: String::new(), + proxy_port: None, + proxy_user: String::new(), + proxy_password: String::new(), } } @@ -93,6 +113,11 @@ impl Session { private_key_inline: String::new(), passphrase: String::new(), last_used: None, + proxy_type: "none".to_string(), + proxy_host: String::new(), + proxy_port: None, + proxy_user: String::new(), + proxy_password: String::new(), } } } @@ -206,6 +231,28 @@ pub struct ConfigFile { pub sync_s3_bucket: String, #[serde(default = "default_s3_object_key")] pub sync_s3_object_key: String, + #[serde(default)] + pub use_proxy: bool, + #[serde(default = "default_read_env_proxy")] + pub read_env_proxy: bool, + #[serde(default = "default_global_proxy_type")] + pub global_proxy_type: String, + #[serde(default)] + pub global_proxy_host: String, + #[serde(default)] + pub global_proxy_port: Option, + #[serde(default)] + pub global_proxy_user: String, + #[serde(default)] + pub global_proxy_password: String, +} + +fn default_read_env_proxy() -> bool { + true +} + +fn default_global_proxy_type() -> String { + "socks5".to_string() } fn default_monitoring_position() -> String { @@ -282,6 +329,13 @@ impl Default for ConfigFile { sync_s3_region: default_s3_region(), sync_s3_bucket: String::new(), sync_s3_object_key: default_s3_object_key(), + use_proxy: false, + read_env_proxy: true, + global_proxy_type: default_global_proxy_type(), + global_proxy_host: String::new(), + global_proxy_port: None, + global_proxy_user: String::new(), + global_proxy_password: String::new(), } } } @@ -624,6 +678,49 @@ impl ConfigStore { self.cache.cursor_style = style; } + pub fn use_proxy(&self) -> bool { + self.cache.use_proxy + } + pub fn set_use_proxy(&mut self, val: bool) { + self.cache.use_proxy = val; + } + pub fn read_env_proxy(&self) -> bool { + self.cache.read_env_proxy + } + pub fn set_read_env_proxy(&mut self, val: bool) { + self.cache.read_env_proxy = val; + } + pub fn global_proxy_type(&self) -> &str { + &self.cache.global_proxy_type + } + pub fn set_global_proxy_type(&mut self, val: String) { + self.cache.global_proxy_type = val; + } + pub fn global_proxy_host(&self) -> &str { + &self.cache.global_proxy_host + } + pub fn set_global_proxy_host(&mut self, val: String) { + self.cache.global_proxy_host = val; + } + pub fn global_proxy_port(&self) -> Option { + self.cache.global_proxy_port + } + pub fn set_global_proxy_port(&mut self, val: Option) { + self.cache.global_proxy_port = val; + } + pub fn global_proxy_user(&self) -> &str { + &self.cache.global_proxy_user + } + pub fn set_global_proxy_user(&mut self, val: String) { + self.cache.global_proxy_user = val; + } + pub fn global_proxy_password(&self) -> &str { + &self.cache.global_proxy_password + } + pub fn set_global_proxy_password(&mut self, val: String) { + self.cache.global_proxy_password = val; + } + pub fn show_hidden_files(&self) -> bool { self.cache.show_hidden_files } @@ -684,3 +781,166 @@ impl ConfigStore { Ok(()) } } + +pub trait ProxyStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static {} +impl ProxyStream for T {} + +use std::sync::OnceLock; + +#[derive(Debug, Clone)] +pub struct EnvProxy { + pub proxy_type: String, + pub host: String, + pub port: Option, + pub user: String, + pub pass: String, +} + +pub static ENV_PROXY: OnceLock> = OnceLock::new(); + +pub async fn connect_proxy(session: &Session) -> Result> { + let target_host = &session.host; + let target_port = session.port; + + let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); + let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = { + if !session.proxy_type.is_empty() && session.proxy_type != "none" { + ( + session.proxy_type.clone(), + session.proxy_host.clone(), + session.proxy_port, + session.proxy_user.clone(), + session.proxy_password.clone(), + ) + } else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() { + let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap(); + ( + env_p.proxy_type.clone(), + env_p.host.clone(), + env_p.port, + env_p.user.clone(), + env_p.pass.clone(), + ) + } else if config.cache.use_proxy { + ( + config.cache.global_proxy_type.clone(), + config.cache.global_proxy_host.clone(), + config.cache.global_proxy_port, + config.cache.global_proxy_user.clone(), + config.cache.global_proxy_password.clone(), + ) + } else { + ("none".to_string(), String::new(), None, String::new(), String::new()) + } + }; + + if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) { + let addr = format!("{}:{}", target_host, target_port); + let stream = tokio::net::TcpStream::connect(&addr).await?; + return Ok(Box::new(stream)); + } + + match proxy_type.as_str() { + "socks5" | "socks5h" => { + let proxy_port = proxy_port.unwrap_or(1080); + let proxy_addr = format!("{}:{}", proxy_host, proxy_port); + + if !proxy_user.is_empty() { + let stream = tokio_socks::tcp::Socks5Stream::connect_with_password( + proxy_addr.as_str(), + (target_host.as_str(), target_port), + &proxy_user, + &proxy_password, + ) + .await + .map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?; + Ok(Box::new(stream)) + } else { + let stream = tokio_socks::tcp::Socks5Stream::connect( + proxy_addr.as_str(), + (target_host.as_str(), target_port), + ) + .await + .map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?; + Ok(Box::new(stream)) + } + } + "http" => { + let proxy_port = proxy_port.unwrap_or(8080); + let proxy_addr = format!("{}:{}", proxy_host, proxy_port); + + use tokio::io::AsyncWriteExt; + let mut stream = tokio::net::TcpStream::connect(&proxy_addr) + .await + .map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?; + + let mut request = format!( + "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", + target_host, target_port, target_host, target_port + ); + if !proxy_user.is_empty() { + use base64::Engine as _; + let auth = format!("{}:{}", proxy_user, proxy_password); + let encoded = base64::engine::general_purpose::STANDARD.encode(auth); + request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded)); + } + request.push_str("\r\n"); + + stream.write_all(request.as_bytes()).await?; + + let mut response = [0u8; 1024]; + let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?; + let resp_str = String::from_utf8_lossy(&response[..n]); + if !resp_str.contains("200") && !resp_str.contains("established") { + return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str)); + } + + Ok(Box::new(stream)) + } + _ => { + let addr = format!("{}:{}", target_host, target_port); + let stream = tokio::net::TcpStream::connect(&addr).await?; + Ok(Box::new(stream)) + } + } +} + +pub fn active_proxy(session: &Session) -> Option<(String, String, Option)> { + let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory()); + let (proxy_type, proxy_host, proxy_port, _, _) = { + if !session.proxy_type.is_empty() && session.proxy_type != "none" { + ( + session.proxy_type.clone(), + session.proxy_host.clone(), + session.proxy_port, + session.proxy_user.clone(), + session.proxy_password.clone(), + ) + } else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() { + let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap(); + ( + env_p.proxy_type.clone(), + env_p.host.clone(), + env_p.port, + env_p.user.clone(), + env_p.pass.clone(), + ) + } else if config.cache.use_proxy { + ( + config.cache.global_proxy_type.clone(), + config.cache.global_proxy_host.clone(), + config.cache.global_proxy_port, + config.cache.global_proxy_user.clone(), + config.cache.global_proxy_password.clone(), + ) + } else { + ("none".to_string(), String::new(), None, String::new(), String::new()) + } + }; + + if proxy_type != "none" && !proxy_host.is_empty() && proxy_port.is_some() { + Some((proxy_type, proxy_host, proxy_port)) + } else { + None + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 4b8f0c8..47028bc 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -79,6 +79,17 @@ impl Ashell { return; } + if self.ssh_proxy_type != "none" { + let proxy_host = self.proxy_host_input.read(cx).value().trim().to_string(); + let proxy_port_str = self.proxy_port_input.read(cx).value().trim().to_string(); + let proxy_port = proxy_port_str.parse::().ok(); + if proxy_host.is_empty() || proxy_port.is_none() { + self.status = "Proxy host and port are required".into(); + cx.notify(); + return; + } + } + let name = if session_name.is_empty() { host.clone() } else { @@ -107,6 +118,17 @@ impl Ashell { session.id = id; } session.last_used = existing_last_used; + session.proxy_type = self.ssh_proxy_type.clone(); + session.proxy_host = self.proxy_host_input.read(cx).value().trim().to_string(); + session.proxy_port = self + .proxy_port_input + .read(cx) + .value() + .trim() + .parse::() + .ok(); + session.proxy_user = self.proxy_user_input.read(cx).value().trim().to_string(); + session.proxy_password = self.proxy_password_input.read(cx).value().to_string(); self.config.upsert(session.clone()); if let Err(err) = self.config.save() { tracing::warn!("failed to save config: {err:#}"); @@ -139,6 +161,11 @@ impl Ashell { Self::set_input_value(&self.key_path_input, "", window, cx); Self::set_input_value(&self.key_inline_input, "", window, cx); Self::set_input_value(&self.passphrase_input, "", window, cx); + self.ssh_proxy_type = "none".to_string(); + Self::set_input_value(&self.proxy_host_input, "", window, cx); + Self::set_input_value(&self.proxy_port_input, "", window, cx); + Self::set_input_value(&self.proxy_user_input, "", window, cx); + Self::set_input_value(&self.proxy_password_input, "", window, cx); } pub(crate) fn load_session_into_form( @@ -172,6 +199,16 @@ impl Ashell { window, cx, ); + self.ssh_proxy_type = session.proxy_type.clone(); + Self::set_input_value(&self.proxy_host_input, session.proxy_host.clone(), window, cx); + Self::set_input_value( + &self.proxy_port_input, + session.proxy_port.map(|p| p.to_string()).unwrap_or_default(), + window, + cx, + ); + Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx); + Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx); } pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context) { @@ -326,6 +363,11 @@ impl Ashell { cx.notify(); } + pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context) { + self.ssh_proxy_type = proxy_type; + cx.notify(); + } + pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context) { tracing::info!( "[ui] user clicked to connect saved session '{}'", diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 77d3b5f..8e33961 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -827,7 +827,8 @@ async fn connect_and_authenticate( ..Default::default() }); let addr = format!("{}:{}", session.host, session.port); - let mut handle = client::connect(config, addr.as_str(), SftpClientHandler) + let stream = crate::session::config::connect_proxy(session).await?; + let mut handle = client::connect_stream(config, stream, SftpClientHandler) .await .with_context(|| format!("connect {addr} failed"))?;