diff --git a/crates/core/src/oauth2/flow.rs b/crates/core/src/oauth2/flow.rs index 72f74a2..30af18d 100644 --- a/crates/core/src/oauth2/flow.rs +++ b/crates/core/src/oauth2/flow.rs @@ -20,6 +20,7 @@ use crate::error::code::ErrorCode; use crate::error::BichonResult; use crate::oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken}; use crate::settings::proxy::Proxy; +use crate::utils::net::parse_proxy_url; use crate::{decrypt, encrypt, raise_error}; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, @@ -265,13 +266,30 @@ 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() + } + }; 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(|e| { raise_error!( format!( "Failed to configure SOCKS5 proxy ({}): {:#?}. Please check", - &proxy.url, e + &proxy_url, e ), ErrorCode::InternalError ) diff --git a/crates/core/src/settings/proxy.rs b/crates/core/src/settings/proxy.rs index 2359240..de86ae3 100644 --- a/crates/core/src/settings/proxy.rs +++ b/crates/core/src/settings/proxy.rs @@ -26,7 +26,7 @@ use crate::{ }, error::{code::ErrorCode, BichonResult}, id, raise_error, utc_now, - utils::net::parse_proxy_addr, + utils::net::parse_proxy_url, }; #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -98,9 +98,9 @@ impl Proxy { insert_impl(DB_MANAGER.db(), self.to_owned()) } - /// Validate that the URL is a valid SOCKS5 proxy URL. + /// Validate that the URL is a valid proxy URL. pub fn validate(&self) -> BichonResult<()> { - parse_proxy_addr(&self.url)?; + parse_proxy_url(&self.url)?; Ok(()) } } @@ -111,7 +111,15 @@ mod tests { #[test] fn test_valid_proxy_urls() { - let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"]; + let urls = vec![ + "socks5://127.0.0.1:1080", + "http://127.0.0.1:8080", + "socks5://proxy.example.com:1080", + "socks5://user:pass@proxy.example.com:1080", + "socks5://user@proxy.example.com:1080", + // Non-standard format: host:port:user:pass + "socks5://server.nodeprovider.com:8080:username123:passwordhere", + ]; for url in urls { let proxy = Proxy::new(url.to_string()); diff --git a/crates/core/src/utils/net.rs b/crates/core/src/utils/net.rs index 7249d51..6f16160 100644 --- a/crates/core/src/utils/net.rs +++ b/crates/core/src/utils/net.rs @@ -32,6 +32,15 @@ use tracing::error; pub(crate) const TIMEOUT: Duration = Duration::from_secs(30); +/// Parsed proxy address components. +#[derive(Debug, Clone)] +pub struct ProxyAddr { + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, +} + pub(crate) async fn establish_tcp_connection_with_timeout( address: SocketAddr, use_proxy: Option, @@ -66,20 +75,27 @@ pub async fn establish_tls_connection( Ok(tls_stream) } -pub fn parse_proxy_addr(input: &str) -> BichonResult { - // Normalize and check protocol prefix - let (scheme, stripped) = if let Some(rest) = input +/// Parse a proxy URL into its components. +/// +/// Supports two formats: +/// - **Standard**: `[scheme://][user:pass@]host:port` +/// - **Non-standard** (some proxy providers): `[scheme://]host:port:username:password` +/// +/// 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 .strip_prefix("socks5://") .or_else(|| input.strip_prefix("SOCKS5://")) .or_else(|| input.strip_prefix("Socks5://")) { - ("socks5", rest) + rest } else if let Some(rest) = input .strip_prefix("http://") .or_else(|| input.strip_prefix("HTTP://")) .or_else(|| input.strip_prefix("Http://")) { - ("http", rest) + rest } else { return Err(raise_error!( format!( @@ -90,43 +106,207 @@ pub fn parse_proxy_addr(input: &str) -> BichonResult { )); }; - // Parse the remaining address - let addr = stripped.parse::().map_err(|e| { - raise_error!( + if stripped.is_empty() { + return Err(raise_error!( + "Proxy URL has empty address after scheme.".into(), + ErrorCode::InvalidParameter + )); + } + + // Check for standard format: user:pass@host:port + if let Some(at_pos) = stripped.rfind('@') { + let userinfo = &stripped[..at_pos]; + let hostport = &stripped[at_pos + 1..]; + + let (username, password) = split_userinfo(userinfo)?; + let (host, port) = split_hostport(hostport)?; + + return Ok(ProxyAddr { + host, + port, + username, + password, + }); + } + + // No '@' — check for non-standard format: host:port:user:pass + let parts: Vec<&str> = stripped.rsplitn(4, ':').collect::>().into_iter().rev().collect::>(); + + match parts.len() { + 2 => { + // host:port, no auth + let (host, port) = split_hostport(stripped)?; + Ok(ProxyAddr { + host, + port, + username: None, + password: None, + }) + } + 4 => { + // 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(); + + if host.is_empty() { + return Err(raise_error!( + "Empty hostname in proxy URL.".into(), + ErrorCode::InvalidParameter + )); + } + if username.is_empty() { + return Err(raise_error!( + "Empty username in proxy URL.".into(), + ErrorCode::InvalidParameter + )); + } + if password.is_empty() { + return Err(raise_error!( + "Empty password in proxy URL.".into(), + ErrorCode::InvalidParameter + )); + } + + Ok(ProxyAddr { + host, + port, + username: Some(username), + password: Some(password), + }) + } + _ => Err(raise_error!( format!( - "Failed to parse {} proxy address '{}': {}", - scheme, stripped, e + "Invalid proxy URL format '{}'. Expected '[scheme://][user:pass@]host:port' or 'scheme://host:port:user:pass'.", + input ), ErrorCode::InvalidParameter + )), + } +} + +/// Split "user:pass" into (Some(user), Some(pass)), or "user" into (Some(user), None). +fn split_userinfo(userinfo: &str) -> BichonResult<(Option, Option)> { + if userinfo.is_empty() { + return Ok((None, None)); + } + if let Some(colon_pos) = userinfo.find(':') { + let user = &userinfo[..colon_pos]; + let pass = &userinfo[colon_pos + 1..]; + if user.is_empty() { + return Err(raise_error!( + "Empty username in proxy URL credentials.".into(), + ErrorCode::InvalidParameter + )); + } + Ok((Some(user.to_string()), Some(pass.to_string()))) + } else { + Ok((Some(userinfo.to_string()), None)) + } +} + +/// Split "host:port" into (host, port). Handles IPv6 addresses in brackets. +fn split_hostport(hostport: &str) -> BichonResult<(String, u16)> { + if hostport.is_empty() { + return Err(raise_error!( + "Empty host:port in proxy URL.".into(), + ErrorCode::InvalidParameter + )); + } + + // IPv6: [::1]:1080 + if hostport.starts_with('[') { + let close_bracket = hostport.find(']').ok_or_else(|| { + raise_error!( + format!("Invalid 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(|_| { + raise_error!( + format!("Invalid port in proxy URL: '{}'.", hostport), + ErrorCode::InvalidParameter + ) + })?; + return Ok((host, port)); + } + + // hostname:port or ip:port — split from right + let last_colon = hostport.rfind(':').ok_or_else(|| { + raise_error!( + format!("Missing port in proxy URL: '{}'.", hostport), + ErrorCode::InvalidParameter + ) + })?; + let host = hostport[..last_colon].to_string(); + let port = hostport[last_colon + 1..].parse::().map_err(|_| { + raise_error!( + format!("Invalid port in proxy URL: '{}'.", hostport), + ErrorCode::InvalidParameter ) })?; - Ok(addr) + if host.is_empty() { + return Err(raise_error!( + "Empty hostname in proxy URL.".into(), + ErrorCode::InvalidParameter + )); + } + + Ok((host, port)) } -/// Try to connect via SOCKS5 proxy or TCP with timeout +/// Try to connect via SOCKS5 proxy or TCP with timeout. async fn connect_with_optional_proxy( use_proxy: Option, address: SocketAddr, ) -> BichonResult { - // Try if proxy is enabled if let Some(proxy_id) = use_proxy { let proxy = Proxy::get(proxy_id)?; - let proxy = parse_proxy_addr(&proxy.url)?; - return timeout(TIMEOUT, Socks5Stream::connect(proxy, address)) + 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 + } else { + timeout(TIMEOUT, Socks5Stream::connect(proxy_addr, address)).await + }; + + return result .map_err(|_| { error!( - "SOCKS5 proxy connection to {} via {} timed out after {}s", + "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", address, - proxy, + addr.host, + addr.port, TIMEOUT.as_secs() ); raise_error!( format!( - "SOCKS5 proxy connection to {} via {} timed out after {}s", + "SOCKS5 proxy connection to {} via {}:{} timed out after {}s", address, - proxy, + addr.host, + addr.port, TIMEOUT.as_secs() ), ErrorCode::ConnectionTimeout 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 266a187..0b427ba 100644 --- a/web/src/features/settings/proxy/components/__tests__/schema.test.ts +++ b/web/src/features/settings/proxy/components/__tests__/schema.test.ts @@ -32,7 +32,7 @@ describe('Proxy Form Schema', () => { if (!result.success) { expect( result.error.issues.some((i) => - i.message?.includes('http:// or socks5://') + i.message?.includes('Invalid format') ) ).toBe(true) } @@ -53,7 +53,7 @@ describe('Proxy Form Schema', () => { if (!result.success) { expect( result.error.issues.some((i) => - i.message?.includes('Invalid URL format') + i.message?.includes('Invalid format') ) ).toBe(true) } @@ -170,4 +170,27 @@ describe('Proxy Form Schema', () => { 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) + }) + }) }) diff --git a/web/src/features/settings/proxy/components/columns.tsx b/web/src/features/settings/proxy/components/columns.tsx index 221c8b3..64a0e3d 100644 --- a/web/src/features/settings/proxy/components/columns.tsx +++ b/web/src/features/settings/proxy/components/columns.tsx @@ -46,7 +46,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef[] => [ cell: ({ row }) => { return {row.original.url} }, - meta: { className: 'w-60' }, + meta: { className: 'max-w-60' }, }, { accessorKey: 'created_at', diff --git a/web/src/features/settings/proxy/components/schema.ts b/web/src/features/settings/proxy/components/schema.ts index 0b8ef9f..b739a27 100644 --- a/web/src/features/settings/proxy/components/schema.ts +++ b/web/src/features/settings/proxy/components/schema.ts @@ -1,63 +1,175 @@ 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 - } + if (value.length === 0) return - let url: URL - try { - url = new URL(value) - } catch (_e) { + // 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 URL format', + message: 'Invalid format. Expected socks5://[user:pass@]host:port or socks5://host:port:user:pass', path: [], }) return } - if (url.protocol !== 'socks5:' && url.protocol !== 'http:') { + 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(url.hostname)) { + if (!/^[a-zA-Z0-9\-\.]+$/.test(parsed.host)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Hostname contains invalid characters', path: [], }) + return } - const port = parseInt(url.port || '1080') - if (port <= 0 || port > 65535) { + if (parsed.port <= 0 || parsed.port > 65535) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Port must be between 1-65535', path: [], }) + return } - if (url.username && !url.password) { + if (parsed.username && !parsed.password) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Password cannot be empty when username is provided', path: [], }) - } else if (url.password && url.password.length < 8) { + return + } + + if (parsed.password && parsed.password.length < 8) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Password must be at least 8 characters', path: [], }) + return } }), })