use std::time::Duration;
use reqwest::{Method, header};
use serde_json::Value;
use url::Url;
use super::{
SubscriptionDevice, SubscriptionDocument, SubscriptionInfo, SubscriptionJson,
SubscriptionMetadata, SubscriptionResponse,
};
use crate::{
Error, ProxyConfig, Result, SubscriptionSettings,
transport::{self, ErrorUrlPolicy},
};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_USER_AGENT: &str = concat!("xui-rs/", env!("CARGO_PKG_VERSION"));
pub const DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
#[must_use]
pub struct SubscriptionClientBuilder {
base_url: Url,
raw_path: String,
json_path: String,
clash_path: String,
timeout: Duration,
connect_timeout: Duration,
accept_invalid_certs: bool,
proxy: Option<ProxyConfig>,
response_body_limit: usize,
device: Option<SubscriptionDevice>,
}
impl SubscriptionClientBuilder {
fn new(base_url: impl AsRef<str>) -> Result<Self> {
Ok(Self {
base_url: normalize_server_url(base_url.as_ref())?,
raw_path: "sub/".to_owned(),
json_path: "json/".to_owned(),
clash_path: "clash/".to_owned(),
timeout: DEFAULT_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
accept_invalid_certs: false,
proxy: None,
response_body_limit: DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT,
device: None,
})
}
pub fn raw_path(mut self, path: impl Into<String>) -> Self {
self.raw_path = path.into();
self
}
pub fn json_path(mut self, path: impl Into<String>) -> Self {
self.json_path = path.into();
self
}
pub fn clash_path(mut self, path: impl Into<String>) -> Self {
self.clash_path = path.into();
self
}
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
self.accept_invalid_certs = accept;
self
}
pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
self.proxy = Some(proxy);
self
}
pub fn proxy_url(self, proxy_url: impl AsRef<str>) -> Result<Self> {
Ok(self.proxy(ProxyConfig::new(proxy_url)?))
}
pub fn no_proxy(mut self) -> Self {
self.proxy = None;
self
}
pub const fn response_body_limit(mut self, limit: usize) -> Self {
self.response_body_limit = limit;
self
}
pub fn device(mut self, device: SubscriptionDevice) -> Self {
self.device = Some(device);
self
}
pub fn no_device(mut self) -> Self {
self.device = None;
self
}
pub fn build(self) -> Result<SubscriptionClient> {
let raw_prefix = prefix_from_path(&self.base_url, &self.raw_path, "raw")?;
let json_prefix = prefix_from_path(&self.base_url, &self.json_path, "JSON")?;
let clash_prefix = prefix_from_path(&self.base_url, &self.clash_path, "Clash")?;
SubscriptionClient::from_parts(
&self.base_url,
[&self.raw_path, &self.json_path, &self.clash_path],
raw_prefix,
json_prefix,
clash_prefix,
SubscriptionTransportConfig {
timeout: self.timeout,
connect_timeout: self.connect_timeout,
accept_invalid_certs: self.accept_invalid_certs,
proxy: self.proxy.as_ref(),
response_body_limit: self.response_body_limit,
device: self.device.as_ref(),
},
)
}
}
impl std::fmt::Debug for SubscriptionClientBuilder {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SubscriptionClientBuilder")
.field("server_origin", &origin(&self.base_url))
.field("raw_path", &"[REDACTED]")
.field("json_path", &"[REDACTED]")
.field("clash_path", &"[REDACTED]")
.field("timeout", &self.timeout)
.field("connect_timeout", &self.connect_timeout)
.field("accept_invalid_certs", &self.accept_invalid_certs)
.field("proxy", &self.proxy.as_ref().map(ProxyConfig::scheme))
.field("response_body_limit", &self.response_body_limit)
.field("device", &self.device)
.finish()
}
}
#[derive(Clone)]
pub struct SubscriptionClient {
http: reqwest::Client,
raw_prefix: Url,
json_prefix: Url,
clash_prefix: Url,
response_body_limit: usize,
mihomo_prefix: Option<Url>,
legacy_prefix: Option<Url>,
}
impl SubscriptionClient {
pub fn new(server_url: impl AsRef<str>) -> Result<Self> {
Self::builder(server_url)?.build()
}
pub fn builder(server_url: impl AsRef<str>) -> Result<SubscriptionClientBuilder> {
SubscriptionClientBuilder::new(server_url)
}
pub fn from_settings(settings: &SubscriptionSettings) -> Result<Self> {
let raw_prefix = normalize_prefix_uri(&settings.sub_uri, "subURI")?;
let server_url = origin_url(&raw_prefix);
let json_prefix = if settings.sub_json_uri.trim().is_empty() {
prefix_from_path(&server_url, &settings.sub_json_path, "JSON")?
} else {
normalize_prefix_uri(&settings.sub_json_uri, "subJsonURI")?
};
let clash_prefix = if settings.sub_clash_uri.trim().is_empty() {
prefix_from_path(&server_url, &settings.sub_clash_path, "Clash")?
} else {
normalize_prefix_uri(&settings.sub_clash_uri, "subClashURI")?
};
Self::from_parts(
&origin_url(&clash_prefix),
[
&settings.sub_path,
&settings.sub_json_path,
&settings.sub_clash_path,
],
raw_prefix,
json_prefix,
clash_prefix,
SubscriptionTransportConfig::default(),
)
}
fn from_parts(
alias_base: &Url,
configured_paths: [&str; 3],
raw_prefix: Url,
json_prefix: Url,
clash_prefix: Url,
transport: SubscriptionTransportConfig<'_>,
) -> Result<Self> {
let mut default_headers = header::HeaderMap::new();
let user_agent = if let Some(device) = transport.device {
if device.hwid().len() < 6 {
return Err(Error::Configuration(
"subscription device HWID must contain at least 6 bytes".to_owned(),
));
}
insert_device_header(&mut default_headers, "x-hwid", device.hwid(), "HWID")?;
insert_device_header(
&mut default_headers,
"x-device-os",
&device.device_os,
"device OS",
)?;
insert_device_header(
&mut default_headers,
"x-ver-os",
&device.os_version,
"OS version",
)?;
insert_device_header(
&mut default_headers,
"x-device-model",
&device.device_model,
"device model",
)?;
if device.user_agent.is_empty() {
DEFAULT_USER_AGENT
} else {
device.user_agent.as_str()
}
} else {
DEFAULT_USER_AGENT
};
let mut http_builder = reqwest::Client::builder()
.no_proxy()
.default_headers(default_headers)
.timeout(transport.timeout)
.connect_timeout(transport.connect_timeout)
.user_agent(user_agent)
.danger_accept_invalid_certs(transport.accept_invalid_certs);
if let Some(proxy) = transport.proxy {
http_builder = http_builder.proxy(proxy.reqwest_proxy()?);
}
let http = http_builder
.build()
.map_err(|error| Error::Configuration(error.to_string()))?;
let prefixes = [&raw_prefix, &json_prefix, &clash_prefix];
let alias_prefix = |path: &str, label: &str, owner_count: usize| -> Result<Option<Url>> {
let candidate = prefix_from_path(alias_base, path, label)?;
let shadowed = prefixes[..owner_count].iter().any(|prefix| {
prefix.path().trim_matches('/') == candidate.path().trim_matches('/')
}) || configured_paths[..owner_count]
.iter()
.any(|configured| configured.trim().trim_matches('/') == path.trim_matches('/'));
Ok((!shadowed).then_some(candidate))
};
let mihomo_prefix = alias_prefix("mihomo/", "Mihomo", 2)?;
let legacy_prefix = alias_prefix("clash-legacy/", "legacy Clash", 3)?;
Ok(Self {
http,
mihomo_prefix,
legacy_prefix,
raw_prefix,
json_prefix,
clash_prefix,
response_body_limit: transport.response_body_limit,
})
}
pub const fn response_body_limit(&self) -> usize {
self.response_body_limit
}
pub async fn raw(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
self.text(
Method::GET,
Format::Raw,
subscription_id,
None,
"text/plain",
)
.await
}
pub async fn html(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
self.text(Method::GET, Format::Raw, subscription_id, None, "text/html")
.await
}
pub async fn info(&self, subscription_id: &str) -> Result<SubscriptionInfo> {
Ok(self.info_with_metadata(subscription_id).await?.content)
}
pub async fn info_with_metadata(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionInfo>> {
let (headers, bytes, method, redacted_url) = self
.send(
Method::GET,
Format::Raw,
subscription_id,
Some(("format", "info")),
"application/json",
)
.await?;
let content = serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
method,
url: Box::new(redacted_url),
source,
})?;
Ok(SubscriptionResponse {
content,
metadata: SubscriptionMetadata::from_headers(&headers),
})
}
pub async fn json(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionJson>> {
let (headers, bytes, method, redacted_url) = self
.send(
Method::GET,
Format::Json,
subscription_id,
Some(("view", "raw")),
"application/json",
)
.await?;
let value: Value = serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
method,
url: Box::new(redacted_url),
source,
})?;
Ok(SubscriptionResponse {
content: SubscriptionJson::new(value),
metadata: SubscriptionMetadata::from_headers(&headers),
})
}
pub async fn clash(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
self.text(
Method::GET,
Format::Clash,
subscription_id,
Some(("view", "raw")),
"application/yaml",
)
.await
}
pub async fn mihomo(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
self.text(
Method::GET,
Format::Mihomo,
subscription_id,
Some(("view", "raw")),
"application/yaml",
)
.await
}
pub async fn mihomo_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
self.head(Format::Mihomo, subscription_id).await
}
pub async fn clash_legacy(
&self,
subscription_id: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
self.text(
Method::GET,
Format::Legacy,
subscription_id,
Some(("view", "raw")),
"application/yaml",
)
.await
}
pub async fn clash_legacy_metadata(
&self,
subscription_id: &str,
) -> Result<SubscriptionMetadata> {
self.head(Format::Legacy, subscription_id).await
}
pub async fn hwid_status(&self, subscription_id: &str) -> Result<super::HwidSlotStatus> {
let (_, bytes, method, url) = self
.send(
Method::GET,
Format::HwidStatus,
subscription_id,
None,
"application/json",
)
.await?;
serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
method,
url: Box::new(url),
source,
})
}
pub async fn hwid_status_metadata(
&self,
subscription_id: &str,
) -> Result<SubscriptionMetadata> {
self.head(Format::HwidStatus, subscription_id).await
}
pub async fn raw_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
self.head(Format::Raw, subscription_id).await
}
pub async fn json_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
self.head(Format::Json, subscription_id).await
}
pub async fn clash_metadata(&self, subscription_id: &str) -> Result<SubscriptionMetadata> {
self.head(Format::Clash, subscription_id).await
}
async fn head(&self, format: Format, subscription_id: &str) -> Result<SubscriptionMetadata> {
let (headers, _, _, _) = self
.send(Method::HEAD, format, subscription_id, None, "*/*")
.await?;
Ok(SubscriptionMetadata::from_headers(&headers))
}
async fn text(
&self,
method: Method,
format: Format,
subscription_id: &str,
query: Option<(&str, &str)>,
accept: &str,
) -> Result<SubscriptionResponse<SubscriptionDocument>> {
let (headers, bytes, method, redacted_url) = self
.send(method, format, subscription_id, query, accept)
.await?;
let text = String::from_utf8(bytes).map_err(|source| Error::Utf8 {
method,
url: Box::new(redacted_url),
source,
})?;
Ok(SubscriptionResponse {
content: SubscriptionDocument::new(text),
metadata: SubscriptionMetadata::from_headers(&headers),
})
}
async fn send(
&self,
method: Method,
format: Format,
subscription_id: &str,
query: Option<(&str, &str)>,
accept: &str,
) -> Result<(header::HeaderMap, Vec<u8>, Method, Url)> {
let prefix = match format {
Format::Raw | Format::HwidStatus => Some(&self.raw_prefix),
Format::Mihomo => self.mihomo_prefix.as_ref(),
Format::Legacy => self.legacy_prefix.as_ref(),
Format::Json => Some(&self.json_prefix),
Format::Clash => Some(&self.clash_prefix),
}
.ok_or_else(|| {
Error::Configuration(
"subscription alias is shadowed by a configured subscription path".into(),
)
})?;
let (mut url, mut redacted_url) = endpoint(prefix, subscription_id)?;
if matches!(format, Format::HwidStatus) {
url.path_segments_mut()
.expect("validated HTTP URL")
.push("hwid-status");
redacted_url
.path_segments_mut()
.expect("validated HTTP URL")
.push("hwid-status");
}
if let Some((key, value)) = query {
url.query_pairs_mut().append_pair(key, value);
redacted_url.query_pairs_mut().append_pair(key, value);
}
let response = transport::send_request(
self.http
.request(method.clone(), url)
.header(header::ACCEPT, accept),
&method,
&redacted_url,
ErrorUrlPolicy::Redact,
)
.await?;
let status = response.status();
if !status.is_success() {
return Err(Error::HttpStatus {
method,
url: Box::new(redacted_url),
status,
});
}
let headers = response.headers().clone();
let bytes = transport::read_response_body(
response,
&method,
&redacted_url,
self.response_body_limit,
ErrorUrlPolicy::Redact,
)
.await?;
Ok((headers, bytes, method, redacted_url))
}
}
#[derive(Clone, Copy)]
struct SubscriptionTransportConfig<'proxy> {
timeout: Duration,
connect_timeout: Duration,
accept_invalid_certs: bool,
proxy: Option<&'proxy ProxyConfig>,
response_body_limit: usize,
device: Option<&'proxy SubscriptionDevice>,
}
impl Default for SubscriptionTransportConfig<'_> {
fn default() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
accept_invalid_certs: false,
proxy: None,
response_body_limit: DEFAULT_SUBSCRIPTION_RESPONSE_BODY_LIMIT,
device: None,
}
}
}
fn insert_device_header(
headers: &mut header::HeaderMap,
name: &'static str,
value: &str,
label: &str,
) -> Result<()> {
if value.is_empty() {
return Ok(());
}
let value = header::HeaderValue::from_str(value)
.map_err(|error| Error::Configuration(format!("invalid {label} header: {error}")))?;
headers.insert(header::HeaderName::from_static(name), value);
Ok(())
}
impl std::fmt::Debug for SubscriptionClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SubscriptionClient")
.field("server_origin", &origin(&self.raw_prefix))
.field("paths", &"[REDACTED]")
.field("response_body_limit", &self.response_body_limit)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy)]
enum Format {
Raw,
Json,
Clash,
Mihomo,
Legacy,
HwidStatus,
}
fn normalize_server_url(value: &str) -> Result<Url> {
let mut url = parse_http_url(value, "subscription server URL")?;
url.set_query(None);
url.set_fragment(None);
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
fn normalize_prefix_uri(value: &str, field: &str) -> Result<Url> {
if value.trim().is_empty() {
return Err(Error::Configuration(format!("{field} must not be empty")));
}
let mut url = parse_http_url(value, field)?;
if url.query().is_some() || url.fragment().is_some() {
return Err(Error::Configuration(format!(
"{field} must not contain a query or fragment"
)));
}
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
fn parse_http_url(value: &str, field: &str) -> Result<Url> {
let url = Url::parse(value)
.map_err(|error| Error::Configuration(format!("invalid {field}: {error}")))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(Error::Configuration(format!(
"{field} must be an absolute HTTP(S) URL"
)));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Configuration(format!(
"{field} must not contain credentials"
)));
}
Ok(url)
}
fn prefix_from_path(base_url: &Url, path: &str, label: &str) -> Result<Url> {
let path = path.trim();
if path.is_empty() {
return Err(Error::Configuration(format!(
"{label} subscription path must not be empty"
)));
}
if path.contains('?') || path.contains('#') {
return Err(Error::Configuration(format!(
"{label} subscription path must not contain a query or fragment"
)));
}
let mut url = base_url
.join(path.trim_start_matches('/'))
.map_err(|error| Error::Configuration(format!("invalid {label} path: {error}")))?;
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
Ok(url)
}
fn endpoint(prefix: &Url, subscription_id: &str) -> Result<(Url, Url)> {
if subscription_id.is_empty() {
return Err(Error::Configuration(
"subscription ID must not be empty".to_owned(),
));
}
let mut url = prefix.clone();
url.path_segments_mut()
.map_err(|()| Error::Configuration("subscription URL cannot be a base".to_owned()))?
.pop_if_empty()
.push(subscription_id);
let mut redacted = prefix.clone();
redacted
.path_segments_mut()
.map_err(|()| Error::Configuration("subscription URL cannot be a base".to_owned()))?
.pop_if_empty()
.push("[REDACTED]");
Ok((url, redacted))
}
fn origin_url(url: &Url) -> Url {
let mut origin = url.clone();
origin.set_path("/");
origin.set_query(None);
origin.set_fragment(None);
origin
}
fn origin(url: &Url) -> String {
let mut value = origin_url(url);
value.set_path("");
value.to_string().trim_end_matches('/').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn configuration_message(error: Error) -> String {
match error {
Error::Configuration(message) => message,
other => panic!("expected configuration error, got {other:?}"),
}
}
#[test]
fn builder_options_are_composable_and_debug_safe() {
let proxy = ProxyConfig::new("http://proxy.example:8080").unwrap();
let device = SubscriptionDevice::new("device-secret", "test-agent");
let builder = SubscriptionClient::builder("https://sub.example/root?ignored=yes#fragment")
.unwrap()
.raw_path("private/raw")
.json_path("private/json")
.clash_path("private/clash")
.timeout(Duration::from_secs(11))
.connect_timeout(Duration::from_secs(3))
.danger_accept_invalid_certs(true)
.response_body_limit(1234)
.proxy(proxy)
.no_proxy()
.device(device)
.no_device();
assert_eq!(builder.timeout, Duration::from_secs(11));
assert_eq!(builder.connect_timeout, Duration::from_secs(3));
assert!(builder.accept_invalid_certs);
assert_eq!(builder.response_body_limit, 1234);
assert!(builder.proxy.is_none());
assert!(builder.device.is_none());
assert!(builder.base_url.query().is_none());
assert!(builder.base_url.fragment().is_none());
assert_eq!(builder.base_url.path(), "/root/");
let debug = format!("{builder:?}");
assert!(debug.contains("https://sub.example"));
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("private/raw"));
let client = builder.build().unwrap();
assert_eq!(client.response_body_limit(), 1234);
assert!(!format!("{client:?}").contains("private/raw"));
}
#[test]
fn proxy_url_and_device_headers_validate_early() {
let builder = SubscriptionClient::builder("https://sub.example")
.unwrap()
.proxy_url("socks5h://proxy.example:1080")
.unwrap();
assert_eq!(
builder.proxy.as_ref().unwrap().scheme(),
crate::ProxyScheme::Socks5h
);
assert!(builder.no_proxy().proxy.is_none());
let error = SubscriptionClient::builder("https://sub.example")
.unwrap()
.proxy_url("not a proxy")
.unwrap_err();
assert!(configuration_message(error).contains("invalid proxy URL"));
let short = SubscriptionDevice::new("short", "agent");
let error = SubscriptionClient::builder("https://sub.example")
.unwrap()
.device(short)
.build()
.unwrap_err();
assert!(configuration_message(error).contains("at least 6 bytes"));
let invalid = SubscriptionDevice::new("bad\nsecret", "agent");
let error = SubscriptionClient::builder("https://sub.example")
.unwrap()
.device(invalid)
.build()
.unwrap_err();
assert!(configuration_message(error).contains("invalid HWID header"));
let empty_optional_headers = SubscriptionDevice::new("device-secret", "");
SubscriptionClient::builder("https://sub.example")
.unwrap()
.device(empty_optional_headers)
.build()
.unwrap();
}
#[test]
fn subscription_urls_reject_ambiguous_or_credentialed_inputs() {
for input in [
"relative/path",
"ftp://sub.example",
"https://user:pass@sub.example",
] {
assert!(SubscriptionClient::builder(input).is_err(), "{input}");
}
for raw_path in ["", "sub?format=raw", "sub#fragment"] {
let error = SubscriptionClient::builder("https://sub.example")
.unwrap()
.raw_path(raw_path)
.build()
.unwrap_err();
assert!(configuration_message(error).contains("raw subscription path"));
}
for value in [
"",
"https://sub.example/path?query=yes",
"https://sub.example/path#fragment",
] {
let error = normalize_prefix_uri(value, "testURI").unwrap_err();
assert!(configuration_message(error).contains("testURI"));
}
let normalized = normalize_prefix_uri("https://sub.example/path", "testURI").unwrap();
assert_eq!(normalized.as_str(), "https://sub.example/path/");
assert_eq!(origin(&normalized), "https://sub.example");
}
#[test]
fn settings_require_valid_public_prefixes_and_fallback_paths() {
let empty = SubscriptionSettings::default();
assert!(SubscriptionClient::from_settings(&empty).is_err());
let invalid_json_path = SubscriptionSettings {
sub_uri: "https://sub.example/raw/".into(),
sub_json_path: "json?view=raw".into(),
sub_clash_path: "clash".into(),
..SubscriptionSettings::default()
};
let error = SubscriptionClient::from_settings(&invalid_json_path).unwrap_err();
assert!(configuration_message(error).contains("JSON subscription path"));
let invalid_clash_uri = SubscriptionSettings {
sub_uri: "https://sub.example/raw/".into(),
sub_json_uri: "https://sub.example/json/".into(),
sub_clash_uri: "https://user:pass@sub.example/clash/".into(),
..SubscriptionSettings::default()
};
let error = SubscriptionClient::from_settings(&invalid_clash_uri).unwrap_err();
assert!(configuration_message(error).contains("subClashURI"));
}
}