Skip to main content

xui_rs/
inbounds.rs

1//! Inbound configuration, traffic, fallback, import, and lifecycle APIs.
2
3use std::fmt;
4
5use reqwest::Method;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use serde_json::Value;
8
9use crate::{Client, Error, Result, client::AuthenticationScope, response::ApiResponse};
10
11const ROOT: &str = "panel/api/inbounds";
12
13/// Xray protocols accepted by 3x-ui v3.8.5 inbound endpoints.
14#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16#[non_exhaustive]
17pub enum InboundProtocol {
18    /// `VMess`.
19    Vmess,
20    /// VLESS.
21    Vless,
22    /// Trojan.
23    Trojan,
24    /// Shadowsocks.
25    Shadowsocks,
26    /// `WireGuard`.
27    Wireguard,
28    /// Hysteria (including Hysteria 2 through stream settings).
29    Hysteria,
30    /// HTTP proxy.
31    Http,
32    /// Mixed HTTP/SOCKS proxy.
33    Mixed,
34    /// Tunnel.
35    Tunnel,
36    /// TUN interface.
37    Tun,
38    /// `MTProto` proxy.
39    Mtproto,
40    /// Embedded `AmneziaWG` tunnel.
41    Amneziawg,
42    /// TUIC v5 through the bundled sidecar.
43    Tuic,
44}
45
46impl InboundProtocol {
47    /// Returns the protocol name used by 3x-ui and Xray.
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Vmess => "vmess",
51            Self::Vless => "vless",
52            Self::Trojan => "trojan",
53            Self::Shadowsocks => "shadowsocks",
54            Self::Wireguard => "wireguard",
55            Self::Hysteria => "hysteria",
56            Self::Http => "http",
57            Self::Mixed => "mixed",
58            Self::Tunnel => "tunnel",
59            Self::Tun => "tun",
60            Self::Mtproto => "mtproto",
61            Self::Amneziawg => "amneziawg",
62            Self::Tuic => "tuic",
63        }
64    }
65}
66
67/// Schedule used to reset an inbound's traffic counters.
68#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
69#[serde(rename_all = "lowercase")]
70#[non_exhaustive]
71pub enum TrafficReset {
72    /// Never reset counters automatically.
73    #[default]
74    Never,
75    /// Reset every hour.
76    Hourly,
77    /// Reset every day.
78    Daily,
79    /// Reset every week.
80    Weekly,
81    /// Reset every month.
82    Monthly,
83}
84
85/// Strategy used to choose the public address in generated share links.
86#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
87#[serde(rename_all = "lowercase")]
88#[non_exhaustive]
89pub enum ShareAddressStrategy {
90    /// Prefer the hosting node's public address.
91    #[default]
92    Node,
93    /// Use the inbound listen address.
94    Listen,
95    /// Use the explicitly configured share address.
96    Custom,
97}
98
99/// Per-client traffic state returned with an inbound.
100#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ClientTraffic {
103    /// Database row identifier.
104    pub id: i64,
105    /// Parent inbound identifier.
106    pub inbound_id: i64,
107    /// Whether this client is enabled.
108    pub enable: bool,
109    /// Globally unique client email/name.
110    pub email: String,
111    /// Protocol client identifier, when applicable.
112    #[serde(default)]
113    pub uuid: String,
114    /// Subscription identifier.
115    #[serde(default)]
116    pub sub_id: String,
117    /// Uploaded bytes.
118    pub up: i64,
119    /// Downloaded bytes.
120    pub down: i64,
121    /// Expiration timestamp in milliseconds, or zero when unlimited.
122    pub expiry_time: i64,
123    /// Traffic limit in bytes, or zero when unlimited.
124    pub total: i64,
125    /// Client traffic reset policy encoded by the panel.
126    pub reset: i32,
127    /// Most recent online timestamp in milliseconds.
128    pub last_online: i64,
129    /// Most recent subscription fetch timestamp in milliseconds.
130    #[serde(default)]
131    pub last_sub_fetch: i64,
132    /// Calendar renewal day; zero selects interval-based renewal.
133    #[serde(default)]
134    pub reset_day: i32,
135    /// Maximum number of automatic renewals; zero is unlimited.
136    #[serde(default)]
137    pub reset_max: i32,
138    /// Number of automatic renewals already applied.
139    #[serde(default)]
140    pub reset_count: i32,
141}
142
143impl fmt::Debug for ClientTraffic {
144    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
145        formatter
146            .debug_struct("ClientTraffic")
147            .field("id", &self.id)
148            .field("inbound_id", &self.inbound_id)
149            .field("enable", &self.enable)
150            .field("email", &self.email)
151            .field("uuid", &"[REDACTED]")
152            .field("sub_id", &"[REDACTED]")
153            .field("up", &self.up)
154            .field("down", &self.down)
155            .field("expiry_time", &self.expiry_time)
156            .field("total", &self.total)
157            .field("reset", &self.reset)
158            .field("last_online", &self.last_online)
159            .field("last_sub_fetch", &self.last_sub_fetch)
160            .field("reset_day", &self.reset_day)
161            .field("reset_max", &self.reset_max)
162            .field("reset_count", &self.reset_count)
163            .finish()
164    }
165}
166
167/// Complete writable configuration accepted by create, update, and import.
168///
169/// Use [`InboundConfig::new`] for safe panel defaults. Convert a fetched
170/// [`Inbound`] with [`Inbound::to_config`] before replacing it through
171/// [`InboundsApi::update`]. The open-ended JSON fields intentionally mirror
172/// Xray's protocol-specific configuration fragments.
173#[derive(Clone, Deserialize, PartialEq, Serialize)]
174#[serde(rename_all = "camelCase")]
175pub struct InboundConfig {
176    /// Uploaded bytes to seed during import.
177    pub up: i64,
178    /// Downloaded bytes to seed during import.
179    pub down: i64,
180    /// Traffic limit in bytes, or zero when unlimited.
181    pub total: i64,
182    /// Human-readable label.
183    pub remark: String,
184    /// Position in subscription output; negatives are allowed, and zero normalizes to one.
185    pub sub_sort_index: i32,
186    /// Whether the inbound is enabled.
187    pub enable: bool,
188    /// Disables client flow values when generating subscription links.
189    #[serde(default)]
190    pub disable_flow: bool,
191    /// Expiration timestamp in milliseconds, or zero when unlimited.
192    pub expiry_time: i64,
193    /// Automatic traffic reset schedule.
194    pub traffic_reset: TrafficReset,
195    /// Day of month used by monthly reset schedules.
196    pub traffic_reset_day: u8,
197    /// Most recent automatic reset timestamp in milliseconds.
198    pub last_traffic_reset_time: i64,
199    /// Per-client traffic rows preserved by imports.
200    #[serde(default)]
201    pub client_stats: Vec<ClientTraffic>,
202    /// Local listen address, or an empty string for all interfaces.
203    pub listen: String,
204    /// Listen port; zero lets compatible server flows choose it.
205    pub port: u16,
206    /// Xray inbound protocol.
207    pub protocol: InboundProtocol,
208    /// Protocol-specific Xray settings object.
209    pub settings: Value,
210    /// Transport and security settings object.
211    pub stream_settings: Value,
212    /// Stable Xray tag; leave empty on create to let 3x-ui generate it.
213    pub tag: String,
214    /// Xray sniffing settings object.
215    pub sniffing: Value,
216    /// Hosting node identifier, or `None` for the local panel.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub node_id: Option<i64>,
219    /// Public share-address selection strategy.
220    pub share_addr_strategy: ShareAddressStrategy,
221    /// Explicit share address used with the custom strategy.
222    pub share_addr: String,
223}
224
225impl InboundConfig {
226    /// Creates an enabled configuration with unlimited traffic and safe panel defaults.
227    pub fn new(protocol: InboundProtocol, port: u16) -> Self {
228        Self {
229            up: 0,
230            down: 0,
231            total: 0,
232            remark: String::new(),
233            sub_sort_index: 1,
234            enable: true,
235            disable_flow: false,
236            expiry_time: 0,
237            traffic_reset: TrafficReset::Never,
238            traffic_reset_day: 1,
239            last_traffic_reset_time: 0,
240            client_stats: Vec::new(),
241            listen: String::new(),
242            port,
243            protocol,
244            settings: Value::Object(serde_json::Map::new()),
245            stream_settings: Value::Object(serde_json::Map::new()),
246            tag: String::new(),
247            sniffing: Value::Object(serde_json::Map::new()),
248            node_id: None,
249            share_addr_strategy: ShareAddressStrategy::Node,
250            share_addr: String::new(),
251        }
252    }
253}
254
255impl fmt::Debug for InboundConfig {
256    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
257        formatter
258            .debug_struct("InboundConfig")
259            .field("up", &self.up)
260            .field("down", &self.down)
261            .field("total", &self.total)
262            .field("remark", &self.remark)
263            .field("sub_sort_index", &self.sub_sort_index)
264            .field("enable", &self.enable)
265            .field("disable_flow", &self.disable_flow)
266            .field("expiry_time", &self.expiry_time)
267            .field("traffic_reset", &self.traffic_reset)
268            .field("traffic_reset_day", &self.traffic_reset_day)
269            .field("last_traffic_reset_time", &self.last_traffic_reset_time)
270            .field("client_stats", &self.client_stats)
271            .field("listen", &self.listen)
272            .field("port", &self.port)
273            .field("protocol", &self.protocol)
274            .field("settings", &"[REDACTED]")
275            .field("stream_settings", &"[REDACTED]")
276            .field("tag", &self.tag)
277            .field("sniffing", &"[REDACTED]")
278            .field("node_id", &self.node_id)
279            .field("share_addr_strategy", &self.share_addr_strategy)
280            .field("share_addr", &self.share_addr)
281            .finish()
282    }
283}
284
285/// Metadata describing a master inbound used by a fallback child.
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct FallbackParent {
289    /// Master inbound identifier.
290    pub master_id: i64,
291    /// Match path on the master.
292    #[serde(default)]
293    pub path: String,
294}
295
296/// Full inbound record returned by 3x-ui.
297#[derive(Clone, Deserialize, PartialEq, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct Inbound {
300    /// Inbound identifier.
301    pub id: i64,
302    /// Writable configuration and current traffic state.
303    #[serde(flatten)]
304    pub config: InboundConfig,
305    /// GUID of the node where the inbound originated.
306    #[serde(default, skip_serializing_if = "String::is_empty")]
307    pub origin_node_guid: String,
308    /// Master relationship when this inbound is used as a fallback.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub fallback_parent: Option<FallbackParent>,
311}
312
313impl Inbound {
314    /// Clones the complete writable payload for update or import.
315    pub fn to_config(&self) -> InboundConfig {
316        self.config.clone()
317    }
318
319    /// Consumes the response and returns its writable payload.
320    pub fn into_config(self) -> InboundConfig {
321        self.config
322    }
323}
324
325impl fmt::Debug for Inbound {
326    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
327        formatter
328            .debug_struct("Inbound")
329            .field("id", &self.id)
330            .field("config", &self.config)
331            .field("origin_node_guid", &self.origin_node_guid)
332            .field("fallback_parent", &self.fallback_parent)
333            .finish()
334    }
335}
336
337/// Lightweight inbound projection used by picker UIs.
338#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
339#[serde(rename_all = "camelCase")]
340pub struct InboundOption {
341    /// Inbound identifier.
342    pub id: i64,
343    /// Human-readable label.
344    pub remark: String,
345    /// Stable Xray tag.
346    pub tag: String,
347    /// Xray inbound protocol.
348    pub protocol: InboundProtocol,
349    /// Listen port.
350    pub port: u16,
351    /// Whether the inbound is enabled.
352    pub enable: bool,
353    /// Whether the transport supports VLESS Vision flow.
354    pub tls_flow_capable: bool,
355    /// Shadowsocks method, when applicable.
356    #[serde(default)]
357    pub ss_method: String,
358    /// `WireGuard` public key, when applicable.
359    #[serde(default)]
360    pub wg_public_key: String,
361    /// `WireGuard` MTU, when applicable.
362    #[serde(default)]
363    pub wg_mtu: u32,
364    /// `WireGuard` DNS value, when applicable.
365    #[serde(default)]
366    pub wg_dns: String,
367    /// `MTProto` domain, when applicable.
368    #[serde(default)]
369    pub mtproto_domain: String,
370    /// Hosting node identifier.
371    #[serde(default)]
372    pub node_id: Option<i64>,
373    /// Hosting node's externally reachable address.
374    #[serde(default)]
375    pub node_address: String,
376    /// Local listen address.
377    #[serde(default)]
378    pub listen: String,
379    /// Explicit public share address.
380    #[serde(default)]
381    pub share_addr: String,
382    /// Share-address strategy, when populated by the panel.
383    #[serde(default)]
384    pub share_addr_strategy: Option<ShareAddressStrategy>,
385    /// Complete `AmneziaWG` server settings, when applicable.
386    #[serde(default)]
387    pub awg_server: Option<AmneziaWgServerSettings>,
388    /// Transport network, when supplied by the panel.
389    #[serde(default)]
390    pub network: String,
391    /// Transport security, when supplied by the panel.
392    #[serde(default)]
393    pub security: String,
394    /// Complete TUIC sidecar settings, when applicable.
395    #[serde(default)]
396    pub tuic_server: Option<TuicServerSettings>,
397}
398
399/// Server-side settings embedded in an `AmneziaWG` inbound.
400#[allow(clippy::struct_excessive_bools)]
401#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
402#[serde(default, rename_all = "camelCase")]
403pub struct AmneziaWgServerSettings {
404    /// Server tunnel private key.
405    pub private_key: String,
406    /// Server tunnel public key.
407    pub public_key: String,
408    /// IPv4 subnet address.
409    pub subnet_ip: String,
410    /// IPv4 subnet prefix length.
411    pub subnet_cidr: u8,
412    /// Primary DNS server offered to clients.
413    pub primary_dns: String,
414    /// Secondary DNS server offered to clients.
415    pub secondary_dns: String,
416    /// Tunnel MTU.
417    pub mtu: i32,
418    /// Preferred external interface.
419    pub external_interface: String,
420    /// Enables IPv6 tunnel addressing.
421    pub ipv6_enabled: bool,
422    /// IPv6 subnet.
423    pub ipv6_subnet: String,
424    /// Preferred external IPv6 interface.
425    pub ipv6_external_interface: String,
426    /// Legacy routing flag retained by 3x-ui for stored configurations.
427    pub route_through_xray: bool,
428    /// `AmneziaWG` junk packet count.
429    pub jc: i32,
430    /// Minimum junk packet size.
431    pub jmin: i32,
432    /// Maximum junk packet size.
433    pub jmax: i32,
434    /// Obfuscation header value.
435    pub s1: i32,
436    /// Obfuscation header value.
437    pub s2: i32,
438    /// Obfuscation header value.
439    pub s3: i32,
440    /// Obfuscation header value.
441    pub s4: i32,
442    /// Magic header value.
443    pub h1: String,
444    /// Magic header value.
445    pub h2: String,
446    /// Magic header value.
447    pub h3: String,
448    /// Magic header value.
449    pub h4: String,
450    /// Init packet signature value.
451    pub i1: String,
452    /// Response packet signature value.
453    pub i2: String,
454    /// Under-load packet signature value.
455    pub i3: String,
456    /// Transport packet signature value.
457    pub i4: String,
458    /// Additional transport signature value.
459    pub i5: String,
460    /// Rekey interval accepted by `AmneziaWG`.
461    pub rekey_after_time: String,
462    /// Rekey timeout accepted by `AmneziaWG`.
463    pub rekey_timeout: String,
464    /// Reject-after interval accepted by `AmneziaWG`.
465    pub reject_after_time: String,
466    /// Keepalive timeout accepted by `AmneziaWG`.
467    pub keepalive_timeout: String,
468    /// Maximum handshake attempts accepted by `AmneziaWG`.
469    pub max_handshake_attempts: String,
470    /// Enables randomized trailer bytes.
471    pub random_trailers: bool,
472    /// Disables `AmneziaWG` cookies.
473    pub disable_cookies: bool,
474    /// Base64 header-protection key for `AmneziaWG` 3.0.
475    pub header_protection_key: String,
476    /// Padding range for `AmneziaWG` 3.0.
477    pub content_padding_addition: String,
478}
479
480/// A persisted fallback rule attached to a master inbound.
481#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
482#[serde(rename_all = "camelCase")]
483pub struct InboundFallback {
484    /// Fallback row identifier.
485    pub id: i64,
486    /// Master inbound identifier.
487    pub master_id: i64,
488    /// Child inbound identifier.
489    pub child_id: i64,
490    /// Optional SNI/server name match.
491    pub name: String,
492    /// Optional ALPN match.
493    pub alpn: String,
494    /// Optional path match.
495    pub path: String,
496    /// Explicit Xray destination, or empty to derive it from the child.
497    pub dest: String,
498    /// PROXY protocol version.
499    pub xver: i32,
500    /// Stable ordering value.
501    pub sort_order: i32,
502}
503
504/// Input used when atomically replacing a master's fallback rules.
505#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
506#[serde(rename_all = "camelCase")]
507pub struct FallbackInput {
508    /// Child inbound identifier.
509    pub child_id: i64,
510    /// Optional SNI/server name match.
511    pub name: String,
512    /// Optional ALPN match.
513    pub alpn: String,
514    /// Optional path match.
515    pub path: String,
516    /// Explicit Xray destination, or empty to derive it from the child.
517    pub dest: String,
518    /// PROXY protocol version.
519    pub xver: i32,
520    /// Stable ordering value.
521    pub sort_order: i32,
522}
523
524/// An inbound that could not be deleted by a bulk operation.
525#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
526pub struct SkippedInbound {
527    /// Inbound identifier.
528    pub id: i64,
529    /// Server-provided failure reason.
530    pub reason: String,
531}
532
533/// Result of deleting multiple inbounds.
534#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
535pub struct BulkDeleteInboundsResult {
536    /// Number of deleted inbounds.
537    pub deleted: u64,
538    /// Per-inbound failures; other inbounds were still processed.
539    #[serde(default)]
540    pub skipped: Vec<SkippedInbound>,
541}
542
543/// A client that could not be deleted by a bulk operation.
544#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
545pub struct SkippedClient {
546    /// Client email/name.
547    pub email: String,
548    /// Server-provided failure reason.
549    pub reason: String,
550}
551
552/// Result of deleting all clients from one inbound.
553#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
554pub struct BulkDeleteClientsResult {
555    /// Number of deleted clients.
556    pub deleted: u64,
557    /// Per-client failures; other clients were still processed.
558    #[serde(default)]
559    pub skipped: Vec<SkippedClient>,
560}
561
562/// Minimal per-client aggregate consumed by traffic synchronization.
563#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
564#[serde(rename_all = "camelCase")]
565pub struct ClientTrafficUsage {
566    /// Client email/name known to the receiving panel.
567    pub email: String,
568    /// Aggregated uploaded bytes.
569    pub up: i64,
570    /// Aggregated downloaded bytes.
571    pub down: i64,
572}
573
574/// Traffic snapshot pushed from a master panel.
575#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
576#[serde(rename_all = "camelCase")]
577pub struct TrafficPushRequest {
578    /// Stable GUID of the sending master panel.
579    pub master_guid: String,
580    /// Aggregated per-client usage rows.
581    pub traffics: Vec<ClientTrafficUsage>,
582}
583
584impl TrafficPushRequest {
585    /// Creates a traffic snapshot for a master panel.
586    pub fn new(master_guid: impl Into<String>, traffics: Vec<ClientTrafficUsage>) -> Self {
587        Self {
588            master_guid: master_guid.into(),
589            traffics,
590        }
591    }
592}
593
594/// Inbound management endpoints for a [`Client`].
595#[derive(Clone, Copy, Debug)]
596pub struct InboundsApi<'client> {
597    client: &'client Client,
598}
599
600impl<'client> InboundsApi<'client> {
601    pub(crate) const fn new(client: &'client Client) -> Self {
602        Self { client }
603    }
604
605    /// Returns all inbounds with complete client configuration.
606    ///
607    /// # Errors
608    ///
609    /// Returns an error when the request fails or the response violates the
610    /// v3.8.5 contract.
611    pub async fn list(self) -> Result<Vec<Inbound>> {
612        self.get_object("list").await
613    }
614
615    /// Returns all inbounds with slimmed `settings.clients` entries.
616    ///
617    /// The outer response remains [`Inbound`]; protocol-specific client JSON
618    /// contains only the fields selected by 3x-ui's slim endpoint.
619    ///
620    /// # Errors
621    ///
622    /// Returns an error when the request fails or the response is invalid.
623    pub async fn list_slim(self) -> Result<Vec<Inbound>> {
624        self.get_object("list/slim").await
625    }
626
627    /// Returns lightweight inbound options for selectors and dashboards.
628    ///
629    /// # Errors
630    ///
631    /// Returns an error when the request fails or the response is invalid.
632    pub async fn options(self) -> Result<Vec<InboundOption>> {
633        self.get_object("options").await
634    }
635
636    /// Returns every generated inbound share link.
637    ///
638    /// An empty panel encodes its nil Go slice as `obj: null`; the SDK
639    /// normalizes that response to an empty vector.
640    ///
641    /// # Errors
642    ///
643    /// Returns an error when the request fails or the response is invalid.
644    pub async fn all_links(self) -> Result<Vec<String>> {
645        let path = format!("{ROOT}/allLinks");
646        let envelope = self
647            .client
648            .execute::<Vec<String>, ()>(Method::GET, &path, None, AuthenticationScope::PanelApi)
649            .await?;
650        Ok(envelope.obj.unwrap_or_default())
651    }
652
653    /// Fetches one inbound by identifier.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error when the request fails or the response is invalid.
658    pub async fn get(self, id: i64) -> Result<Inbound> {
659        self.get_object(&format!("get/{id}")).await
660    }
661
662    /// Creates a new inbound.
663    ///
664    /// # Errors
665    ///
666    /// Returns an error when validation fails, the request fails, or the
667    /// response is invalid.
668    pub async fn create(self, inbound: &InboundConfig) -> Result<Inbound> {
669        self.post_object("add", Some(inbound)).await
670    }
671
672    /// Permanently deletes one inbound and returns its identifier.
673    ///
674    /// # Errors
675    ///
676    /// Returns an error when deletion fails or the response is invalid.
677    pub async fn delete(self, id: i64) -> Result<i64> {
678        self.post_object::<i64, ()>(&format!("del/{id}"), None)
679            .await
680    }
681
682    /// Deletes multiple inbounds, retaining per-item failure reports.
683    ///
684    /// # Errors
685    ///
686    /// Returns an error when the bulk operation itself fails or its response
687    /// is invalid.
688    pub async fn delete_many(self, ids: &[i64]) -> Result<BulkDeleteInboundsResult> {
689        #[derive(Serialize)]
690        struct Body<'a> {
691            ids: &'a [i64],
692        }
693
694        self.post_object("bulkDel", Some(&Body { ids })).await
695    }
696
697    /// Replaces an inbound's complete writable configuration.
698    ///
699    /// Start from [`Inbound::to_config`] when editing an existing inbound;
700    /// 3x-ui's update endpoint is a replacement operation rather than PATCH.
701    ///
702    /// # Errors
703    ///
704    /// Returns an error when validation fails, the request fails, or the
705    /// response is invalid.
706    pub async fn update(self, id: i64, inbound: &InboundConfig) -> Result<Inbound> {
707        self.post_object(&format!("update/{id}"), Some(inbound))
708            .await
709    }
710
711    /// Enables or disables an inbound without sending its large settings JSON.
712    ///
713    /// # Errors
714    ///
715    /// Returns an error when the panel cannot persist or apply the change.
716    pub async fn set_enabled(self, id: i64, enable: bool) -> Result<()> {
717        #[derive(Serialize)]
718        struct Body {
719            enable: bool,
720        }
721
722        self.post_empty(&format!("setEnable/{id}"), Some(&Body { enable }))
723            .await
724    }
725
726    /// Changes only the inbound's nonzero subscription ordering index (negatives allowed).
727    ///
728    /// This avoids replacing large protocol settings and potentially writing
729    /// a stale client list during a concurrent edit.
730    ///
731    /// # Errors
732    ///
733    /// Returns an error when the index is invalid or cannot be persisted.
734    pub async fn set_subscription_sort_index(self, id: i64, sub_sort_index: i32) -> Result<()> {
735        #[derive(Serialize)]
736        #[serde(rename_all = "camelCase")]
737        struct Body {
738            sub_sort_index: i32,
739        }
740
741        self.post_empty(
742            &format!("{id}/subSortIndex"),
743            Some(&Body { sub_sort_index }),
744        )
745        .await
746    }
747
748    /// Resets all traffic counters for one inbound.
749    ///
750    /// # Errors
751    ///
752    /// Returns an error when the panel cannot reset the counters.
753    pub async fn reset_traffic(self, id: i64) -> Result<()> {
754        self.post_empty::<()>(&format!("{id}/resetTraffic"), None)
755            .await
756    }
757
758    /// Deletes every client attached to one inbound.
759    ///
760    /// # Errors
761    ///
762    /// Returns an error when the bulk operation itself fails or its response
763    /// is invalid.
764    pub async fn delete_all_clients(self, id: i64) -> Result<BulkDeleteClientsResult> {
765        self.post_object::<BulkDeleteClientsResult, ()>(&format!("{id}/delAllClients"), None)
766            .await
767    }
768
769    /// Resets traffic counters across every inbound.
770    ///
771    /// # Errors
772    ///
773    /// Returns an error when the panel cannot reset the counters.
774    pub async fn reset_all_traffic(self) -> Result<()> {
775        self.post_empty::<()>("resetAllTraffics", None).await
776    }
777
778    /// Imports a complete inbound configuration using the panel's form endpoint.
779    ///
780    /// Unlike [`InboundsApi::create`], this preserves supplied client traffic
781    /// rows while resetting panel-local database identifiers.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error when JSON/form encoding, validation, transport, or
786    /// response decoding fails.
787    pub async fn import(self, inbound: &InboundConfig) -> Result<Inbound> {
788        #[derive(Serialize)]
789        struct Form<'a> {
790            data: &'a str,
791        }
792
793        let data = serde_json::to_string(inbound).map_err(|source| Error::Encode {
794            operation: "import inbound",
795            source,
796        })?;
797        let path = format!("{ROOT}/import");
798        let envelope = self
799            .client
800            .execute_form::<Inbound, _>(
801                Method::POST,
802                &path,
803                &Form { data: &data },
804                AuthenticationScope::PanelApi,
805            )
806            .await?;
807        self.required_object(Method::POST, &path, envelope)
808    }
809
810    /// Pushes a master's aggregate client traffic snapshot.
811    ///
812    /// # Errors
813    ///
814    /// Returns an error when the receiving panel rejects the snapshot.
815    pub async fn push_client_traffic(self, traffic: &TrafficPushRequest) -> Result<()> {
816        self.post_empty("pushClientTraffics", Some(traffic)).await
817    }
818
819    /// Returns fallback rules attached to a master inbound.
820    ///
821    /// # Errors
822    ///
823    /// Returns an error when the request fails or the response is invalid.
824    pub async fn fallbacks(self, id: i64) -> Result<Vec<InboundFallback>> {
825        self.get_object(&format!("{id}/fallbacks")).await
826    }
827
828    /// Atomically replaces every fallback rule on a master inbound.
829    ///
830    /// # Errors
831    ///
832    /// Returns an error when validation or replacement fails.
833    pub async fn set_fallbacks(self, id: i64, fallbacks: &[FallbackInput]) -> Result<()> {
834        #[derive(Serialize)]
835        struct Body<'a> {
836            fallbacks: &'a [FallbackInput],
837        }
838
839        self.post_empty(&format!("{id}/fallbacks"), Some(&Body { fallbacks }))
840            .await
841    }
842
843    async fn get_object<T>(self, suffix: &str) -> Result<T>
844    where
845        T: DeserializeOwned,
846    {
847        let path = format!("{ROOT}/{suffix}");
848        let envelope = self
849            .client
850            .execute::<T, ()>(Method::GET, &path, None, AuthenticationScope::PanelApi)
851            .await?;
852        self.required_object(Method::GET, &path, envelope)
853    }
854
855    async fn post_object<T, B>(self, suffix: &str, body: Option<&B>) -> Result<T>
856    where
857        T: DeserializeOwned,
858        B: Serialize + ?Sized,
859    {
860        let path = format!("{ROOT}/{suffix}");
861        let envelope = self
862            .client
863            .execute(Method::POST, &path, body, AuthenticationScope::PanelApi)
864            .await?;
865        self.required_object(Method::POST, &path, envelope)
866    }
867
868    async fn post_empty<B>(self, suffix: &str, body: Option<&B>) -> Result<()>
869    where
870        B: Serialize + ?Sized,
871    {
872        let path = format!("{ROOT}/{suffix}");
873        self.client
874            .execute::<Value, B>(Method::POST, &path, body, AuthenticationScope::PanelApi)
875            .await?;
876        Ok(())
877    }
878
879    fn required_object<T>(self, method: Method, path: &str, envelope: ApiResponse<T>) -> Result<T> {
880        let url = self.client.endpoint(path)?;
881        envelope.obj.ok_or_else(|| Error::MissingObject {
882            method,
883            url: Box::new(url),
884        })
885    }
886}
887
888/// TUIC v5 server settings, using the sidecar's `snake_case` wire names.
889#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
890#[serde(default)]
891pub struct TuicServerSettings {
892    /// Certificate file path.
893    pub certificate: String,
894    /// Private-key file path.
895    pub private_key: String,
896    /// Congestion control algorithm, such as `bbr`.
897    pub congestion_control: String,
898    /// Advertised ALPN protocols.
899    pub alpn: Vec<String>,
900    /// UDP relay mode, such as `native`.
901    pub udp_relay_mode: String,
902    /// Enables zero-RTT handshakes.
903    pub zero_rtt_handshake: bool,
904    /// Sidecar log level.
905    pub log_level: String,
906    /// Maximum idle duration in seconds.
907    pub max_idle_time: i32,
908    /// Authentication timeout in seconds.
909    pub authentication_timeout: i32,
910    /// Maximum relayed UDP packet size in bytes.
911    pub max_udp_relay_packet_size: i32,
912    /// TLS server name.
913    pub sni: String,
914}
915
916impl fmt::Debug for TuicServerSettings {
917    /// Formats TUIC transport settings while redacting the private key and omitting other details.
918    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
919        formatter
920            .debug_struct("TuicServerSettings")
921            .field("private_key", &"[REDACTED]")
922            .field("congestion_control", &self.congestion_control)
923            .field("alpn", &self.alpn)
924            .finish_non_exhaustive()
925    }
926}
927
928#[cfg(test)]
929mod tests {
930    use std::collections::BTreeSet;
931
932    use super::*;
933
934    const SDK_ROUTES: &[(&str, &str, Option<&str>)] = &[
935        (
936            "get",
937            "/panel/api/inbounds/list",
938            Some("get_panel_api_inbounds_list"),
939        ),
940        (
941            "get",
942            "/panel/api/inbounds/list/slim",
943            Some("get_panel_api_inbounds_list_slim"),
944        ),
945        (
946            "get",
947            "/panel/api/inbounds/options",
948            Some("get_panel_api_inbounds_options"),
949        ),
950        (
951            "get",
952            "/panel/api/inbounds/allLinks",
953            Some("get_panel_api_inbounds_allLinks"),
954        ),
955        (
956            "get",
957            "/panel/api/inbounds/get/{id}",
958            Some("get_panel_api_inbounds_get_id"),
959        ),
960        (
961            "get",
962            "/panel/api/inbounds/{id}/fallbacks",
963            Some("get_panel_api_inbounds_id_fallbacks"),
964        ),
965        (
966            "post",
967            "/panel/api/inbounds/add",
968            Some("post_panel_api_inbounds_add"),
969        ),
970        (
971            "post",
972            "/panel/api/inbounds/del/{id}",
973            Some("post_panel_api_inbounds_del_id"),
974        ),
975        (
976            "post",
977            "/panel/api/inbounds/bulkDel",
978            Some("post_panel_api_inbounds_bulkDel"),
979        ),
980        (
981            "post",
982            "/panel/api/inbounds/update/{id}",
983            Some("post_panel_api_inbounds_update_id"),
984        ),
985        (
986            "post",
987            "/panel/api/inbounds/setEnable/{id}",
988            Some("post_panel_api_inbounds_setEnable_id"),
989        ),
990        (
991            "post",
992            "/panel/api/inbounds/{id}/subSortIndex",
993            Some("post_panel_api_inbounds_id_subSortIndex"),
994        ),
995        (
996            "post",
997            "/panel/api/inbounds/{id}/resetTraffic",
998            Some("post_panel_api_inbounds_id_resetTraffic"),
999        ),
1000        (
1001            "post",
1002            "/panel/api/inbounds/{id}/delAllClients",
1003            Some("post_panel_api_inbounds_id_delAllClients"),
1004        ),
1005        (
1006            "post",
1007            "/panel/api/inbounds/resetAllTraffics",
1008            Some("post_panel_api_inbounds_resetAllTraffics"),
1009        ),
1010        (
1011            "post",
1012            "/panel/api/inbounds/import",
1013            Some("post_panel_api_inbounds_import"),
1014        ),
1015        (
1016            "post",
1017            "/panel/api/inbounds/{id}/fallbacks",
1018            Some("post_panel_api_inbounds_id_fallbacks"),
1019        ),
1020        (
1021            "post",
1022            "/panel/api/inbounds/pushClientTraffics",
1023            Some("post_panel_api_inbounds_pushClientTraffics"),
1024        ),
1025    ];
1026
1027    /// Compares the SDK route inventory with both tagged `OpenAPI` operations and Go-router snapshots.
1028    /// Source-only routes are checked separately so omissions in `OpenAPI` cannot hide SDK gaps.
1029    #[test]
1030    fn sdk_covers_openapi_and_source_routes() {
1031        let openapi: Value =
1032            serde_json::from_str(include_str!("../spec/3x-ui-v3.8.5.openapi.json")).unwrap();
1033        let paths = openapi["paths"].as_object().unwrap();
1034        let http_methods = [
1035            "get", "post", "put", "patch", "delete", "head", "options", "trace",
1036        ];
1037        let operation_count = paths
1038            .values()
1039            .filter_map(Value::as_object)
1040            .flat_map(serde_json::Map::keys)
1041            .filter(|method| http_methods.contains(&method.as_str()))
1042            .count();
1043        assert_eq!(operation_count, 193, "vendored OpenAPI changed");
1044
1045        let documented = paths
1046            .iter()
1047            .flat_map(|(path, item)| {
1048                item.as_object().into_iter().flat_map(move |operations| {
1049                    operations
1050                        .iter()
1051                        .filter(|(_, operation)| {
1052                            operation["tags"]
1053                                .as_array()
1054                                .is_some_and(|tags| tags.iter().any(|tag| tag == "Inbounds"))
1055                        })
1056                        .map(move |(method, operation)| {
1057                            (
1058                                method.as_str(),
1059                                path.as_str(),
1060                                operation["operationId"].as_str().unwrap(),
1061                            )
1062                        })
1063                })
1064            })
1065            .collect::<BTreeSet<_>>();
1066        let implemented_openapi = SDK_ROUTES
1067            .iter()
1068            .filter_map(|(method, path, operation)| {
1069                operation.map(|operation| (*method, *path, operation))
1070            })
1071            .collect::<BTreeSet<_>>();
1072        assert_eq!(documented, implemented_openapi);
1073
1074        let source: Value =
1075            serde_json::from_str(include_str!("../spec/3x-ui-v3.8.5.inbounds-routes.json"))
1076                .unwrap();
1077        let source_routes = source["routes"]
1078            .as_array()
1079            .unwrap()
1080            .iter()
1081            .map(|route| {
1082                (
1083                    route["method"].as_str().unwrap(),
1084                    route["path"].as_str().unwrap(),
1085                )
1086            })
1087            .collect::<BTreeSet<_>>();
1088        let implemented_routes = SDK_ROUTES
1089            .iter()
1090            .map(|(method, path, _)| (*method, *path))
1091            .collect::<BTreeSet<_>>();
1092        assert_eq!(source_routes, implemented_routes);
1093    }
1094
1095    #[test]
1096    fn defaults_match_server_normalization() {
1097        let config = InboundConfig::new(InboundProtocol::Vless, 443);
1098        assert!(config.enable);
1099        assert_eq!(config.sub_sort_index, 1);
1100        assert_eq!(config.traffic_reset, TrafficReset::Never);
1101        assert_eq!(config.traffic_reset_day, 1);
1102        assert_eq!(config.share_addr_strategy, ShareAddressStrategy::Node);
1103    }
1104
1105    #[test]
1106    fn sensitive_xray_values_are_redacted_from_debug() {
1107        let mut config = InboundConfig::new(InboundProtocol::Trojan, 443);
1108        config.settings = serde_json::json!({"clients": [{"password": "secret"}]});
1109        config.stream_settings = serde_json::json!({"privateKey": "private-secret"});
1110        let output = format!("{config:?}");
1111        assert!(!output.contains("secret"));
1112        assert!(!output.contains("private-secret"));
1113    }
1114
1115    /// Checks that `WireGuard` and remote-node option fields decode into their typed SDK fields.
1116    #[test]
1117    fn actual_v385_option_extensions_are_typed() {
1118        let option: InboundOption = serde_json::from_value(serde_json::json!({
1119            "id": 7,
1120            "remark": "wg",
1121            "tag": "in-wg",
1122            "protocol": "wireguard",
1123            "port": 51820,
1124            "enable": true,
1125            "tlsFlowCapable": false,
1126            "ssMethod": "",
1127            "wgPublicKey": "public",
1128            "wgMtu": 1420,
1129            "wgDns": "1.1.1.1",
1130            "nodeId": 3,
1131            "nodeAddress": "node.example.com",
1132            "shareAddrStrategy": "node"
1133        }))
1134        .unwrap();
1135
1136        assert_eq!(option.wg_mtu, 1420);
1137        assert_eq!(option.node_id, Some(3));
1138        assert_eq!(option.node_address, "node.example.com");
1139    }
1140
1141    #[test]
1142    fn config_round_trips_nested_xray_json() {
1143        let mut config = InboundConfig::new(InboundProtocol::Vmess, 20_000);
1144        config.settings = serde_json::json!({"clients": [{"id": "client-secret"}]});
1145        let value = serde_json::to_value(&config).unwrap();
1146        let decoded: InboundConfig = serde_json::from_value(value.clone()).unwrap();
1147        assert_eq!(decoded, config);
1148        assert!(value["settings"].is_object());
1149    }
1150
1151    /// Pins each inbound protocol to its Xray wire name, including the TUIC sidecar.
1152    #[test]
1153    fn every_inbound_protocol_has_the_exact_xray_name() {
1154        assert_eq!(
1155            [
1156                InboundProtocol::Vmess,
1157                InboundProtocol::Vless,
1158                InboundProtocol::Trojan,
1159                InboundProtocol::Shadowsocks,
1160                InboundProtocol::Wireguard,
1161                InboundProtocol::Hysteria,
1162                InboundProtocol::Http,
1163                InboundProtocol::Mixed,
1164                InboundProtocol::Tunnel,
1165                InboundProtocol::Tun,
1166                InboundProtocol::Mtproto,
1167                InboundProtocol::Amneziawg,
1168                InboundProtocol::Tuic,
1169            ]
1170            .map(InboundProtocol::as_str),
1171            [
1172                "vmess",
1173                "vless",
1174                "trojan",
1175                "shadowsocks",
1176                "wireguard",
1177                "hysteria",
1178                "http",
1179                "mixed",
1180                "tunnel",
1181                "tun",
1182                "mtproto",
1183                "amneziawg",
1184                "tuic",
1185            ]
1186        );
1187    }
1188
1189    #[test]
1190    fn traffic_and_inbound_debug_redact_client_credentials() {
1191        let traffic: ClientTraffic = serde_json::from_value(serde_json::json!({
1192            "id": 1,
1193            "inboundId": 7,
1194            "enable": true,
1195            "email": "alice",
1196            "uuid": "traffic-uuid-secret",
1197            "subId": "traffic-sub-secret",
1198            "up": 10,
1199            "down": 20,
1200            "expiryTime": 0,
1201            "total": 100,
1202            "reset": 0,
1203            "lastOnline": 30
1204        }))
1205        .unwrap();
1206        let output = format!("{traffic:?}");
1207        assert!(output.contains("alice"));
1208        assert!(!output.contains("traffic-uuid-secret"));
1209        assert!(!output.contains("traffic-sub-secret"));
1210
1211        let mut config = InboundConfig::new(InboundProtocol::Vless, 443);
1212        config.remark = "production".into();
1213        let inbound = Inbound {
1214            id: 7,
1215            config: config.clone(),
1216            origin_node_guid: "node-guid".into(),
1217            fallback_parent: Some(FallbackParent {
1218                master_id: 3,
1219                path: "/fallback".into(),
1220            }),
1221        };
1222        assert_eq!(inbound.to_config(), config);
1223        assert!(format!("{inbound:?}").contains("node-guid"));
1224        assert_eq!(inbound.into_config(), config);
1225    }
1226}