1use 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16#[non_exhaustive]
17pub enum InboundProtocol {
18 Vmess,
20 Vless,
22 Trojan,
24 Shadowsocks,
26 Wireguard,
28 Hysteria,
30 Http,
32 Mixed,
34 Tunnel,
36 Tun,
38 Mtproto,
40 Amneziawg,
42 Tuic,
44}
45
46impl InboundProtocol {
47 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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
69#[serde(rename_all = "lowercase")]
70#[non_exhaustive]
71pub enum TrafficReset {
72 #[default]
74 Never,
75 Hourly,
77 Daily,
79 Weekly,
81 Monthly,
83}
84
85#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
87#[serde(rename_all = "lowercase")]
88#[non_exhaustive]
89pub enum ShareAddressStrategy {
90 #[default]
92 Node,
93 Listen,
95 Custom,
97}
98
99#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ClientTraffic {
103 pub id: i64,
105 pub inbound_id: i64,
107 pub enable: bool,
109 pub email: String,
111 #[serde(default)]
113 pub uuid: String,
114 #[serde(default)]
116 pub sub_id: String,
117 pub up: i64,
119 pub down: i64,
121 pub expiry_time: i64,
123 pub total: i64,
125 pub reset: i32,
127 pub last_online: i64,
129 #[serde(default)]
131 pub last_sub_fetch: i64,
132 #[serde(default)]
134 pub reset_day: i32,
135 #[serde(default)]
137 pub reset_max: i32,
138 #[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#[derive(Clone, Deserialize, PartialEq, Serialize)]
174#[serde(rename_all = "camelCase")]
175pub struct InboundConfig {
176 pub up: i64,
178 pub down: i64,
180 pub total: i64,
182 pub remark: String,
184 pub sub_sort_index: i32,
186 pub enable: bool,
188 #[serde(default)]
190 pub disable_flow: bool,
191 pub expiry_time: i64,
193 pub traffic_reset: TrafficReset,
195 pub traffic_reset_day: u8,
197 pub last_traffic_reset_time: i64,
199 #[serde(default)]
201 pub client_stats: Vec<ClientTraffic>,
202 pub listen: String,
204 pub port: u16,
206 pub protocol: InboundProtocol,
208 pub settings: Value,
210 pub stream_settings: Value,
212 pub tag: String,
214 pub sniffing: Value,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub node_id: Option<i64>,
219 pub share_addr_strategy: ShareAddressStrategy,
221 pub share_addr: String,
223}
224
225impl InboundConfig {
226 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct FallbackParent {
289 pub master_id: i64,
291 #[serde(default)]
293 pub path: String,
294}
295
296#[derive(Clone, Deserialize, PartialEq, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct Inbound {
300 pub id: i64,
302 #[serde(flatten)]
304 pub config: InboundConfig,
305 #[serde(default, skip_serializing_if = "String::is_empty")]
307 pub origin_node_guid: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub fallback_parent: Option<FallbackParent>,
311}
312
313impl Inbound {
314 pub fn to_config(&self) -> InboundConfig {
316 self.config.clone()
317 }
318
319 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
339#[serde(rename_all = "camelCase")]
340pub struct InboundOption {
341 pub id: i64,
343 pub remark: String,
345 pub tag: String,
347 pub protocol: InboundProtocol,
349 pub port: u16,
351 pub enable: bool,
353 pub tls_flow_capable: bool,
355 #[serde(default)]
357 pub ss_method: String,
358 #[serde(default)]
360 pub wg_public_key: String,
361 #[serde(default)]
363 pub wg_mtu: u32,
364 #[serde(default)]
366 pub wg_dns: String,
367 #[serde(default)]
369 pub mtproto_domain: String,
370 #[serde(default)]
372 pub node_id: Option<i64>,
373 #[serde(default)]
375 pub node_address: String,
376 #[serde(default)]
378 pub listen: String,
379 #[serde(default)]
381 pub share_addr: String,
382 #[serde(default)]
384 pub share_addr_strategy: Option<ShareAddressStrategy>,
385 #[serde(default)]
387 pub awg_server: Option<AmneziaWgServerSettings>,
388 #[serde(default)]
390 pub network: String,
391 #[serde(default)]
393 pub security: String,
394 #[serde(default)]
396 pub tuic_server: Option<TuicServerSettings>,
397}
398
399#[allow(clippy::struct_excessive_bools)]
401#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
402#[serde(default, rename_all = "camelCase")]
403pub struct AmneziaWgServerSettings {
404 pub private_key: String,
406 pub public_key: String,
408 pub subnet_ip: String,
410 pub subnet_cidr: u8,
412 pub primary_dns: String,
414 pub secondary_dns: String,
416 pub mtu: i32,
418 pub external_interface: String,
420 pub ipv6_enabled: bool,
422 pub ipv6_subnet: String,
424 pub ipv6_external_interface: String,
426 pub route_through_xray: bool,
428 pub jc: i32,
430 pub jmin: i32,
432 pub jmax: i32,
434 pub s1: i32,
436 pub s2: i32,
438 pub s3: i32,
440 pub s4: i32,
442 pub h1: String,
444 pub h2: String,
446 pub h3: String,
448 pub h4: String,
450 pub i1: String,
452 pub i2: String,
454 pub i3: String,
456 pub i4: String,
458 pub i5: String,
460 pub rekey_after_time: String,
462 pub rekey_timeout: String,
464 pub reject_after_time: String,
466 pub keepalive_timeout: String,
468 pub max_handshake_attempts: String,
470 pub random_trailers: bool,
472 pub disable_cookies: bool,
474 pub header_protection_key: String,
476 pub content_padding_addition: String,
478}
479
480#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
482#[serde(rename_all = "camelCase")]
483pub struct InboundFallback {
484 pub id: i64,
486 pub master_id: i64,
488 pub child_id: i64,
490 pub name: String,
492 pub alpn: String,
494 pub path: String,
496 pub dest: String,
498 pub xver: i32,
500 pub sort_order: i32,
502}
503
504#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
506#[serde(rename_all = "camelCase")]
507pub struct FallbackInput {
508 pub child_id: i64,
510 pub name: String,
512 pub alpn: String,
514 pub path: String,
516 pub dest: String,
518 pub xver: i32,
520 pub sort_order: i32,
522}
523
524#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
526pub struct SkippedInbound {
527 pub id: i64,
529 pub reason: String,
531}
532
533#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
535pub struct BulkDeleteInboundsResult {
536 pub deleted: u64,
538 #[serde(default)]
540 pub skipped: Vec<SkippedInbound>,
541}
542
543#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
545pub struct SkippedClient {
546 pub email: String,
548 pub reason: String,
550}
551
552#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
554pub struct BulkDeleteClientsResult {
555 pub deleted: u64,
557 #[serde(default)]
559 pub skipped: Vec<SkippedClient>,
560}
561
562#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
564#[serde(rename_all = "camelCase")]
565pub struct ClientTrafficUsage {
566 pub email: String,
568 pub up: i64,
570 pub down: i64,
572}
573
574#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
576#[serde(rename_all = "camelCase")]
577pub struct TrafficPushRequest {
578 pub master_guid: String,
580 pub traffics: Vec<ClientTrafficUsage>,
582}
583
584impl TrafficPushRequest {
585 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#[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 pub async fn list(self) -> Result<Vec<Inbound>> {
612 self.get_object("list").await
613 }
614
615 pub async fn list_slim(self) -> Result<Vec<Inbound>> {
624 self.get_object("list/slim").await
625 }
626
627 pub async fn options(self) -> Result<Vec<InboundOption>> {
633 self.get_object("options").await
634 }
635
636 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 pub async fn get(self, id: i64) -> Result<Inbound> {
659 self.get_object(&format!("get/{id}")).await
660 }
661
662 pub async fn create(self, inbound: &InboundConfig) -> Result<Inbound> {
669 self.post_object("add", Some(inbound)).await
670 }
671
672 pub async fn delete(self, id: i64) -> Result<i64> {
678 self.post_object::<i64, ()>(&format!("del/{id}"), None)
679 .await
680 }
681
682 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 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 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 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 pub async fn reset_traffic(self, id: i64) -> Result<()> {
754 self.post_empty::<()>(&format!("{id}/resetTraffic"), None)
755 .await
756 }
757
758 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 pub async fn reset_all_traffic(self) -> Result<()> {
775 self.post_empty::<()>("resetAllTraffics", None).await
776 }
777
778 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 pub async fn push_client_traffic(self, traffic: &TrafficPushRequest) -> Result<()> {
816 self.post_empty("pushClientTraffics", Some(traffic)).await
817 }
818
819 pub async fn fallbacks(self, id: i64) -> Result<Vec<InboundFallback>> {
825 self.get_object(&format!("{id}/fallbacks")).await
826 }
827
828 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#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
890#[serde(default)]
891pub struct TuicServerSettings {
892 pub certificate: String,
894 pub private_key: String,
896 pub congestion_control: String,
898 pub alpn: Vec<String>,
900 pub udp_relay_mode: String,
902 pub zero_rtt_handshake: bool,
904 pub log_level: String,
906 pub max_idle_time: i32,
908 pub authentication_timeout: i32,
910 pub max_udp_relay_packet_size: i32,
912 pub sni: String,
914}
915
916impl fmt::Debug for TuicServerSettings {
917 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 #[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 #[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 #[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}