Skip to main content

xui_rs/subscription/
models.rs

1use std::{fmt, string::FromUtf8Error};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use reqwest::header::{self, HeaderMap};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use thiserror::Error;
8
9/// Device identity sent to subscription endpoints when HWID limiting is enabled.
10#[derive(Clone, Default, Eq, PartialEq)]
11pub struct SubscriptionDevice {
12    hwid: String,
13    /// Subscription application's user agent.
14    pub user_agent: String,
15    /// Operating-system name sent as `X-Device-OS`.
16    pub device_os: String,
17    /// Operating-system version sent as `X-Ver-OS`.
18    pub os_version: String,
19    /// Device model sent as `X-Device-Model`.
20    pub device_model: String,
21}
22
23impl SubscriptionDevice {
24    /// Creates a device identity. 3x-ui requires an HWID of at least six bytes.
25    pub fn new(hwid: impl Into<String>, user_agent: impl Into<String>) -> Self {
26        Self {
27            hwid: hwid.into(),
28            user_agent: user_agent.into(),
29            ..Self::default()
30        }
31    }
32
33    /// Borrows the raw HWID. Avoid logging or persisting this value unnecessarily.
34    pub fn hwid(&self) -> &str {
35        &self.hwid
36    }
37}
38
39impl fmt::Debug for SubscriptionDevice {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter
42            .debug_struct("SubscriptionDevice")
43            .field("hwid", &"[REDACTED]")
44            .field("user_agent", &self.user_agent)
45            .field("device_os", &self.device_os)
46            .field("os_version", &self.os_version)
47            .field("device_model", &self.device_model)
48            .finish()
49    }
50}
51
52/// Failure while decoding an encrypted raw subscription body.
53#[derive(Debug, Error)]
54#[non_exhaustive]
55pub enum SubscriptionDecodeError {
56    /// The body is not valid standard base64.
57    #[error("subscription body is not valid base64: {0}")]
58    Base64(#[from] base64::DecodeError),
59    /// The decoded body is not valid UTF-8.
60    #[error("decoded subscription body is not valid UTF-8: {0}")]
61    Utf8(#[from] FromUtf8Error),
62}
63
64/// Secret-bearing text returned by raw, Clash, or HTML subscription views.
65#[derive(Clone, Default, Eq, PartialEq)]
66pub struct SubscriptionDocument(String);
67
68impl SubscriptionDocument {
69    pub(crate) fn new(value: String) -> Self {
70        Self(value)
71    }
72
73    /// Borrows the exact response body.
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77
78    /// Consumes the wrapper and returns the exact response body.
79    pub fn into_string(self) -> String {
80        self.0
81    }
82
83    /// Decodes a raw subscription body when `subEncrypt` is enabled.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error when the body is not standard base64 or its decoded
88    /// bytes are not UTF-8.
89    pub fn decode_base64(&self) -> Result<Self, SubscriptionDecodeError> {
90        let bytes = STANDARD.decode(self.0.trim())?;
91        String::from_utf8(bytes).map(Self).map_err(Into::into)
92    }
93
94    /// Iterates over non-empty lines in a plaintext or decoded document.
95    pub fn lines(&self) -> impl Iterator<Item = &str> {
96        self.0
97            .lines()
98            .map(str::trim)
99            .filter(|line| !line.is_empty())
100    }
101}
102
103impl fmt::Debug for SubscriptionDocument {
104    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105        formatter
106            .debug_tuple("SubscriptionDocument")
107            .field(&"[REDACTED]")
108            .finish()
109    }
110}
111
112/// Secret-bearing Xray JSON subscription document.
113#[derive(Clone, Deserialize, PartialEq, Serialize)]
114#[serde(transparent)]
115pub struct SubscriptionJson(Value);
116
117impl SubscriptionJson {
118    pub(crate) const fn new(value: Value) -> Self {
119        Self(value)
120    }
121
122    /// Borrows the parsed JSON configuration.
123    pub const fn as_value(&self) -> &Value {
124        &self.0
125    }
126
127    /// Consumes the wrapper and returns the parsed JSON configuration.
128    pub fn into_value(self) -> Value {
129        self.0
130    }
131}
132
133impl fmt::Debug for SubscriptionJson {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter
136            .debug_tuple("SubscriptionJson")
137            .field(&"[REDACTED]")
138            .finish()
139    }
140}
141
142/// Parsed traffic and expiry values from `Subscription-Userinfo`.
143#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
144pub struct SubscriptionTraffic {
145    /// Uploaded bytes.
146    pub upload: i64,
147    /// Downloaded bytes.
148    pub download: i64,
149    /// Total traffic allowance in bytes, or zero when unlimited.
150    pub total: i64,
151    /// Expiration Unix timestamp in seconds, or zero when unlimited.
152    pub expire: i64,
153}
154
155/// Application management headers emitted for Happ subscription clients.
156#[allow(clippy::struct_excessive_bools)]
157#[derive(Clone, Default, Eq, PartialEq)]
158pub struct HappMetadata {
159    /// Exact `ProviderID` header value.
160    pub provider_id: Option<String>,
161    /// Exact `New-Url` header value.
162    pub new_url: Option<String>,
163    /// Exact `Fallback-Url` header value.
164    pub fallback_url: Option<String>,
165    /// Exact `Sub-Info-Color` header value.
166    pub sub_info_color: Option<String>,
167    /// Exact `Sub-Info-Text` header value.
168    pub sub_info_text: Option<String>,
169    /// Exact `Sub-Info-Button-Text` header value.
170    pub sub_info_button_text: Option<String>,
171    /// Exact `Sub-Info-Button-Link` header value.
172    pub sub_info_button_link: Option<String>,
173    /// Exact `Sub-Expire-Button-Link` header value.
174    pub sub_expire_button_link: Option<String>,
175    /// Exact `Tun-Mode` header value.
176    pub tun_mode: Option<String>,
177    /// Exact `Tun-Type` header value.
178    pub tun_type: Option<String>,
179    /// Exact `Exclude-Routes` header value.
180    pub exclude_routes: Option<String>,
181    /// Exact `Color-Profile` header value.
182    pub color_profile: Option<String>,
183    /// Exact `Ping-Type` header value.
184    pub ping_type: Option<String>,
185    /// Exact `Subscription-Autoconnect-Type` header value.
186    pub autoconnect_type: Option<String>,
187    /// Exact `Per-App-Proxy-Mode` header value.
188    pub per_app_proxy_mode: Option<String>,
189    /// Exact `Per-App-Proxy-List` header value.
190    pub per_app_proxy_list: Option<String>,
191    /// Whether `Sub-Expire` is enabled.
192    pub sub_expire: bool,
193    /// Whether `Notification-Subs-Expire` is enabled.
194    pub notification_subs_expire: bool,
195    /// Whether `No-Limit-Enabled` is enabled.
196    pub no_limit_enabled: bool,
197    /// Whether `Subscription-Always-Hwid-Enable` is enabled.
198    pub always_hwid: bool,
199    /// Whether `Exclude-Apns-Enable` is enabled.
200    pub exclude_apns: bool,
201    /// Whether `Subscription-Autoconnect` is enabled.
202    pub autoconnect: bool,
203}
204
205impl HappMetadata {
206    /// Reads optional Happ headers and their exact upstream boolean encodings.
207    /// Absent or non-text headers leave optional fields unset and boolean flags disabled.
208    fn from_headers(headers: &HeaderMap) -> Self {
209        Self {
210            provider_id: header_text(headers, "providerid").map(str::to_owned),
211            new_url: header_text(headers, "new-url").map(str::to_owned),
212            fallback_url: header_text(headers, "fallback-url").map(str::to_owned),
213            sub_info_color: header_text(headers, "sub-info-color").map(str::to_owned),
214            sub_info_text: header_text(headers, "sub-info-text").map(str::to_owned),
215            sub_info_button_text: header_text(headers, "sub-info-button-text").map(str::to_owned),
216            sub_info_button_link: header_text(headers, "sub-info-button-link").map(str::to_owned),
217            sub_expire_button_link: header_text(headers, "sub-expire-button-link")
218                .map(str::to_owned),
219            tun_mode: header_text(headers, "tun-mode").map(str::to_owned),
220            tun_type: header_text(headers, "tun-type").map(str::to_owned),
221            exclude_routes: header_text(headers, "exclude-routes").map(str::to_owned),
222            color_profile: header_text(headers, "color-profile").map(str::to_owned),
223            ping_type: header_text(headers, "ping-type").map(str::to_owned),
224            autoconnect_type: header_text(headers, "subscription-autoconnect-type")
225                .map(str::to_owned),
226            per_app_proxy_mode: header_text(headers, "per-app-proxy-mode").map(str::to_owned),
227            per_app_proxy_list: header_text(headers, "per-app-proxy-list").map(str::to_owned),
228            sub_expire: header_text(headers, "sub-expire") == Some("1"),
229            notification_subs_expire: header_text(headers, "notification-subs-expire") == Some("1"),
230            no_limit_enabled: header_text(headers, "no-limit-enabled") == Some("1"),
231            always_hwid: header_text(headers, "subscription-always-hwid-enable") == Some("1"),
232            exclude_apns: header_text(headers, "exclude-apns-enable") == Some("true"),
233            autoconnect: header_text(headers, "subscription-autoconnect") == Some("1"),
234        }
235    }
236}
237
238impl fmt::Debug for HappMetadata {
239    /// Identifies Happ metadata without exposing header values or subscription links.
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        formatter
242            .debug_tuple("HappMetadata")
243            .field(&"[REDACTED]")
244            .finish()
245    }
246}
247
248/// Common response headers emitted by all three subscription formats.
249#[allow(clippy::struct_excessive_bools)]
250#[derive(Clone, Default, Eq, PartialEq)]
251pub struct SubscriptionMetadata {
252    /// Happ application management headers.
253    pub happ: HappMetadata,
254    /// Parsed traffic/expiry header, when present and valid.
255    pub traffic: Option<SubscriptionTraffic>,
256    /// Decoded profile title.
257    pub profile_title: Option<String>,
258    /// Suggested polling interval in minutes.
259    pub update_interval_minutes: Option<u32>,
260    /// Configured support URL.
261    pub support_url: Option<String>,
262    /// Decoded announcement.
263    pub announcement: Option<String>,
264    /// Whether compatible clients should enable routing.
265    pub routing_enabled: bool,
266    /// Whether clients should hide editable settings.
267    pub hide_settings: bool,
268    /// Response MIME type.
269    pub content_type: Option<String>,
270    /// Download/profile filename header.
271    pub content_disposition: Option<String>,
272    /// Whether HWID enforcement is enabled for this subscription.
273    pub hwid_active: bool,
274    /// Whether the client omitted or sent an unsupported HWID.
275    pub hwid_not_supported: bool,
276    /// Whether all configured HWID slots are currently occupied.
277    pub hwid_limit_reached: bool,
278    /// Whether this request was rejected because no device slot was available.
279    pub hwid_max_devices_reached: bool,
280    profile_web_page_url: Option<String>,
281    routing_rules: Option<String>,
282}
283
284impl SubscriptionMetadata {
285    /// Decodes subscription response headers, tolerating absent or malformed optional values.
286    /// Preserves secret-bearing profile URLs and routing rules for explicit accessors.
287    pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
288        Self {
289            happ: HappMetadata::from_headers(headers),
290            traffic: header_text(headers, "subscription-userinfo")
291                .and_then(parse_subscription_userinfo),
292            profile_title: header_text(headers, "profile-title").and_then(decode_text_header),
293            update_interval_minutes: header_text(headers, "profile-update-interval")
294                .and_then(|value| value.parse().ok()),
295            support_url: header_text(headers, "support-url").map(str::to_owned),
296            announcement: header_text(headers, "announce").and_then(decode_text_header),
297            routing_enabled: header_text(headers, "routing-enable") == Some("true"),
298            hide_settings: header_text(headers, "hide-settings") == Some("1"),
299            content_type: headers
300                .get(header::CONTENT_TYPE)
301                .and_then(|value| value.to_str().ok())
302                .map(str::to_owned),
303            content_disposition: headers
304                .get(header::CONTENT_DISPOSITION)
305                .and_then(|value| value.to_str().ok())
306                .map(str::to_owned),
307            hwid_active: header_text(headers, "x-hwid-active") == Some("true"),
308            hwid_not_supported: header_text(headers, "x-hwid-not-supported") == Some("true"),
309            hwid_limit_reached: header_text(headers, "x-hwid-limit") == Some("true"),
310            hwid_max_devices_reached: header_text(headers, "x-hwid-max-devices-reached")
311                == Some("true"),
312            profile_web_page_url: header_text(headers, "profile-web-page-url").map(str::to_owned),
313            routing_rules: header_text(headers, "routing").map(str::to_owned),
314        }
315    }
316
317    /// Returns the profile web-page URL, which normally embeds the secret
318    /// subscription identifier.
319    pub fn profile_web_page_url(&self) -> Option<&str> {
320        self.profile_web_page_url.as_deref()
321    }
322
323    /// Returns routing rules supplied to compatible subscription clients.
324    pub fn routing_rules(&self) -> Option<&str> {
325        self.routing_rules.as_deref()
326    }
327}
328
329impl fmt::Debug for SubscriptionMetadata {
330    /// Formats subscription metadata while redacting Happ values, the profile URL, and routing rules.
331    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
332        formatter
333            .debug_struct("SubscriptionMetadata")
334            .field("happ", &self.happ)
335            .field("traffic", &self.traffic)
336            .field("profile_title", &self.profile_title)
337            .field("update_interval_minutes", &self.update_interval_minutes)
338            .field("support_url", &self.support_url)
339            .field("announcement", &self.announcement)
340            .field("routing_enabled", &self.routing_enabled)
341            .field("hide_settings", &self.hide_settings)
342            .field("content_type", &self.content_type)
343            .field("content_disposition", &self.content_disposition)
344            .field("hwid_active", &self.hwid_active)
345            .field("hwid_not_supported", &self.hwid_not_supported)
346            .field("hwid_limit_reached", &self.hwid_limit_reached)
347            .field("hwid_max_devices_reached", &self.hwid_max_devices_reached)
348            .field("profile_web_page_url", &"[REDACTED]")
349            .field("routing_rules", &"[REDACTED]")
350            .finish()
351    }
352}
353
354/// Body plus typed metadata returned by a subscription endpoint.
355#[derive(Clone, Debug, Default, PartialEq)]
356pub struct SubscriptionResponse<T> {
357    /// Secret-bearing body or parsed document.
358    pub content: T,
359    /// Traffic, profile, routing, and content headers.
360    pub metadata: SubscriptionMetadata,
361}
362
363/// Typed `format=info` subscription status view.
364#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
365#[serde(default, rename_all = "camelCase")]
366pub struct SubscriptionInfo {
367    /// Secret subscription identifier.
368    #[serde(rename = "sId")]
369    pub subscription_id: String,
370    /// Whether the shared traffic record is enabled.
371    pub enabled: bool,
372    /// Whether any attached client is currently online.
373    pub is_online: bool,
374    /// Human-formatted downloaded traffic.
375    pub download: String,
376    /// Human-formatted uploaded traffic.
377    pub upload: String,
378    /// Human-formatted total allowance.
379    pub total: String,
380    /// Human-formatted used traffic.
381    pub used: String,
382    /// Human-formatted remaining traffic.
383    pub remained: String,
384    /// Expiration Unix timestamp in seconds.
385    pub expire: i64,
386    /// Last-online timestamp in milliseconds.
387    pub last_online: i64,
388    /// Downloaded bytes.
389    pub download_byte: i64,
390    /// Uploaded bytes.
391    pub upload_byte: i64,
392    /// Total allowance in bytes.
393    pub total_byte: i64,
394    /// Raw subscription URL.
395    pub sub_url: String,
396    /// JSON subscription URL, or empty when disabled.
397    pub sub_json_url: String,
398    /// Clash subscription URL, or empty when disabled.
399    pub sub_clash_url: String,
400    /// Profile title.
401    pub sub_title: String,
402    /// Support URL.
403    pub sub_support_url: String,
404    /// Suggested subscription update interval in hours.
405    pub sub_updates: i32,
406    /// Distinct client emails represented by the subscription.
407    pub emails: Vec<String>,
408    /// Calendar identifier used by the information page.
409    pub datepicker: String,
410    /// Subscription announcement.
411    pub announce: String,
412}
413
414impl fmt::Debug for SubscriptionInfo {
415    /// Formats subscription status while redacting identifiers, connection links, client emails, and URIs.
416    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
417        formatter
418            .debug_struct("SubscriptionInfo")
419            .field("sub_updates", &self.sub_updates)
420            .field("subscription_id", &"[REDACTED]")
421            .field("enabled", &self.enabled)
422            .field("is_online", &self.is_online)
423            .field("download", &self.download)
424            .field("upload", &self.upload)
425            .field("total", &self.total)
426            .field("used", &self.used)
427            .field("remained", &self.remained)
428            .field("expire", &self.expire)
429            .field("last_online", &self.last_online)
430            .field("download_byte", &self.download_byte)
431            .field("upload_byte", &self.upload_byte)
432            .field("total_byte", &self.total_byte)
433            .field("sub_url", &"[REDACTED]")
434            .field("sub_json_url", &"[REDACTED]")
435            .field("sub_clash_url", &"[REDACTED]")
436            .field("sub_title", &self.sub_title)
437            .field("sub_support_url", &self.sub_support_url)
438            .field("emails", &"[REDACTED]")
439            .field("datepicker", &self.datepicker)
440            .field("announce", &self.announce)
441            .finish()
442    }
443}
444
445fn header_text<'headers>(headers: &'headers HeaderMap, name: &str) -> Option<&'headers str> {
446    headers.get(name)?.to_str().ok()
447}
448
449fn parse_subscription_userinfo(value: &str) -> Option<SubscriptionTraffic> {
450    let mut upload = None;
451    let mut download = None;
452    let mut total = None;
453    let mut expire = None;
454    for item in value.split(';') {
455        let (key, value) = item.trim().split_once('=')?;
456        let value = value.trim().parse().ok()?;
457        match key.trim() {
458            "upload" => upload = Some(value),
459            "download" => download = Some(value),
460            "total" => total = Some(value),
461            "expire" => expire = Some(value),
462            _ => {}
463        }
464    }
465    Some(SubscriptionTraffic {
466        upload: upload?,
467        download: download?,
468        total: total?,
469        expire: expire?,
470    })
471}
472
473fn decode_text_header(value: &str) -> Option<String> {
474    let encoded = value.strip_prefix("base64:")?;
475    let bytes = STANDARD.decode(encoded).ok()?;
476    String::from_utf8(bytes).ok()
477}
478
479/// Read-only aggregate HWID slot status; fetching this never registers a device.
480#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
481pub struct HwidSlotStatus {
482    /// Whether any represented client enforces an HWID limit.
483    pub active: bool,
484    /// Effective device limit shared by the enabled clients in this subscription.
485    pub limit: i32,
486    /// Number of registered devices.
487    pub registered: i32,
488    /// Number of remaining slots.
489    pub remaining: i32,
490    /// Whether all available slots are occupied.
491    pub full: bool,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use serde_json::json;
498
499    #[test]
500    fn device_and_documents_expose_secrets_only_explicitly() {
501        let mut device = SubscriptionDevice::new("device-hwid-secret", "test-agent");
502        device.device_os = "linux".into();
503        device.os_version = "rolling".into();
504        device.device_model = "server".into();
505        assert_eq!(device.hwid(), "device-hwid-secret");
506        assert!(!format!("{device:?}").contains("device-hwid-secret"));
507
508        let document = SubscriptionDocument::new(" first\n\n second \n".into());
509        assert_eq!(document.as_str(), " first\n\n second \n");
510        assert_eq!(document.lines().collect::<Vec<_>>(), ["first", "second"]);
511        assert!(!format!("{document:?}").contains("first"));
512        assert_eq!(document.into_string(), " first\n\n second \n");
513
514        let encoded = SubscriptionDocument::new("dmxlc3M6Ly9leGFtcGxl".into());
515        assert_eq!(encoded.decode_base64().unwrap().as_str(), "vless://example");
516        assert!(matches!(
517            SubscriptionDocument::new("%%%".into()).decode_base64(),
518            Err(SubscriptionDecodeError::Base64(_))
519        ));
520        assert!(matches!(
521            SubscriptionDocument::new("/w==".into()).decode_base64(),
522            Err(SubscriptionDecodeError::Utf8(_))
523        ));
524    }
525
526    #[test]
527    fn json_document_accessors_preserve_exact_value() {
528        let value = json!({"outbounds": [{"password": "subscription-secret"}]});
529        let document = SubscriptionJson::new(value.clone());
530        assert_eq!(document.as_value(), &value);
531        assert!(!format!("{document:?}").contains("subscription-secret"));
532        assert_eq!(document.into_value(), value);
533    }
534
535    #[test]
536    fn userinfo_parser_ignores_extensions_but_rejects_malformed_values() {
537        let parsed =
538            parse_subscription_userinfo("upload=1; download=2; total=3; expire=4; future=99")
539                .unwrap();
540        assert_eq!(parsed.upload, 1);
541        assert_eq!(parsed.download, 2);
542        assert_eq!(parsed.total, 3);
543        assert_eq!(parsed.expire, 4);
544        assert!(parse_subscription_userinfo("upload=bad").is_none());
545        assert!(parse_subscription_userinfo("upload=1; malformed").is_none());
546        assert_eq!(decode_text_header("plain"), None);
547        assert_eq!(decode_text_header("base64:%%%"), None);
548        assert_eq!(decode_text_header("base64:/w=="), None);
549    }
550}