From d608ffeeda1a479c9dc0e5f6ad269ba07a510d7f Mon Sep 17 00:00:00 2001 From: Andrew Guschin Date: Fri, 30 Oct 2026 03:10:41 +0400 Subject: Implement Linux proxy controller, subscription state, and CLI/TUI --- src/subscription.rs | 305 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/subscription.rs (limited to 'src/subscription.rs') diff --git a/src/subscription.rs b/src/subscription.rs new file mode 100644 index 0000000..df5b318 --- /dev/null +++ b/src/subscription.rs @@ -0,0 +1,305 @@ +use crate::model::{Entry, Trojan, display_text}; +use anyhow::{Result, anyhow, bail, ensure}; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose}; +use percent_encoding::percent_decode_str; +use std::time::Duration; +use url::Url; +use uuid::Uuid; + +pub const MAX_SUBSCRIPTION_BYTES: usize = 4 * 1024 * 1024; + +#[async_trait] +pub trait SubscriptionSource: Send { + async fn fetch(&self, url: &str) -> Result>; +} + +pub struct HttpSource; + +pub fn subscription_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| anyhow!("Invalid subscription URL"))?; + ensure!( + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none(), + "Use an HTTP or HTTPS subscription URL without userinfo or a fragment" + ); + Ok(url) +} + +#[async_trait] +impl SubscriptionSource for HttpSource { + async fn fetch(&self, value: &str) -> Result> { + let mut url = subscription_url(value)?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .user_agent(concat!("Roentgen/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| anyhow!("Could not initialize subscription TLS client"))?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + for redirects in 0..=5 { + let mut response = tokio::time::timeout_at( + deadline, + client + .get(url.clone()) + .header("Accept", "text/plain") + .send(), + ) + .await + .map_err(|_| anyhow!("Subscription request timed out"))? + .map_err(|_| anyhow!("Subscription request failed; check connectivity and TLS"))?; + if response.status().is_redirection() { + ensure!( + matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308), + "Unexpected subscription redirect response" + ); + ensure!(redirects < 5, "Subscription exceeded five redirects"); + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("Subscription redirect has no valid location"))?; + let next = url + .join(location) + .map_err(|_| anyhow!("Invalid subscription redirect"))?; + subscription_url(next.as_str())?; + ensure!( + !(url.scheme() == "https" && next.scheme() != "https"), + "Refusing HTTPS-to-HTTP subscription redirect" + ); + url = next; + continue; + } + ensure!( + response.status() == reqwest::StatusCode::OK, + "Subscription returned HTTP {}", + response.status().as_u16() + ); + if let Some(kind) = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + { + ensure!( + !["html", "json", "yaml"] + .iter() + .any(|v| kind.to_ascii_lowercase().contains(v)), + "Subscription returned an unsupported document format; use a plain or Base64 link list" + ); + } + let mut body = Vec::new(); + while let Some(chunk) = tokio::time::timeout_at(deadline, response.chunk()) + .await + .map_err(|_| anyhow!("Subscription body timed out"))? + .map_err(|_| anyhow!("Subscription body download failed"))? + { + ensure!( + body.len() + chunk.len() <= MAX_SUBSCRIPTION_BYTES, + "Subscription exceeds the 4 MiB limit" + ); + body.extend_from_slice(&chunk); + } + return Ok(body); + } + unreachable!() + } +} + +pub fn import(bytes: &[u8]) -> Result> { + ensure!( + bytes.len() <= MAX_SUBSCRIPTION_BYTES, + "Subscription exceeds the 4 MiB limit" + ); + let text = std::str::from_utf8(bytes) + .map_err(|_| anyhow!("Subscription is not UTF-8"))? + .trim_start_matches('\u{feff}') + .trim(); + if text.is_empty() { + return Ok(vec![]); + } + let decoded; + let plain = + if text.contains("://") || text.contains('\n') && text.lines().any(|l| l.contains("://")) { + text + } else { + let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + let mut result = None; + for engine in [ + general_purpose::STANDARD, + general_purpose::STANDARD_NO_PAD, + general_purpose::URL_SAFE, + general_purpose::URL_SAFE_NO_PAD, + ] { + if let Ok(value) = engine.decode(&compact) { + result = Some(value); + break; + } + } + decoded = result + .ok_or_else(|| anyhow!("Subscription is neither a link list nor valid Base64"))?; + ensure!( + decoded.len() <= MAX_SUBSCRIPTION_BYTES, + "Decoded subscription exceeds the limit" + ); + std::str::from_utf8(&decoded) + .map_err(|_| anyhow!("Decoded subscription is not UTF-8"))? + .trim() + }; + let lower = plain.to_ascii_lowercase(); + ensure!( + !plain.starts_with(['<', '{', '[']) + && !lower.starts_with("proxies:") + && !lower.starts_with("proxy-providers:"), + "Unsupported subscription envelope; use a plain or Base64 link list" + ); + // A readable empty envelope is accepted, but arbitrary prose is not an entry list. + ensure!( + plain.is_empty() || plain.lines().any(|line| line.contains("://")), + "Subscription does not contain a link list" + ); + Ok(plain + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(parse_entry) + .collect()) +} + +fn decode(value: &str) -> Result { + let bytes = value.as_bytes(); + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'%' { + ensure!( + bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit) + && bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit), + "Invalid percent encoding" + ); + } + } + percent_decode_str(value) + .decode_utf8() + .map(|v| v.into_owned()) + .map_err(|_| anyhow!("Invalid UTF-8 in URI component")) +} + +fn parse_entry(raw: &str) -> Entry { + let mut entry = Entry { + id: Uuid::new_v4().to_string(), + name: "Invalid entry".into(), + status: "invalid".into(), + explanation: None, + connection: None, + }; + let result = (|| -> Result { + ensure!( + !raw.chars().any(char::is_control), + "Control characters in connection link" + ); + let url = Url::parse(raw).map_err(|_| anyhow!("Malformed connection link"))?; + if let Some(label) = url.fragment() { + entry.name = display_text(&decode(label)?); + } + if url.scheme() != "trojan" { + entry.status = "unsupported".into(); + bail!("Only Trojan TCP + TLS connections are supported"); + } + ensure!( + url.password().is_none(), + "Trojan credentials must be a percent-encoded password" + ); + let password = decode(url.username())?; + ensure!(!password.is_empty(), "Missing Trojan password"); + let host = match url + .host() + .ok_or_else(|| anyhow!("Missing connection host"))? + { + url::Host::Domain(v) => v.to_ascii_lowercase(), + url::Host::Ipv4(v) => v.to_string(), + url::Host::Ipv6(v) => v.to_string(), + }; + let port = url + .port() + .ok_or_else(|| anyhow!("Missing connection port"))?; + ensure!(port != 0, "Connection port must be positive"); + if entry.name == "Invalid entry" || entry.name.is_empty() { + entry.name = display_text(&host); + } + let mut server_name = host.clone(); + let mut alpn = vec![]; + let mut fingerprint = None; + let mut seen = std::collections::HashSet::new(); + for (key, value) in url.query_pairs() { + ensure!(seen.insert(key.to_string()), "Duplicate connection option"); + match key.as_ref() { + "type" if matches!(value.as_ref(), "tcp" | "raw") => {} + "security" if value == "tls" => {} + "sni" | "peer" => { + ensure!( + !value.is_empty() && !value.chars().any(char::is_control), + "Invalid TLS server name" + ); + server_name = value.into_owned(); + } + "alpn" => { + alpn = value.split(',').map(str::to_owned).collect(); + ensure!( + alpn.iter().all(|v| !v.is_empty() + && v.len() <= 255 + && !v.chars().any(char::is_control)), + "Invalid TLS ALPN list" + ); + } + "fp" => { + ensure!( + matches!( + value.as_ref(), + "chrome" + | "firefox" + | "safari" + | "ios" + | "android" + | "edge" + | "360" + | "qq" + | "random" + | "randomized" + ), + "Unsupported TLS fingerprint" + ); + fingerprint = Some(value.into_owned()); + } + "allowInsecure" if value == "0" || value == "false" => {} + "headerType" if value == "none" => {} + _ => { + entry.status = "unsupported".into(); + bail!("Unsupported transport, TLS, or connection option"); + } + } + } + ensure!( + !(seen.contains("sni") && seen.contains("peer")), + "Specify only one TLS server name option" + ); + Ok(Trojan { + host, + port, + password, + server_name, + alpn, + fingerprint, + }) + })(); + match result { + Ok(connection) => { + entry.status = "supported".into(); + entry.connection = Some(connection); + } + Err(error) => entry.explanation = Some(error.to_string()), + } + entry +} -- cgit v1.2.3