Skip to main content

xui_rs/settings/
models.rs

1#![allow(clippy::struct_excessive_bools)]
2
3use std::{collections::BTreeMap, fmt};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Panel listener, session, and outbound settings.
9#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
10#[serde(default, rename_all = "camelCase")]
11pub struct WebSettings {
12    /// Listener IP, or an empty string for all interfaces.
13    pub web_listen: String,
14    /// Public panel domain.
15    pub web_domain: String,
16    /// Panel TCP port.
17    pub web_port: u16,
18    /// TLS certificate path.
19    pub web_cert_file: String,
20    /// TLS private-key path.
21    pub web_key_file: String,
22    /// Secret panel base path.
23    pub web_base_path: String,
24    /// Session lifetime in minutes.
25    pub session_max_age: u32,
26    /// Comma-separated trusted reverse-proxy CIDRs.
27    #[serde(rename = "trustedProxyCIDRs")]
28    pub trusted_proxy_cidrs: String,
29    /// Xray outbound tag used by panel-originated traffic.
30    pub panel_outbound: String,
31}
32
33impl fmt::Debug for WebSettings {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter
36            .debug_struct("WebSettings")
37            .field("web_listen", &self.web_listen)
38            .field("web_domain", &self.web_domain)
39            .field("web_port", &self.web_port)
40            .field("web_cert_file", &self.web_cert_file)
41            .field("web_key_file", &"[REDACTED]")
42            .field("web_base_path", &"[REDACTED]")
43            .field("session_max_age", &self.session_max_age)
44            .field("trusted_proxy_cidrs", &self.trusted_proxy_cidrs)
45            .field("panel_outbound", &"[REDACTED]")
46            .finish_non_exhaustive()
47    }
48}
49
50/// Panel display and warning thresholds.
51#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(default, rename_all = "camelCase")]
53pub struct DisplaySettings {
54    /// Rows per page.
55    pub page_size: u32,
56    /// Expiration warning window.
57    pub expire_diff: u32,
58    /// Traffic warning percentage.
59    pub traffic_diff: u8,
60    /// Inbound remark template.
61    pub remark_template: String,
62    /// Shows client identity on every generated link.
63    pub sub_show_identity_on_all_links: bool,
64    /// Date picker calendar identifier.
65    pub datepicker: String,
66}
67
68/// Telegram notification settings.
69#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
70#[serde(default, rename_all = "camelCase")]
71pub struct TelegramSettings {
72    /// Enables the Telegram bot.
73    pub tg_bot_enable: bool,
74    /// Telegram bot token. A blank update preserves the stored token.
75    pub tg_bot_token: String,
76    /// Optional bot proxy URL.
77    pub tg_bot_proxy: String,
78    /// Optional Telegram-compatible API server.
79    #[serde(rename = "tgBotAPIServer")]
80    pub tg_bot_api_server: String,
81    /// Target chat identifier.
82    pub tg_bot_chat_id: String,
83    /// Scheduled report time.
84    pub tg_run_time: String,
85    /// Enables database backups through Telegram.
86    pub tg_bot_backup: bool,
87    /// CPU warning percentage.
88    pub tg_cpu: u8,
89    /// Memory warning percentage.
90    pub tg_memory: u8,
91    /// Notification language.
92    pub tg_lang: String,
93    /// Comma-separated enabled event identifiers.
94    pub tg_enabled_events: String,
95}
96
97impl fmt::Debug for TelegramSettings {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        formatter
100            .debug_struct("TelegramSettings")
101            .field("tg_bot_enable", &self.tg_bot_enable)
102            .field("tg_bot_token", &"[REDACTED]")
103            .field("tg_bot_proxy", &"[REDACTED]")
104            .field("tg_bot_api_server", &self.tg_bot_api_server)
105            .field("tg_bot_chat_id", &self.tg_bot_chat_id)
106            .field("tg_run_time", &self.tg_run_time)
107            .field("tg_bot_backup", &self.tg_bot_backup)
108            .field("tg_cpu", &self.tg_cpu)
109            .field("tg_memory", &self.tg_memory)
110            .field("tg_lang", &self.tg_lang)
111            .field("tg_enabled_events", &self.tg_enabled_events)
112            .finish()
113    }
114}
115
116/// SMTP notification settings.
117#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
118#[serde(default, rename_all = "camelCase")]
119pub struct SmtpSettings {
120    /// Enables SMTP notifications.
121    pub smtp_enable: bool,
122    /// SMTP server host.
123    pub smtp_host: String,
124    /// SMTP server port.
125    pub smtp_port: u16,
126    /// SMTP username.
127    pub smtp_username: String,
128    /// SMTP password. A blank update preserves the stored password.
129    pub smtp_password: String,
130    /// Envelope sender address.
131    pub smtp_from: String,
132    /// Display name for the sender.
133    pub smtp_from_name: String,
134    /// Recipient address list.
135    pub smtp_to: String,
136    /// Encryption mode understood by 3x-ui.
137    pub smtp_encryption_type: String,
138    /// Comma-separated enabled event identifiers.
139    pub smtp_enabled_events: String,
140    /// CPU warning percentage.
141    pub smtp_cpu: u8,
142    /// Memory warning percentage.
143    pub smtp_memory: u8,
144}
145
146impl fmt::Debug for SmtpSettings {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        formatter
149            .debug_struct("SmtpSettings")
150            .field("smtp_enable", &self.smtp_enable)
151            .field("smtp_host", &self.smtp_host)
152            .field("smtp_port", &self.smtp_port)
153            .field("smtp_username", &self.smtp_username)
154            .field("smtp_password", &"[REDACTED]")
155            .field("smtp_from", &self.smtp_from)
156            .field("smtp_from_name", &self.smtp_from_name)
157            .field("smtp_to", &self.smtp_to)
158            .field("smtp_encryption_type", &self.smtp_encryption_type)
159            .field("smtp_enabled_events", &self.smtp_enabled_events)
160            .field("smtp_cpu", &self.smtp_cpu)
161            .field("smtp_memory", &self.smtp_memory)
162            .finish()
163    }
164}
165
166/// Security, time-zone, and integration health settings.
167#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
168#[serde(default, rename_all = "camelCase")]
169pub struct SecuritySettings {
170    /// Comma-separated host:port candidates for REALITY scans.
171    pub reality_scan_candidates: String,
172    /// Percentage below which an outbound is treated as down.
173    pub outbound_down_threshold: u8,
174    /// Comma-separated addresses or CIDRs exempt from client IP limits.
175    pub ip_limit_allowlist: String,
176    /// IANA time-zone name.
177    pub time_location: String,
178    /// Enables two-factor authentication.
179    pub two_factor_enable: bool,
180    /// Two-factor seed. It is redacted on reads.
181    pub two_factor_token: String,
182    /// Automatic WARP IP update interval.
183    pub warp_update_interval: u32,
184}
185
186impl fmt::Debug for SecuritySettings {
187    /// Formats security settings without exposing the two-factor authentication seed.
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        formatter
190            .debug_struct("SecuritySettings")
191            .field("reality_scan_candidates", &self.reality_scan_candidates)
192            .field("outbound_down_threshold", &self.outbound_down_threshold)
193            .field("ip_limit_allowlist", &self.ip_limit_allowlist)
194            .field("time_location", &self.time_location)
195            .field("two_factor_enable", &self.two_factor_enable)
196            .field("two_factor_token", &"[REDACTED]")
197            .field("warp_update_interval", &self.warp_update_interval)
198            .finish()
199    }
200}
201
202/// Subscription server and generated-format settings.
203#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
204#[serde(default, rename_all = "camelCase")]
205pub struct SubscriptionSettings {
206    /// Enables local Happ Crypt5 link generation.
207    pub happ_link_enable: bool,
208    /// Adds a synthetic usage/status node to subscriptions.
209    pub sub_info_node_enable: bool,
210    /// Presents calendar expiry as the inclusive end of the previous month.
211    pub sub_calendar_expire_inclusive: bool,
212    /// Subscription template for expired clients.
213    pub sub_expired_template: String,
214    /// Subscription template for clients with depleted traffic.
215    pub sub_traffic_depleted_template: String,
216    /// Happ application settings, flattened on the wire.
217    #[serde(flatten)]
218    pub happ: HappSettings,
219    /// Enables the subscription server.
220    pub sub_enable: bool,
221    /// Enables JSON subscriptions.
222    pub sub_json_enable: bool,
223    /// Auto-detects JSON subscription clients.
224    pub sub_json_auto_detect: bool,
225    /// Always emits a JSON array.
226    pub sub_json_always_array: bool,
227    /// User-agent regex for JSON subscriptions.
228    pub sub_json_user_agent_regex: String,
229    /// Auto-detects Clash clients.
230    pub sub_clash_auto_detect: bool,
231    /// User-agent regex for Clash subscriptions.
232    pub sub_clash_user_agent_regex: String,
233    /// Subscription title.
234    pub sub_title: String,
235    /// Support URL.
236    pub sub_support_url: String,
237    /// Profile page mode: `none` (default), `builtin`, or `custom`.
238    pub sub_profile_mode: String,
239    /// Profile URL.
240    pub sub_profile_url: String,
241    /// Announcement text.
242    pub sub_announce: String,
243    /// Enables base subscription routing rules.
244    pub sub_enable_routing: bool,
245    /// Base subscription routing rules.
246    pub sub_routing_rules: String,
247    /// Enables Incy routing rules.
248    pub sub_incy_enable_routing: bool,
249    /// Incy routing rules.
250    pub sub_incy_routing_rules: String,
251    /// Subscription listener IP.
252    pub sub_listen: String,
253    /// Subscription server port.
254    pub sub_port: u16,
255    /// Base subscription path.
256    pub sub_path: String,
257    /// Subscription domain.
258    pub sub_domain: String,
259    /// Subscription TLS certificate path.
260    pub sub_cert_file: String,
261    /// Subscription TLS private-key path.
262    pub sub_key_file: String,
263    /// Subscription update interval.
264    pub sub_updates: u32,
265    /// Enables external traffic reporting.
266    pub external_traffic_inform_enable: bool,
267    /// External traffic reporting URI.
268    #[serde(rename = "externalTrafficInformURI")]
269    pub external_traffic_inform_uri: String,
270    /// Restarts Xray when a client is disabled.
271    pub restart_xray_on_client_disable: bool,
272    /// Encrypts subscription identifiers.
273    pub sub_encrypt: bool,
274    /// Public base subscription URI.
275    #[serde(rename = "subURI")]
276    pub sub_uri: String,
277    /// JSON subscription path.
278    pub sub_json_path: String,
279    /// Public JSON subscription URI.
280    #[serde(rename = "subJsonURI")]
281    pub sub_json_uri: String,
282    /// Enables Clash subscriptions.
283    pub sub_clash_enable: bool,
284    /// Clash subscription path.
285    pub sub_clash_path: String,
286    /// Public Clash subscription URI.
287    #[serde(rename = "subClashURI")]
288    pub sub_clash_uri: String,
289    /// Enables Clash routing.
290    pub sub_clash_enable_routing: bool,
291    /// Clash routing rules.
292    pub sub_clash_rules: String,
293    /// JSON mux template.
294    pub sub_json_mux: String,
295    /// JSON routing rules.
296    pub sub_json_rules: String,
297    /// Additional JSON subscription routing rules.
298    pub sub_json_routing_rules: String,
299    /// DNS servers included in JSON subscriptions.
300    pub sub_json_dns: String,
301    /// JSON final-mask template.
302    pub sub_json_final_mask: String,
303    /// JSON observatory template merged into generated subscriptions.
304    pub sub_json_observatory: String,
305    /// Subscription theme directory.
306    pub sub_theme_dir: String,
307    /// Hides settings from subscription output.
308    pub sub_hide_settings: bool,
309}
310
311impl fmt::Debug for SubscriptionSettings {
312    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
313        formatter
314            .debug_struct("SubscriptionSettings")
315            .field("sub_enable", &self.sub_enable)
316            .field("sub_json_enable", &self.sub_json_enable)
317            .field("sub_json_auto_detect", &self.sub_json_auto_detect)
318            .field("sub_json_always_array", &self.sub_json_always_array)
319            .field("sub_clash_auto_detect", &self.sub_clash_auto_detect)
320            .field("sub_title", &self.sub_title)
321            .field("sub_listen", &self.sub_listen)
322            .field("sub_port", &self.sub_port)
323            .field("sub_domain", &self.sub_domain)
324            .field("sub_updates", &self.sub_updates)
325            .field(
326                "external_traffic_inform_enable",
327                &self.external_traffic_inform_enable,
328            )
329            .field(
330                "restart_xray_on_client_disable",
331                &self.restart_xray_on_client_disable,
332            )
333            .field("sub_encrypt", &self.sub_encrypt)
334            .field("sub_clash_enable", &self.sub_clash_enable)
335            .field("sub_clash_enable_routing", &self.sub_clash_enable_routing)
336            .field("sub_hide_settings", &self.sub_hide_settings)
337            .field("paths_uris_rules_and_key_material", &"[REDACTED]")
338            .finish_non_exhaustive()
339    }
340}
341
342/// LDAP synchronization and client defaults.
343#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
344#[serde(default, rename_all = "camelCase")]
345pub struct LdapSettings {
346    /// Enables LDAP synchronization.
347    pub ldap_enable: bool,
348    /// LDAP host.
349    pub ldap_host: String,
350    /// LDAP port.
351    pub ldap_port: u16,
352    /// Enables LDAP TLS.
353    #[serde(rename = "ldapUseTLS")]
354    pub ldap_use_tls: bool,
355    /// Disables LDAP certificate verification.
356    pub ldap_insecure_skip_verify: bool,
357    /// Bind distinguished name.
358    #[serde(rename = "ldapBindDN")]
359    pub ldap_bind_dn: String,
360    /// Bind password. A blank update preserves the stored password.
361    pub ldap_password: String,
362    /// Search base distinguished name.
363    #[serde(rename = "ldapBaseDN")]
364    pub ldap_base_dn: String,
365    /// LDAP user filter.
366    pub ldap_user_filter: String,
367    /// LDAP username attribute.
368    pub ldap_user_attr: String,
369    /// LDAP field used for VLESS identity.
370    pub ldap_vless_field: String,
371    /// Synchronization cron expression.
372    pub ldap_sync_cron: String,
373    /// LDAP enabled/disabled flag field.
374    pub ldap_flag_field: String,
375    /// Values treated as true for the flag field.
376    pub ldap_truthy_values: String,
377    /// Inverts the LDAP flag.
378    pub ldap_invert_flag: bool,
379    /// Inbound tags assigned to synchronized clients.
380    pub ldap_inbound_tags: String,
381    /// Creates missing panel clients.
382    pub ldap_auto_create: bool,
383    /// Deletes panel clients absent from LDAP.
384    pub ldap_auto_delete: bool,
385    /// Default traffic quota in gigabytes.
386    #[serde(rename = "ldapDefaultTotalGB")]
387    pub ldap_default_total_gb: u64,
388    /// Default expiry in days.
389    pub ldap_default_expiry_days: u32,
390    /// Default IP limit.
391    #[serde(rename = "ldapDefaultLimitIP")]
392    pub ldap_default_limit_ip: u32,
393}
394
395impl fmt::Debug for LdapSettings {
396    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
397        formatter
398            .debug_struct("LdapSettings")
399            .field("ldap_enable", &self.ldap_enable)
400            .field("ldap_host", &self.ldap_host)
401            .field("ldap_port", &self.ldap_port)
402            .field("ldap_use_tls", &self.ldap_use_tls)
403            .field("ldap_insecure_skip_verify", &self.ldap_insecure_skip_verify)
404            .field("ldap_bind_dn", &self.ldap_bind_dn)
405            .field("ldap_password", &"[REDACTED]")
406            .field("ldap_base_dn", &self.ldap_base_dn)
407            .field("ldap_user_filter", &self.ldap_user_filter)
408            .field("ldap_user_attr", &self.ldap_user_attr)
409            .field("ldap_vless_field", &self.ldap_vless_field)
410            .field("ldap_sync_cron", &self.ldap_sync_cron)
411            .field("ldap_flag_field", &self.ldap_flag_field)
412            .field("ldap_truthy_values", &self.ldap_truthy_values)
413            .field("ldap_invert_flag", &self.ldap_invert_flag)
414            .field("ldap_inbound_tags", &self.ldap_inbound_tags)
415            .field("ldap_auto_create", &self.ldap_auto_create)
416            .field("ldap_auto_delete", &self.ldap_auto_delete)
417            .field("ldap_default_total_gb", &self.ldap_default_total_gb)
418            .field("ldap_default_expiry_days", &self.ldap_default_expiry_days)
419            .field("ldap_default_limit_ip", &self.ldap_default_limit_ip)
420            .finish()
421    }
422}
423
424/// Discord notification and bot settings.
425#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
426#[serde(default, rename_all = "camelCase")]
427pub struct DiscordSettings {
428    /// Comma-separated Discord administrator user IDs.
429    pub discord_admin_ids: String,
430    /// Enables database backups through Discord.
431    pub discord_bot_backup: bool,
432    /// Enables the Discord notification bot.
433    pub discord_bot_enable: bool,
434    /// Discord bot token (blank updates preserve the stored token).
435    pub discord_bot_token: String,
436    /// Discord channel ID for notifications.
437    pub discord_channel_id: String,
438    /// CPU warning threshold in percent.
439    pub discord_cpu: u8,
440    /// Comma-separated enabled notification event names.
441    pub discord_enabled_events: String,
442    /// Notification language.
443    pub discord_lang: String,
444    /// Memory warning threshold in percent.
445    pub discord_memory: u8,
446    /// Scheduled report time.
447    pub discord_run_time: String,
448}
449
450impl fmt::Debug for DiscordSettings {
451    /// Identifies Discord settings in diagnostics without exposing their configuration or credentials.
452    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
453        formatter
454            .debug_struct("DiscordSettings")
455            .field("settings", &"[REDACTED]")
456            .finish_non_exhaustive()
457    }
458}
459
460/// Happ application management and routing settings.
461#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
462#[serde(default, rename_all = "camelCase")]
463pub struct HappSettings {
464    /// Always requests device identification from Happ.
465    pub sub_happ_always_hwid: bool,
466    /// Automatically connects after importing the subscription.
467    pub sub_happ_auto_connect: bool,
468    /// Automatic connection selection policy.
469    pub sub_happ_auto_connect_type: String,
470    /// Emits Happ headers when its user agent is detected.
471    pub sub_happ_auto_detect: bool,
472    /// Application color profile.
473    pub sub_happ_color_profile: String,
474    /// Bypasses Apple Push Notification traffic.
475    pub sub_happ_exclude_apns: bool,
476    /// Routes excluded from the tunnel.
477    pub sub_happ_exclude_routes: String,
478    /// Fallback subscription URL.
479    pub sub_happ_fallback_url: String,
480    /// Replacement subscription URL.
481    pub sub_happ_new_url: String,
482    /// Enables the Happ no-limit indicator.
483    pub sub_happ_no_limit: bool,
484    /// Enables subscription expiry notifications.
485    pub sub_happ_notification_expire: bool,
486    /// Applications selected for per-app proxy rules.
487    pub sub_happ_per_app_list: String,
488    /// Per-app proxy policy: off, include, or exclude.
489    pub sub_happ_per_app_mode: String,
490    /// Latency probe mode.
491    pub sub_happ_ping_type: String,
492    /// Subscription provider identifier.
493    pub sub_happ_provider_id: String,
494    /// Enables the subscription expiry notice.
495    pub sub_happ_sub_expire: bool,
496    /// URL opened by the expiry notice button.
497    pub sub_happ_sub_expire_button_link: String,
498    /// URL opened by the subscription information button.
499    pub sub_happ_sub_info_button_link: String,
500    /// Subscription information button label.
501    pub sub_happ_sub_info_button_text: String,
502    /// Subscription information notice color.
503    pub sub_happ_sub_info_color: String,
504    /// Subscription information notice text.
505    pub sub_happ_sub_info_text: String,
506    /// Requested TUN mode.
507    pub sub_happ_tun_mode: String,
508    /// Requested TUN implementation.
509    pub sub_happ_tun_type: String,
510}
511
512impl fmt::Debug for HappSettings {
513    /// Identifies Happ settings in diagnostics without exposing application routing or subscription data.
514    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
515        formatter
516            .debug_struct("HappSettings")
517            .field("settings", &"[REDACTED]")
518            .finish_non_exhaustive()
519    }
520}
521
522/// Complete persisted panel settings, grouped ergonomically but flattened on the wire.
523#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
524#[serde(default)]
525pub struct PanelSettings {
526    /// Web listener and session settings.
527    #[serde(flatten)]
528    pub web: WebSettings,
529    /// Display settings.
530    #[serde(flatten)]
531    pub display: DisplaySettings,
532    /// Telegram settings.
533    #[serde(flatten)]
534    pub telegram: TelegramSettings,
535    /// SMTP settings.
536    #[serde(flatten)]
537    pub smtp: SmtpSettings,
538    /// Discord notification settings.
539    #[serde(flatten)]
540    pub discord: DiscordSettings,
541    /// Security and integration settings.
542    #[serde(flatten)]
543    pub security: SecuritySettings,
544    /// Subscription server settings.
545    #[serde(flatten)]
546    pub subscriptions: SubscriptionSettings,
547    /// LDAP settings.
548    #[serde(flatten)]
549    pub ldap: LdapSettings,
550}
551
552/// Browser-safe settings view with secret-presence indicators.
553#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
554#[serde(default, rename_all = "camelCase")]
555pub struct PanelSettingsView {
556    /// Redacted settings.
557    #[serde(flatten)]
558    pub settings: PanelSettings,
559    /// A Telegram token is stored.
560    pub has_tg_bot_token: bool,
561    /// A two-factor seed is stored.
562    pub has_two_factor_token: bool,
563    /// An LDAP password is stored.
564    pub has_ldap_password: bool,
565    /// At least one API token exists.
566    pub has_api_token: bool,
567    /// WARP credentials are stored.
568    pub has_warp_secret: bool,
569    /// `NordVPN` credentials are stored.
570    pub has_nord_secret: bool,
571    /// A Discord bot token is stored.
572    pub has_discord_bot_token: bool,
573    /// An SMTP password is stored.
574    pub has_smtp_password: bool,
575}
576
577/// Full-replacement settings update plus request-only secret controls.
578#[derive(Clone, Debug, Default, Serialize)]
579#[serde(rename_all = "camelCase")]
580pub struct PanelSettingsUpdate {
581    /// Complete settings replacement.
582    #[serde(flatten)]
583    pub settings: PanelSettings,
584    /// Current 2FA code, required when disabling 2FA.
585    pub two_factor_code: String,
586    /// Explicitly clears the stored Discord bot token.
587    pub clear_discord_bot_token: bool,
588    /// Explicitly clears the stored Telegram token.
589    pub clear_tg_bot_token: bool,
590    /// Explicitly clears the stored LDAP password.
591    pub clear_ldap_password: bool,
592    /// Explicitly clears the stored SMTP password.
593    pub clear_smtp_password: bool,
594}
595
596impl PanelSettingsUpdate {
597    /// Creates an update that preserves redacted blank secrets.
598    pub fn new(settings: PanelSettings) -> Self {
599        Self {
600            settings,
601            ..Self::default()
602        }
603    }
604}
605
606/// Host-derived defaults used by the panel UI.
607#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
608#[serde(default, rename_all = "camelCase")]
609pub struct EffectiveDefaults {
610    /// Expiration warning window.
611    pub expire_diff: i64,
612    /// Traffic warning percentage.
613    pub traffic_diff: i64,
614    /// Page size.
615    pub page_size: i64,
616    /// Default certificate path.
617    pub default_cert: String,
618    /// Default key path.
619    pub default_key: String,
620    /// Whether Telegram is enabled.
621    pub tg_bot_enable: bool,
622    /// Subscription theme directory.
623    pub sub_theme_dir: String,
624    /// Whether the subscription server is enabled.
625    pub sub_enable: bool,
626    /// Whether JSON subscriptions are enabled.
627    pub sub_json_enable: bool,
628    /// Whether Clash subscriptions are enabled.
629    pub sub_clash_enable: bool,
630    /// Subscription title.
631    pub sub_title: String,
632    /// Base subscription URI.
633    #[serde(rename = "subURI")]
634    pub sub_uri: String,
635    /// JSON subscription URI.
636    #[serde(rename = "subJsonURI")]
637    pub sub_json_uri: String,
638    /// Clash subscription URI.
639    #[serde(rename = "subClashURI")]
640    pub sub_clash_uri: String,
641    /// Calendar type.
642    pub datepicker: String,
643    /// Whether IP limits are active.
644    pub ip_limit_enable: bool,
645    /// Whether access logging is active.
646    pub access_log_enable: bool,
647    /// Web domain.
648    pub web_domain: String,
649    /// Subscription domain.
650    pub sub_domain: String,
651    /// Whether the development update channel is enabled.
652    pub dev_channel_enable: bool,
653    /// Whether this panel is a development build.
654    pub is_dev_build: bool,
655}
656
657/// Factory defaults keyed by their 3x-ui setting names.
658pub type FactoryDefaults = BTreeMap<String, String>;
659
660/// Current and replacement panel credentials.
661#[derive(Clone, Serialize)]
662#[serde(rename_all = "camelCase")]
663pub struct UserCredentialsUpdate {
664    /// Current username.
665    pub old_username: String,
666    /// Current password.
667    pub old_password: String,
668    /// Replacement username.
669    pub new_username: String,
670    /// Replacement password.
671    pub new_password: String,
672    /// Current 2FA code when enabled.
673    pub two_factor_code: String,
674}
675
676impl fmt::Debug for UserCredentialsUpdate {
677    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
678        formatter
679            .debug_struct("UserCredentialsUpdate")
680            .field("old_username", &self.old_username)
681            .field("old_password", &"[REDACTED]")
682            .field("new_username", &self.new_username)
683            .field("new_password", &"[REDACTED]")
684            .field("two_factor_code", &"[REDACTED]")
685            .finish()
686    }
687}
688
689/// Metadata for a panel API token. The plaintext token is never returned by list calls.
690#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
691#[serde(rename_all = "camelCase")]
692pub struct ApiTokenMetadata {
693    /// Database identifier.
694    pub id: i64,
695    /// User-provided token name.
696    pub name: String,
697    /// Whether authentication with this token is enabled.
698    pub enabled: bool,
699    /// Unix creation timestamp in seconds.
700    pub created_at: i64,
701    /// Authorization scope enforced by 3x-ui.
702    #[serde(default)]
703    pub scope: ApiTokenScope,
704    /// Expiration timestamp in Unix milliseconds; zero means no expiry.
705    #[serde(default)]
706    pub expires_at: i64,
707}
708
709/// Authorization scope assigned to a 3x-ui API token.
710#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
711#[serde(rename_all = "kebab-case")]
712#[non_exhaustive]
713pub enum ApiTokenScope {
714    /// Full panel API access.
715    #[default]
716    Admin,
717    /// Read-only monitoring access.
718    Monitor,
719    /// Node synchronization access.
720    NodeSync,
721}
722
723impl ApiTokenScope {
724    pub(crate) const fn as_str(self) -> &'static str {
725        match self {
726            Self::Admin => "admin",
727            Self::Monitor => "monitor",
728            Self::NodeSync => "node-sync",
729        }
730    }
731}
732
733/// Options used when creating an API token.
734#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
735#[serde(rename_all = "camelCase")]
736pub struct ApiTokenCreateRequest {
737    /// User-visible token name.
738    pub name: String,
739    /// Authorization scope.
740    #[serde(default)]
741    pub scope: ApiTokenScope,
742    /// Expiration timestamp in Unix milliseconds; zero means no expiry.
743    #[serde(default)]
744    pub expires_at: i64,
745}
746
747impl ApiTokenCreateRequest {
748    /// Creates a non-expiring administrative token request.
749    pub fn new(name: impl Into<String>) -> Self {
750        Self {
751            name: name.into(),
752            scope: ApiTokenScope::Admin,
753            expires_at: 0,
754        }
755    }
756}
757
758/// Newly created API token, whose plaintext value is shown exactly once.
759#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
760#[serde(rename_all = "camelCase")]
761pub struct CreatedApiToken {
762    /// Database identifier.
763    pub id: i64,
764    /// User-provided token name.
765    pub name: String,
766    /// Plaintext bearer token.
767    pub token: String,
768    /// Whether authentication with this token is enabled.
769    pub enabled: bool,
770    /// Unix creation timestamp in seconds.
771    pub created_at: i64,
772    /// Authorization scope enforced by 3x-ui.
773    pub scope: ApiTokenScope,
774    /// Expiration timestamp in Unix milliseconds; zero means no expiry.
775    pub expires_at: i64,
776}
777
778impl fmt::Debug for CreatedApiToken {
779    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
780        formatter
781            .debug_struct("CreatedApiToken")
782            .field("id", &self.id)
783            .field("name", &self.name)
784            .field("token", &"[REDACTED]")
785            .field("enabled", &self.enabled)
786            .field("created_at", &self.created_at)
787            .field("scope", &self.scope)
788            .field("expires_at", &self.expires_at)
789            .finish()
790    }
791}
792
793/// One geodata database resolved from Xray's asset directory.
794#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
795#[serde(default, rename_all = "camelCase")]
796pub struct GeoFile {
797    /// Basename inside the asset directory.
798    pub name: String,
799    /// Detected database kind, normally `site` or `ip`.
800    pub kind: String,
801    /// File size in bytes.
802    pub size: i64,
803    /// Modification timestamp in Unix milliseconds.
804    pub modified_at: i64,
805    /// Number of indexed categories.
806    pub categories: i32,
807    /// Parse error for a file that could not be indexed.
808    pub error: String,
809}
810
811/// One category in a geosite or geoip database.
812#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
813#[serde(default)]
814pub struct GeoCategory {
815    /// Case-insensitive category code.
816    pub code: String,
817    /// Number of contained entries.
818    pub entries: i32,
819    /// Domain attributes present in this category.
820    pub attributes: Vec<String>,
821}
822
823/// Page of geodata categories.
824#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
825#[serde(default)]
826pub struct GeoCategoryPage {
827    /// Selected category rows.
828    pub items: Vec<GeoCategory>,
829    /// Total matching row count before paging.
830    pub total: i32,
831}
832
833/// One rule inside a geodata category.
834#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
835#[serde(default)]
836pub struct GeoEntry {
837    /// Rule kind such as `domain`, `full`, `keyword`, `regexp`, or `cidr`.
838    pub kind: String,
839    /// Domain expression or CIDR value.
840    pub value: String,
841}
842
843/// Page of rules inside a geodata category.
844#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
845#[serde(default)]
846pub struct GeoEntryPage {
847    /// Selected rules.
848    pub items: Vec<GeoEntry>,
849    /// Total matching rule count before paging.
850    pub total: i32,
851}
852
853/// One invalid or non-resolving routing token.
854#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
855#[serde(default)]
856pub struct GeodataTokenIssue {
857    /// Original routing token.
858    pub token: String,
859    /// Stable reason such as `syntax`, `fileMissing`, or `categoryMissing`.
860    pub reason: String,
861    /// Referenced database filename, when parsed.
862    pub file: String,
863    /// Referenced category or missing attribute, when parsed.
864    pub code: String,
865}
866
867/// Country represented in PIA's signed server catalog.
868#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
869#[serde(default)]
870pub struct PiaCountry {
871    /// ISO 3166-1 alpha-2 country code.
872    pub code: String,
873}
874
875/// PIA region metadata.
876#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
877#[serde(default)]
878pub struct PiaRegion {
879    /// Provider region identifier.
880    pub id: String,
881    /// User-visible region name.
882    pub name: String,
883}
884
885/// One PIA `WireGuard` server.
886#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
887#[serde(default, rename_all = "camelCase")]
888pub struct PiaServer {
889    /// Provider hostname.
890    pub hostname: String,
891    /// Provider IP address.
892    pub ip: String,
893    /// Parent region identifier.
894    pub region_id: String,
895    /// Parent region name.
896    pub region_name: String,
897}
898
899/// PIA regions and `WireGuard` servers for a selected country.
900#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
901#[serde(default)]
902pub struct PiaServers {
903    /// Matching provider regions.
904    pub regions: Vec<PiaRegion>,
905    /// Matching `WireGuard` servers.
906    pub servers: Vec<PiaServer>,
907}
908
909/// Redacted view of the stored PIA account.
910#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
911#[serde(default, rename_all = "camelCase")]
912pub struct PiaAccount {
913    /// Stored PIA username.
914    pub username: String,
915    /// Masked username suitable for display.
916    pub account_hint: String,
917}
918
919/// `WireGuard` material registered with one PIA server.
920#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
921#[serde(default, rename_all = "camelCase")]
922pub struct PiaKey {
923    /// Stable suggested Xray outbound tag.
924    pub tag: String,
925    /// Provider server hostname.
926    pub hostname: String,
927    /// Generated `WireGuard` private key.
928    pub secret_key: String,
929    /// Assigned peer address.
930    pub address: String,
931    /// Provider `WireGuard` public key.
932    pub public_key: String,
933    /// Provider `WireGuard` endpoint.
934    pub endpoint: String,
935}
936
937impl fmt::Debug for PiaKey {
938    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
939        formatter
940            .debug_struct("PiaKey")
941            .field("tag", &self.tag)
942            .field("hostname", &self.hostname)
943            .field("secret_key", &"[REDACTED]")
944            .field("address", &self.address)
945            .field("public_key", &self.public_key)
946            .field("endpoint", &self.endpoint)
947            .finish()
948    }
949}
950
951/// Structured result of the panel's SMTP connection test.
952#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
953pub struct SmtpTestResult {
954    /// Whether every test stage succeeded.
955    pub success: bool,
956    /// Stage that succeeded or failed.
957    #[serde(default)]
958    pub stage: String,
959    /// Human-readable result message.
960    #[serde(default, rename = "msg")]
961    pub message: String,
962}
963
964/// Open-ended Xray JSON returned by an integration action.
965#[derive(Clone, Deserialize, PartialEq, Serialize)]
966#[serde(transparent)]
967pub struct SensitivePayload(String);
968
969impl SensitivePayload {
970    /// Borrows the original response string.
971    pub fn as_str(&self) -> &str {
972        &self.0
973    }
974
975    /// Parses the response as JSON when the action returned JSON.
976    ///
977    /// # Errors
978    ///
979    /// Returns a JSON decoding error when the response is empty or not JSON.
980    pub fn json(&self) -> serde_json::Result<Value> {
981        serde_json::from_str(&self.0)
982    }
983}
984
985impl fmt::Debug for SensitivePayload {
986    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
987        formatter
988            .debug_tuple("SensitivePayload")
989            .field(&"[REDACTED]")
990            .finish()
991    }
992}
993
994/// WARP key material used for registration.
995#[derive(Clone, Serialize)]
996#[serde(rename_all = "camelCase")]
997pub struct WarpRegistration {
998    /// Existing WARP private key, or an empty string to generate one.
999    pub private_key: String,
1000    /// Existing WARP public key, or an empty string to generate one.
1001    pub public_key: String,
1002}
1003
1004impl fmt::Debug for WarpRegistration {
1005    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1006        formatter
1007            .debug_struct("WarpRegistration")
1008            .field("private_key", &"[REDACTED]")
1009            .field("public_key", &self.public_key)
1010            .finish()
1011    }
1012}
1013
1014/// Standard geodata download preset supplied by the panel.
1015#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1016pub struct GeodataSource {
1017    /// Download URL.
1018    pub url: String,
1019    /// Destination database filename.
1020    pub file: String,
1021}
1022
1023/// Decoded Xray settings response, including runtime-only subscription outbounds.
1024#[derive(Clone, Deserialize, PartialEq, Serialize)]
1025#[serde(default, rename_all = "camelCase")]
1026pub struct XraySettingsSnapshot {
1027    /// Editable Xray template.
1028    pub xray_setting: crate::XrayConfig,
1029    /// Standard downloadable geodata source presets.
1030    pub geodata_sources: Vec<GeodataSource>,
1031    /// Known inbound tags.
1032    pub inbound_tags: Vec<String>,
1033    /// Client reverse-proxy tags.
1034    pub client_reverse_tags: Vec<String>,
1035    /// URL used for outbound tests.
1036    pub outbound_test_url: String,
1037    /// Runtime-injected subscription outbounds.
1038    pub subscription_outbounds: Vec<Value>,
1039    /// Tags of runtime-injected subscription outbounds.
1040    pub subscription_outbound_tags: Vec<String>,
1041}
1042
1043impl Default for XraySettingsSnapshot {
1044    /// Creates an empty Xray settings snapshot with an object template and no runtime subscription outbounds.
1045    fn default() -> Self {
1046        Self {
1047            xray_setting: crate::XrayConfig::from(Value::Object(serde_json::Map::new())),
1048            inbound_tags: Vec::new(),
1049            geodata_sources: Vec::new(),
1050            client_reverse_tags: Vec::new(),
1051            outbound_test_url: String::new(),
1052            subscription_outbounds: Vec::new(),
1053            subscription_outbound_tags: Vec::new(),
1054        }
1055    }
1056}
1057
1058impl fmt::Debug for XraySettingsSnapshot {
1059    /// Formats Xray settings metadata while redacting the template and runtime subscription outbounds.
1060    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1061        formatter
1062            .debug_struct("XraySettingsSnapshot")
1063            .field("geodata_sources", &self.geodata_sources)
1064            .field("xray_setting", &"[REDACTED]")
1065            .field("inbound_tags", &self.inbound_tags)
1066            .field("client_reverse_tags", &self.client_reverse_tags)
1067            .field("outbound_test_url", &self.outbound_test_url)
1068            .field("subscription_outbounds", &"[REDACTED]")
1069            .field(
1070                "subscription_outbound_tags",
1071                &self.subscription_outbound_tags,
1072            )
1073            .finish()
1074    }
1075}
1076
1077/// Cumulative traffic counters for one Xray outbound.
1078#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1079#[serde(default)]
1080pub struct OutboundTraffic {
1081    /// Database identifier, when present.
1082    pub id: i64,
1083    /// Outbound tag.
1084    pub tag: String,
1085    /// Uploaded bytes.
1086    pub up: i64,
1087    /// Downloaded bytes.
1088    pub down: i64,
1089    /// Total byte allowance or counter.
1090    pub total: i64,
1091}
1092
1093/// Probe mode for outbound tests.
1094#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
1095#[non_exhaustive]
1096pub enum OutboundTestMode {
1097    /// Full HTTP probe through a temporary Xray instance.
1098    #[default]
1099    Http,
1100    /// Fast TCP dial-only probe.
1101    Tcp,
1102    /// Reports the cold full-request delay.
1103    Real,
1104}
1105
1106impl OutboundTestMode {
1107    pub(crate) const fn as_str(self) -> &'static str {
1108        match self {
1109            Self::Http => "",
1110            Self::Tcp => "tcp",
1111            Self::Real => "real",
1112        }
1113    }
1114}
1115
1116/// Result for one endpoint in a TCP-mode outbound test.
1117#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1118#[serde(default)]
1119pub struct TestEndpointResult {
1120    /// Endpoint address.
1121    pub address: String,
1122    /// Whether dialing succeeded.
1123    pub success: bool,
1124    /// Dial delay in milliseconds.
1125    pub delay: i64,
1126    /// Error message.
1127    pub error: String,
1128}
1129
1130/// Public egress identity observed by an HTTP-mode test.
1131#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1132#[serde(default)]
1133pub struct TestEgressResult {
1134    /// Observed IPv4 address.
1135    pub ipv4: String,
1136    /// Observed IPv6 address.
1137    pub ipv6: String,
1138    /// Observed country code.
1139    pub country: String,
1140    /// Cloudflare WARP status.
1141    pub warp: String,
1142}
1143
1144/// Result of an outbound connectivity test.
1145#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1146#[serde(default, rename_all = "camelCase")]
1147pub struct OutboundTestResult {
1148    /// Tested outbound tag.
1149    pub tag: String,
1150    /// Whether the probe succeeded.
1151    pub success: bool,
1152    /// End-to-end delay in milliseconds.
1153    pub delay: i64,
1154    /// Failure message.
1155    pub error: String,
1156    /// Effective probe mode.
1157    pub mode: String,
1158    /// HTTP status returned by the test URL.
1159    pub http_status: i32,
1160    /// Local test-inbound connection time in milliseconds.
1161    pub connect_ms: i64,
1162    /// Outbound-chain and target TLS time in milliseconds.
1163    #[serde(rename = "tlsMs")]
1164    pub tls_ms: i64,
1165    /// Time to first byte in milliseconds.
1166    #[serde(rename = "ttfbMs")]
1167    pub ttfb_ms: i64,
1168    /// Per-endpoint TCP results.
1169    pub endpoints: Vec<TestEndpointResult>,
1170    /// Observed egress identity.
1171    pub egress: Option<TestEgressResult>,
1172}
1173
1174/// Current live status of one Xray balancer.
1175#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1176#[serde(default)]
1177pub struct BalancerStatus {
1178    /// Balancer tag.
1179    pub tag: String,
1180    /// Whether the balancer exists in the running core.
1181    pub running: bool,
1182    /// Forced outbound target, if any.
1183    #[serde(rename = "override")]
1184    pub override_target: String,
1185    /// Targets selected by the strategy.
1186    pub selected: Vec<String>,
1187}
1188
1189/// Synthetic connection used to test Xray routing.
1190#[derive(Clone, Debug, Default, Serialize)]
1191#[serde(rename_all = "camelCase")]
1192pub struct RouteTestRequest {
1193    /// Simulated inbound tag.
1194    pub inbound_tag: String,
1195    /// Destination domain.
1196    pub domain: String,
1197    /// Destination IP.
1198    pub ip: String,
1199    /// Destination port.
1200    pub port: u16,
1201    /// Network, usually `tcp` or `udp`.
1202    pub network: String,
1203    /// Optional sniffed protocol.
1204    pub protocol: String,
1205    /// Optional user email.
1206    pub email: String,
1207}
1208
1209/// Routing decision returned by Xray.
1210#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1211#[serde(default, rename_all = "camelCase")]
1212pub struct RouteTestResult {
1213    /// Whether a routing rule matched.
1214    pub matched: bool,
1215    /// Selected outbound tag.
1216    pub outbound_tag: String,
1217    /// Balancer chain traversed by the decision.
1218    pub group_tags: Vec<String>,
1219}
1220
1221/// Stored remote outbound subscription.
1222#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
1223#[serde(default, rename_all = "camelCase")]
1224pub struct OutboundSubscription {
1225    /// Database identifier.
1226    pub id: i64,
1227    /// User-visible remark.
1228    pub remark: String,
1229    /// Remote subscription URL.
1230    pub url: String,
1231    /// Whether it contributes outbounds.
1232    pub enabled: bool,
1233    /// Whether private destination addresses are allowed.
1234    pub allow_private: bool,
1235    /// Whether invalid TLS certificates are accepted.
1236    pub allow_insecure: bool,
1237    /// User agent used to fetch this remote subscription.
1238    pub user_agent: String,
1239    /// Prefix applied to generated outbound tags.
1240    pub tag_prefix: String,
1241    /// Refresh interval in seconds.
1242    pub update_interval: i32,
1243    /// Merge priority.
1244    pub priority: i32,
1245    /// Whether remote outbounds precede manual outbounds.
1246    pub prepend: bool,
1247    /// Last successful refresh timestamp.
1248    pub last_updated: i64,
1249    /// Last refresh error.
1250    pub last_error: String,
1251    /// Cached raw outbounds, normally redacted by list operations.
1252    pub last_fetched_outbounds: String,
1253    /// Unix creation timestamp in milliseconds.
1254    pub created_at: i64,
1255    /// Unix update timestamp in milliseconds.
1256    pub updated_at: i64,
1257    /// Parsed outbound count.
1258    pub outbound_count: i32,
1259}
1260
1261impl fmt::Debug for OutboundSubscription {
1262    /// Formats outbound-subscription state while redacting its URL, user agent, and cached outbounds.
1263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1264        formatter
1265            .debug_struct("OutboundSubscription")
1266            .field("user_agent", &"[REDACTED]")
1267            .field("id", &self.id)
1268            .field("remark", &self.remark)
1269            .field("url", &"[REDACTED]")
1270            .field("enabled", &self.enabled)
1271            .field("allow_private", &self.allow_private)
1272            .field("allow_insecure", &self.allow_insecure)
1273            .field("tag_prefix", &self.tag_prefix)
1274            .field("update_interval", &self.update_interval)
1275            .field("priority", &self.priority)
1276            .field("prepend", &self.prepend)
1277            .field("last_updated", &self.last_updated)
1278            .field("last_error", &self.last_error)
1279            .field("last_fetched_outbounds", &"[REDACTED]")
1280            .field("created_at", &self.created_at)
1281            .field("updated_at", &self.updated_at)
1282            .field("outbound_count", &self.outbound_count)
1283            .finish()
1284    }
1285}
1286
1287/// Full replacement payload for a remote outbound subscription.
1288#[derive(Clone, Serialize)]
1289#[serde(rename_all = "camelCase")]
1290pub struct OutboundSubscriptionInput {
1291    /// User-visible remark.
1292    pub remark: String,
1293    /// Remote subscription URL.
1294    pub url: String,
1295    /// User agent used to fetch this remote subscription.
1296    pub user_agent: String,
1297    /// Prefix applied to generated tags.
1298    pub tag_prefix: String,
1299    /// Whether it contributes outbounds.
1300    pub enabled: bool,
1301    /// Whether private destination addresses are allowed.
1302    pub allow_private: bool,
1303    /// Whether invalid TLS certificates are accepted.
1304    pub allow_insecure: bool,
1305    /// Whether remote outbounds precede manual outbounds.
1306    pub prepend: bool,
1307    /// Refresh interval in seconds.
1308    pub update_interval: i32,
1309}
1310
1311impl OutboundSubscriptionInput {
1312    /// Creates enabled subscription input with the upstream 600-second interval.
1313    pub fn new(url: impl Into<String>) -> Self {
1314        Self {
1315            remark: String::new(),
1316            url: url.into(),
1317            tag_prefix: String::new(),
1318            user_agent: String::new(),
1319            enabled: true,
1320            allow_private: false,
1321            allow_insecure: false,
1322            prepend: false,
1323            update_interval: 600,
1324        }
1325    }
1326}
1327
1328impl fmt::Debug for OutboundSubscriptionInput {
1329    /// Formats outbound-subscription options while redacting the URL and custom user agent.
1330    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1331        formatter
1332            .debug_struct("OutboundSubscriptionInput")
1333            .field("user_agent", &"[REDACTED]")
1334            .field("remark", &self.remark)
1335            .field("url", &"[REDACTED]")
1336            .field("tag_prefix", &self.tag_prefix)
1337            .field("enabled", &self.enabled)
1338            .field("allow_private", &self.allow_private)
1339            .field("allow_insecure", &self.allow_insecure)
1340            .field("prepend", &self.prepend)
1341            .field("update_interval", &self.update_interval)
1342            .finish()
1343    }
1344}
1345
1346/// Direction used to reorder an outbound subscription.
1347#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1348#[non_exhaustive]
1349pub enum MoveDirection {
1350    /// Move toward lower priority numbers.
1351    Up,
1352    /// Move toward higher priority numbers.
1353    Down,
1354}
1355
1356impl MoveDirection {
1357    pub(crate) const fn as_str(self) -> &'static str {
1358        match self {
1359            Self::Up => "up",
1360            Self::Down => "down",
1361        }
1362    }
1363}
1364
1365/// Secret-bearing list of parsed Xray outbounds.
1366#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
1367#[serde(transparent)]
1368pub struct OutboundDocuments(Vec<Value>);
1369
1370impl OutboundDocuments {
1371    /// Borrows parsed outbound JSON documents.
1372    pub fn as_slice(&self) -> &[Value] {
1373        &self.0
1374    }
1375    /// Consumes the wrapper.
1376    pub fn into_inner(self) -> Vec<Value> {
1377        self.0
1378    }
1379}
1380
1381impl fmt::Debug for OutboundDocuments {
1382    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1383        formatter
1384            .debug_tuple("OutboundDocuments")
1385            .field(&"[REDACTED]")
1386            .finish()
1387    }
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393    use serde_json::json;
1394
1395    #[test]
1396    fn secret_bearing_models_redact_debug_output() {
1397        let credentials = UserCredentialsUpdate {
1398            old_username: "old-user".into(),
1399            old_password: "old-password-secret".into(),
1400            new_username: "new-user".into(),
1401            new_password: "new-password-secret".into(),
1402            two_factor_code: "123456-secret".into(),
1403        };
1404        let created = CreatedApiToken {
1405            id: 7,
1406            name: "automation".into(),
1407            token: "bearer-token-secret".into(),
1408            enabled: true,
1409            created_at: 10,
1410            scope: ApiTokenScope::Monitor,
1411            expires_at: 20,
1412        };
1413        let pia_key: PiaKey = serde_json::from_value(json!({
1414            "tag": "pia-de",
1415            "hostname": "de.example.com",
1416            "secretKey": "pia-private-secret",
1417            "address": "10.0.0.2/32",
1418            "publicKey": "public-key",
1419            "endpoint": "de.example.com:1337"
1420        }))
1421        .unwrap();
1422        let registration = WarpRegistration {
1423            private_key: "warp-private-secret".into(),
1424            public_key: "warp-public".into(),
1425        };
1426        let outbound: OutboundSubscription = serde_json::from_value(json!({
1427            "id": 9,
1428            "remark": "remote",
1429            "url": "https://user:subscription-secret@example.com/sub",
1430            "enabled": true,
1431            "lastFetchedOutbounds": "outbound-private-secret"
1432        }))
1433        .unwrap();
1434        let input = OutboundSubscriptionInput::new(
1435            "https://user:input-subscription-secret@example.com/sub",
1436        );
1437        let documents: OutboundDocuments =
1438            serde_json::from_value(json!([{"password": "document-secret"}])).unwrap();
1439        let snapshot = XraySettingsSnapshot {
1440            xray_setting: crate::XrayConfig::from(json!({"privateKey": "xray-secret"})),
1441            subscription_outbounds: vec![json!({"password": "snapshot-secret"})],
1442            ..XraySettingsSnapshot::default()
1443        };
1444
1445        let outputs = [
1446            format!("{credentials:?}"),
1447            format!("{created:?}"),
1448            format!("{pia_key:?}"),
1449            format!("{registration:?}"),
1450            format!("{outbound:?}"),
1451            format!("{input:?}"),
1452            format!("{documents:?}"),
1453            format!("{snapshot:?}"),
1454        ];
1455        for (output, secret) in outputs.iter().zip([
1456            "old-password-secret",
1457            "bearer-token-secret",
1458            "pia-private-secret",
1459            "warp-private-secret",
1460            "subscription-secret",
1461            "input-subscription-secret",
1462            "document-secret",
1463            "snapshot-secret",
1464        ]) {
1465            assert!(!output.contains(secret));
1466            assert!(output.contains("REDACTED"));
1467        }
1468    }
1469
1470    #[test]
1471    fn helpers_preserve_wire_values_and_owned_payloads() {
1472        assert_eq!(ApiTokenScope::Admin.as_str(), "admin");
1473        assert_eq!(ApiTokenScope::Monitor.as_str(), "monitor");
1474        assert_eq!(ApiTokenScope::NodeSync.as_str(), "node-sync");
1475        let request = ApiTokenCreateRequest::new("deploy");
1476        assert_eq!(request.name, "deploy");
1477        assert_eq!(request.scope, ApiTokenScope::Admin);
1478        assert_eq!(request.expires_at, 0);
1479
1480        let payload: SensitivePayload = serde_json::from_value(json!("{\"ok\":true}")).unwrap();
1481        assert_eq!(payload.as_str(), "{\"ok\":true}");
1482        assert_eq!(payload.json().unwrap(), json!({"ok": true}));
1483        assert!(
1484            serde_json::from_value::<SensitivePayload>(json!("not-json"))
1485                .unwrap()
1486                .json()
1487                .is_err()
1488        );
1489        assert!(!format!("{payload:?}").contains("ok"));
1490
1491        assert_eq!(OutboundTestMode::Http.as_str(), "");
1492        assert_eq!(OutboundTestMode::Tcp.as_str(), "tcp");
1493        assert_eq!(OutboundTestMode::Real.as_str(), "real");
1494        assert_eq!(MoveDirection::Up.as_str(), "up");
1495        assert_eq!(MoveDirection::Down.as_str(), "down");
1496
1497        let documents: OutboundDocuments =
1498            serde_json::from_value(json!([{"tag": "one"}, {"tag": "two"}])).unwrap();
1499        assert_eq!(documents.as_slice().len(), 2);
1500        assert_eq!(documents.into_inner()[1]["tag"], "two");
1501    }
1502}