use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer}; use base64::{engine::general_purpose, Engine}; use serde_json::{json, Map, Value}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; const DEFAULT_APP_NAME: &str = "ProxyWarden"; const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const SUBSCRIPTION_MAX_REDIRECTS: usize = 5; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionError { pub message: String, } impl SubscriptionError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for SubscriptionError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.message) } } impl std::error::Error for SubscriptionError {} #[derive(Debug, Clone, PartialEq)] pub struct ParsedSubscription { pub config: Value, pub servers: Vec, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct SubscriptionFetchPolicy { pub allow_unsafe_local_urls: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionFetchIdentity { pub device_hwid: Option, pub app_name: String, pub user_agent: String, pub device_os: String, pub device_os_version: Option, pub device_model: String, } impl SubscriptionFetchIdentity { pub fn with_device_hwid(device_hwid: Option<&str>) -> Self { Self { device_hwid: device_hwid .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), ..Self::default() } } pub fn request_headers_without_device_hwid(&self) -> Vec<(&'static str, String)> { let mut headers = vec![ ("User-Agent", self.user_agent.clone()), ("X-App-Name", self.app_name.clone()), ("X-Device-OS", self.device_os.clone()), ("X-Device-Model", self.device_model.clone()), ]; if let Some(device_os_version) = self .device_os_version .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { let header_value = sanitize_header_value(device_os_version); if !header_value.is_empty() { headers.push(("X-Device-OS-Version", header_value.clone())); headers.push(("X-Ver-OS", header_value)); } } headers } } impl Default for SubscriptionFetchIdentity { fn default() -> Self { let device_os = std::env::consts::OS.to_string(); Self { device_hwid: None, app_name: DEFAULT_APP_NAME.to_string(), user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"), device_os, device_os_version: detect_device_os_version(), device_model: DEFAULT_APP_NAME.to_string(), } } } pub fn parse_subscription_body(body: &str) -> Result { let config = match serde_json::from_str::(body) { Ok(value) => value, Err(_) => parse_link_subscription(body)?, }; let servers = servers_from_config(&config)?; Ok(ParsedSubscription { config, servers }) } pub fn parse_user_info(header_value: Option<&str>) -> Map { let mut result = Map::new(); let Some(header_value) = header_value else { return result; }; for part in header_value.split(';') { let Some((key, value)) = part.trim().split_once('=') else { continue; }; let key = key.trim(); if key.is_empty() { continue; } if let Ok(parsed) = value.trim().parse::() { result.insert(key.to_string(), Value::Number(parsed.into())); } } result } pub fn fetch_subscription(url: &str) -> Result { fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default()) } pub fn fetch_subscription_with_identity( url: &str, identity: &SubscriptionFetchIdentity, ) -> Result { fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default()) } pub fn fetch_subscription_with_identity_and_policy( url: &str, identity: &SubscriptionFetchIdentity, policy: SubscriptionFetchPolicy, ) -> Result { let parsed_url = Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; let mut current_url = parsed_url; for redirect_count in 0..=SUBSCRIPTION_MAX_REDIRECTS { validate_subscription_fetch_url(¤t_url, policy)?; let client = subscription_client_for_url(¤t_url, policy)?; let mut request = client.get(current_url.clone()); for (name, value) in identity.request_headers_without_device_hwid() { request = request.header(name, value); } if let Some(device_hwid) = identity .device_hwid .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { request = request.header("x-hwid", device_hwid); } let response = request.send().map_err(|error| { SubscriptionError::new(format!( "Subscription request failed: {}", error.without_url() )) })?; let status = response.status(); if status.is_redirection() { if redirect_count == SUBSCRIPTION_MAX_REDIRECTS { return Err(SubscriptionError::new( "Subscription request exceeded redirect limit", )); } let location = response .headers() .get(reqwest::header::LOCATION) .and_then(|value| value.to_str().ok()) .ok_or_else(|| { SubscriptionError::new("Subscription redirect has no valid Location header") })?; current_url = current_url .join(location) .map_err(|_| SubscriptionError::new("Subscription redirect URL is invalid"))?; continue; } if !status.is_success() { return Err(SubscriptionError::new(format!( "Subscription request failed: HTTP {}", status.as_u16() ))); } let user_info = parse_user_info( response .headers() .get("subscription-userinfo") .and_then(|value| value.to_str().ok()), ); let body = response.text().map_err(|_error| { SubscriptionError::new("Subscription body read failed".to_string()) })?; let parsed = parse_subscription_body(&body)?; return Ok(SubscriptionCache { config: parsed.config, servers: parsed.servers, user_info, fetched_at: now_timestamp(), }); } Err(SubscriptionError::new( "Subscription request could not complete", )) } fn subscription_client_for_url( parsed_url: &Url, policy: SubscriptionFetchPolicy, ) -> Result { let mut builder = reqwest::blocking::Client::builder() .connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT) .timeout(SUBSCRIPTION_REQUEST_TIMEOUT) .redirect(reqwest::redirect::Policy::none()); if !policy.allow_unsafe_local_urls { let host = parsed_url .host_str() .ok_or_else(|| SubscriptionError::new("Subscription URL has no host"))?; if host.parse::().is_err() { let port = parsed_url .port_or_known_default() .ok_or_else(|| SubscriptionError::new("Subscription URL has no resolvable port"))?; let addresses = (host, port) .to_socket_addrs() .map_err(|error| { SubscriptionError::new(format!( "Subscription host DNS resolution failed: {error}" )) })? .collect::>(); validate_resolved_subscription_addresses(&addresses)?; builder = builder.resolve_to_addrs(host, &addresses); } } builder.build().map_err(|error| { SubscriptionError::new(format!("Subscription client setup failed: {error}")) }) } pub fn validate_resolved_subscription_addresses( addresses: &[SocketAddr], ) -> Result<(), SubscriptionError> { if addresses.is_empty() { return Err(SubscriptionError::new( "Subscription host DNS resolution returned no addresses", )); } if addresses.iter().any(|address| is_unsafe_ip(address.ip())) { return Err(SubscriptionError::new( "Subscription host resolves to a local, private, link-local, multicast, or metadata address", )); } Ok(()) } fn validate_subscription_fetch_url( parsed_url: &Url, policy: SubscriptionFetchPolicy, ) -> Result<(), SubscriptionError> { if !matches!(parsed_url.scheme(), "http" | "https") { return Err(SubscriptionError::new( "Subscription URL must use http or https", )); } if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) { return Err(SubscriptionError::new( "Subscription URL host is local, private, link-local, multicast, or metadata-only", )); } Ok(()) } fn is_unsafe_subscription_host(parsed_url: &Url) -> bool { let Some(host) = parsed_url.host_str() else { return true; }; let host = host.trim_matches(['[', ']']).to_ascii_lowercase(); if matches!(host.as_str(), "localhost" | "metadata.google.internal") || host.ends_with(".localhost") { return true; } host.parse::().is_ok_and(is_unsafe_ip) } fn is_unsafe_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => { ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() || ip.is_unspecified() || ip.octets() == [169, 254, 169, 254] } IpAddr::V6(ip) => { ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || is_unique_local_ipv6(ip) || is_unicast_link_local_ipv6(ip) } } } fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool { (ip.segments()[0] & 0xfe00) == 0xfc00 } fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool { (ip.segments()[0] & 0xffc0) == 0xfe80 } fn parse_link_subscription(body: &str) -> Result { let decoded = maybe_decode_base64(body); let links = decoded .lines() .map(str::trim) .filter(|line| { ["vless://", "trojan://", "ss://", "vmess://"] .iter() .any(|scheme| line.starts_with(scheme)) }) .collect::>(); if links.is_empty() { return Err(SubscriptionError::new( "Subscription does not contain JSON config or supported VLESS, VMess, Trojan, or Shadowsocks links", )); } let outbounds = links .into_iter() .map(|link| { if link.starts_with("vless://") { parse_vless_url(link) } else if link.starts_with("trojan://") { parse_trojan_url(link) } else if link.starts_with("ss://") { parse_shadowsocks_url(link) } else { parse_vmess_url(link) } }) .collect::, _>>()?; Ok(json!({ "outbounds": outbounds })) } fn parse_trojan_url(raw_url: &str) -> Result { let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Trojan URL"))?; let password = parsed.username().trim().to_string(); let server = parsed.host_str().map(str::to_string).unwrap_or_default(); let server_port = parsed.port_or_known_default().unwrap_or(443); if password.is_empty() || server.is_empty() { return Err(SubscriptionError::new( "Trojan URL misses password, host or port", )); } let tag = parsed .fragment() .map(decode_percent_encoded_utf8) .unwrap_or_else(|| "trojan-out".to_string()); let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone()); Ok(json!({ "type": "trojan", "tag": tag, "server": server, "server_port": server_port, "password": password, "tls": { "enabled": true, "server_name": server_name } })) } fn parse_shadowsocks_url(raw_url: &str) -> Result { let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid Shadowsocks URL"))?; let server = parsed.host_str().map(str::to_string).unwrap_or_default(); let server_port = parsed.port().unwrap_or(8388); let credentials = match parsed.password() { Some(password) => format!("{}:{password}", parsed.username()), None => decode_base64_text(parsed.username()).ok_or_else(|| { SubscriptionError::new("Shadowsocks credentials are not valid base64") })?, }; let (method, password) = credentials .split_once(':') .ok_or_else(|| SubscriptionError::new("Shadowsocks URL misses method or password"))?; if method.trim().is_empty() || password.is_empty() || server.is_empty() { return Err(SubscriptionError::new( "Shadowsocks URL misses method, password, host or port", )); } let tag = parsed .fragment() .map(decode_percent_encoded_utf8) .unwrap_or_else(|| "shadowsocks-out".to_string()); Ok(json!({ "type": "shadowsocks", "tag": tag, "server": server, "server_port": server_port, "method": method, "password": password })) } fn parse_vmess_url(raw_url: &str) -> Result { let payload = raw_url .strip_prefix("vmess://") .and_then(|value| value.split('#').next()) .ok_or_else(|| SubscriptionError::new("Invalid VMess URL"))?; let decoded = decode_base64_text(payload) .ok_or_else(|| SubscriptionError::new("VMess payload is not valid base64"))?; let source: Value = serde_json::from_str(&decoded) .map_err(|_| SubscriptionError::new("VMess payload is not valid JSON"))?; let server = source .get("add") .and_then(Value::as_str) .unwrap_or_default(); let server_port = source .get("port") .and_then(|value| value.as_u64().or_else(|| value.as_str()?.parse().ok())) .and_then(|value| u16::try_from(value).ok()) .unwrap_or(443); let uuid = source.get("id").and_then(Value::as_str).unwrap_or_default(); if server.is_empty() || uuid.is_empty() { return Err(SubscriptionError::new( "VMess payload misses host, port or uuid", )); } let tag = source .get("ps") .and_then(Value::as_str) .map(decode_percent_encoded_utf8) .unwrap_or_else(|| "vmess-out".to_string()); let security = source .get("scy") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .unwrap_or("auto"); let mut outbound = json!({ "type": "vmess", "tag": tag, "server": server, "server_port": server_port, "uuid": uuid, "security": security }); if source.get("tls").and_then(Value::as_str) == Some("tls") { let server_name = source .get("sni") .or_else(|| source.get("host")) .and_then(Value::as_str) .filter(|value| !value.is_empty()) .unwrap_or(server); outbound["tls"] = json!({ "enabled": true, "server_name": server_name }); } if source.get("net").and_then(Value::as_str) == Some("ws") { let path = source .get("path") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .unwrap_or("/"); let host = source .get("host") .and_then(Value::as_str) .filter(|value| !value.is_empty()); outbound["transport"] = json!({ "type": "ws", "path": path, "headers": host.map(|host| json!({ "Host": host })).unwrap_or_else(|| json!({})) }); } Ok(outbound) } fn parse_vless_url(raw_url: &str) -> Result { if !raw_url.starts_with("vless://") { return Err(SubscriptionError::new("VLESS URL must start with vless://")); } let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?; let tag = parsed .fragment() .map(decode_percent_encoded_utf8) .unwrap_or_else(|| "vless-out".to_string()); let uuid = parsed.username().trim().to_string(); let server = parsed.host_str().map(str::to_string).unwrap_or_default(); let server_port = parsed.port_or_known_default().unwrap_or(443); let public_key = query_value(&parsed, "pbk").unwrap_or_default(); let short_id = query_value(&parsed, "sid").unwrap_or_default(); let server_name = query_value(&parsed, "sni").unwrap_or_else(|| server.clone()); let fingerprint = query_value(&parsed, "fp").unwrap_or_else(|| "chrome".to_string()); let flow = query_value(&parsed, "flow").unwrap_or_default(); if uuid.is_empty() || server.is_empty() { return Err(SubscriptionError::new( "VLESS URL misses uuid, host or port", )); } if public_key.is_empty() || short_id.is_empty() { return Err(SubscriptionError::new( "VLESS REALITY parameters pbk and sid are required", )); } Ok(json!({ "type": "vless", "tag": tag, "server": server, "server_port": server_port, "uuid": uuid, "flow": flow, "tls": { "enabled": true, "server_name": server_name, "utls": { "enabled": true, "fingerprint": fingerprint }, "reality": { "enabled": true, "public_key": public_key, "short_id": short_id } }, "packet_encoding": "xudp" })) } fn servers_from_config(config: &Value) -> Result, SubscriptionError> { let servers = config .get("outbounds") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(server_from_outbound) .collect::>(); if servers.is_empty() { return Err(SubscriptionError::new( "No supported proxy outbounds found in subscription", )); } Ok(servers) } fn server_from_outbound(outbound: &Value) -> Option { let server_type = outbound.get("type")?.as_str()?.to_string(); if !SUPPORTED_PROXY_TYPES.contains(&server_type.as_str()) { return None; } let server = outbound .get("server") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); let server_port = outbound .get("server_port") .and_then(Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .unwrap_or(443); let tag = outbound .get("tag") .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| format!("{server_type}-{server}")); Some(SubscriptionServer { id: outbound_server_id(outbound), tag, server_type, server, server_port, }) } pub(crate) fn outbound_server_id(outbound: &Value) -> String { let bytes = serde_json::to_vec(outbound).unwrap_or_default(); let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) }); format!("pw-{hash:016x}") } fn maybe_decode_base64(content: &str) -> String { let compact = content.split_whitespace().collect::(); if compact.is_empty() || !compact .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_')) { return content.to_string(); } for engine in [general_purpose::STANDARD, general_purpose::URL_SAFE] { if let Ok(decoded) = engine.decode(compact.as_bytes()) { if let Ok(decoded) = String::from_utf8(decoded) { if ["vless://", "vmess://", "trojan://", "ss://"] .iter() .any(|scheme| decoded.contains(scheme)) || decoded.contains('{') { return decoded; } } } } content.to_string() } fn decode_base64_text(value: &str) -> Option { let value = value.trim(); for engine in [ general_purpose::STANDARD, general_purpose::STANDARD_NO_PAD, general_purpose::URL_SAFE, general_purpose::URL_SAFE_NO_PAD, ] { if let Ok(decoded) = engine.decode(value.as_bytes()) { if let Ok(decoded) = String::from_utf8(decoded) { return Some(decoded); } } } None } fn query_value(url: &Url, key: &str) -> Option { url.query_pairs() .find(|(name, _)| name == key) .map(|(_, value)| value.into_owned()) } fn sanitize_header_value(value: &str) -> String { value .chars() .filter(|ch| ch.is_ascii_graphic() || *ch == ' ') .collect::() } fn detect_device_os_version() -> Option { #[cfg(windows)] { windows_device_os_version() } #[cfg(not(windows))] { None } } #[cfg(windows)] fn windows_device_os_version() -> Option { use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey}; let current_version = RegKey::predef(HKEY_LOCAL_MACHINE) .open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion") .ok()?; let product_name = current_version .get_value::("ProductName") .ok() .map(|value| normalize_windows_product_name(&value, ¤t_version)) .filter(|value| !value.trim().is_empty()); let display_version = current_version .get_value::("DisplayVersion") .ok() .or_else(|| current_version.get_value::("ReleaseId").ok()) .filter(|value| !value.trim().is_empty()); let build = current_version .get_value::("CurrentBuildNumber") .ok() .or_else(|| current_version.get_value::("CurrentBuild").ok()) .filter(|value| !value.trim().is_empty()); let ubr = current_version.get_value::("UBR").ok(); let build = match (build, ubr) { (Some(build), Some(ubr)) => Some(format!("{build}.{ubr}")), (build, _) => build, }; let mut parts = Vec::new(); if let Some(product_name) = product_name { parts.push(product_name); } if let Some(display_version) = display_version { parts.push(display_version); } if let Some(build) = build { parts.push(format!("build {build}")); } let version = parts.join(" | "); (!version.is_empty()).then_some(version) } #[cfg(windows)] fn normalize_windows_product_name(value: &str, current_version: &winreg::RegKey) -> String { let trimmed = value.trim(); let build_number = current_version .get_value::("CurrentBuildNumber") .ok() .or_else(|| current_version.get_value::("CurrentBuild").ok()) .and_then(|value| value.parse::().ok()) .unwrap_or_default(); if build_number >= 22000 && trimmed.starts_with("Windows 10") { return trimmed.replacen("Windows 10", "Windows 11", 1); } trimmed.to_string() } fn now_timestamp() -> String { let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); format!("unix:{seconds}") }