Skip to main content

xui_rs/clients/
models.rs

1use std::{collections::HashMap, fmt};
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::{ClientTraffic, InboundProtocol};
6
7pub(crate) fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
8where
9    D: Deserializer<'de>,
10    T: Deserialize<'de> + Default,
11{
12    Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
13}
14
15/// VLESS reverse-proxy configuration attached to a client.
16#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
17pub struct ClientReverse {
18    /// Xray reverse tag.
19    pub tag: String,
20}
21
22/// Complete writable client configuration accepted by 3x-ui.
23///
24/// Protocol-specific credentials may be left empty on create; 3x-ui generates
25/// the appropriate UUID, password, auth value, or `MTProto` secret from the
26/// target inbound. Use [`ClientRecord::to_config`] before replacement updates.
27#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
28#[serde(default, rename_all = "camelCase")]
29pub struct ClientConfig {
30    /// VMess/VLESS UUID.
31    #[serde(rename = "id", skip_serializing_if = "String::is_empty")]
32    pub protocol_id: String,
33    /// Protocol security method.
34    pub security: String,
35    /// Trojan or Shadowsocks password/key.
36    #[serde(skip_serializing_if = "String::is_empty")]
37    pub password: String,
38    /// XTLS flow value.
39    #[serde(skip_serializing_if = "String::is_empty")]
40    pub flow: String,
41    /// VLESS reverse-proxy settings.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub reverse: Option<ClientReverse>,
44    /// Hysteria authentication value.
45    #[serde(skip_serializing_if = "String::is_empty")]
46    pub auth: String,
47    /// `WireGuard` private key.
48    #[serde(skip_serializing_if = "String::is_empty")]
49    pub private_key: String,
50    /// `WireGuard` public key.
51    #[serde(skip_serializing_if = "String::is_empty")]
52    pub public_key: String,
53    /// `WireGuard` allowed networks.
54    #[serde(
55        rename = "allowedIPs",
56        skip_serializing_if = "Vec::is_empty",
57        deserialize_with = "deserialize_null_default"
58    )]
59    pub allowed_ips: Vec<String>,
60    /// Per-inbound `WireGuard`/`AmneziaWG` allowed-network overrides.
61    #[serde(skip_serializing_if = "HashMap::is_empty")]
62    pub allowed_ips_by_inbound: HashMap<i64, Vec<String>>,
63    /// `WireGuard` pre-shared key.
64    #[serde(skip_serializing_if = "String::is_empty")]
65    pub pre_shared_key: String,
66    /// `WireGuard` keepalive seconds: `None` preserves the stored value; `Some(0)` disables it.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub keep_alive: Option<i32>,
69    /// `AmneziaWG` per-client port-forwarding specification.
70    #[serde(skip_serializing_if = "String::is_empty")]
71    pub forwarded_ports: String,
72    /// `MTProto` client secret.
73    #[serde(skip_serializing_if = "String::is_empty")]
74    pub secret: String,
75    /// `MTProto` advertisement tag.
76    #[serde(skip_serializing_if = "String::is_empty")]
77    pub ad_tag: String,
78    /// Unique client email/name.
79    pub email: String,
80    /// Maximum concurrent source IP count.
81    pub limit_ip: i32,
82    /// Maximum registered subscription devices; zero disables HWID limiting.
83    pub limit_hwid: i32,
84    /// Traffic quota in bytes, despite the historical `totalGB` wire name.
85    #[serde(rename = "totalGB")]
86    pub total_gb: i64,
87    /// Expiration timestamp in milliseconds; zero means unlimited.
88    pub expiry_time: i64,
89    /// Whether the client is enabled.
90    pub enable: bool,
91    /// Telegram user identifier for notifications.
92    pub tg_id: i64,
93    /// Subscription identifier.
94    pub sub_id: String,
95    /// Optional logical group label.
96    #[serde(skip_serializing_if = "String::is_empty")]
97    pub group: String,
98    /// Operator comment.
99    pub comment: String,
100    /// Automatic reset period encoded by 3x-ui.
101    pub reset: i32,
102    /// Calendar renewal day; zero keeps interval-based renewal.
103    pub reset_day: i32,
104    /// Maximum automatic renewals; zero is unlimited.
105    pub reset_max: i32,
106    /// Independent per-client traffic reset cycle.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub traffic_reset: Option<crate::TrafficReset>,
109    /// Day of month used by the per-client monthly reset cycle.
110    #[serde(skip_serializing_if = "is_zero_i32")]
111    pub traffic_reset_day: i32,
112    /// Creation timestamp in milliseconds used by import/export.
113    #[serde(rename = "created_at", skip_serializing_if = "is_zero_i64")]
114    pub created_at: i64,
115    /// Update timestamp in milliseconds used by import/export.
116    #[serde(rename = "updated_at", skip_serializing_if = "is_zero_i64")]
117    pub updated_at: i64,
118}
119
120impl ClientConfig {
121    /// Creates an enabled, unlimited client and lets 3x-ui generate credentials.
122    pub fn new(email: impl Into<String>) -> Self {
123        Self {
124            email: email.into(),
125            security: "auto".to_owned(),
126            enable: true,
127            ..Self::default()
128        }
129    }
130}
131
132impl fmt::Debug for ClientConfig {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        formatter
135            .debug_struct("ClientConfig")
136            .field("protocol_id", &"[REDACTED]")
137            .field("security", &self.security)
138            .field("password", &"[REDACTED]")
139            .field("flow", &self.flow)
140            .field("reverse", &self.reverse)
141            .field("auth", &"[REDACTED]")
142            .field("private_key", &"[REDACTED]")
143            .field("public_key", &"[REDACTED]")
144            .field("allowed_ips", &self.allowed_ips)
145            .field("allowed_ips_by_inbound", &self.allowed_ips_by_inbound)
146            .field("pre_shared_key", &"[REDACTED]")
147            .field("keep_alive", &self.keep_alive)
148            .field("forwarded_ports", &self.forwarded_ports)
149            .field("secret", &"[REDACTED]")
150            .field("ad_tag", &"[REDACTED]")
151            .field("email", &self.email)
152            .field("limit_ip", &self.limit_ip)
153            .field("limit_hwid", &self.limit_hwid)
154            .field("total_gb", &self.total_gb)
155            .field("expiry_time", &self.expiry_time)
156            .field("enable", &self.enable)
157            .field("tg_id", &self.tg_id)
158            .field("sub_id", &"[REDACTED]")
159            .field("group", &self.group)
160            .field("comment", &self.comment)
161            .field("reset", &self.reset)
162            .field("reset_day", &self.reset_day)
163            .field("reset_max", &self.reset_max)
164            .field("traffic_reset", &self.traffic_reset)
165            .field("traffic_reset_day", &self.traffic_reset_day)
166            .field("created_at", &self.created_at)
167            .field("updated_at", &self.updated_at)
168            .finish()
169    }
170}
171
172/// Canonical database-backed client record returned by list/get endpoints.
173#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
174#[serde(default, rename_all = "camelCase")]
175pub struct ClientRecord {
176    /// Database identifier.
177    pub id: i64,
178    /// Unique client email/name.
179    pub email: String,
180    /// Subscription identifier.
181    pub sub_id: String,
182    /// VMess/VLESS UUID.
183    pub uuid: String,
184    /// Trojan or Shadowsocks password/key.
185    pub password: String,
186    /// Hysteria authentication value.
187    pub auth: String,
188    /// Effective XTLS flow.
189    pub flow: String,
190    /// Protocol security method.
191    pub security: String,
192    /// VLESS reverse-proxy settings, serialized as a nested object by v3.8.5.
193    pub reverse: Option<ClientReverse>,
194    /// `WireGuard` private key.
195    pub private_key: String,
196    /// `WireGuard` public key.
197    pub public_key: String,
198    /// Comma-separated `WireGuard` allowed networks.
199    #[serde(rename = "allowedIPs")]
200    pub allowed_ips: String,
201    /// `WireGuard` pre-shared key.
202    pub pre_shared_key: String,
203    /// `WireGuard` keepalive interval.
204    pub keep_alive: i32,
205    /// `AmneziaWG` per-client port-forwarding specification.
206    pub forwarded_ports: String,
207    /// `MTProto` client secret.
208    pub secret: String,
209    /// `MTProto` advertisement tag.
210    pub ad_tag: String,
211    /// Maximum concurrent source IP count.
212    pub limit_ip: i32,
213    /// Maximum registered subscription devices.
214    pub limit_hwid: i32,
215    /// Traffic quota in bytes.
216    #[serde(rename = "totalGB")]
217    pub total_gb: i64,
218    /// Expiration timestamp in milliseconds.
219    pub expiry_time: i64,
220    /// Whether the client is enabled.
221    pub enable: bool,
222    /// Telegram user identifier.
223    pub tg_id: i64,
224    /// Logical group label.
225    pub group: String,
226    /// Operator comment.
227    pub comment: String,
228    /// Automatic reset period encoded by 3x-ui.
229    pub reset: i32,
230    /// Calendar renewal day; zero keeps interval-based renewal.
231    pub reset_day: i32,
232    /// Maximum automatic renewals; zero is unlimited.
233    pub reset_max: i32,
234    /// Independent per-client traffic reset cycle.
235    pub traffic_reset: String,
236    /// Day of month for the per-client monthly reset cycle.
237    pub traffic_reset_day: i32,
238    /// Creation timestamp in milliseconds.
239    pub created_at: i64,
240    /// Last update timestamp in milliseconds.
241    pub updated_at: i64,
242}
243
244impl ClientRecord {
245    /// Converts the response into the full writable replacement payload.
246    pub fn to_config(&self) -> ClientConfig {
247        ClientConfig {
248            protocol_id: self.uuid.clone(),
249            security: self.security.clone(),
250            password: self.password.clone(),
251            flow: self.flow.clone(),
252            reverse: self.reverse.clone(),
253            auth: self.auth.clone(),
254            private_key: self.private_key.clone(),
255            public_key: self.public_key.clone(),
256            allowed_ips: self
257                .allowed_ips
258                .split(',')
259                .map(str::trim)
260                .filter(|value| !value.is_empty())
261                .map(ToOwned::to_owned)
262                .collect(),
263            allowed_ips_by_inbound: HashMap::new(),
264            pre_shared_key: self.pre_shared_key.clone(),
265            keep_alive: Some(self.keep_alive),
266            forwarded_ports: self.forwarded_ports.clone(),
267            secret: self.secret.clone(),
268            ad_tag: self.ad_tag.clone(),
269            email: self.email.clone(),
270            limit_ip: self.limit_ip,
271            limit_hwid: self.limit_hwid,
272            total_gb: self.total_gb,
273            expiry_time: self.expiry_time,
274            enable: self.enable,
275            tg_id: self.tg_id,
276            sub_id: self.sub_id.clone(),
277            group: self.group.clone(),
278            comment: self.comment.clone(),
279            reset: self.reset,
280            reset_day: self.reset_day,
281            reset_max: self.reset_max,
282            traffic_reset: match self.traffic_reset.as_str() {
283                "hourly" => Some(crate::TrafficReset::Hourly),
284                "daily" => Some(crate::TrafficReset::Daily),
285                "weekly" => Some(crate::TrafficReset::Weekly),
286                "monthly" => Some(crate::TrafficReset::Monthly),
287                "never" => Some(crate::TrafficReset::Never),
288                _ => None,
289            },
290            traffic_reset_day: self.traffic_reset_day,
291            created_at: self.created_at,
292            updated_at: self.updated_at,
293        }
294    }
295}
296
297impl fmt::Debug for ClientRecord {
298    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
299        formatter
300            .debug_struct("ClientRecord")
301            .field("id", &self.id)
302            .field("email", &self.email)
303            .field("sub_id", &"[REDACTED]")
304            .field("uuid", &"[REDACTED]")
305            .field("password", &"[REDACTED]")
306            .field("auth", &"[REDACTED]")
307            .field("flow", &self.flow)
308            .field("security", &self.security)
309            .field("reverse", &self.reverse)
310            .field("private_key", &"[REDACTED]")
311            .field("public_key", &"[REDACTED]")
312            .field("allowed_ips", &self.allowed_ips)
313            .field("pre_shared_key", &"[REDACTED]")
314            .field("keep_alive", &self.keep_alive)
315            .field("forwarded_ports", &self.forwarded_ports)
316            .field("secret", &"[REDACTED]")
317            .field("ad_tag", &"[REDACTED]")
318            .field("limit_ip", &self.limit_ip)
319            .field("limit_hwid", &self.limit_hwid)
320            .field("total_gb", &self.total_gb)
321            .field("expiry_time", &self.expiry_time)
322            .field("enable", &self.enable)
323            .field("tg_id", &self.tg_id)
324            .field("group", &self.group)
325            .field("comment", &self.comment)
326            .field("reset", &self.reset)
327            .field("reset_day", &self.reset_day)
328            .field("reset_max", &self.reset_max)
329            .field("traffic_reset", &self.traffic_reset)
330            .field("traffic_reset_day", &self.traffic_reset_day)
331            .field("created_at", &self.created_at)
332            .field("updated_at", &self.updated_at)
333            .finish()
334    }
335}
336
337/// Client record enriched with attachments and traffic state.
338#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
339#[serde(default, rename_all = "camelCase")]
340pub struct ClientWithAttachments {
341    /// Canonical client record.
342    #[serde(flatten)]
343    pub client: ClientRecord,
344    /// Attached inbound identifiers.
345    #[serde(deserialize_with = "deserialize_null_default")]
346    pub inbound_ids: Vec<i64>,
347    /// Shared traffic record, when one exists.
348    pub traffic: Option<ClientTraffic>,
349}
350
351/// Full payload returned by the single-client lookup endpoints.
352#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
353#[serde(rename_all = "camelCase")]
354pub struct ClientDetails {
355    /// Canonical client record.
356    pub client: ClientRecord,
357    /// Attached inbound identifiers.
358    #[serde(default, deserialize_with = "deserialize_null_default")]
359    pub inbound_ids: Vec<i64>,
360    /// Persisted external links and subscriptions.
361    #[serde(default, deserialize_with = "deserialize_null_default")]
362    pub external_links: Vec<ClientExternalLink>,
363    /// Sum of uploaded and downloaded bytes.
364    pub used_traffic: i64,
365    /// Effective `WireGuard`/`AmneziaWG` addresses keyed by inbound identifier.
366    #[serde(default, deserialize_with = "deserialize_null_default")]
367    pub tunnel_allowed_ips: HashMap<i64, Vec<String>>,
368}
369
370/// Registered subscription device metadata. Raw HWIDs are never returned.
371#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
372#[serde(default, rename_all = "camelCase")]
373pub struct ClientHwidDevice {
374    /// Database identifier used to revoke this registration.
375    pub id: i64,
376    /// First successful subscription fetch in Unix milliseconds.
377    pub first_seen: i64,
378    /// Most recent successful subscription fetch in Unix milliseconds.
379    pub last_seen: i64,
380    /// Reported subscription-client user agent.
381    pub user_agent: String,
382    /// Reported operating system.
383    #[serde(rename = "deviceOs")]
384    pub device_os: String,
385    /// Reported operating-system version.
386    pub os_version: String,
387    /// Short fingerprint of the registered HWID hash.
388    pub fingerprint: String,
389    /// Reported device model.
390    pub device_model: String,
391}
392
393/// Lightweight client row returned by paginated listing.
394#[derive(Clone, Default, Deserialize, Eq, PartialEq)]
395#[serde(default, rename_all = "camelCase")]
396pub struct ClientSlim {
397    /// Unique client email/name.
398    pub email: String,
399    /// Subscription identifier.
400    pub sub_id: String,
401    /// Whether the client is enabled.
402    pub enable: bool,
403    /// Traffic quota in bytes.
404    #[serde(rename = "totalGB")]
405    pub total_gb: i64,
406    /// Expiration timestamp in milliseconds.
407    pub expiry_time: i64,
408    /// Maximum concurrent source IP count.
409    pub limit_ip: i32,
410    /// Automatic reset period encoded by 3x-ui.
411    pub reset: i32,
412    /// Logical group label.
413    pub group: String,
414    /// Operator comment.
415    pub comment: String,
416    /// Attached inbound identifiers.
417    #[serde(deserialize_with = "deserialize_null_default")]
418    pub inbound_ids: Vec<i64>,
419    /// Shared traffic state, when present.
420    pub traffic: Option<ClientTraffic>,
421    /// Creation timestamp in milliseconds.
422    pub created_at: i64,
423    /// Last update timestamp in milliseconds.
424    pub updated_at: i64,
425}
426
427impl fmt::Debug for ClientSlim {
428    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
429        formatter
430            .debug_struct("ClientSlim")
431            .field("email", &self.email)
432            .field("sub_id", &"[REDACTED]")
433            .field("enable", &self.enable)
434            .field("total_gb", &self.total_gb)
435            .field("expiry_time", &self.expiry_time)
436            .field("limit_ip", &self.limit_ip)
437            .field("reset", &self.reset)
438            .field("group", &self.group)
439            .field("comment", &self.comment)
440            .field("inbound_ids", &self.inbound_ids)
441            .field("traffic", &self.traffic)
442            .field("created_at", &self.created_at)
443            .field("updated_at", &self.updated_at)
444            .finish()
445    }
446}
447
448/// Aggregate counts and capped email buckets returned with a client page.
449#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
450#[serde(default, rename_all = "camelCase")]
451pub struct ClientSummary {
452    /// Total client count.
453    pub total: u64,
454    /// Enabled, non-depleted, non-expiring client count.
455    pub active: u64,
456    /// Online client count.
457    pub online_count: u64,
458    /// Depleted client count.
459    pub depleted_count: u64,
460    /// Expiring client count.
461    pub expiring_count: u64,
462    /// Disabled, non-depleted client count.
463    pub deactive_count: u64,
464    /// Capped list of online emails.
465    #[serde(deserialize_with = "deserialize_null_default")]
466    pub online: Vec<String>,
467    /// Capped list of depleted emails.
468    #[serde(deserialize_with = "deserialize_null_default")]
469    pub depleted: Vec<String>,
470    /// Capped list of expiring emails.
471    #[serde(deserialize_with = "deserialize_null_default")]
472    pub expiring: Vec<String>,
473    /// Capped list of disabled emails.
474    #[serde(deserialize_with = "deserialize_null_default")]
475    pub deactive: Vec<String>,
476}
477
478/// One page of clients and the global dashboard summary.
479#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
480#[serde(default, rename_all = "camelCase")]
481pub struct ClientPage {
482    /// Page rows.
483    #[serde(deserialize_with = "deserialize_null_default")]
484    pub items: Vec<ClientSlim>,
485    /// Unfiltered database row count.
486    pub total: u64,
487    /// Count after filters but before pagination.
488    pub filtered: u64,
489    /// One-indexed page number selected by the server.
490    pub page: u32,
491    /// Effective page size, capped by 3x-ui at 200.
492    pub page_size: u16,
493    /// Global dashboard summary.
494    pub summary: ClientSummary,
495    /// Available group names.
496    #[serde(deserialize_with = "deserialize_null_default")]
497    pub groups: Vec<String>,
498}
499
500/// Status bucket used by paginated client filtering.
501#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
502#[non_exhaustive]
503pub enum ClientStatusFilter {
504    /// Enabled and not depleted.
505    Active,
506    /// Disabled.
507    Deactive,
508    /// Traffic- or time-depleted.
509    Depleted,
510    /// Near expiry or quota according to panel thresholds.
511    Expiring,
512    /// Currently connected.
513    Online,
514}
515
516impl ClientStatusFilter {
517    pub(crate) const fn as_str(self) -> &'static str {
518        match self {
519            Self::Active => "active",
520            Self::Deactive => "deactive",
521            Self::Depleted => "depleted",
522            Self::Expiring => "expiring",
523            Self::Online => "online",
524        }
525    }
526}
527
528/// Sort key used by paginated client listing.
529#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
530#[non_exhaustive]
531pub enum ClientSort {
532    /// Enabled state.
533    Enable,
534    /// Email/name.
535    Email,
536    /// Number of attached inbounds.
537    InboundCount,
538    /// Consumed traffic.
539    Traffic,
540    /// Remaining quota.
541    Remaining,
542    /// Expiration timestamp.
543    ExpiryTime,
544    /// Creation timestamp.
545    CreatedAt,
546    /// Update timestamp.
547    UpdatedAt,
548    /// Last online timestamp.
549    LastOnline,
550}
551
552impl ClientSort {
553    pub(crate) const fn as_str(self) -> &'static str {
554        match self {
555            Self::Enable => "enable",
556            Self::Email => "email",
557            Self::InboundCount => "inboundIds",
558            Self::Traffic => "traffic",
559            Self::Remaining => "remaining",
560            Self::ExpiryTime => "expiryTime",
561            Self::CreatedAt => "createdAt",
562            Self::UpdatedAt => "updatedAt",
563            Self::LastOnline => "lastOnline",
564        }
565    }
566}
567
568/// Sort direction used by paginated client listing.
569#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
570#[non_exhaustive]
571pub enum SortOrder {
572    /// Ascending order.
573    #[default]
574    Ascending,
575    /// Descending order.
576    Descending,
577}
578
579impl SortOrder {
580    pub(crate) const fn as_str(self) -> &'static str {
581        match self {
582            Self::Ascending => "ascend",
583            Self::Descending => "descend",
584        }
585    }
586}
587
588/// Filters, ranges, and ordering accepted by the paginated listing endpoint.
589#[derive(Clone, Debug, Eq, PartialEq)]
590pub struct ClientPageRequest {
591    /// One-indexed page number.
592    pub page: u32,
593    /// Requested rows per page; the server caps this at 200.
594    pub page_size: u16,
595    /// Case-insensitive search across identity and comment fields.
596    pub search: String,
597    /// OR-combined status buckets.
598    pub statuses: Vec<ClientStatusFilter>,
599    /// OR-combined attached inbound protocols.
600    pub protocols: Vec<InboundProtocol>,
601    /// OR-combined attached inbound identifiers.
602    pub inbound_ids: Vec<i64>,
603    /// Optional sort key.
604    pub sort: Option<ClientSort>,
605    /// Sort direction.
606    pub order: SortOrder,
607    /// Inclusive minimum expiry timestamp in milliseconds.
608    pub expiry_from: Option<i64>,
609    /// Inclusive maximum expiry timestamp in milliseconds.
610    pub expiry_to: Option<i64>,
611    /// Inclusive minimum used bytes.
612    pub usage_from: Option<i64>,
613    /// Inclusive maximum used bytes.
614    pub usage_to: Option<i64>,
615    /// Filters by whether automatic renewal is configured.
616    pub auto_renew: Option<bool>,
617    /// Filters by whether a Telegram ID is present.
618    pub has_telegram_id: Option<bool>,
619    /// Filters by whether a comment is present.
620    pub has_comment: Option<bool>,
621    /// OR-combined, case-insensitive group names.
622    pub groups: Vec<String>,
623}
624
625impl Default for ClientPageRequest {
626    fn default() -> Self {
627        Self {
628            page: 1,
629            page_size: 25,
630            search: String::new(),
631            statuses: Vec::new(),
632            protocols: Vec::new(),
633            inbound_ids: Vec::new(),
634            sort: None,
635            order: SortOrder::Ascending,
636            expiry_from: None,
637            expiry_to: None,
638            usage_from: None,
639            usage_to: None,
640            auto_renew: None,
641            has_telegram_id: None,
642            has_comment: None,
643            groups: Vec::new(),
644        }
645    }
646}
647
648/// Request accepted by create, bulk-create, export, and import.
649#[derive(Clone, Eq, PartialEq, Serialize)]
650#[serde(rename_all = "camelCase")]
651pub struct ClientCreateRequest {
652    /// Client configuration.
653    pub client: ClientConfig,
654    /// Inbounds to attach the client to.
655    pub inbound_ids: Vec<i64>,
656}
657
658impl ClientCreateRequest {
659    /// Creates a payload from a client and target inbound identifiers.
660    pub fn new(client: ClientConfig, inbound_ids: Vec<i64>) -> Self {
661        Self {
662            client,
663            inbound_ids,
664        }
665    }
666}
667
668impl fmt::Debug for ClientCreateRequest {
669    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
670        formatter
671            .debug_struct("ClientCreateRequest")
672            .field("client", &self.client)
673            .field("inbound_ids", &self.inbound_ids)
674            .finish()
675    }
676}
677
678impl<'de> Deserialize<'de> for ClientCreateRequest {
679    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
680    where
681        D: Deserializer<'de>,
682    {
683        #[derive(Deserialize)]
684        #[serde(rename_all = "camelCase")]
685        struct Wire {
686            client: ClientConfig,
687            #[serde(default, deserialize_with = "deserialize_null_default")]
688            inbound_ids: Vec<i64>,
689        }
690
691        Wire::deserialize(deserializer).map(|wire| Self {
692            client: wire.client,
693            inbound_ids: wire.inbound_ids,
694        })
695    }
696}
697
698/// Additional state returned after a mutation involving remote nodes.
699#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
700#[serde(default, rename_all = "camelCase")]
701pub struct ClientMutationStatus {
702    /// The change is committed locally and awaits an offline node.
703    pub node_pending: bool,
704}
705
706/// Kind of per-client external subscription entry.
707#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
708#[serde(rename_all = "lowercase")]
709#[non_exhaustive]
710pub enum ClientExternalLinkKind {
711    /// One share link.
712    #[default]
713    Link,
714    /// Remote HTTP(S) subscription.
715    Subscription,
716}
717
718/// Writable external link row.
719#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
720#[serde(default, rename_all = "camelCase")]
721pub struct ClientExternalLinkInput {
722    /// Link or subscription kind.
723    pub kind: ClientExternalLinkKind,
724    /// Share link or subscription URL.
725    pub value: String,
726    /// Optional display label.
727    pub remark: String,
728}
729
730impl fmt::Debug for ClientExternalLinkInput {
731    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
732        formatter
733            .debug_struct("ClientExternalLinkInput")
734            .field("kind", &self.kind)
735            .field("value", &"[REDACTED]")
736            .field("remark", &self.remark)
737            .finish()
738    }
739}
740
741/// Persisted external link row returned with client details.
742#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
743#[serde(default, rename_all = "camelCase")]
744pub struct ClientExternalLink {
745    /// Database identifier.
746    pub id: i64,
747    /// Parent client record identifier.
748    pub client_id: i64,
749    /// Link or subscription kind.
750    pub kind: ClientExternalLinkKind,
751    /// Share link or subscription URL.
752    pub value: String,
753    /// Optional display label.
754    pub remark: String,
755    /// Stable display order.
756    pub sort_index: i32,
757    /// Creation timestamp in milliseconds.
758    pub created_at: i64,
759}
760
761impl fmt::Debug for ClientExternalLink {
762    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
763        formatter
764            .debug_struct("ClientExternalLink")
765            .field("id", &self.id)
766            .field("client_id", &self.client_id)
767            .field("kind", &self.kind)
768            .field("value", &"[REDACTED]")
769            .field("remark", &self.remark)
770            .field("sort_index", &self.sort_index)
771            .field("created_at", &self.created_at)
772            .finish()
773    }
774}
775
776/// Typed directive for the optional flow part of a bulk adjustment.
777#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
778#[non_exhaustive]
779pub enum BulkFlowAdjustment {
780    /// Leave flow unchanged.
781    #[default]
782    Unchanged,
783    /// Clear flow.
784    Clear,
785    /// Set `xtls-rprx-vision` where supported.
786    Vision,
787    /// Set `xtls-rprx-vision-udp443` where supported.
788    VisionUdp443,
789}
790
791impl BulkFlowAdjustment {
792    pub(crate) const fn as_str(self) -> &'static str {
793        match self {
794            Self::Unchanged => "",
795            Self::Clear => "none",
796            Self::Vision => "xtls-rprx-vision",
797            Self::VisionUdp443 => "xtls-rprx-vision-udp443",
798        }
799    }
800}
801
802/// Bulk expiry/quota/flow adjustment request.
803#[derive(Clone, Default, Eq, PartialEq)]
804pub struct BulkAdjustRequest {
805    /// Target emails. The panel trims whitespace, drops empty entries, and
806    /// deduplicates exact, case-sensitive matches within this request only.
807    pub emails: Vec<String>,
808    /// Signed number of days added to finite expiry times.
809    pub add_days: i32,
810    /// Signed bytes added to finite quotas.
811    pub add_bytes: i64,
812    /// Optional typed flow directive.
813    pub flow: BulkFlowAdjustment,
814    /// Device limit: `None` leaves it unchanged; `Some(0)` disables the limit.
815    pub limit_hwid: Option<i32>,
816    /// `MTProto` advertisement tag; empty leaves it unchanged, `none` clears it.
817    pub ad_tag: String,
818}
819
820impl fmt::Debug for BulkAdjustRequest {
821    /// Formats bulk adjustment fields while redacting the advertisement tag.
822    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
823        formatter
824            .debug_struct("BulkAdjustRequest")
825            .field("emails", &self.emails)
826            .field("add_days", &self.add_days)
827            .field("add_bytes", &self.add_bytes)
828            .field("flow", &self.flow)
829            .field("limit_hwid", &self.limit_hwid)
830            .field("ad_tag", &"[REDACTED]")
831            .finish()
832    }
833}
834
835/// Per-client failure or skip report used by bulk operations.
836#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
837pub struct BulkClientIssue {
838    /// Client email/name.
839    pub email: String,
840    /// Server-provided reason.
841    pub reason: String,
842}
843
844/// Result of bulk expiry/quota/flow adjustment.
845#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
846pub struct BulkAdjustResult {
847    /// Number of adjusted clients.
848    pub adjusted: u64,
849    /// Per-client skip reasons. A client can also be counted in `adjusted` when
850    /// some requested fields changed and others were skipped.
851    #[serde(default, deserialize_with = "deserialize_null_default")]
852    pub skipped: Vec<BulkClientIssue>,
853}
854
855/// Result shared by bulk create and import.
856#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
857pub struct BulkCreateResult {
858    /// Number of created clients.
859    pub created: u64,
860    /// Per-client skips.
861    #[serde(default, deserialize_with = "deserialize_null_default")]
862    pub skipped: Vec<BulkClientIssue>,
863}
864
865/// Result of bulk deletion.
866#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
867pub struct BulkDeleteResult {
868    /// Number of deleted clients.
869    pub deleted: u64,
870    /// Per-client skips.
871    #[serde(default, deserialize_with = "deserialize_null_default")]
872    pub skipped: Vec<BulkClientIssue>,
873}
874
875/// Result of bulk enable/disable.
876#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
877pub struct BulkSetEnabledResult {
878    /// Number of clients whose state changed.
879    pub changed: u64,
880    /// Per-client skips.
881    #[serde(default, deserialize_with = "deserialize_null_default")]
882    pub skipped: Vec<BulkClientIssue>,
883}
884
885/// Result of attaching multiple clients to multiple inbounds.
886#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
887#[serde(default)]
888pub struct BulkAttachResult {
889    /// Successfully attached emails.
890    #[serde(deserialize_with = "deserialize_null_default")]
891    pub attached: Vec<String>,
892    /// Emails that were already in the desired state.
893    #[serde(deserialize_with = "deserialize_null_default")]
894    pub skipped: Vec<String>,
895    /// Human-readable per-target errors.
896    #[serde(deserialize_with = "deserialize_null_default")]
897    pub errors: Vec<String>,
898}
899
900/// Result of detaching multiple clients from multiple inbounds.
901#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
902#[serde(default)]
903pub struct BulkDetachResult {
904    /// Successfully detached emails.
905    #[serde(deserialize_with = "deserialize_null_default")]
906    pub detached: Vec<String>,
907    /// Emails that were not attached to the requested inbounds.
908    #[serde(deserialize_with = "deserialize_null_default")]
909    pub skipped: Vec<String>,
910    /// Human-readable per-target errors.
911    #[serde(deserialize_with = "deserialize_null_default")]
912    pub errors: Vec<String>,
913}
914
915/// A simple deleted-row count.
916#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
917pub struct DeletedCount {
918    /// Number of deleted rows.
919    pub deleted: u64,
920}
921
922/// A simple affected-row count.
923#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
924pub struct AffectedCount {
925    /// Number of affected rows.
926    pub affected: u64,
927}
928
929/// Name echoed after group creation or traffic reset.
930#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
931pub struct GroupName {
932    /// Group name.
933    pub name: String,
934}
935
936/// Group membership and traffic totals since the last group reset.
937#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
938#[serde(default, rename_all = "camelCase")]
939pub struct GroupSummary {
940    /// Group name.
941    pub name: String,
942    /// Number of clients in the group.
943    pub client_count: u64,
944    /// Uploaded plus downloaded bytes since group reset.
945    pub traffic_used: i64,
946    /// Uploaded bytes since group reset.
947    pub up: i64,
948    /// Downloaded bytes since group reset.
949    pub down: i64,
950}
951
952/// One observed source IP and its last-seen Unix timestamp in seconds.
953#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
954pub struct ClientIpEntry {
955    /// Source IP address.
956    pub ip: String,
957    /// Last-seen Unix timestamp in seconds.
958    pub timestamp: i64,
959}
960
961/// Display-ready client IP annotated with its node.
962#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
963pub struct ClientIpInfo {
964    /// Source IP address.
965    pub ip: String,
966    /// Panel-formatted local timestamp.
967    pub time: String,
968    /// Node display name, or empty for the local panel.
969    pub node: String,
970}
971
972/// Online emails grouped by stable panel GUID.
973pub type ClientsByGuid = HashMap<String, Vec<String>>;
974
975/// Active inbound tags grouped by stable panel GUID.
976pub type ActiveInboundsByGuid = HashMap<String, Vec<String>>;
977
978/// Per-client IP observations grouped by panel GUID and email.
979pub type ClientIpsByGuid = HashMap<String, HashMap<String, Vec<ClientIpEntry>>>;
980
981/// Last-online timestamps keyed by client email.
982pub type LastOnlineByEmail = HashMap<String, i64>;
983
984#[allow(clippy::trivially_copy_pass_by_ref)]
985const fn is_zero_i32(value: &i32) -> bool {
986    *value == 0
987}
988
989#[allow(clippy::trivially_copy_pass_by_ref)]
990const fn is_zero_i64(value: &i64) -> bool {
991    *value == 0
992}
993
994/// Locally generated, secret-bearing Happ Crypt5 subscription link.
995#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
996#[serde(rename_all = "camelCase")]
997pub struct HappLink {
998    /// Encrypted subscription link for importing into Happ.
999    pub encrypted_link: String,
1000}
1001
1002impl fmt::Debug for HappLink {
1003    /// Formats a Happ link without exposing its encrypted subscription URL.
1004    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1005        formatter
1006            .debug_struct("HappLink")
1007            .field("encrypted_link", &"[REDACTED]")
1008            .finish()
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn record_conversion_preserves_exact_acronym_wire_fields() {
1018        let record: ClientRecord = serde_json::from_value(serde_json::json!({
1019            "id": 9,
1020            "email": "alice",
1021            "uuid": "uuid-secret",
1022            "subId": "sub-secret",
1023            "allowedIPs": "10.0.0.2/32, fd00::2/128",
1024            "totalGB": 4096,
1025            "enable": true,
1026            "reverse": {"tag": "reverse-tag"}
1027        }))
1028        .unwrap();
1029        let config = record.to_config();
1030        let wire = serde_json::to_value(&config).unwrap();
1031
1032        assert_eq!(config.allowed_ips, ["10.0.0.2/32", "fd00::2/128"]);
1033        assert_eq!(
1034            wire["allowedIPs"],
1035            serde_json::json!(["10.0.0.2/32", "fd00::2/128"])
1036        );
1037        assert_eq!(wire["totalGB"], 4096);
1038        assert!(wire.get("allowedIps").is_none());
1039        assert!(wire.get("totalGb").is_none());
1040    }
1041
1042    #[test]
1043    fn client_debug_redacts_protocol_and_subscription_secrets() {
1044        let mut client = ClientConfig::new("alice");
1045        client.protocol_id = "uuid-secret".to_owned();
1046        client.password = "password-secret".to_owned();
1047        client.auth = "auth-secret".to_owned();
1048        client.private_key = "private-secret".to_owned();
1049        client.pre_shared_key = "psk-secret".to_owned();
1050        client.secret = "mtproto-secret".to_owned();
1051        client.sub_id = "subscription-secret".to_owned();
1052        let output = format!("{client:?}");
1053
1054        for secret in [
1055            "uuid-secret",
1056            "password-secret",
1057            "auth-secret",
1058            "private-secret",
1059            "psk-secret",
1060            "mtproto-secret",
1061            "subscription-secret",
1062        ] {
1063            assert!(!output.contains(secret));
1064        }
1065        assert!(output.contains("alice"));
1066
1067        let slim = ClientSlim {
1068            email: "alice".to_owned(),
1069            sub_id: "slim-subscription-secret".to_owned(),
1070            ..ClientSlim::default()
1071        };
1072        assert!(!format!("{slim:?}").contains("slim-subscription-secret"));
1073    }
1074
1075    #[test]
1076    fn nil_go_slices_decode_as_empty_rust_vectors() {
1077        let result: BulkAttachResult = serde_json::from_value(serde_json::json!({
1078            "attached": null,
1079            "skipped": null,
1080            "errors": null
1081        }))
1082        .unwrap();
1083        let client: ClientWithAttachments = serde_json::from_value(serde_json::json!({
1084            "email": "orphan",
1085            "inboundIds": null
1086        }))
1087        .unwrap();
1088
1089        assert!(result.attached.is_empty());
1090        assert!(result.skipped.is_empty());
1091        assert!(result.errors.is_empty());
1092        assert!(client.inbound_ids.is_empty());
1093    }
1094
1095    #[test]
1096    fn record_and_link_debug_views_redact_credentials() {
1097        let record = ClientRecord {
1098            email: "alice".into(),
1099            uuid: "record-uuid-secret".into(),
1100            password: "record-password-secret".into(),
1101            auth: "record-auth-secret".into(),
1102            private_key: "record-private-secret".into(),
1103            public_key: "record-public-secret".into(),
1104            pre_shared_key: "record-psk-secret".into(),
1105            secret: "record-mtproto-secret".into(),
1106            ad_tag: "record-ad-secret".into(),
1107            sub_id: "record-sub-secret".into(),
1108            ..ClientRecord::default()
1109        };
1110        let record_debug = format!("{record:?}");
1111        assert!(record_debug.contains("alice"));
1112        for secret in [
1113            "record-uuid-secret",
1114            "record-password-secret",
1115            "record-auth-secret",
1116            "record-private-secret",
1117            "record-public-secret",
1118            "record-psk-secret",
1119            "record-mtproto-secret",
1120            "record-ad-secret",
1121            "record-sub-secret",
1122        ] {
1123            assert!(!record_debug.contains(secret));
1124        }
1125
1126        let request = ClientCreateRequest::new(ClientConfig::new("bob"), vec![7, 9]);
1127        assert!(format!("{request:?}").contains("bob"));
1128        let input = ClientExternalLinkInput {
1129            kind: ClientExternalLinkKind::Subscription,
1130            value: "https://example.com/input-link-secret".into(),
1131            remark: "primary".into(),
1132        };
1133        let link = ClientExternalLink {
1134            id: 3,
1135            client_id: 4,
1136            kind: ClientExternalLinkKind::Link,
1137            value: "vless://persisted-link-secret".into(),
1138            remark: "backup".into(),
1139            sort_index: 2,
1140            created_at: 5,
1141        };
1142        assert!(!format!("{input:?}").contains("input-link-secret"));
1143        assert!(!format!("{link:?}").contains("persisted-link-secret"));
1144    }
1145
1146    #[test]
1147    fn paging_and_bulk_enums_match_source_vocabulary() {
1148        assert_eq!(
1149            [
1150                ClientStatusFilter::Active,
1151                ClientStatusFilter::Deactive,
1152                ClientStatusFilter::Depleted,
1153                ClientStatusFilter::Expiring,
1154                ClientStatusFilter::Online,
1155            ]
1156            .map(ClientStatusFilter::as_str),
1157            ["active", "deactive", "depleted", "expiring", "online"]
1158        );
1159        assert_eq!(
1160            [
1161                ClientSort::Enable,
1162                ClientSort::Email,
1163                ClientSort::InboundCount,
1164                ClientSort::Traffic,
1165                ClientSort::Remaining,
1166                ClientSort::ExpiryTime,
1167                ClientSort::CreatedAt,
1168                ClientSort::UpdatedAt,
1169                ClientSort::LastOnline,
1170            ]
1171            .map(ClientSort::as_str),
1172            [
1173                "enable",
1174                "email",
1175                "inboundIds",
1176                "traffic",
1177                "remaining",
1178                "expiryTime",
1179                "createdAt",
1180                "updatedAt",
1181                "lastOnline",
1182            ]
1183        );
1184        assert_eq!(SortOrder::Ascending.as_str(), "ascend");
1185        assert_eq!(SortOrder::Descending.as_str(), "descend");
1186        assert_eq!(
1187            [
1188                BulkFlowAdjustment::Unchanged,
1189                BulkFlowAdjustment::Clear,
1190                BulkFlowAdjustment::Vision,
1191                BulkFlowAdjustment::VisionUdp443,
1192            ]
1193            .map(BulkFlowAdjustment::as_str),
1194            ["", "none", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
1195        );
1196    }
1197
1198    #[test]
1199    fn create_request_accepts_legacy_null_inbound_ids() {
1200        let request: ClientCreateRequest = serde_json::from_value(serde_json::json!({
1201            "client": {"email": "alice"},
1202            "inboundIds": null
1203        }))
1204        .unwrap();
1205        assert_eq!(request.client.email, "alice");
1206        assert!(request.inbound_ids.is_empty());
1207    }
1208}