diff --git a/crates/core/src/oauth2/flow.rs b/crates/core/src/oauth2/flow.rs index 30af18d..fa5fa80 100644 --- a/crates/core/src/oauth2/flow.rs +++ b/crates/core/src/oauth2/flow.rs @@ -266,31 +266,12 @@ impl OAuth2Flow { fn build_http_client(use_proxy: Option) -> BichonResult { if let Some(proxy_id) = use_proxy { let proxy = Proxy::get(proxy_id)?; - // Normalize the URL: reqwest only understands standard format user:pass@host:port. - // Our parse_proxy_url handles both standard and non-standard (host:port:user:pass). - let proxy_url = match parse_proxy_url(&proxy.url) { - Ok(addr) => { - if let (Some(user), Some(pass)) = (&addr.username, &addr.password) { - format!("socks5://{}:{}@{}:{}", user, pass, addr.host, addr.port) - } else if let Some(user) = &addr.username { - format!("socks5://{}@{}:{}", user, addr.host, addr.port) - } else { - format!("socks5://{}:{}", addr.host, addr.port) - } - } - Err(_) => { - // Fallback: pass through as-is for backward compatibility - proxy.url.clone() - } - }; + let proxy_url = parse_proxy_url(&proxy.url)?.standard_url(); return oauth2::reqwest::ClientBuilder::new() .redirect(oauth2::reqwest::redirect::Policy::none()) - .proxy(reqwest::Proxy::all(&proxy_url).map_err(|e| { + .proxy(reqwest::Proxy::all(&proxy_url).map_err(|_| { raise_error!( - format!( - "Failed to configure SOCKS5 proxy ({}): {:#?}. Please check", - &proxy_url, e - ), + "Failed to configure proxy. Please check the proxy configuration.".into(), ErrorCode::InternalError ) })?) diff --git a/crates/core/src/settings/proxy.rs b/crates/core/src/settings/proxy.rs index de86ae3..ddfbc03 100644 --- a/crates/core/src/settings/proxy.rs +++ b/crates/core/src/settings/proxy.rs @@ -18,6 +18,7 @@ //use poem_openapi::Object; use serde::{Deserialize, Serialize}; +use std::{error::Error, time::Duration}; use crate::{ database::{ @@ -29,6 +30,27 @@ use crate::{ utils::net::parse_proxy_url, }; +const PROXY_TEST_TIMEOUT: Duration = Duration::from_secs(8); +const GEO_PROVIDERS: &[GeoProvider] = &[ + GeoProvider { + name: "ip-api.com", + url: "http://ip-api.com/json/?fields=status,message,query,country,countryCode,regionName,city,isp,timezone,lat,lon", + }, + GeoProvider { + name: "ipwho.is", + url: "https://ipwho.is/", + }, + GeoProvider { + name: "ipapi.co", + url: "https://ipapi.co/json/", + }, +]; + +struct GeoProvider { + name: &'static str, + url: &'static str, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] pub struct Proxy { @@ -45,6 +67,16 @@ pub struct Proxy { pub updated_at: i64, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))] +pub struct ProxyTestResult { + pub ip: Option, + pub country: Option, + pub region: Option, + pub city: Option, + pub isp: Option, +} + impl MemDbModel for Proxy { fn collection() -> &'static str { "proxies" @@ -85,9 +117,10 @@ impl Proxy { pub fn update(id: u64, url: String) -> BichonResult<()> { update_impl(DB_MANAGER.db(), &id.to_string(), move |current: Proxy| { - let mut updated = current.clone(); + let mut updated = current; updated.url = url; updated.updated_at = utc_now!(); + updated.validate()?; Ok(updated) })?; Ok(()) @@ -103,6 +136,180 @@ impl Proxy { parse_proxy_url(&self.url)?; Ok(()) } + + pub async fn test_connectivity(&self) -> BichonResult { + test_proxy_url(&self.url).await + } + + pub async fn test(id: u64) -> BichonResult { + let proxy = Self::get(id)?; + proxy.test_connectivity().await + } +} + +async fn test_proxy_url(url: &str) -> BichonResult { + let proxy_url = parse_proxy_url(url)?.standard_url(); + let client = reqwest::Client::builder() + .timeout(PROXY_TEST_TIMEOUT) + .proxy(reqwest::Proxy::all(&proxy_url).map_err(|_| { + raise_error!( + "Failed to configure proxy. Please check the proxy configuration.".into(), + ErrorCode::InvalidParameter + ) + })?) + .build() + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + let mut last_error = None; + for provider in GEO_PROVIDERS { + match test_geo_provider(&client, provider).await { + Ok(result) => return Ok(result), + Err(err) => last_error = Some(err.to_string()), + } + } + + Err(raise_error!( + format!( + "Proxy check failed with all geo providers: {}", + last_error.unwrap_or_else(|| "unknown error".into()) + ), + ErrorCode::NetworkError + )) +} + +async fn test_geo_provider( + client: &reqwest::Client, + provider: &GeoProvider, +) -> BichonResult { + let value = client + .get(provider.url) + .send() + .await + .map_err(|e| { + raise_error!( + proxy_request_error_message( + &format!("Proxy check request failed via {}", provider.name), + &e + ), + ErrorCode::NetworkError + ) + })? + .error_for_status() + .map_err(|e| { + raise_error!( + proxy_request_error_message( + &format!("Proxy check request failed via {}", provider.name), + &e + ), + ErrorCode::NetworkError + ) + })? + .json::() + .await + .map_err(|e| { + raise_error!( + proxy_request_error_message( + &format!("Failed to read proxy check response from {}", provider.name), + &e + ), + ErrorCode::NetworkError + ) + })?; + + proxy_test_result_from_value(provider.name, &value) +} + +fn proxy_test_result_from_value( + provider: &str, + value: &serde_json::Value, +) -> BichonResult { + if provider == "ip-api.com" && value["status"].as_str() == Some("fail") { + return Err(raise_error!( + format!( + "ip-api.com proxy check failed: {}", + value["message"].as_str().unwrap_or("unknown error") + ), + ErrorCode::NetworkError + )); + } + if provider == "ipwho.is" && value["success"].as_bool() == Some(false) { + return Err(raise_error!( + format!( + "ipwho.is proxy check failed: {}", + value["message"].as_str().unwrap_or("unknown error") + ), + ErrorCode::NetworkError + )); + } + if provider == "ipapi.co" && value["error"].as_bool() == Some(true) { + return Err(raise_error!( + format!( + "ipapi.co proxy check failed: {}", + value["reason"].as_str().unwrap_or("unknown error") + ), + ErrorCode::NetworkError + )); + } + + let ip_key = if provider == "ip-api.com" { + "query" + } else { + "ip" + }; + let ip = value[ip_key].as_str().ok_or_else(|| { + raise_error!( + format!("{provider} did not return an IP address"), + ErrorCode::NetworkError + ) + })?; + let connection = &value["connection"]; + + Ok(ProxyTestResult { + ip: Some(ip.to_string()), + country: value[if provider == "ipapi.co" { + "country_name" + } else { + "country" + }] + .as_str() + .map(str::to_string), + region: value[if provider == "ip-api.com" { + "regionName" + } else { + "region" + }] + .as_str() + .map(str::to_string), + city: value["city"].as_str().map(str::to_string), + isp: if provider == "ipapi.co" { + value["org"].as_str().map(str::to_string) + } else if provider == "ip-api.com" { + value["isp"].as_str().map(str::to_string) + } else { + connection["isp"].as_str().map(str::to_string) + }, + }) +} + +fn proxy_request_error_message(context: &str, err: &reqwest::Error) -> String { + let kind = if err.is_timeout() { + "timed out" + } else if err.is_connect() { + "could not connect through the proxy" + } else if err.is_status() { + "received an error response" + } else { + "request failed" + }; + let mut message = format!("{context}: {kind}: {err}"); + let mut source = err.source(); + + while let Some(err) = source { + message.push_str(&format!(": {err}")); + source = err.source(); + } + + message } #[cfg(test)] @@ -116,9 +323,12 @@ mod tests { "http://127.0.0.1:8080", "socks5://proxy.example.com:1080", "socks5://user:pass@proxy.example.com:1080", - "socks5://user@proxy.example.com:1080", + "http://user:pass@proxy.example.com:8080", + "socks5://[::1]:1080", + "socks5://user:pass@[::1]:1080", // Non-standard format: host:port:user:pass "socks5://server.nodeprovider.com:8080:username123:passwordhere", + "http://server.nodeprovider.com:8080:username123:passwordhere", ]; for url in urls { @@ -126,4 +336,36 @@ mod tests { assert!(proxy.validate().is_ok(), "URL should be valid: {}", url); } } + + #[test] + fn test_invalid_proxy_urls() { + for url in ["socks5://user@proxy.example.com:1080", "socks5://::1:1080"] { + let proxy = Proxy::new(url.to_string()); + assert!(proxy.validate().is_err(), "URL should be invalid: {}", url); + } + } + + #[test] + fn test_ipv6_proxy_urls_render_with_brackets() { + let addr = parse_proxy_url("socks5://[::1]:1080").unwrap(); + assert_eq!(addr.standard_url(), "socks5://[::1]:1080"); + + let addr = parse_proxy_url("socks5://user:pass@[::1]:1080").unwrap(); + assert_eq!(addr.standard_url(), "socks5://user:pass@[::1]:1080"); + } + + #[test] + fn proxy_test_result_rejects_empty_provider_response() { + let result = proxy_test_result_from_value("ipwho.is", &serde_json::json!({})); + assert!(result.is_err()); + } + + #[test] + fn proxy_test_result_rejects_provider_error_response() { + let result = proxy_test_result_from_value( + "ipwho.is", + &serde_json::json!({ "success": false, "message": "reserved range" }), + ); + assert!(result.is_err()); + } } diff --git a/crates/core/src/utils/net.rs b/crates/core/src/utils/net.rs index 6f16160..caa449a 100644 --- a/crates/core/src/utils/net.rs +++ b/crates/core/src/utils/net.rs @@ -21,9 +21,11 @@ use crate::raise_error; use crate::settings::proxy::Proxy; use crate::utils::tls::establish_tls_stream; use crate::{error::BichonResult, imap::session::SessionStream}; +use base64::{engine::general_purpose, Engine as _}; use std::net::SocketAddr; use std::pin::Pin; use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; use tokio_io_timeout::TimeoutStream; @@ -32,15 +34,54 @@ use tracing::error; pub(crate) const TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ProxyScheme { + Socks5, + Http, +} + +impl ProxyScheme { + fn as_str(self) -> &'static str { + match self { + Self::Socks5 => "socks5", + Self::Http => "http", + } + } +} + /// Parsed proxy address components. #[derive(Debug, Clone)] pub struct ProxyAddr { + pub scheme: ProxyScheme, pub host: String, pub port: u16, pub username: Option, pub password: Option, } +impl ProxyAddr { + pub fn standard_url(&self) -> String { + let host = if self.host.contains(':') { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + + if let (Some(user), Some(pass)) = (&self.username, &self.password) { + format!( + "{}://{}:{}@{}:{}", + self.scheme.as_str(), + user, + pass, + host, + self.port + ) + } else { + format!("{}://{}:{}", self.scheme.as_str(), host, self.port) + } + } +} + pub(crate) async fn establish_tcp_connection_with_timeout( address: SocketAddr, use_proxy: Option, @@ -84,24 +125,21 @@ pub async fn establish_tls_connection( /// The distinguishing feature is the `@` sign in the standard format. pub fn parse_proxy_url(input: &str) -> BichonResult { // Normalize and strip scheme prefix - let stripped = if let Some(rest) = input + let (scheme, stripped) = if let Some(rest) = input .strip_prefix("socks5://") .or_else(|| input.strip_prefix("SOCKS5://")) .or_else(|| input.strip_prefix("Socks5://")) { - rest + (ProxyScheme::Socks5, rest) } else if let Some(rest) = input .strip_prefix("http://") .or_else(|| input.strip_prefix("HTTP://")) .or_else(|| input.strip_prefix("Http://")) { - rest + (ProxyScheme::Http, rest) } else { return Err(raise_error!( - format!( - "Invalid proxy URL: must start with 'http://' or 'socks5://', got '{}'", - input - ), + "Invalid proxy URL: must start with 'http://' or 'socks5://'".into(), ErrorCode::InvalidParameter )); }; @@ -122,6 +160,7 @@ pub fn parse_proxy_url(input: &str) -> BichonResult { let (host, port) = split_hostport(hostport)?; return Ok(ProxyAddr { + scheme, host, port, username, @@ -130,32 +169,44 @@ pub fn parse_proxy_url(input: &str) -> BichonResult { } // No '@' — check for non-standard format: host:port:user:pass - let parts: Vec<&str> = stripped.rsplitn(4, ':').collect::>().into_iter().rev().collect::>(); + if stripped.starts_with('[') { + let (host, port) = split_hostport(stripped)?; + return Ok(ProxyAddr { + scheme, + host, + port, + username: None, + password: None, + }); + } - match parts.len() { - 2 => { + let mut parts = stripped.split(':'); + match ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ) { + (Some(_), Some(_), None, None, None) => { // host:port, no auth let (host, port) = split_hostport(stripped)?; Ok(ProxyAddr { + scheme, host, port, username: None, password: None, }) } - 4 => { + (Some(host), Some(port), Some(username), Some(password), None) => { // Non-standard: host:port:username:password - let host = parts[0].to_string(); - let port = parts[1] - .parse::() - .map_err(|_| { - raise_error!( - format!("Invalid port '{}' in proxy URL.", parts[1]), - ErrorCode::InvalidParameter - ) - })?; - let username = parts[2].to_string(); - let password = parts[3].to_string(); + let port = port.parse::().map_err(|_| { + raise_error!( + format!("Invalid port '{}' in proxy URL.", port), + ErrorCode::InvalidParameter + ) + })?; if host.is_empty() { return Err(raise_error!( @@ -163,6 +214,12 @@ pub fn parse_proxy_url(input: &str) -> BichonResult { ErrorCode::InvalidParameter )); } + if host.contains(':') || host.contains('[') || host.contains(']') { + return Err(raise_error!( + "IPv6 proxy hosts are not supported.".into(), + ErrorCode::InvalidParameter + )); + } if username.is_empty() { return Err(raise_error!( "Empty username in proxy URL.".into(), @@ -177,23 +234,21 @@ pub fn parse_proxy_url(input: &str) -> BichonResult { } Ok(ProxyAddr { - host, + scheme, + host: host.to_string(), port, - username: Some(username), - password: Some(password), + username: Some(username.to_string()), + password: Some(password.to_string()), }) } _ => Err(raise_error!( - format!( - "Invalid proxy URL format '{}'. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.", - input - ), + "Invalid proxy URL format. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.".into(), ErrorCode::InvalidParameter )), } } -/// Split "user:pass" into (Some(user), Some(pass)), or "user" into (Some(user), None). +/// Split "user:pass" into (Some(user), Some(pass)). fn split_userinfo(userinfo: &str) -> BichonResult<(Option, Option)> { if userinfo.is_empty() { return Ok((None, None)); @@ -207,13 +262,22 @@ fn split_userinfo(userinfo: &str) -> BichonResult<(Option, Option BichonResult<(String, u16)> { if hostport.is_empty() { return Err(raise_error!( @@ -222,29 +286,31 @@ fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> { )); } - // IPv6: [::1]:1080 - if hostport.starts_with('[') { - let close_bracket = hostport.find(']').ok_or_else(|| { - raise_error!( + if let Some(rest) = hostport.strip_prefix('[') { + let Some(close_bracket) = rest.find(']') else { + return Err(raise_error!( format!("Invalid IPv6 address in proxy URL: '{}'.", hostport), ErrorCode::InvalidParameter + )); + }; + let host = &rest[..close_bracket]; + let port_text = rest[close_bracket + 1..].strip_prefix(':').ok_or_else(|| { + raise_error!( + format!( + "Missing port after IPv6 address in proxy URL: '{}'.", + hostport + ), + ErrorCode::InvalidParameter ) })?; - let host = hostport[1..close_bracket].to_string(); - let after_bracket = &hostport[close_bracket + 1..]; - if !after_bracket.starts_with(':') { - return Err(raise_error!( - format!("Missing port after IPv6 address in proxy URL: '{}'.", hostport), - ErrorCode::InvalidParameter - )); - } - let port = after_bracket[1..].parse::().map_err(|_| { + let port = port_text.parse::().map_err(|_| { raise_error!( format!("Invalid port in proxy URL: '{}'.", hostport), ErrorCode::InvalidParameter ) })?; - return Ok((host, port)); + + return Ok((host.to_string(), port)); } // hostname:port or ip:port — split from right @@ -268,6 +334,12 @@ fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> { ErrorCode::InvalidParameter )); } + if host.contains(':') || host.contains('[') || host.contains(']') { + return Err(raise_error!( + "IPv6 proxy hosts are not supported.".into(), + ErrorCode::InvalidParameter + )); + } Ok((host, port)) } @@ -280,40 +352,11 @@ async fn connect_with_optional_proxy( if let Some(proxy_id) = use_proxy { let proxy = Proxy::get(proxy_id)?; let addr = parse_proxy_url(&proxy.url)?; - let proxy_addr = (addr.host.as_str(), addr.port); - - let result = if let (Some(ref user), Some(ref pass)) = (addr.username, addr.password) { - timeout( - TIMEOUT, - Socks5Stream::connect_with_password(proxy_addr, address, user.as_str(), pass.as_str()), - ) - .await + return if addr.scheme == ProxyScheme::Http { + connect_via_http_proxy(&addr, address).await } else { - timeout(TIMEOUT, Socks5Stream::connect(proxy_addr, address)).await + connect_via_socks5_proxy(&addr, address).await }; - - return result - .map_err(|_| { - error!( - "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", - address, - addr.host, - addr.port, - TIMEOUT.as_secs() - ); - raise_error!( - format!( - "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", - address, - addr.host, - addr.port, - TIMEOUT.as_secs() - ), - ErrorCode::ConnectionTimeout - ) - })? - .map(|s| s.into_inner()) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError)); } // Fallback to direct TCP connection timeout(TIMEOUT, TcpStream::connect(address)) @@ -335,3 +378,135 @@ async fn connect_with_optional_proxy( })? .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError)) } + +async fn connect_via_socks5_proxy( + addr: &ProxyAddr, + address: SocketAddr, +) -> BichonResult { + let proxy_addr = (addr.host.as_str(), addr.port); + let result = if let (Some(user), Some(pass)) = (&addr.username, &addr.password) { + timeout( + TIMEOUT, + Socks5Stream::connect_with_password(proxy_addr, address, user.as_str(), pass.as_str()), + ) + .await + } else { + timeout(TIMEOUT, Socks5Stream::connect(proxy_addr, address)).await + }; + + result + .map_err(|_| { + error!( + "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", + address, + addr.host, + addr.port, + TIMEOUT.as_secs() + ); + raise_error!( + format!( + "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", + address, + addr.host, + addr.port, + TIMEOUT.as_secs() + ), + ErrorCode::ConnectionTimeout + ) + })? + .map(|s| s.into_inner()) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError)) +} + +async fn connect_via_http_proxy(addr: &ProxyAddr, address: SocketAddr) -> BichonResult { + let mut stream = timeout(TIMEOUT, TcpStream::connect((addr.host.as_str(), addr.port))) + .await + .map_err(|_| { + raise_error!( + format!( + "HTTP proxy connection to {}:{} timed out after {}s", + addr.host, + addr.port, + TIMEOUT.as_secs() + ), + ErrorCode::ConnectionTimeout + ) + })? + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))?; + + let mut request = format!( + "CONNECT {address} HTTP/1.1\r\nHost: {address}\r\nProxy-Connection: keep-alive\r\n" + ); + if let (Some(user), Some(pass)) = (&addr.username, &addr.password) { + let auth = general_purpose::STANDARD.encode(format!("{user}:{pass}")); + request.push_str(&format!("Proxy-Authorization: Basic {auth}\r\n")); + } + request.push_str("\r\n"); + + timeout(TIMEOUT, stream.write_all(request.as_bytes())) + .await + .map_err(|_| { + raise_error!( + format!( + "HTTP proxy CONNECT to {} via {}:{} timed out after {}s", + address, + addr.host, + addr.port, + TIMEOUT.as_secs() + ), + ErrorCode::ConnectionTimeout + ) + })? + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))?; + + let mut response = Vec::new(); + timeout(TIMEOUT, async { + let mut byte = [0u8; 1]; + while !response.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await?; + response.push(byte[0]); + if response.len() > 8192 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP proxy CONNECT response headers are too large", + )); + } + } + Ok::<(), std::io::Error>(()) + }) + .await + .map_err(|_| { + raise_error!( + format!( + "HTTP proxy CONNECT response from {}:{} timed out after {}s", + addr.host, + addr.port, + TIMEOUT.as_secs() + ), + ErrorCode::ConnectionTimeout + ) + })? + .map_err(|e| { + if e.kind() == std::io::ErrorKind::InvalidData { + raise_error!(e.to_string(), ErrorCode::NetworkError) + } else { + raise_error!(format!("{:#?}", e), ErrorCode::NetworkError) + } + })?; + + let response = String::from_utf8_lossy(&response); + if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { + Ok(stream) + } else { + Err(raise_error!( + format!( + "HTTP proxy CONNECT to {} via {}:{} failed: {}", + address, + addr.host, + addr.port, + response.lines().next().unwrap_or("invalid response") + ), + ErrorCode::NetworkError + )) + } +} diff --git a/crates/server/src/rest/api/system.rs b/crates/server/src/rest/api/system.rs index 9a27bd9..3205c7a 100644 --- a/crates/server/src/rest/api/system.rs +++ b/crates/server/src/rest/api/system.rs @@ -23,7 +23,7 @@ use bichon_core::dashboard::DashboardStats; use bichon_core::error::code::ErrorCode; use bichon_core::raise_error; use bichon_core::settings::cli::SETTINGS; -use bichon_core::settings::proxy::Proxy; +use bichon_core::settings::proxy::{Proxy, ProxyTestResult}; use bichon_core::settings::SystemConfigurations; use bichon_core::users::permissions::Permission; use bichon_core::version::{fetch_notifications, Notifications}; @@ -70,7 +70,7 @@ impl SystemApi { Ok(Json(stats)) } - /// Get the full list of SOCKS5 proxy configurations. + /// Get the full list of proxy configurations. #[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")] async fn list_proxy(&self, _context: WrappedContext) -> ApiResult>> { //The proxy list is visible to all users. @@ -103,6 +103,17 @@ impl SystemApi { Ok(Json(Proxy::get(id.0)?)) } + /// Test whether a proxy can reach a geo lookup service. Requires root permission. + #[oai(path = "/proxy/:id/test", method = "post", operation_id = "test_proxy")] + async fn test_proxy( + &self, + id: Path, + context: WrappedContext, + ) -> ApiResult> { + context.require_permission(None, Permission::ROOT)?; + Ok(Json(Proxy::test(id.0).await?)) + } + /// Create a new proxy configuration. Requires root permission. #[oai(path = "/proxy", method = "post", operation_id = "create_proxy")] async fn create_proxy(&self, url: PlainText, context: WrappedContext) -> ApiResult<()> { @@ -122,6 +133,7 @@ impl SystemApi { context.require_permission(None, Permission::ROOT)?; Ok(Proxy::update(id.0, url.0)?) } + /// Get system configurations. /// /// Returns a read-only snapshot of the server configuration diff --git a/web/src/api/system/api.ts b/web/src/api/system/api.ts index 20cad67..e2da91b 100644 --- a/web/src/api/system/api.ts +++ b/web/src/api/system/api.ts @@ -105,6 +105,14 @@ export interface Proxy { updated_at: number; } +export interface ProxyTestResult { + ip?: string | null; + country?: string | null; + region?: string | null; + city?: string | null; + isp?: string | null; +} + export type ServerConfigurations = { bichon_log_level: string bichon_http_port: number @@ -168,6 +176,11 @@ export const update_proxy = async (id: number, url: string) => { return response.data; }; +export const test_proxy = async (id: number) => { + const response = await axiosInstance.post(`api/v1/proxy/${id}/test`); + return response.data; +}; + export const add_proxy = async (url: string) => { const response = await axiosInstance.post(`api/v1/proxy`, url, { headers: { @@ -181,4 +194,4 @@ export const add_proxy = async (url: string) => { export const get_system_configurations = async () => { const response = await axiosInstance.get(`api/v1/system-configurations`); return response.data; -}; \ No newline at end of file +}; diff --git a/web/src/features/settings/proxy/components/__tests__/schema.test.ts b/web/src/features/settings/proxy/components/__tests__/schema.test.ts index 0b427ba..bd59e4f 100644 --- a/web/src/features/settings/proxy/components/__tests__/schema.test.ts +++ b/web/src/features/settings/proxy/components/__tests__/schema.test.ts @@ -1,196 +1,16 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { proxyFormSchema } from '../schema' describe('Proxy Form Schema', () => { - describe('url field - basic validation', () => { - it('rejects empty URL', () => { - const result = proxyFormSchema.safeParse({ url: '' }) - expect(result.success).toBe(false) - }) - - it('accepts valid socks5 URL', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:1080', - }) - expect(result.success).toBe(true) - }) - - it('accepts valid http URL', () => { - const result = proxyFormSchema.safeParse({ - url: 'http://proxy.example.com:8080', - }) - expect(result.success).toBe(true) - }) + it('rejects empty URL', () => { + const result = proxyFormSchema.safeParse({ url: '' }) + expect(result.success).toBe(false) }) - describe('url field - protocol validation', () => { - it('rejects https protocol', () => { - const result = proxyFormSchema.safeParse({ - url: 'https://proxy.example.com:443', - }) - expect(result.success).toBe(false) - if (!result.success) { - expect( - result.error.issues.some((i) => - i.message?.includes('Invalid format') - ) - ).toBe(true) - } - }) - - it('rejects ftp protocol', () => { - const result = proxyFormSchema.safeParse({ - url: 'ftp://files.example.com', - }) - expect(result.success).toBe(false) - }) - - it('rejects URL without protocol', () => { - const result = proxyFormSchema.safeParse({ - url: '127.0.0.1:1080', - }) - expect(result.success).toBe(false) - if (!result.success) { - expect( - result.error.issues.some((i) => - i.message?.includes('Invalid format') - ) - ).toBe(true) - } - }) - }) - - describe('url field - port validation', () => { - it('rejects port 0', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:0', - }) - expect(result.success).toBe(false) - }) - - it('rejects port > 65535', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:99999', - }) - expect(result.success).toBe(false) - }) - - it('accepts port 65535', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:65535', - }) - expect(result.success).toBe(true) - }) - - it('accepts port 1', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:1', - }) - expect(result.success).toBe(true) - }) - - it('defaults to port 1080 when no port specified', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1', - }) - expect(result.success).toBe(true) - }) - }) - - describe('url field - hostname validation', () => { - it('accepts IP address hostname', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://192.168.1.1:1080', - }) - expect(result.success).toBe(true) - }) - - it('accepts domain hostname', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://proxy.internal:1080', - }) - expect(result.success).toBe(true) - }) - - it('rejects hostname with invalid characters', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://proxy_host:1080', - }) - expect(result.success).toBe(false) - if (!result.success) { - expect( - result.error.issues.some((i) => - i.message?.includes('Hostname contains invalid characters') - ) - ).toBe(true) - } - }) - }) - - describe('url field - auth validation', () => { - it('rejects username without password', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://user@127.0.0.1:1080', - }) - expect(result.success).toBe(false) - if (!result.success) { - expect( - result.error.issues.some((i) => - i.message?.includes('Password cannot be empty') - ) - ).toBe(true) - } - }) - - it('rejects short password when username provided', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://user:short@127.0.0.1:1080', - }) - expect(result.success).toBe(false) - if (!result.success) { - expect( - result.error.issues.some((i) => - i.message?.includes('Password must be at least 8') - ) - ).toBe(true) - } - }) - - it('accepts valid auth credentials', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://user:password123@127.0.0.1:1080', - }) - expect(result.success).toBe(true) - }) - - it('accepts URL without auth (no credentials)', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://127.0.0.1:1080', - }) - expect(result.success).toBe(true) - }) - }) - - describe('url field - non-standard format (host:port:user:pass)', () => { - it('accepts non-standard format with auth', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://server.nodeprovider.com:8080:nodeprovider_a1234_alias_com-country-us-region-california-sid-b123123123-filter-medium:passwordhere', - }) - expect(result.success).toBe(true) - }) - - it('accepts simple non-standard format', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://proxy.example.com:1080:myuser:mypassword', - }) - expect(result.success).toBe(true) - }) - - it('rejects non-standard format without password', () => { - const result = proxyFormSchema.safeParse({ - url: 'socks5://proxy.example.com:1080:myuser', - }) - expect(result.success).toBe(false) + it('leaves proxy URL validation to the server', () => { + const result = proxyFormSchema.safeParse({ + url: 'socks5://proxy.example.com:8080:customer-zone-us:secret', }) + expect(result.success).toBe(true) }) }) diff --git a/web/src/features/settings/proxy/components/columns.tsx b/web/src/features/settings/proxy/components/columns.tsx index 64a0e3d..8770a2a 100644 --- a/web/src/features/settings/proxy/components/columns.tsx +++ b/web/src/features/settings/proxy/components/columns.tsx @@ -35,18 +35,25 @@ export const getColumns = (t: (key: string) => string): ColumnDef[] => [ {`${row.original.id}`} ), enableHiding: false, - meta: { className: 'w-60' }, - enableSorting: false + meta: { className: 'w-44' }, + enableSorting: false, }, { - accessorKey: "url", + accessorKey: 'url', header: ({ column }) => ( ), cell: ({ row }) => { - return {row.original.url} + return ( + + {row.original.url} + + ) }, - meta: { className: 'max-w-60' }, + meta: { className: 'min-w-0' }, }, { accessorKey: 'created_at', @@ -54,11 +61,12 @@ export const getColumns = (t: (key: string) => string): ColumnDef[] => [ ), cell: ({ row }) => { - const created_at = row.original.created_at; - const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss'); - return {date}; + const created_at = row.original.created_at + const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss') + return {date} }, enableHiding: false, + meta: { className: 'w-44 whitespace-nowrap' }, }, { accessorKey: 'updated_at', @@ -66,14 +74,16 @@ export const getColumns = (t: (key: string) => string): ColumnDef[] => [ ), cell: ({ row }) => { - const updated_at = row.original.updated_at; - const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss'); - return {date}; + const updated_at = row.original.updated_at + const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss') + return {date} }, enableHiding: false, + meta: { className: 'w-44 whitespace-nowrap' }, }, { id: 'actions', cell: DataTableRowActions, + meta: { className: 'w-16' }, }, ] diff --git a/web/src/features/settings/proxy/components/data-table-row-actions.tsx b/web/src/features/settings/proxy/components/data-table-row-actions.tsx index bfceee7..e3aa964 100644 --- a/web/src/features/settings/proxy/components/data-table-row-actions.tsx +++ b/web/src/features/settings/proxy/components/data-table-row-actions.tsx @@ -18,8 +18,10 @@ import { DotsHorizontalIcon } from '@radix-ui/react-icons' +import { useMutation } from '@tanstack/react-query' import { Row } from '@tanstack/react-table' import { IconEdit, IconTrash } from '@tabler/icons-react' +import { AxiosError } from 'axios' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -31,8 +33,9 @@ import { } from '@/components/ui/dropdown-menu' import { useProxyContext } from '../context' import { useTranslation } from 'react-i18next' -import { Proxy } from '@/api/system/api' +import { Proxy, test_proxy } from '@/api/system/api' import { useCurrentUser } from '@/hooks/use-current-user' +import { toast } from '@/hooks/use-toast' interface DataTableRowActionsProps { @@ -43,6 +46,34 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { const { setOpen, setCurrentRow } = useProxyContext() const { require_any_permission } = useCurrentUser() const { t } = useTranslation() + const canManage = require_any_permission(['system:root']) + const testMutation = useMutation({ + mutationFn: () => test_proxy(row.original.id), + onSuccess: (result) => { + const description = [ + result.ip, + result.city, + result.region, + result.country, + result.isp, + ] + .filter(Boolean) + .join(' - ') + toast({ + title: t('settings.proxyTestSuccess'), + description: description || undefined, + }) + }, + onError: (error) => { + const axiosError = error as AxiosError<{ message?: string }> + toast({ + variant: 'destructive', + title: t('settings.proxyTestFailed'), + description: axiosError.response?.data?.message || axiosError.message, + }) + }, + }) + return ( <> @@ -57,7 +88,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { { setCurrentRow(row.original) setOpen('edit') @@ -68,9 +99,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { + testMutation.mutate()} + > + {testMutation.isPending + ? t('settings.proxyTesting') + : t('settings.proxyTest')} + { setCurrentRow(row.original) setOpen('delete') diff --git a/web/src/features/settings/proxy/components/delete-dialog.tsx b/web/src/features/settings/proxy/components/delete-dialog.tsx index 5e5d45d..2698deb 100644 --- a/web/src/features/settings/proxy/components/delete-dialog.tsx +++ b/web/src/features/settings/proxy/components/delete-dialog.tsx @@ -15,22 +15,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - - import { useState } from 'react' +import { AxiosError } from 'axios' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { IconAlertTriangle } from '@tabler/icons-react' +import { useTranslation } from 'react-i18next' +import { delete_proxy } from '@/api/system/api' +import { Proxy } from '@/api/system/api' import { toast } from '@/hooks/use-toast' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { ConfirmDialog } from '@/components/confirm-dialog' -import { useMutation, useQueryClient } from '@tanstack/react-query' import { ToastAction } from '@/components/ui/toast' -import { AxiosError } from 'axios' -import { delete_proxy } from '@/api/system/api' -import { useTranslation } from 'react-i18next' -import { Proxy } from '@/api/system/api' - +import { ConfirmDialog } from '@/components/confirm-dialog' interface Props { open: boolean @@ -40,41 +37,50 @@ interface Props { export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) { const { t } = useTranslation() - const [value, setValue] = useState(0) - const queryClient = useQueryClient(); + const [value, setValue] = useState('') + const queryClient = useQueryClient() function handleSuccess() { toast({ title: t('proxyDelete.successTitle'), description: t('proxyDelete.successDesc'), - action: {t('proxyDelete.close')}, - }); - queryClient.invalidateQueries({ queryKey: ['proxy-list'] }); - onOpenChange(false); + action: ( + + {t('proxyDelete.close')} + + ), + }) + queryClient.invalidateQueries({ queryKey: ['proxy-list'] }) + onOpenChange(false) } function handleError(error: AxiosError) { - const errorMessage = (error.response?.data as { message?: string })?.message || + const errorMessage = + (error.response?.data as { message?: string })?.message || error.message || - t('proxyDelete.failedDesc'); + t('proxyDelete.failedDesc') toast({ - variant: "destructive", + variant: 'destructive', title: t('proxyDelete.failedTitle'), description: errorMessage as string, - action: {t('proxyDelete.tryAgain')}, - }); - console.error(error); + action: ( + + {t('proxyDelete.tryAgain')} + + ), + }) + console.error(error) } const deleteMutation = useMutation({ mutationFn: (id: number) => delete_proxy(id), onSuccess: handleSuccess, - onError: handleError + onError: handleError, }) const handleDelete = () => { - if (value !== currentRow.id) return + if (value !== `${currentRow.id}`) return deleteMutation.mutate(currentRow.id) } @@ -83,8 +89,8 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) { open={open} onOpenChange={onOpenChange} handleConfirm={handleDelete} - disabled={value !== currentRow.id} - className="max-w-2xl" + disabled={value !== `${currentRow.id}`} + className='max-w-2xl' title={

- {t('proxyDelete.confirmText')} {`${currentRow.id}`}? + {t('proxyDelete.confirmText')}{' '} + {`${currentRow.id}`}?
{t('proxyDelete.permanent')}

@@ -105,19 +112,16 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) { {t('proxyDelete.warningTitle', 'Warning!')} - - {t('proxyDelete.warningDesc')} - + {t('proxyDelete.warningDesc')} } @@ -126,4 +130,3 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) { /> ) } - diff --git a/web/src/features/settings/proxy/components/schema.ts b/web/src/features/settings/proxy/components/schema.ts index b739a27..42dbe17 100644 --- a/web/src/features/settings/proxy/components/schema.ts +++ b/web/src/features/settings/proxy/components/schema.ts @@ -1,177 +1,7 @@ import { z } from 'zod' -// Parse a proxy URL into components. Supports two formats: -// Standard: socks5://[user:pass@]host:port -// Non-standard: socks5://host:port:user:pass (some proxy providers) -function parseProxyUrl(value: string): { - scheme: string - host: string - port: number - username?: string - password?: string -} | null { - // Strip scheme - let stripped: string - let scheme: string - const lower = value.toLowerCase() - if (lower.startsWith('socks5://')) { - scheme = 'socks5' - stripped = value.slice('socks5://'.length) - } else if (lower.startsWith('http://')) { - scheme = 'http' - stripped = value.slice('http://'.length) - } else { - return null - } - - if (!stripped) return null - - // Standard format: user:pass@host:port - const atIdx = stripped.lastIndexOf('@') - if (atIdx >= 0) { - const userinfo = stripped.slice(0, atIdx) - const hostport = stripped.slice(atIdx + 1) - - // Parse userinfo - let username: string | undefined - let password: string | undefined - if (userinfo) { - const colonIdx = userinfo.indexOf(':') - if (colonIdx >= 0) { - username = userinfo.slice(0, colonIdx) - password = userinfo.slice(colonIdx + 1) - } else { - username = userinfo - } - } - - // Parse host:port - const { host, port } = splitHostPort(hostport) - if (!host || !port) return null - - return { scheme, host, port, username, password } - } - - // Non-standard format: host:port[:user[:pass]] - const parts = stripped.split(':') - if (parts.length === 1) { - // host only, default port to 1080 - const host = parts[0] - if (!host) return null - return { scheme, host, port: 1080 } - } - if (parts.length === 2) { - // host:port, no auth - const host = parts[0] - const port = parseInt(parts[1], 10) - if (!host || isNaN(port)) return null - return { scheme, host, port } - } - if (parts.length >= 4) { - // host:port:username:password (and possibly more colons in user/pass) - // Last part = password, second-to-last = username, rest = host:port - const password = parts[parts.length - 1] - const username = parts[parts.length - 2] - const hostport = parts.slice(0, parts.length - 2).join(':') - const { host, port } = splitHostPort(hostport) - if (!host || !port || !username || !password) return null - return { scheme, host, port, username, password } - } - - return null -} - -function splitHostPort(hostport: string): { host: string; port: number | null } { - if (!hostport) return { host: '', port: null } - - // IPv6: [::1]:1080 or [::1] - if (hostport.startsWith('[')) { - const close = hostport.indexOf(']') - if (close < 0) return { host: '', port: null } - const host = hostport.slice(1, close) - const after = hostport.slice(close + 1) - if (!after.startsWith(':')) { - // No port specified, default to 1080 - return { host, port: 1080 } - } - const port = parseInt(after.slice(1), 10) - return { host, port: isNaN(port) ? null : port } - } - - const lastColon = hostport.lastIndexOf(':') - if (lastColon < 0) { - // No port specified, default to 1080 - return { host: hostport, port: 1080 } - } - const host = hostport.slice(0, lastColon) - const port = parseInt(hostport.slice(lastColon + 1), 10) - return { host, port: isNaN(port) ? null : port } -} - export const proxyFormSchema = z.object({ - url: z - .string() - .min(1, 'Proxy address cannot be empty') - .superRefine((value, ctx) => { - if (value.length === 0) return - - // Try our custom parser first (handles both standard and non-standard) - const parsed = parseProxyUrl(value) - - if (!parsed) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid format. Expected socks5://[user:pass@]host:port or socks5://host:port:user:pass', - path: [], - }) - return - } - - if (parsed.scheme !== 'socks5' && parsed.scheme !== 'http') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'URL must start with http:// or socks5://', - path: [], - }) - return - } - - if (!/^[a-zA-Z0-9\-\.]+$/.test(parsed.host)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Hostname contains invalid characters', - path: [], - }) - return - } - - if (parsed.port <= 0 || parsed.port > 65535) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Port must be between 1-65535', - path: [], - }) - return - } - - if (parsed.username && !parsed.password) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Password cannot be empty when username is provided', - path: [], - }) - return - } - - if (parsed.password && parsed.password.length < 8) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Password must be at least 8 characters', - path: [], - }) - return - } - }), + url: z.string().min(1, 'Proxy address cannot be empty'), }) export type ProxyFormValues = z.infer diff --git a/web/src/features/settings/proxy/components/table.tsx b/web/src/features/settings/proxy/components/table.tsx index f178bc9..86ae917 100644 --- a/web/src/features/settings/proxy/components/table.tsx +++ b/web/src/features/settings/proxy/components/table.tsx @@ -79,8 +79,8 @@ export function ProxyTable({ columns, data }: DataTableProps) { initialState: { pagination: { pageIndex: 0, - pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10 - } + pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10, + }, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, @@ -98,8 +98,8 @@ export function ProxyTable({ columns, data }: DataTableProps) { return (
-
- +
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -113,9 +113,9 @@ export function ProxyTable({ columns, data }: DataTableProps) { {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} ) })} @@ -133,7 +133,7 @@ export function ProxyTable({ columns, data }: DataTableProps) { {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 64a29f6..35bee57 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -240,7 +240,7 @@ "imapPassword": "IMAP Password", "imapPort": "IMAP Port", "imapPortPlaceholder": "e.g 993", - "imapProxy": "Use a SOCKS5 proxy for IMAP connections.", + "imapProxy": "Use a proxy (http/socks5) for IMAP connections.", "incDownload": "Interval", "lastSync": "Last Sync", "leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.", @@ -924,7 +924,7 @@ "updateOrCreationFailed": "{{action}} failed, please try again later", "updated": "Updated", "useProxyOptional": "Use Proxy (optional):", - "useSocks5ProxyForOAuthRequests": "Use SOCKS5 proxy for OAuth requests when direct access is blocked.", + "useSocks5ProxyForOAuthRequests": "Use a proxy for OAuth requests when direct access is blocked.", "value": "Value:", "valueCannotBeEmpty": "Value cannot be empty", "valueIsRequired": "Value is required", @@ -1420,6 +1420,10 @@ } }, "proxy": "Proxy", + "proxyTest": "Check Proxy", + "proxyTesting": "Checking...", + "proxyTestFailed": "Proxy check failed", + "proxyTestSuccess": "Proxy works", "proxyUpdateOrAddFailed": "{{action}} failed, please try again later", "reset": "Reset", "resetRootPassword": "Reset Root Password", @@ -1820,4 +1824,4 @@ "singleRequestBatchSizeTooLarge": "Batch size must be at most 200", "singleRequestBatchSizeTooSmall": "Batch size must be at least 10" } -} \ No newline at end of file +}