From e653d50cd6904a2ebbf39d05ff5db44f849765ca Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:11:40 +0800 Subject: [PATCH] fix: apply protocol-matched User-Agent for subscription fetches Subscription panels (v2board/xboard/sspanel) route responses by UA. The previous client sent no UA (default reqwest/0.12.x) so these panels rejected the request, surfacing as "address unreachable" even though the host was reachable (sing-box SFM with a proper UA worked fine). - loader: apply user_agent to reqwest client; derive per-protocol default (sing-box / mihomo / v2rayN) from source_type or flag - config: default user_agent to empty so the per-protocol default kicks in; non-empty config value still wins as an explicit override Co-Authored-By: Claude Opus 4.7 --- src/core/config.rs | 10 +++++----- src/utils/source/loader.rs | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/core/config.rs b/src/core/config.rs index 2877e5a..d05ab8c 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -49,7 +49,10 @@ pub struct AppConfig { } fn default_user_agent() -> String { - "Mozilla/5.0 (compatible; ProxyConvert/2.0)".to_string() + // Empty by default -> SourceLoader picks a protocol-matched UA per-request + // (sing-box/mihomo/v2rayN). Subscription panels route by UA, so a generic + // UA like "Mozilla/... ProxyConvert/..." gets rejected. + String::new() } fn default_timeout() -> u64 { @@ -229,10 +232,7 @@ mod tests { fn test_default_config() { let config = AppConfig::default(); - assert_eq!( - config.user_agent, - "Mozilla/5.0 (compatible; ProxyConvert/2.0)" - ); + assert_eq!(config.user_agent, ""); assert_eq!(config.timeout_seconds, 30); assert_eq!(config.retry_count, 3); assert_eq!(config.log_level, "info"); diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index efd5356..5b71d0e 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -46,7 +46,12 @@ impl SourceLoader { // Use source flag if set (empty = &flag=), else protocol default let url_with_flag = Self::append_flag_to_url(source, &source_meta.source_type, source_meta.flag.as_deref()); - Self::load_from_url(&url_with_flag, config).await + // Pick a User-Agent that subscription panels recognize. + // Why: v2board/xboard/sspanel-style panels route responses by UA; + // the default reqwest UA is rejected or silently dropped, surfacing + // as "address unreachable" even when the host is reachable. + let ua = Self::effective_user_agent(&source_meta.source_type, source_meta.flag.as_deref(), config); + Self::load_from_url(&url_with_flag, &ua, config).await } else { // File path: use only the part before ? (query params are kept in source string for reference) let path = source.find('?').map(|i| &source[..i]).unwrap_or(source.as_str()); @@ -54,6 +59,30 @@ impl SourceLoader { } } + /// Choose the User-Agent to send with subscription requests. + /// Precedence: explicit `config.user_agent` (non-empty) > protocol-matched default. + fn effective_user_agent( + source_type: &Protocol, + flag_override: Option<&str>, + config: &AppConfig, + ) -> String { + let ua = config.user_agent.trim(); + if !ua.is_empty() { + return ua.to_string(); + } + // Derive from flag if the user overrode it, otherwise from source_type. + let kind = flag_override + .and_then(Protocol::from_str) + .unwrap_or(*source_type); + // Name-only; subscription panels typically match on the keyword, not the version. + // Users who hit a version-strict panel can override via config.user_agent. + match kind { + Protocol::SingBox => "sing-box".to_string(), + Protocol::Clash => "mihomo".to_string(), + Protocol::V2Ray => "v2rayN".to_string(), + } + } + /// Append or update flag query parameter to URL. /// Use flag_override if set (empty string = &flag=), else source_type default. fn append_flag_to_url( @@ -151,11 +180,12 @@ impl SourceLoader { } /// Load content from URL (uses NetworkError for fetch failures). - async fn load_from_url(url: &str, config: &AppConfig) -> Result { - tracing::info!("Fetching URL: {}", url); + async fn load_from_url(url: &str, user_agent: &str, config: &AppConfig) -> Result { + tracing::info!("Fetching URL: {} (UA: {})", url, user_agent); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(config.timeout_seconds)) + .user_agent(user_agent) .build() .map_err(|e| ConvertError::network_error(e.to_string().as_str()))?;