Files
ProxyWarden/src-tauri/src/subscription.rs
T
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

766 lines
24 KiB
Rust

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<String>) -> 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<SubscriptionServer>,
}
#[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<String>,
pub app_name: String,
pub user_agent: String,
pub device_os: String,
pub device_os_version: Option<String>,
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<ParsedSubscription, SubscriptionError> {
let config = match serde_json::from_str::<Value>(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<String, Value> {
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::<i64>() {
result.insert(key.to_string(), Value::Number(parsed.into()));
}
}
result
}
pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionError> {
fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default())
}
pub fn fetch_subscription_with_identity(
url: &str,
identity: &SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, SubscriptionError> {
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<SubscriptionCache, SubscriptionError> {
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(&current_url, policy)?;
let client = subscription_client_for_url(&current_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<reqwest::blocking::Client, SubscriptionError> {
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::<IpAddr>().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::<Vec<_>>();
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::<IpAddr>().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<Value, SubscriptionError> {
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::<Vec<_>>();
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::<Result<Vec<_>, _>>()?;
Ok(json!({ "outbounds": outbounds }))
}
fn parse_trojan_url(raw_url: &str) -> Result<Value, SubscriptionError> {
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<Value, SubscriptionError> {
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<Value, SubscriptionError> {
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<Value, SubscriptionError> {
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<Vec<SubscriptionServer>, SubscriptionError> {
let servers = config
.get("outbounds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(server_from_outbound)
.collect::<Vec<_>>();
if servers.is_empty() {
return Err(SubscriptionError::new(
"No supported proxy outbounds found in subscription",
));
}
Ok(servers)
}
fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
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::<String>();
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<String> {
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<String> {
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::<String>()
}
fn detect_device_os_version() -> Option<String> {
#[cfg(windows)]
{
windows_device_os_version()
}
#[cfg(not(windows))]
{
None
}
}
#[cfg(windows)]
fn windows_device_os_version() -> Option<String> {
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::<String, _>("ProductName")
.ok()
.map(|value| normalize_windows_product_name(&value, &current_version))
.filter(|value| !value.trim().is_empty());
let display_version = current_version
.get_value::<String, _>("DisplayVersion")
.ok()
.or_else(|| current_version.get_value::<String, _>("ReleaseId").ok())
.filter(|value| !value.trim().is_empty());
let build = current_version
.get_value::<String, _>("CurrentBuildNumber")
.ok()
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
.filter(|value| !value.trim().is_empty());
let ubr = current_version.get_value::<u32, _>("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::<String, _>("CurrentBuildNumber")
.ok()
.or_else(|| current_version.get_value::<String, _>("CurrentBuild").ok())
.and_then(|value| value.parse::<u32>().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}")
}