mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
expand proxy provider support
This commit is contained in:
@@ -266,31 +266,12 @@ impl OAuth2Flow {
|
|||||||
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
|
fn build_http_client(use_proxy: Option<u64>) -> BichonResult<reqwest::Client> {
|
||||||
if let Some(proxy_id) = use_proxy {
|
if let Some(proxy_id) = use_proxy {
|
||||||
let proxy = Proxy::get(proxy_id)?;
|
let proxy = Proxy::get(proxy_id)?;
|
||||||
// Normalize the URL: reqwest only understands standard format user:pass@host:port.
|
let proxy_url = parse_proxy_url(&proxy.url)?.standard_url();
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return oauth2::reqwest::ClientBuilder::new()
|
return oauth2::reqwest::ClientBuilder::new()
|
||||||
.redirect(oauth2::reqwest::redirect::Policy::none())
|
.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!(
|
raise_error!(
|
||||||
format!(
|
"Failed to configure proxy. Please check the proxy configuration.".into(),
|
||||||
"Failed to configure SOCKS5 proxy ({}): {:#?}. Please check",
|
|
||||||
&proxy_url, e
|
|
||||||
),
|
|
||||||
ErrorCode::InternalError
|
ErrorCode::InternalError
|
||||||
)
|
)
|
||||||
})?)
|
})?)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
//use poem_openapi::Object;
|
//use poem_openapi::Object;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{error::Error, time::Duration};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::{
|
database::{
|
||||||
@@ -29,6 +30,27 @@ use crate::{
|
|||||||
utils::net::parse_proxy_url,
|
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)]
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
|
||||||
pub struct Proxy {
|
pub struct Proxy {
|
||||||
@@ -45,6 +67,16 @@ pub struct Proxy {
|
|||||||
pub updated_at: i64,
|
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<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub isp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl MemDbModel for Proxy {
|
impl MemDbModel for Proxy {
|
||||||
fn collection() -> &'static str {
|
fn collection() -> &'static str {
|
||||||
"proxies"
|
"proxies"
|
||||||
@@ -85,9 +117,10 @@ impl Proxy {
|
|||||||
|
|
||||||
pub fn update(id: u64, url: String) -> BichonResult<()> {
|
pub fn update(id: u64, url: String) -> BichonResult<()> {
|
||||||
update_impl(DB_MANAGER.db(), &id.to_string(), move |current: Proxy| {
|
update_impl(DB_MANAGER.db(), &id.to_string(), move |current: Proxy| {
|
||||||
let mut updated = current.clone();
|
let mut updated = current;
|
||||||
updated.url = url;
|
updated.url = url;
|
||||||
updated.updated_at = utc_now!();
|
updated.updated_at = utc_now!();
|
||||||
|
updated.validate()?;
|
||||||
Ok(updated)
|
Ok(updated)
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -103,6 +136,180 @@ impl Proxy {
|
|||||||
parse_proxy_url(&self.url)?;
|
parse_proxy_url(&self.url)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn test_connectivity(&self) -> BichonResult<ProxyTestResult> {
|
||||||
|
test_proxy_url(&self.url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn test(id: u64) -> BichonResult<ProxyTestResult> {
|
||||||
|
let proxy = Self::get(id)?;
|
||||||
|
proxy.test_connectivity().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_proxy_url(url: &str) -> BichonResult<ProxyTestResult> {
|
||||||
|
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<ProxyTestResult> {
|
||||||
|
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::<serde_json::Value>()
|
||||||
|
.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<ProxyTestResult> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
@@ -116,9 +323,12 @@ mod tests {
|
|||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
"socks5://proxy.example.com:1080",
|
"socks5://proxy.example.com:1080",
|
||||||
"socks5://user:pass@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
|
// Non-standard format: host:port:user:pass
|
||||||
"socks5://server.nodeprovider.com:8080:username123:passwordhere",
|
"socks5://server.nodeprovider.com:8080:username123:passwordhere",
|
||||||
|
"http://server.nodeprovider.com:8080:username123:passwordhere",
|
||||||
];
|
];
|
||||||
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
@@ -126,4 +336,36 @@ mod tests {
|
|||||||
assert!(proxy.validate().is_ok(), "URL should be valid: {}", url);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-74
@@ -21,9 +21,11 @@ use crate::raise_error;
|
|||||||
use crate::settings::proxy::Proxy;
|
use crate::settings::proxy::Proxy;
|
||||||
use crate::utils::tls::establish_tls_stream;
|
use crate::utils::tls::establish_tls_stream;
|
||||||
use crate::{error::BichonResult, imap::session::SessionStream};
|
use crate::{error::BichonResult, imap::session::SessionStream};
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tokio_io_timeout::TimeoutStream;
|
use tokio_io_timeout::TimeoutStream;
|
||||||
@@ -32,15 +34,54 @@ use tracing::error;
|
|||||||
|
|
||||||
pub(crate) const TIMEOUT: Duration = Duration::from_secs(30);
|
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.
|
/// Parsed proxy address components.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProxyAddr {
|
pub struct ProxyAddr {
|
||||||
|
pub scheme: ProxyScheme,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
pub(crate) async fn establish_tcp_connection_with_timeout(
|
||||||
address: SocketAddr,
|
address: SocketAddr,
|
||||||
use_proxy: Option<u64>,
|
use_proxy: Option<u64>,
|
||||||
@@ -84,24 +125,21 @@ pub async fn establish_tls_connection(
|
|||||||
/// The distinguishing feature is the `@` sign in the standard format.
|
/// The distinguishing feature is the `@` sign in the standard format.
|
||||||
pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
||||||
// Normalize and strip scheme prefix
|
// Normalize and strip scheme prefix
|
||||||
let stripped = if let Some(rest) = input
|
let (scheme, stripped) = if let Some(rest) = input
|
||||||
.strip_prefix("socks5://")
|
.strip_prefix("socks5://")
|
||||||
.or_else(|| input.strip_prefix("SOCKS5://"))
|
.or_else(|| 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
|
} else if let Some(rest) = input
|
||||||
.strip_prefix("http://")
|
.strip_prefix("http://")
|
||||||
.or_else(|| input.strip_prefix("HTTP://"))
|
.or_else(|| input.strip_prefix("HTTP://"))
|
||||||
.or_else(|| input.strip_prefix("Http://"))
|
.or_else(|| input.strip_prefix("Http://"))
|
||||||
{
|
{
|
||||||
rest
|
(ProxyScheme::Http, rest)
|
||||||
} else {
|
} else {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
format!(
|
"Invalid proxy URL: must start with 'http://' or 'socks5://'".into(),
|
||||||
"Invalid proxy URL: must start with 'http://' or 'socks5://', got '{}'",
|
|
||||||
input
|
|
||||||
),
|
|
||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
@@ -122,6 +160,7 @@ pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
|||||||
let (host, port) = split_hostport(hostport)?;
|
let (host, port) = split_hostport(hostport)?;
|
||||||
|
|
||||||
return Ok(ProxyAddr {
|
return Ok(ProxyAddr {
|
||||||
|
scheme,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
@@ -130,32 +169,44 @@ pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No '@' — check for non-standard format: host:port:user:pass
|
// No '@' — check for non-standard format: host:port:user:pass
|
||||||
let parts: Vec<&str> = stripped.rsplitn(4, ':').collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>();
|
if stripped.starts_with('[') {
|
||||||
|
let (host, port) = split_hostport(stripped)?;
|
||||||
|
return Ok(ProxyAddr {
|
||||||
|
scheme,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
match parts.len() {
|
let mut parts = stripped.split(':');
|
||||||
2 => {
|
match (
|
||||||
|
parts.next(),
|
||||||
|
parts.next(),
|
||||||
|
parts.next(),
|
||||||
|
parts.next(),
|
||||||
|
parts.next(),
|
||||||
|
) {
|
||||||
|
(Some(_), Some(_), None, None, None) => {
|
||||||
// host:port, no auth
|
// host:port, no auth
|
||||||
let (host, port) = split_hostport(stripped)?;
|
let (host, port) = split_hostport(stripped)?;
|
||||||
Ok(ProxyAddr {
|
Ok(ProxyAddr {
|
||||||
|
scheme,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
4 => {
|
(Some(host), Some(port), Some(username), Some(password), None) => {
|
||||||
// Non-standard: host:port:username:password
|
// Non-standard: host:port:username:password
|
||||||
let host = parts[0].to_string();
|
let port = port.parse::<u16>().map_err(|_| {
|
||||||
let port = parts[1]
|
|
||||||
.parse::<u16>()
|
|
||||||
.map_err(|_| {
|
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!("Invalid port '{}' in proxy URL.", parts[1]),
|
format!("Invalid port '{}' in proxy URL.", port),
|
||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let username = parts[2].to_string();
|
|
||||||
let password = parts[3].to_string();
|
|
||||||
|
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
@@ -163,6 +214,12 @@ pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
|||||||
ErrorCode::InvalidParameter
|
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() {
|
if username.is_empty() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
"Empty username in proxy URL.".into(),
|
"Empty username in proxy URL.".into(),
|
||||||
@@ -177,23 +234,21 @@ pub fn parse_proxy_url(input: &str) -> BichonResult<ProxyAddr> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(ProxyAddr {
|
Ok(ProxyAddr {
|
||||||
host,
|
scheme,
|
||||||
|
host: host.to_string(),
|
||||||
port,
|
port,
|
||||||
username: Some(username),
|
username: Some(username.to_string()),
|
||||||
password: Some(password),
|
password: Some(password.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => Err(raise_error!(
|
_ => Err(raise_error!(
|
||||||
format!(
|
"Invalid proxy URL format. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.".into(),
|
||||||
"Invalid proxy URL format '{}'. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.",
|
|
||||||
input
|
|
||||||
),
|
|
||||||
ErrorCode::InvalidParameter
|
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<String>, Option<String>)> {
|
fn split_userinfo(userinfo: &str) -> BichonResult<(Option<String>, Option<String>)> {
|
||||||
if userinfo.is_empty() {
|
if userinfo.is_empty() {
|
||||||
return Ok((None, None));
|
return Ok((None, None));
|
||||||
@@ -207,13 +262,22 @@ fn split_userinfo(userinfo: &str) -> BichonResult<(Option<String>, Option<String
|
|||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if pass.is_empty() {
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Empty password in proxy URL credentials.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok((Some(user.to_string()), Some(pass.to_string())))
|
Ok((Some(user.to_string()), Some(pass.to_string())))
|
||||||
} else {
|
} else {
|
||||||
Ok((Some(userinfo.to_string()), None))
|
Err(raise_error!(
|
||||||
|
"Password cannot be empty when username is provided.".into(),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split "host:port" into (host, port). Handles IPv6 addresses in brackets.
|
/// Split "host:port" into (host, port). Bracketed IPv6 is accepted.
|
||||||
fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
|
fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
|
||||||
if hostport.is_empty() {
|
if hostport.is_empty() {
|
||||||
return Err(raise_error!(
|
return Err(raise_error!(
|
||||||
@@ -222,29 +286,31 @@ fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv6: [::1]:1080
|
if let Some(rest) = hostport.strip_prefix('[') {
|
||||||
if hostport.starts_with('[') {
|
let Some(close_bracket) = rest.find(']') else {
|
||||||
let close_bracket = hostport.find(']').ok_or_else(|| {
|
return Err(raise_error!(
|
||||||
raise_error!(
|
|
||||||
format!("Invalid IPv6 address in proxy URL: '{}'.", hostport),
|
format!("Invalid IPv6 address in proxy URL: '{}'.", hostport),
|
||||||
ErrorCode::InvalidParameter
|
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 port = port_text.parse::<u16>().map_err(|_| {
|
||||||
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::<u16>().map_err(|_| {
|
|
||||||
raise_error!(
|
raise_error!(
|
||||||
format!("Invalid port in proxy URL: '{}'.", hostport),
|
format!("Invalid port in proxy URL: '{}'.", hostport),
|
||||||
ErrorCode::InvalidParameter
|
ErrorCode::InvalidParameter
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
return Ok((host, port));
|
|
||||||
|
return Ok((host.to_string(), port));
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostname:port or ip:port — split from right
|
// hostname:port or ip:port — split from right
|
||||||
@@ -268,6 +334,12 @@ fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> {
|
|||||||
ErrorCode::InvalidParameter
|
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))
|
Ok((host, port))
|
||||||
}
|
}
|
||||||
@@ -280,40 +352,11 @@ async fn connect_with_optional_proxy(
|
|||||||
if let Some(proxy_id) = use_proxy {
|
if let Some(proxy_id) = use_proxy {
|
||||||
let proxy = Proxy::get(proxy_id)?;
|
let proxy = Proxy::get(proxy_id)?;
|
||||||
let addr = parse_proxy_url(&proxy.url)?;
|
let addr = parse_proxy_url(&proxy.url)?;
|
||||||
let proxy_addr = (addr.host.as_str(), addr.port);
|
return if addr.scheme == ProxyScheme::Http {
|
||||||
|
connect_via_http_proxy(&addr, address).await
|
||||||
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
|
|
||||||
} else {
|
} 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
|
// Fallback to direct TCP connection
|
||||||
timeout(TIMEOUT, TcpStream::connect(address))
|
timeout(TIMEOUT, TcpStream::connect(address))
|
||||||
@@ -335,3 +378,135 @@ async fn connect_with_optional_proxy(
|
|||||||
})?
|
})?
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::NetworkError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn connect_via_socks5_proxy(
|
||||||
|
addr: &ProxyAddr,
|
||||||
|
address: SocketAddr,
|
||||||
|
) -> BichonResult<TcpStream> {
|
||||||
|
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<TcpStream> {
|
||||||
|
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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use bichon_core::dashboard::DashboardStats;
|
|||||||
use bichon_core::error::code::ErrorCode;
|
use bichon_core::error::code::ErrorCode;
|
||||||
use bichon_core::raise_error;
|
use bichon_core::raise_error;
|
||||||
use bichon_core::settings::cli::SETTINGS;
|
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::settings::SystemConfigurations;
|
||||||
use bichon_core::users::permissions::Permission;
|
use bichon_core::users::permissions::Permission;
|
||||||
use bichon_core::version::{fetch_notifications, Notifications};
|
use bichon_core::version::{fetch_notifications, Notifications};
|
||||||
@@ -70,7 +70,7 @@ impl SystemApi {
|
|||||||
Ok(Json(stats))
|
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")]
|
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
|
||||||
async fn list_proxy(&self, _context: WrappedContext) -> ApiResult<Json<Vec<Proxy>>> {
|
async fn list_proxy(&self, _context: WrappedContext) -> ApiResult<Json<Vec<Proxy>>> {
|
||||||
//The proxy list is visible to all users.
|
//The proxy list is visible to all users.
|
||||||
@@ -103,6 +103,17 @@ impl SystemApi {
|
|||||||
Ok(Json(Proxy::get(id.0)?))
|
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<u64>,
|
||||||
|
context: WrappedContext,
|
||||||
|
) -> ApiResult<Json<ProxyTestResult>> {
|
||||||
|
context.require_permission(None, Permission::ROOT)?;
|
||||||
|
Ok(Json(Proxy::test(id.0).await?))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new proxy configuration. Requires root permission.
|
/// Create a new proxy configuration. Requires root permission.
|
||||||
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
|
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
|
||||||
async fn create_proxy(&self, url: PlainText<String>, context: WrappedContext) -> ApiResult<()> {
|
async fn create_proxy(&self, url: PlainText<String>, context: WrappedContext) -> ApiResult<()> {
|
||||||
@@ -122,6 +133,7 @@ impl SystemApi {
|
|||||||
context.require_permission(None, Permission::ROOT)?;
|
context.require_permission(None, Permission::ROOT)?;
|
||||||
Ok(Proxy::update(id.0, url.0)?)
|
Ok(Proxy::update(id.0, url.0)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get system configurations.
|
/// Get system configurations.
|
||||||
///
|
///
|
||||||
/// Returns a read-only snapshot of the server configuration
|
/// Returns a read-only snapshot of the server configuration
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ export interface Proxy {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyTestResult {
|
||||||
|
ip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
isp?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerConfigurations = {
|
export type ServerConfigurations = {
|
||||||
bichon_log_level: string
|
bichon_log_level: string
|
||||||
bichon_http_port: number
|
bichon_http_port: number
|
||||||
@@ -168,6 +176,11 @@ export const update_proxy = async (id: number, url: string) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const test_proxy = async (id: number) => {
|
||||||
|
const response = await axiosInstance.post<ProxyTestResult>(`api/v1/proxy/${id}/test`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const add_proxy = async (url: string) => {
|
export const add_proxy = async (url: string) => {
|
||||||
const response = await axiosInstance.post(`api/v1/proxy`, url, {
|
const response = await axiosInstance.post(`api/v1/proxy`, url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,196 +1,16 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { proxyFormSchema } from '../schema'
|
import { proxyFormSchema } from '../schema'
|
||||||
|
|
||||||
describe('Proxy Form Schema', () => {
|
describe('Proxy Form Schema', () => {
|
||||||
describe('url field - basic validation', () => {
|
|
||||||
it('rejects empty URL', () => {
|
it('rejects empty URL', () => {
|
||||||
const result = proxyFormSchema.safeParse({ url: '' })
|
const result = proxyFormSchema.safeParse({ url: '' })
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts valid socks5 URL', () => {
|
it('leaves proxy URL validation to the server', () => {
|
||||||
const result = proxyFormSchema.safeParse({
|
const result = proxyFormSchema.safeParse({
|
||||||
url: 'socks5://127.0.0.1:1080',
|
url: 'socks5://proxy.example.com:8080:customer-zone-us:secret',
|
||||||
})
|
|
||||||
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)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -35,18 +35,25 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
|||||||
<LongText className='max-w-72'>{`${row.original.id}`}</LongText>
|
<LongText className='max-w-72'>{`${row.original.id}`}</LongText>
|
||||||
),
|
),
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
meta: { className: 'w-60' },
|
meta: { className: 'w-44' },
|
||||||
enableSorting: false
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "url",
|
accessorKey: 'url',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t('settings.url')} />
|
<DataTableColumnHeader column={column} title={t('settings.url')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <LongText>{row.original.url}</LongText>
|
return (
|
||||||
|
<LongText
|
||||||
|
className='max-w-full'
|
||||||
|
contentClassName='max-w-[32rem] break-all'
|
||||||
|
>
|
||||||
|
{row.original.url}
|
||||||
|
</LongText>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
meta: { className: 'max-w-60' },
|
meta: { className: 'min-w-0' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
@@ -54,11 +61,12 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
|||||||
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
|
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const created_at = row.original.created_at;
|
const created_at = row.original.created_at
|
||||||
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
|
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss')
|
||||||
return <LongText>{date}</LongText>;
|
return <LongText>{date}</LongText>
|
||||||
},
|
},
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
|
meta: { className: 'w-44 whitespace-nowrap' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'updated_at',
|
accessorKey: 'updated_at',
|
||||||
@@ -66,14 +74,16 @@ export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
|
|||||||
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
|
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const updated_at = row.original.updated_at;
|
const updated_at = row.original.updated_at
|
||||||
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
|
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss')
|
||||||
return <LongText>{date}</LongText>;
|
return <LongText>{date}</LongText>
|
||||||
},
|
},
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
|
meta: { className: 'w-44 whitespace-nowrap' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: DataTableRowActions,
|
cell: DataTableRowActions,
|
||||||
|
meta: { className: 'w-16' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
|
|
||||||
|
|
||||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Row } from '@tanstack/react-table'
|
import { Row } from '@tanstack/react-table'
|
||||||
import { IconEdit, IconTrash } from '@tabler/icons-react'
|
import { IconEdit, IconTrash } from '@tabler/icons-react'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -31,8 +33,9 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useProxyContext } from '../context'
|
import { useProxyContext } from '../context'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { useCurrentUser } from '@/hooks/use-current-user'
|
||||||
|
import { toast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
|
||||||
interface DataTableRowActionsProps {
|
interface DataTableRowActionsProps {
|
||||||
@@ -43,6 +46,34 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
|||||||
const { setOpen, setCurrentRow } = useProxyContext()
|
const { setOpen, setCurrentRow } = useProxyContext()
|
||||||
const { require_any_permission } = useCurrentUser()
|
const { require_any_permission } = useCurrentUser()
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@@ -57,7 +88,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!require_any_permission(['system:root'])}
|
disabled={!canManage}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentRow(row.original)
|
setCurrentRow(row.original)
|
||||||
setOpen('edit')
|
setOpen('edit')
|
||||||
@@ -68,9 +99,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
|||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</DropdownMenuShortcut>
|
</DropdownMenuShortcut>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={!canManage || testMutation.isPending}
|
||||||
|
onClick={() => testMutation.mutate()}
|
||||||
|
>
|
||||||
|
{testMutation.isPending
|
||||||
|
? t('settings.proxyTesting')
|
||||||
|
: t('settings.proxyTest')}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!require_any_permission(['system:root'])}
|
disabled={!canManage}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentRow(row.original)
|
setCurrentRow(row.original)
|
||||||
setOpen('delete')
|
setOpen('delete')
|
||||||
|
|||||||
@@ -15,22 +15,19 @@
|
|||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { IconAlertTriangle } from '@tabler/icons-react'
|
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 { toast } from '@/hooks/use-toast'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
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 { ToastAction } from '@/components/ui/toast'
|
||||||
import { AxiosError } from 'axios'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { delete_proxy } from '@/api/system/api'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Proxy } from '@/api/system/api'
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -40,41 +37,50 @@ interface Props {
|
|||||||
|
|
||||||
export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [value, setValue] = useState(0)
|
const [value, setValue] = useState('')
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
function handleSuccess() {
|
function handleSuccess() {
|
||||||
toast({
|
toast({
|
||||||
title: t('proxyDelete.successTitle'),
|
title: t('proxyDelete.successTitle'),
|
||||||
description: t('proxyDelete.successDesc'),
|
description: t('proxyDelete.successDesc'),
|
||||||
action: <ToastAction altText={t('proxyDelete.close')}>{t('proxyDelete.close')}</ToastAction>,
|
action: (
|
||||||
});
|
<ToastAction altText={t('proxyDelete.close')}>
|
||||||
queryClient.invalidateQueries({ queryKey: ['proxy-list'] });
|
{t('proxyDelete.close')}
|
||||||
onOpenChange(false);
|
</ToastAction>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['proxy-list'] })
|
||||||
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleError(error: AxiosError) {
|
function handleError(error: AxiosError) {
|
||||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
const errorMessage =
|
||||||
|
(error.response?.data as { message?: string })?.message ||
|
||||||
error.message ||
|
error.message ||
|
||||||
t('proxyDelete.failedDesc');
|
t('proxyDelete.failedDesc')
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
title: t('proxyDelete.failedTitle'),
|
title: t('proxyDelete.failedTitle'),
|
||||||
description: errorMessage as string,
|
description: errorMessage as string,
|
||||||
action: <ToastAction altText={t('proxyDelete.tryAgain')}>{t('proxyDelete.tryAgain')}</ToastAction>,
|
action: (
|
||||||
});
|
<ToastAction altText={t('proxyDelete.tryAgain')}>
|
||||||
console.error(error);
|
{t('proxyDelete.tryAgain')}
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => delete_proxy(id),
|
mutationFn: (id: number) => delete_proxy(id),
|
||||||
onSuccess: handleSuccess,
|
onSuccess: handleSuccess,
|
||||||
onError: handleError
|
onError: handleError,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (value !== currentRow.id) return
|
if (value !== `${currentRow.id}`) return
|
||||||
deleteMutation.mutate(currentRow.id)
|
deleteMutation.mutate(currentRow.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +89,8 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
handleConfirm={handleDelete}
|
handleConfirm={handleDelete}
|
||||||
disabled={value !== currentRow.id}
|
disabled={value !== `${currentRow.id}`}
|
||||||
className="max-w-2xl"
|
className='max-w-2xl'
|
||||||
title={
|
title={
|
||||||
<span className='text-destructive'>
|
<span className='text-destructive'>
|
||||||
<IconAlertTriangle
|
<IconAlertTriangle
|
||||||
@@ -97,7 +103,8 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
|||||||
desc={
|
desc={
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<p className='mb-2'>
|
<p className='mb-2'>
|
||||||
{t('proxyDelete.confirmText')} <span className='font-bold'>{`${currentRow.id}`}</span>?
|
{t('proxyDelete.confirmText')}{' '}
|
||||||
|
<span className='font-bold'>{`${currentRow.id}`}</span>?
|
||||||
<br />
|
<br />
|
||||||
{t('proxyDelete.permanent')}
|
{t('proxyDelete.permanent')}
|
||||||
</p>
|
</p>
|
||||||
@@ -105,19 +112,16 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
|||||||
<Label className='my-2'>
|
<Label className='my-2'>
|
||||||
{t('proxyDelete.proxyIdLabel')}
|
{t('proxyDelete.proxyIdLabel')}
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
value={value}
|
||||||
value={`${value}`}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onChange={(e) => setValue(parseInt(e.target.value, 10))}
|
|
||||||
placeholder={t('proxyDelete.proxyIdPlaceholder')}
|
placeholder={t('proxyDelete.proxyIdPlaceholder')}
|
||||||
className="mt-2"
|
className='mt-2'
|
||||||
/>
|
/>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Alert variant='destructive'>
|
<Alert variant='destructive'>
|
||||||
<AlertTitle>{t('proxyDelete.warningTitle', 'Warning!')}</AlertTitle>
|
<AlertTitle>{t('proxyDelete.warningTitle', 'Warning!')}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>{t('proxyDelete.warningDesc')}</AlertDescription>
|
||||||
{t('proxyDelete.warningDesc')}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -126,4 +130,3 @@ export function ProxyDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,177 +1,7 @@
|
|||||||
import { z } from 'zod'
|
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({
|
export const proxyFormSchema = z.object({
|
||||||
url: z
|
url: z.string().min(1, 'Proxy address cannot be empty'),
|
||||||
.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
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
export type ProxyFormValues = z.infer<typeof proxyFormSchema>
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
|||||||
initialState: {
|
initialState: {
|
||||||
pagination: {
|
pagination: {
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10
|
pageSize: Number(localStorage.getItem('bichon_proxy_page_size')) || 10,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
@@ -98,8 +98,8 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<DataTableToolbar table={table} />
|
<DataTableToolbar table={table} />
|
||||||
<div className='rounded-md border'>
|
<div className='overflow-x-auto rounded-md border'>
|
||||||
<Table>
|
<Table className='w-full table-fixed'>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id} className='group/row'>
|
<TableRow key={headerGroup.id} className='group/row'>
|
||||||
@@ -133,7 +133,7 @@ export function ProxyTable({ columns, data }: DataTableProps) {
|
|||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={cell.column.columnDef.meta?.className ?? ''}
|
className={`overflow-hidden ${cell.column.columnDef.meta?.className ?? ''}`}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
|
|||||||
@@ -240,7 +240,7 @@
|
|||||||
"imapPassword": "IMAP Password",
|
"imapPassword": "IMAP Password",
|
||||||
"imapPort": "IMAP Port",
|
"imapPort": "IMAP Port",
|
||||||
"imapPortPlaceholder": "e.g 993",
|
"imapPortPlaceholder": "e.g 993",
|
||||||
"imapProxy": "Use a SOCKS5 proxy for IMAP connections.",
|
"imapProxy": "Use a proxy (http/socks5) for IMAP connections.",
|
||||||
"incDownload": "Interval",
|
"incDownload": "Interval",
|
||||||
"lastSync": "Last Sync",
|
"lastSync": "Last Sync",
|
||||||
"leaveEmptyToKeepExisting": "Leave empty to keep the existing password, or enter a new password to update it.",
|
"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",
|
"updateOrCreationFailed": "{{action}} failed, please try again later",
|
||||||
"updated": "Updated",
|
"updated": "Updated",
|
||||||
"useProxyOptional": "Use Proxy (optional):",
|
"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:",
|
"value": "Value:",
|
||||||
"valueCannotBeEmpty": "Value cannot be empty",
|
"valueCannotBeEmpty": "Value cannot be empty",
|
||||||
"valueIsRequired": "Value is required",
|
"valueIsRequired": "Value is required",
|
||||||
@@ -1420,6 +1420,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"proxy": "Proxy",
|
"proxy": "Proxy",
|
||||||
|
"proxyTest": "Check Proxy",
|
||||||
|
"proxyTesting": "Checking...",
|
||||||
|
"proxyTestFailed": "Proxy check failed",
|
||||||
|
"proxyTestSuccess": "Proxy works",
|
||||||
"proxyUpdateOrAddFailed": "{{action}} failed, please try again later",
|
"proxyUpdateOrAddFailed": "{{action}} failed, please try again later",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"resetRootPassword": "Reset Root Password",
|
"resetRootPassword": "Reset Root Password",
|
||||||
|
|||||||
Reference in New Issue
Block a user