766 lines
24 KiB
Rust
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(¤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<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, ¤t_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}")
|
|
}
|