Skip to main content

xui_rs/hosts/
models.rs

1#![allow(clippy::struct_excessive_bools)]
2
3use std::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use serde_json::Value;
7
8fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
9where
10    D: Deserializer<'de>,
11    T: Deserialize<'de> + Default,
12{
13    Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
14}
15
16/// TLS/security override applied to generated host links.
17#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
18#[non_exhaustive]
19pub enum HostSecurity {
20    /// Inherit security from the inbound.
21    #[default]
22    Same,
23    /// Force TLS.
24    Tls,
25    /// Disable transport security.
26    None,
27    /// Force REALITY.
28    Reality,
29    /// Value introduced by a newer panel release.
30    Other(String),
31}
32
33impl HostSecurity {
34    /// Returns the exact 3x-ui wire value.
35    pub fn as_str(&self) -> &str {
36        match self {
37            Self::Same => "same",
38            Self::Tls => "tls",
39            Self::None => "none",
40            Self::Reality => "reality",
41            Self::Other(value) => value,
42        }
43    }
44}
45
46impl Serialize for HostSecurity {
47    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
48    where
49        S: Serializer,
50    {
51        serializer.serialize_str(self.as_str())
52    }
53}
54
55impl<'de> Deserialize<'de> for HostSecurity {
56    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        let value = String::deserialize(deserializer)?;
61        Ok(match value.as_str() {
62            "" | "same" => Self::Same,
63            "tls" => Self::Tls,
64            "none" => Self::None,
65            "reality" => Self::Reality,
66            _ => Self::Other(value),
67        })
68    }
69}
70
71/// IP-family preference emitted for Mihomo subscriptions.
72#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
73#[non_exhaustive]
74pub enum MihomoIpVersion {
75    /// No override.
76    #[default]
77    Inherit,
78    /// Dual stack without preference.
79    Dual,
80    /// IPv4 only.
81    Ipv4,
82    /// IPv6 only.
83    Ipv6,
84    /// Dual stack preferring IPv4.
85    Ipv4Prefer,
86    /// Dual stack preferring IPv6.
87    Ipv6Prefer,
88    /// Value introduced by a newer panel release.
89    Other(String),
90}
91
92impl MihomoIpVersion {
93    /// Returns the exact 3x-ui wire value.
94    pub fn as_str(&self) -> &str {
95        match self {
96            Self::Inherit => "",
97            Self::Dual => "dual",
98            Self::Ipv4 => "ipv4",
99            Self::Ipv6 => "ipv6",
100            Self::Ipv4Prefer => "ipv4-prefer",
101            Self::Ipv6Prefer => "ipv6-prefer",
102            Self::Other(value) => value,
103        }
104    }
105}
106
107impl Serialize for MihomoIpVersion {
108    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
109    where
110        S: Serializer,
111    {
112        serializer.serialize_str(self.as_str())
113    }
114}
115
116impl<'de> Deserialize<'de> for MihomoIpVersion {
117    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118    where
119        D: Deserializer<'de>,
120    {
121        let value = String::deserialize(deserializer)?;
122        Ok(match value.as_str() {
123            "" => Self::Inherit,
124            "dual" => Self::Dual,
125            "ipv4" => Self::Ipv4,
126            "ipv6" => Self::Ipv6,
127            "ipv4-prefer" => Self::Ipv4Prefer,
128            "ipv6-prefer" => Self::Ipv6Prefer,
129            _ => Self::Other(value),
130        })
131    }
132}
133
134/// Subscription representation from which a host should be excluded.
135#[derive(Clone, Debug, Eq, Hash, PartialEq)]
136#[non_exhaustive]
137pub enum SubscriptionFormat {
138    /// Raw share-link subscription.
139    Raw,
140    /// JSON subscription.
141    Json,
142    /// Clash/Mihomo subscription.
143    Clash,
144    /// Value introduced by a newer panel release.
145    Other(String),
146}
147
148impl SubscriptionFormat {
149    /// Returns the exact 3x-ui wire value.
150    pub fn as_str(&self) -> &str {
151        match self {
152            Self::Raw => "raw",
153            Self::Json => "json",
154            Self::Clash => "clash",
155            Self::Other(value) => value,
156        }
157    }
158}
159
160impl Serialize for SubscriptionFormat {
161    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162    where
163        S: Serializer,
164    {
165        serializer.serialize_str(self.as_str())
166    }
167}
168
169impl<'de> Deserialize<'de> for SubscriptionFormat {
170    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171    where
172        D: Deserializer<'de>,
173    {
174        let value = String::deserialize(deserializer)?;
175        Ok(match value.as_str() {
176            "raw" => Self::Raw,
177            "json" => Self::Json,
178            "clash" => Self::Clash,
179            _ => Self::Other(value),
180        })
181    }
182}
183
184/// Optional VLESS route port embedded into generated subscription identities.
185#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
186pub struct VlessRoute(Option<u16>);
187
188impl VlessRoute {
189    /// Creates a configured route port.
190    pub const fn new(port: u16) -> Self {
191        Self(Some(port))
192    }
193
194    /// Returns the configured port, or `None` when the override is disabled.
195    pub const fn port(self) -> Option<u16> {
196        self.0
197    }
198}
199
200impl From<u16> for VlessRoute {
201    fn from(port: u16) -> Self {
202        Self::new(port)
203    }
204}
205
206impl Serialize for VlessRoute {
207    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
208    where
209        S: Serializer,
210    {
211        match self.0 {
212            Some(port) => serializer.serialize_str(&port.to_string()),
213            None => serializer.serialize_str(""),
214        }
215    }
216}
217
218impl<'de> Deserialize<'de> for VlessRoute {
219    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
220    where
221        D: Deserializer<'de>,
222    {
223        struct Visitor;
224
225        impl de::Visitor<'_> for Visitor {
226            type Value = VlessRoute;
227
228            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229                formatter.write_str("an empty string or a port from 0 through 65535")
230            }
231
232            fn visit_none<E>(self) -> Result<Self::Value, E> {
233                Ok(VlessRoute::default())
234            }
235
236            fn visit_unit<E>(self) -> Result<Self::Value, E> {
237                Ok(VlessRoute::default())
238            }
239
240            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
241            where
242                E: de::Error,
243            {
244                u16::try_from(value)
245                    .map(VlessRoute::new)
246                    .map_err(|_| E::custom("VLESS route is outside the port range"))
247            }
248
249            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
250            where
251                E: de::Error,
252            {
253                u16::try_from(value)
254                    .map(VlessRoute::new)
255                    .map_err(|_| E::custom("VLESS route is outside the port range"))
256            }
257
258            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
259            where
260                E: de::Error,
261            {
262                if value.is_empty() {
263                    return Ok(VlessRoute::default());
264                }
265                value.parse::<u16>().map(VlessRoute::new).map_err(E::custom)
266            }
267        }
268
269        deserializer.deserialize_any(Visitor)
270    }
271}
272
273/// JSON override stored by 3x-ui as a string field.
274///
275/// The SDK preserves the raw value for forward compatibility while offering
276/// structured construction and parsing, so callers never have to stringify
277/// JSON manually.
278#[derive(Clone, Default, Eq, PartialEq)]
279pub struct HostJsonOverride(String);
280
281impl HostJsonOverride {
282    /// Serializes a structured JSON override.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error when the JSON value cannot be encoded.
287    pub fn from_value(value: &Value) -> serde_json::Result<Self> {
288        serde_json::to_string(value).map(Self)
289    }
290
291    /// Creates an empty override that inherits the panel/inbound setting.
292    pub const fn empty() -> Self {
293        Self(String::new())
294    }
295
296    /// Borrows the exact string stored by 3x-ui.
297    pub fn as_raw(&self) -> &str {
298        &self.0
299    }
300
301    /// Returns whether the override is empty.
302    pub fn is_empty(&self) -> bool {
303        self.0.is_empty()
304    }
305
306    /// Parses the configured JSON, returning `None` for an empty override.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error when a non-empty legacy value is not valid JSON.
311    pub fn value(&self) -> serde_json::Result<Option<Value>> {
312        if self.is_empty() {
313            Ok(None)
314        } else {
315            serde_json::from_str(&self.0).map(Some)
316        }
317    }
318}
319
320impl fmt::Debug for HostJsonOverride {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        formatter
323            .debug_tuple("HostJsonOverride")
324            .field(&if self.is_empty() {
325                "[EMPTY]"
326            } else {
327                "[CONFIGURED]"
328            })
329            .finish()
330    }
331}
332
333impl Serialize for HostJsonOverride {
334    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
335    where
336        S: Serializer,
337    {
338        serializer.serialize_str(&self.0)
339    }
340}
341
342impl<'de> Deserialize<'de> for HostJsonOverride {
343    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
344    where
345        D: Deserializer<'de>,
346    {
347        Option::<String>::deserialize(deserializer).map(|value| Self(value.unwrap_or_default()))
348    }
349}
350
351/// Writable override fields shared by grouped and database-row host views.
352#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
353#[serde(default, rename_all = "camelCase")]
354pub struct HostOptions {
355    /// Group order among generated host links.
356    pub sort_order: i64,
357    /// User-visible name.
358    pub remark: String,
359    /// Optional short server description.
360    pub server_description: String,
361    /// Whether the host is disabled.
362    pub is_disabled: bool,
363    /// Whether the host is hidden from normal subscription output.
364    pub is_hidden: bool,
365    /// Operator-defined uppercase tags.
366    #[serde(deserialize_with = "deserialize_null_default")]
367    pub tags: Vec<String>,
368    /// Default port used when a host string does not include one.
369    pub port: u16,
370    /// TLS/security override.
371    pub security: HostSecurity,
372    /// TLS/REALITY server name override.
373    pub sni: String,
374    /// HTTP Host header override.
375    pub host_header: String,
376    /// Transport path override.
377    pub path: String,
378    /// ALPN override list.
379    #[serde(deserialize_with = "deserialize_null_default")]
380    pub alpn: Vec<String>,
381    /// uTLS fingerprint identifier.
382    pub fingerprint: String,
383    /// Derives SNI from each configured host address.
384    pub override_sni_from_address: bool,
385    /// Keeps SNI blank instead of inheriting it.
386    pub keep_sni_blank: bool,
387    /// Accepted peer certificate SHA-256 pins.
388    #[serde(deserialize_with = "deserialize_null_default")]
389    pub pinned_peer_cert_sha256: Vec<String>,
390    /// Peer certificate DNS name to verify.
391    pub verify_peer_cert_by_name: String,
392    /// Disables peer certificate verification.
393    pub allow_insecure: bool,
394    /// ECH configuration list.
395    pub ech_config_list: String,
396    /// Xray mux override stored as nested JSON.
397    pub mux_params: HostJsonOverride,
398    /// Xray socket options stored as nested JSON.
399    pub sockopt_params: HostJsonOverride,
400    /// Xray final-mask override stored as nested JSON.
401    pub final_mask: HostJsonOverride,
402    /// Optional VLESS route port.
403    pub vless_route: VlessRoute,
404    /// Subscription formats that should omit this host.
405    #[serde(deserialize_with = "deserialize_null_default")]
406    pub exclude_from_sub_types: Vec<SubscriptionFormat>,
407    /// Node GUID allowlist for this override.
408    #[serde(deserialize_with = "deserialize_null_default")]
409    pub node_guids: Vec<String>,
410    /// Mihomo IP-family preference.
411    #[serde(rename = "mihomoIpVersion")]
412    pub mihomo_ip_version: MihomoIpVersion,
413    /// Enables Mihomo X25519 output.
414    pub mihomo_x25519: bool,
415    /// Randomizes host selection in generated output.
416    pub shuffle_host: bool,
417}
418
419/// One logical host override shared across one or more inbounds and addresses.
420///
421/// The same type is accepted for create and full-replacement update operations.
422#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
423#[serde(default, rename_all = "camelCase")]
424pub struct HostGroup {
425    /// Stable logical group identifier. Leave blank on create to generate one.
426    pub group_id: String,
427    /// Inbounds to which this group applies.
428    #[serde(deserialize_with = "deserialize_null_default")]
429    pub inbound_ids: Vec<i64>,
430    /// Hostnames/IPs, optionally including per-address ports.
431    #[serde(deserialize_with = "deserialize_null_default")]
432    pub hosts: Vec<String>,
433    /// Shared host override options.
434    #[serde(flatten)]
435    pub options: HostOptions,
436}
437
438impl HostGroup {
439    /// Creates an enabled group that inherits inbound security.
440    pub fn new(inbound_ids: Vec<i64>, remark: impl Into<String>) -> Self {
441        Self {
442            inbound_ids,
443            options: HostOptions {
444                remark: remark.into(),
445                ..HostOptions::default()
446            },
447            ..Self::default()
448        }
449    }
450}
451
452/// One physical database row created for a group/address/inbound combination.
453#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(default, rename_all = "camelCase")]
455pub struct HostRow {
456    /// Database identifier.
457    pub id: i64,
458    /// Logical group identifier.
459    pub group_id: String,
460    /// Parent inbound identifier.
461    pub inbound_id: i64,
462    /// Hostname or IP without its port.
463    pub address: String,
464    /// Shared host override options.
465    #[serde(flatten)]
466    pub options: HostOptions,
467    /// Unix creation timestamp in milliseconds.
468    pub created_at: i64,
469    /// Unix update timestamp in milliseconds.
470    pub updated_at: i64,
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use serde_json::json;
477
478    #[test]
479    fn extensible_host_enums_round_trip_known_and_future_values() {
480        for value in [
481            HostSecurity::Same,
482            HostSecurity::Tls,
483            HostSecurity::None,
484            HostSecurity::Reality,
485            HostSecurity::Other("future-security".into()),
486        ] {
487            let wire = value.as_str().to_owned();
488            assert_eq!(serde_json::to_value(&value).unwrap(), wire);
489            assert_eq!(
490                serde_json::from_value::<HostSecurity>(json!(wire)).unwrap(),
491                value
492            );
493        }
494        assert_eq!(
495            serde_json::from_value::<HostSecurity>(json!("")).unwrap(),
496            HostSecurity::Same
497        );
498
499        for value in [
500            MihomoIpVersion::Inherit,
501            MihomoIpVersion::Dual,
502            MihomoIpVersion::Ipv4,
503            MihomoIpVersion::Ipv6,
504            MihomoIpVersion::Ipv4Prefer,
505            MihomoIpVersion::Ipv6Prefer,
506            MihomoIpVersion::Other("future-ip-mode".into()),
507        ] {
508            let wire = value.as_str().to_owned();
509            assert_eq!(serde_json::to_value(&value).unwrap(), wire);
510            assert_eq!(
511                serde_json::from_value::<MihomoIpVersion>(json!(wire)).unwrap(),
512                value
513            );
514        }
515
516        for value in [
517            SubscriptionFormat::Raw,
518            SubscriptionFormat::Json,
519            SubscriptionFormat::Clash,
520            SubscriptionFormat::Other("future-format".into()),
521        ] {
522            let wire = value.as_str().to_owned();
523            assert_eq!(serde_json::to_value(&value).unwrap(), wire);
524            assert_eq!(
525                serde_json::from_value::<SubscriptionFormat>(json!(wire)).unwrap(),
526                value
527            );
528        }
529    }
530
531    #[test]
532    fn vless_route_accepts_every_upstream_representation() {
533        let configured = VlessRoute::new(8443);
534        assert_eq!(configured.port(), Some(8443));
535        assert_eq!(VlessRoute::from(443).port(), Some(443));
536        assert_eq!(serde_json::to_value(configured).unwrap(), "8443");
537        assert_eq!(serde_json::to_value(VlessRoute::default()).unwrap(), "");
538        assert_eq!(
539            serde_json::from_value::<VlessRoute>(json!(2053))
540                .unwrap()
541                .port(),
542            Some(2053)
543        );
544        assert_eq!(
545            serde_json::from_value::<VlessRoute>(json!("2087"))
546                .unwrap()
547                .port(),
548            Some(2087)
549        );
550        assert_eq!(
551            serde_json::from_value::<VlessRoute>(Value::Null)
552                .unwrap()
553                .port(),
554            None
555        );
556        assert!(serde_json::from_value::<VlessRoute>(json!(-1)).is_err());
557        assert!(serde_json::from_value::<VlessRoute>(json!(70_000)).is_err());
558        assert!(serde_json::from_value::<VlessRoute>(json!("invalid")).is_err());
559    }
560
561    #[test]
562    fn nested_json_override_preserves_data_but_redacts_debug() {
563        let value = json!({"password": "host-json-secret"});
564        let configured = HostJsonOverride::from_value(&value).unwrap();
565        assert!(!configured.is_empty());
566        assert_eq!(configured.value().unwrap(), Some(value));
567        assert!(configured.as_raw().contains("host-json-secret"));
568        assert_eq!(
569            format!("{configured:?}"),
570            "HostJsonOverride(\"[CONFIGURED]\")"
571        );
572
573        let empty = HostJsonOverride::empty();
574        assert!(empty.is_empty());
575        assert_eq!(empty.value().unwrap(), None);
576        assert_eq!(format!("{empty:?}"), "HostJsonOverride(\"[EMPTY]\")");
577        assert_eq!(
578            serde_json::from_value::<HostJsonOverride>(Value::Null).unwrap(),
579            empty
580        );
581        let invalid: HostJsonOverride = serde_json::from_value(json!("not-json")).unwrap();
582        assert!(invalid.value().is_err());
583    }
584
585    #[test]
586    fn host_group_constructor_sets_only_safe_defaults() {
587        let group = HostGroup::new(vec![7, 9], "production");
588        assert_eq!(group.inbound_ids, [7, 9]);
589        assert_eq!(group.options.remark, "production");
590        assert_eq!(group.options.security, HostSecurity::Same);
591        assert!(group.group_id.is_empty());
592        assert!(group.hosts.is_empty());
593    }
594}