Expand README with architecture and setup details
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
use crate::models::{SubscriptionCache, SubscriptionServer};
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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> {
|
||||
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",
|
||||
));
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.get(parsed_url)
|
||||
.header("user-agent", "singbox")
|
||||
.header("x-device-os", std::env::consts::OS)
|
||||
.header("x-device-model", "proxywarden")
|
||||
.send()
|
||||
.map_err(|error| SubscriptionError::new(format!("Subscription request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
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(format!("Subscription body read failed: {error}"))
|
||||
})?;
|
||||
let parsed = parse_subscription_body(&body)?;
|
||||
|
||||
Ok(SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info,
|
||||
fetched_at: now_timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
|
||||
let decoded = maybe_decode_base64(body);
|
||||
let links = decoded
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("vless://"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if links.is_empty() {
|
||||
return Err(SubscriptionError::new(
|
||||
"Subscription does not contain JSON config or VLESS links",
|
||||
));
|
||||
}
|
||||
|
||||
let outbounds = links
|
||||
.into_iter()
|
||||
.map(parse_vless_url)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({ "outbounds": outbounds }))
|
||||
}
|
||||
|
||||
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().unwrap_or("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 {
|
||||
tag,
|
||||
server_type,
|
||||
server,
|
||||
server_port,
|
||||
})
|
||||
}
|
||||
|
||||
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 decoded.contains("vless://") || decoded.contains('{') {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.to_string()
|
||||
}
|
||||
|
||||
fn query_value(url: &Url, key: &str) -> Option<String> {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn now_timestamp() -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!("unix:{seconds}")
|
||||
}
|
||||
Reference in New Issue
Block a user