Skip to main content

xui_rs/nodes/
models.rs

1#![allow(clippy::struct_excessive_bools)]
2
3use std::fmt;
4
5use secrecy::{ExposeSecret, SecretString};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
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#[allow(clippy::ref_option)] // serde's field callback receives `&Option<T>`.
17fn serialize_optional_secret<S>(
18    value: &Option<SecretString>,
19    serializer: S,
20) -> Result<S::Ok, S::Error>
21where
22    S: Serializer,
23{
24    match value {
25        Some(value) => serializer.serialize_some(value.expose_secret()),
26        None => serializer.serialize_none(),
27    }
28}
29
30#[allow(clippy::trivially_copy_pass_by_ref)] // serde's predicate receives `&T`.
31const fn is_false(value: &bool) -> bool {
32    !*value
33}
34
35macro_rules! impl_string_enum {
36    ($name:ident { $($wire:literal => $variant:ident),+ $(,)? }) => {
37        impl $name {
38            /// Returns the exact 3x-ui wire value.
39            pub fn as_str(&self) -> &str {
40                match self {
41                    $(Self::$variant => $wire,)+
42                    Self::Other(value) => value,
43                }
44            }
45        }
46
47        impl Serialize for $name {
48            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
49            where
50                S: Serializer,
51            {
52                serializer.serialize_str(self.as_str())
53            }
54        }
55
56        impl<'de> Deserialize<'de> for $name {
57            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
58            where
59                D: Deserializer<'de>,
60            {
61                let value = String::deserialize(deserializer)?;
62                Ok(match value.as_str() {
63                    $($wire => Self::$variant,)+
64                    _ => Self::Other(value),
65                })
66            }
67        }
68    };
69}
70
71/// URL scheme used to contact a remote panel.
72#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
73#[non_exhaustive]
74pub enum NodeScheme {
75    /// Plain HTTP.
76    Http,
77    /// HTTPS.
78    #[default]
79    Https,
80    /// Value introduced by a newer panel release.
81    Other(String),
82}
83
84impl_string_enum!(NodeScheme {
85    "http" => Http,
86    "https" => Https,
87});
88
89/// TLS verification/authentication mode for a remote panel.
90#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
91#[non_exhaustive]
92pub enum NodeTlsVerifyMode {
93    /// Verify the node certificate against system roots.
94    #[default]
95    Verify,
96    /// Skip certificate verification.
97    Skip,
98    /// Verify the exact SHA-256 leaf-certificate fingerprint.
99    Pin,
100    /// Authenticate with a panel-generated mTLS client certificate.
101    Mtls,
102    /// Value introduced by a newer panel release.
103    Other(String),
104}
105
106impl_string_enum!(NodeTlsVerifyMode {
107    "verify" => Verify,
108    "skip" => Skip,
109    "pin" => Pin,
110    "mtls" => Mtls,
111});
112
113/// Which remote inbounds a node imports into the managing panel.
114#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
115#[non_exhaustive]
116pub enum NodeInboundSyncMode {
117    /// Import all remote inbounds.
118    #[default]
119    All,
120    /// Import only explicitly selected tags.
121    Selected,
122    /// Value introduced by a newer panel release.
123    Other(String),
124}
125
126impl_string_enum!(NodeInboundSyncMode {
127    "all" => All,
128    "selected" => Selected,
129});
130
131/// Cached panel reachability state.
132#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
133#[non_exhaustive]
134pub enum NodeStatus {
135    /// No successful heartbeat has established state yet.
136    #[default]
137    Unknown,
138    /// The remote panel API is reachable.
139    Online,
140    /// The remote panel API is unreachable.
141    Offline,
142    /// Value introduced by a newer panel release.
143    Other(String),
144}
145
146impl_string_enum!(NodeStatus {
147    "unknown" => Unknown,
148    "online" => Online,
149    "offline" => Offline,
150});
151
152/// Protocol reported by a remote inbound-discovery request.
153#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
154#[non_exhaustive]
155pub enum RemoteInboundProtocol {
156    /// No protocol was reported.
157    #[default]
158    Unknown,
159    /// `VMess`.
160    Vmess,
161    /// VLESS.
162    Vless,
163    /// Trojan.
164    Trojan,
165    /// Shadowsocks.
166    Shadowsocks,
167    /// `WireGuard`.
168    Wireguard,
169    /// Hysteria.
170    Hysteria,
171    /// HTTP proxy.
172    Http,
173    /// Mixed HTTP/SOCKS proxy.
174    Mixed,
175    /// Tunnel.
176    Tunnel,
177    /// TUN interface.
178    Tun,
179    /// `MTProto` proxy.
180    Mtproto,
181    /// Embedded `AmneziaWG` tunnel.
182    Amneziawg,
183    /// TUIC v5 sidecar.
184    Tuic,
185    /// Value introduced by a newer panel release.
186    Other(String),
187}
188
189impl_string_enum!(RemoteInboundProtocol {
190    "amneziawg" => Amneziawg,
191    "tuic" => Tuic,
192    "" => Unknown,
193    "vmess" => Vmess,
194    "vless" => Vless,
195    "trojan" => Trojan,
196    "shadowsocks" => Shadowsocks,
197    "wireguard" => Wireguard,
198    "hysteria" => Hysteria,
199    "http" => Http,
200    "mixed" => Mixed,
201    "tunnel" => Tunnel,
202    "tun" => Tun,
203    "mtproto" => Mtproto,
204});
205
206/// One browser-safe view of a direct or transitive node.
207///
208/// The tagged v3.8.5 read contract exposes `hasApiToken`; the token itself
209/// is write-only and is never included in node views.
210#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
211#[serde(default, rename_all = "camelCase")]
212pub struct NodeView {
213    /// Panel-local database identifier. Transitive projections use zero.
214    pub id: i64,
215    /// Unique configured name.
216    pub name: String,
217    /// Operator-visible description.
218    pub remark: String,
219    /// Remote panel URL scheme.
220    pub scheme: NodeScheme,
221    /// Hostname or IP address without a port.
222    pub address: String,
223    /// Remote panel port.
224    pub port: u16,
225    /// Remote panel base path.
226    pub base_path: String,
227    /// Whether a write-only API token is stored.
228    pub has_api_token: bool,
229    /// Whether node synchronization is enabled.
230    pub enable: bool,
231    /// Whether guarded node connections may resolve to private addresses.
232    pub allow_private_address: bool,
233    /// Node TLS verification/authentication mode.
234    pub tls_verify_mode: NodeTlsVerifyMode,
235    /// Base64 SHA-256 leaf-certificate pin.
236    pub pinned_cert_sha256: String,
237    /// Remote inbound import mode.
238    pub inbound_sync_mode: NodeInboundSyncMode,
239    /// Remote inbound tags selected for import.
240    #[serde(deserialize_with = "deserialize_null_default")]
241    pub inbound_tags: Vec<String>,
242    /// Local Xray outbound used to reach the node.
243    pub outbound_tag: String,
244    /// Stable GUID learned from the remote panel.
245    pub guid: String,
246    /// Cached remote panel reachability.
247    pub status: NodeStatus,
248    /// Most recent heartbeat Unix timestamp in seconds.
249    pub last_heartbeat: i64,
250    /// Most recent request latency in milliseconds.
251    pub latency_ms: i64,
252    /// Remote Xray version.
253    pub xray_version: String,
254    /// Remote panel version.
255    pub panel_version: String,
256    /// Remote CPU utilization percentage.
257    pub cpu_pct: f64,
258    /// Remote memory utilization percentage.
259    pub mem_pct: f64,
260    /// Remote host uptime in seconds.
261    pub uptime_secs: u64,
262    /// Remote interface upload throughput in bytes per second.
263    pub net_up: u64,
264    /// Remote interface download throughput in bytes per second.
265    pub net_down: u64,
266    /// Last panel connectivity error.
267    pub last_error: String,
268    /// Remote Xray process state.
269    pub xray_state: String,
270    /// Remote Xray process error.
271    pub xray_error: String,
272    /// Whether node-backed configuration needs reconciliation.
273    pub config_dirty: bool,
274    /// Unix timestamp associated with the dirty configuration generation.
275    pub config_dirty_at: i64,
276    /// Number of imported inbounds hosted by the node.
277    pub inbound_count: i64,
278    /// Number of clients hosted by the node.
279    pub client_count: i64,
280    /// Number of currently online clients.
281    pub online_count: i64,
282    /// Number of active clients.
283    pub active_count: i64,
284    /// Number of disabled clients.
285    pub disabled_count: i64,
286    /// Number of depleted clients.
287    pub depleted_count: i64,
288    /// Stable GUID of the managing parent panel/node.
289    pub parent_guid: String,
290    /// Whether this is a read-only downstream projection.
291    pub transitive: bool,
292    /// Database creation timestamp in milliseconds.
293    pub created_at: i64,
294    /// Database update timestamp in milliseconds.
295    pub updated_at: i64,
296}
297
298impl NodeView {
299    /// Builds a full replacement request while retaining the stored API token.
300    pub fn to_request(&self) -> NodeRequest {
301        NodeRequest {
302            id: self.id,
303            name: self.name.clone(),
304            remark: self.remark.clone(),
305            scheme: self.scheme.clone(),
306            address: self.address.clone(),
307            port: self.port,
308            base_path: self.base_path.clone(),
309            api_token: None,
310            clear_api_token: false,
311            enable: self.enable,
312            allow_private_address: self.allow_private_address,
313            tls_verify_mode: self.tls_verify_mode.clone(),
314            pinned_cert_sha256: self.pinned_cert_sha256.clone(),
315            inbound_sync_mode: self.inbound_sync_mode.clone(),
316            inbound_tags: self.inbound_tags.clone(),
317            outbound_tag: self.outbound_tag.clone(),
318        }
319    }
320}
321
322impl fmt::Debug for NodeView {
323    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
324        formatter
325            .debug_struct("NodeView")
326            .field("id", &self.id)
327            .field("name", &self.name)
328            .field("remark", &self.remark)
329            .field("scheme", &self.scheme)
330            .field("address", &self.address)
331            .field("port", &self.port)
332            .field("base_path", &"[REDACTED]")
333            .field("has_api_token", &self.has_api_token)
334            .field("enable", &self.enable)
335            .field("allow_private_address", &self.allow_private_address)
336            .field("tls_verify_mode", &self.tls_verify_mode)
337            .field("pinned_cert_sha256", &self.pinned_cert_sha256)
338            .field("inbound_sync_mode", &self.inbound_sync_mode)
339            .field("inbound_tags", &self.inbound_tags)
340            .field("outbound_tag", &"[REDACTED]")
341            .field("guid", &self.guid)
342            .field("status", &self.status)
343            .field("last_heartbeat", &self.last_heartbeat)
344            .field("latency_ms", &self.latency_ms)
345            .field("xray_version", &self.xray_version)
346            .field("panel_version", &self.panel_version)
347            .field("cpu_pct", &self.cpu_pct)
348            .field("mem_pct", &self.mem_pct)
349            .field("uptime_secs", &self.uptime_secs)
350            .field("net_up", &self.net_up)
351            .field("net_down", &self.net_down)
352            .field("last_error", &self.last_error)
353            .field("xray_state", &self.xray_state)
354            .field("xray_error", &self.xray_error)
355            .field("config_dirty", &self.config_dirty)
356            .field("config_dirty_at", &self.config_dirty_at)
357            .field("inbound_count", &self.inbound_count)
358            .field("client_count", &self.client_count)
359            .field("online_count", &self.online_count)
360            .field("active_count", &self.active_count)
361            .field("disabled_count", &self.disabled_count)
362            .field("depleted_count", &self.depleted_count)
363            .field("parent_guid", &self.parent_guid)
364            .field("transitive", &self.transitive)
365            .field("created_at", &self.created_at)
366            .field("updated_at", &self.updated_at)
367            .finish()
368    }
369}
370
371/// Complete writable node connection and synchronization configuration.
372///
373/// API tokens are write-only and redacted from `Debug`. The request encodes
374/// the controller's three update states without allowing contradictory
375/// `apiToken` and `clearApiToken` fields.
376#[derive(Clone, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct NodeRequest {
379    /// Existing node ID used when probing an edit form, or zero for a new node.
380    pub id: i64,
381    /// Unique configured name.
382    pub name: String,
383    /// Operator-visible description.
384    pub remark: String,
385    /// Remote panel URL scheme.
386    pub scheme: NodeScheme,
387    /// Hostname or IP address without a port.
388    pub address: String,
389    /// Remote panel port.
390    pub port: u16,
391    /// Remote panel base path.
392    pub base_path: String,
393    #[serde(
394        skip_serializing_if = "Option::is_none",
395        serialize_with = "serialize_optional_secret"
396    )]
397    api_token: Option<SecretString>,
398    #[serde(skip_serializing_if = "is_false")]
399    clear_api_token: bool,
400    /// Whether node synchronization is enabled.
401    pub enable: bool,
402    /// Whether guarded node connections may resolve to private addresses.
403    pub allow_private_address: bool,
404    /// Node TLS verification/authentication mode.
405    pub tls_verify_mode: NodeTlsVerifyMode,
406    /// Base64 SHA-256 leaf-certificate pin.
407    pub pinned_cert_sha256: String,
408    /// Remote inbound import mode.
409    pub inbound_sync_mode: NodeInboundSyncMode,
410    /// Remote inbound tags selected for import.
411    pub inbound_tags: Vec<String>,
412    /// Local Xray outbound used to reach the node.
413    pub outbound_tag: String,
414}
415
416impl NodeRequest {
417    /// Creates an enabled HTTPS node using normal certificate verification.
418    pub fn new(name: impl Into<String>, address: impl Into<String>, port: u16) -> Self {
419        Self {
420            id: 0,
421            name: name.into(),
422            remark: String::new(),
423            scheme: NodeScheme::Https,
424            address: address.into(),
425            port,
426            base_path: "/".to_owned(),
427            api_token: None,
428            clear_api_token: false,
429            enable: true,
430            allow_private_address: false,
431            tls_verify_mode: NodeTlsVerifyMode::Verify,
432            pinned_cert_sha256: String::new(),
433            inbound_sync_mode: NodeInboundSyncMode::All,
434            inbound_tags: Vec::new(),
435            outbound_tag: String::new(),
436        }
437    }
438
439    /// Configures a replacement API token and returns the request.
440    #[must_use]
441    pub fn with_api_token(mut self, token: impl Into<String>) -> Self {
442        self.set_api_token(token);
443        self
444    }
445
446    /// Configures a replacement API token.
447    pub fn set_api_token(&mut self, token: impl Into<String>) {
448        self.api_token = Some(SecretString::from(token.into()));
449        self.clear_api_token = false;
450    }
451
452    /// Explicitly clears the stored token on update.
453    ///
454    /// 3x-ui only permits an enabled tokenless node when mTLS is active.
455    pub fn clear_stored_api_token(&mut self) {
456        self.api_token = None;
457        self.clear_api_token = true;
458    }
459
460    /// Omits both credential fields so an update retains the stored token.
461    pub fn retain_stored_api_token(&mut self) {
462        self.api_token = None;
463        self.clear_api_token = false;
464    }
465
466    /// Exposes the replacement token when explicitly needed by the caller.
467    pub fn api_token(&self) -> Option<&str> {
468        self.api_token.as_ref().map(ExposeSecret::expose_secret)
469    }
470
471    /// Returns whether this update explicitly clears the stored token.
472    pub const fn clears_stored_api_token(&self) -> bool {
473        self.clear_api_token
474    }
475}
476
477impl fmt::Debug for NodeRequest {
478    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
479        formatter
480            .debug_struct("NodeRequest")
481            .field("id", &self.id)
482            .field("name", &self.name)
483            .field("remark", &self.remark)
484            .field("scheme", &self.scheme)
485            .field("address", &self.address)
486            .field("port", &self.port)
487            .field("base_path", &"[REDACTED]")
488            .field("api_token", &self.api_token.as_ref().map(|_| "[REDACTED]"))
489            .field("clear_api_token", &self.clear_api_token)
490            .field("enable", &self.enable)
491            .field("allow_private_address", &self.allow_private_address)
492            .field("tls_verify_mode", &self.tls_verify_mode)
493            .field("pinned_cert_sha256", &self.pinned_cert_sha256)
494            .field("inbound_sync_mode", &self.inbound_sync_mode)
495            .field("inbound_tags", &self.inbound_tags)
496            .field("outbound_tag", &"[REDACTED]")
497            .finish()
498    }
499}
500
501/// Result of probing saved or unsaved node connection details.
502///
503/// An unreachable node is represented by `status == Offline` and `error`, not
504/// by an API-envelope failure.
505#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
506#[serde(default, rename_all = "camelCase")]
507pub struct NodeProbeResult {
508    /// Reachability result.
509    pub status: NodeStatus,
510    /// Round-trip latency in milliseconds.
511    pub latency_ms: i64,
512    /// Remote Xray version.
513    pub xray_version: String,
514    /// Remote panel version.
515    pub panel_version: String,
516    /// Remote CPU utilization percentage.
517    pub cpu_pct: f64,
518    /// Remote memory utilization percentage.
519    pub mem_pct: f64,
520    /// Remote host uptime in seconds.
521    pub uptime_secs: u64,
522    /// Friendly connectivity diagnostic when offline.
523    pub error: String,
524    /// Remote Xray process state when the panel itself was reachable.
525    pub xray_state: String,
526    /// Remote Xray process error.
527    pub xray_error: String,
528}
529
530/// One inbound available for selective import from a remote node.
531#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
532#[serde(default, rename_all = "camelCase")]
533pub struct RemoteInboundOption {
534    /// Stable remote Xray tag.
535    pub tag: String,
536    /// Human-readable remote label.
537    pub remark: String,
538    /// Remote inbound protocol.
539    pub protocol: RemoteInboundProtocol,
540    /// Remote listen port.
541    pub port: u16,
542}
543
544/// Outcome of triggering the official panel updater on one node.
545#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
546#[serde(default, rename_all = "camelCase")]
547pub struct NodeUpdateResult {
548    /// Requested node ID.
549    pub id: i64,
550    /// Configured node name when it was found.
551    pub name: String,
552    /// Whether the update job was accepted.
553    pub ok: bool,
554    /// Per-node skip or launch diagnostic.
555    pub error: String,
556}
557
558/// Target release channel for remote panel self-updates.
559#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
560#[non_exhaustive]
561pub enum NodeUpdateChannel {
562    /// Latest stable release.
563    #[default]
564    Stable,
565    /// Rolling per-commit development build.
566    Development,
567}
568
569impl NodeUpdateChannel {
570    pub(crate) const fn is_development(self) -> bool {
571        matches!(self, Self::Development)
572    }
573}
574
575/// Metric accepted by the per-node history endpoint.
576#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
577#[non_exhaustive]
578pub enum NodeMetric {
579    /// CPU utilization percentage.
580    Cpu,
581    /// Memory utilization percentage.
582    Memory,
583    /// Interface upload throughput in bytes per second.
584    NetworkUp,
585    /// Interface download throughput in bytes per second.
586    NetworkDown,
587}
588
589impl NodeMetric {
590    pub(crate) const fn as_str(self) -> &'static str {
591        match self {
592            Self::Cpu => "cpu",
593            Self::Memory => "mem",
594            Self::NetworkUp => "netUp",
595            Self::NetworkDown => "netDown",
596        }
597    }
598}
599
600/// Public node-auth CA certificate generated by the managing panel.
601#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
602#[serde(default, rename_all = "camelCase")]
603pub struct NodeMtlsCa {
604    /// PEM-encoded public CA certificate.
605    pub ca_cert: String,
606}