Skip to main content

threexui_rs/models/
inbound.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3fn deserialize_null_default<'de, D, T>(de: D) -> Result<T, D::Error>
4where
5    D: Deserializer<'de>,
6    T: Default + Deserialize<'de>,
7{
8    let opt = Option::<T>::deserialize(de)?;
9    Ok(opt.unwrap_or_default())
10}
11
12/// Accept i64 from JSON number, string, or null.
13/// 3x-ui sometimes serializes `tgId` as a string (e.g. `"77313385"`).
14fn deserialize_flex_i64<'de, D>(de: D) -> Result<i64, D::Error>
15where
16    D: Deserializer<'de>,
17{
18    use serde::de::Error;
19    let v = serde_json::Value::deserialize(de)?;
20    match v {
21        serde_json::Value::Null => Ok(0),
22        serde_json::Value::Number(n) => n
23            .as_i64()
24            .or_else(|| n.as_f64().map(|f| f as i64))
25            .ok_or_else(|| D::Error::custom("number out of range for i64")),
26        serde_json::Value::String(s) => {
27            if s.is_empty() {
28                Ok(0)
29            } else {
30                s.parse::<i64>().map_err(D::Error::custom)
31            }
32        }
33        other => Err(D::Error::custom(format!(
34            "expected i64-compatible value, got {}",
35            other
36        ))),
37    }
38}
39
40fn serialize_i64<S>(v: &i64, ser: S) -> Result<S::Ok, S::Error>
41where
42    S: serde::Serializer,
43{
44    ser.serialize_i64(*v)
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum Protocol {
50    VMess,
51    VLess,
52    Trojan,
53    Shadowsocks,
54    Hysteria,
55    Hysteria2,
56    WireGuard,
57    HTTP,
58    Mixed,
59    #[serde(other)]
60    #[default]
61    Unknown,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ClientTraffic {
67    pub id: i64,
68    pub inbound_id: i64,
69    pub enable: bool,
70    pub email: String,
71    #[serde(default)]
72    pub uuid: String,
73    #[serde(default)]
74    pub sub_id: String,
75    pub up: i64,
76    pub down: i64,
77    #[serde(default)]
78    pub all_time: i64,
79    pub expiry_time: i64,
80    pub total: i64,
81    #[serde(default)]
82    pub reset: i32,
83    #[serde(default)]
84    pub last_online: i64,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(rename_all = "camelCase")]
89pub struct Inbound {
90    #[serde(default)]
91    pub id: i64,
92    pub up: i64,
93    pub down: i64,
94    pub total: i64,
95    #[serde(default)]
96    pub all_time: i64,
97    pub remark: String,
98    pub enable: bool,
99    pub expiry_time: i64,
100    #[serde(default)]
101    pub traffic_reset: String,
102    #[serde(default)]
103    pub last_traffic_reset_time: i64,
104    #[serde(default, deserialize_with = "deserialize_null_default")]
105    pub client_stats: Vec<ClientTraffic>,
106    pub listen: String,
107    pub port: u16,
108    pub protocol: Protocol,
109    pub settings: serde_json::Value,
110    pub stream_settings: serde_json::Value,
111    pub tag: String,
112    pub sniffing: serde_json::Value,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116#[serde(rename_all = "camelCase")]
117pub struct InboundClient {
118    #[serde(default, skip_serializing_if = "String::is_empty")]
119    pub id: String,
120    pub email: String,
121    pub enable: bool,
122    #[serde(default, skip_serializing_if = "String::is_empty")]
123    pub flow: String,
124    #[serde(default, skip_serializing_if = "String::is_empty")]
125    pub password: String,
126    #[serde(default, skip_serializing_if = "String::is_empty")]
127    pub security: String,
128    #[serde(default)]
129    pub limit_ip: i32,
130    #[serde(default, rename = "totalGB")]
131    pub total_gb: i64,
132    #[serde(default)]
133    pub expiry_time: i64,
134    #[serde(
135        default,
136        deserialize_with = "deserialize_flex_i64",
137        serialize_with = "serialize_i64"
138    )]
139    pub tg_id: i64,
140    #[serde(default, skip_serializing_if = "String::is_empty")]
141    pub sub_id: String,
142    #[serde(default, skip_serializing_if = "String::is_empty")]
143    pub comment: String,
144    pub reset: i32,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn protocol_deserializes() {
153        let p: Protocol = serde_json::from_str(r#""vmess""#).unwrap();
154        assert_eq!(p, Protocol::VMess);
155        let p: Protocol = serde_json::from_str(r#""vless""#).unwrap();
156        assert_eq!(p, Protocol::VLess);
157    }
158
159    #[test]
160    fn protocol_unknown_variant() {
161        let p: Protocol = serde_json::from_str(r#""socks5""#).unwrap();
162        assert_eq!(p, Protocol::Unknown);
163    }
164
165    #[test]
166    fn inbound_with_null_client_stats() {
167        // 3x-ui /panel/api/inbounds/get/{id} returns clientStats: null
168        let raw = r#"{
169            "id":1,"up":0,"down":0,"total":0,"remark":"x","enable":true,
170            "expiryTime":0,"listen":"","port":80,"protocol":"vless",
171            "settings":"{}","streamSettings":"{}","tag":"i","sniffing":"{}",
172            "clientStats":null
173        }"#;
174        let inb: Inbound = serde_json::from_str(raw).unwrap();
175        assert!(inb.client_stats.is_empty());
176    }
177
178    #[test]
179    fn inbound_client_with_newer_fields() {
180        // Real 3x-ui client payload: totalGB (uppercase GB), tgId as string,
181        // plus unknown fields comment/created_at/updated_at.
182        let raw = r#"{
183            "id":"abc","email":"u@example.com","enable":true,"flow":"",
184            "limitIp":1,"totalGB":1073741824,"expiryTime":-604800000,
185            "tgId":"77313385","subId":"x","comment":"hi",
186            "created_at":1777667608000,"updated_at":1777667608000,
187            "reset":0
188        }"#;
189        let c: InboundClient = serde_json::from_str(raw).unwrap();
190        assert_eq!(c.total_gb, 1073741824);
191        assert_eq!(c.tg_id, 77313385);
192        assert_eq!(c.comment, "hi");
193    }
194
195    #[test]
196    fn inbound_client_tg_id_as_int_or_null() {
197        let raw_int = r#"{"id":"a","email":"e","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"tgId":42,"reset":0}"#;
198        let c: InboundClient = serde_json::from_str(raw_int).unwrap();
199        assert_eq!(c.tg_id, 42);
200
201        let raw_null = r#"{"id":"a","email":"e","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"tgId":null,"reset":0}"#;
202        let c: InboundClient = serde_json::from_str(raw_null).unwrap();
203        assert_eq!(c.tg_id, 0);
204    }
205
206    #[test]
207    fn inbound_deserializes() {
208        let raw = r#"{
209            "id":1,"up":0,"down":0,"total":0,"remark":"test",
210            "enable":true,"expiryTime":0,"listen":"","port":443,
211            "protocol":"vless","settings":{},"streamSettings":{},
212            "tag":"inbound-443","sniffing":{},"clientStats":[]
213        }"#;
214        let inbound: Inbound = serde_json::from_str(raw).unwrap();
215        assert_eq!(inbound.id, 1);
216        assert_eq!(inbound.protocol, Protocol::VLess);
217        assert_eq!(inbound.port, 443);
218    }
219}