Refactor ProxyWarden routing and settings flow

This commit is contained in:
2026-07-09 11:51:16 +03:00
parent db0c1dede9
commit 1bb795a532
18 changed files with 1018 additions and 210 deletions
+98 -6
View File
@@ -1,11 +1,16 @@
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine};
use reqwest::redirect;
use serde_json::{json, Map, Value};
use std::net::{IpAddr, Ipv6Addr};
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);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError {
@@ -34,6 +39,11 @@ pub struct ParsedSubscription {
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>,
@@ -134,16 +144,35 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
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"))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(SubscriptionError::new(
"Subscription URL must use http or https",
));
}
validate_subscription_fetch_url(&parsed_url, policy)?;
let mut request = reqwest::blocking::Client::new().get(parsed_url);
let redirect_policy = redirect::Policy::custom(move |attempt| {
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
attempt.follow()
} else {
attempt.stop()
}
});
let client = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(redirect_policy)
.build()
.map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})?;
let mut request = client.get(parsed_url);
for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value);
@@ -189,6 +218,69 @@ pub fn fetch_subscription_with_identity(
})
}
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