Skip to main content

xui_rs/server/
api.rs

1use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
2use reqwest::{Method, header};
3use serde::{Serialize, de::DeserializeOwned};
4use serde_json::Value;
5
6use super::models::{
7    AmneziaWgLogs, ClientIpRecord, DatabaseFile, EchKeyPair, Fail2banStatus, HistoryBucket,
8    LegacyCpuPoint, MetricPoint, Mldsa65KeyPair, Mlkem768KeyPair, NodeSummary, PanelLogRequest,
9    PanelUpdateInfo, PanelUpdateRun, PanelUpdateStatus, RealityScanRequest, RealityScanResult,
10    ServerStatus, SystemMetric, VlessEncryptionOptions, WebCertificateFiles, X25519KeyPair,
11    XrayConfig, XrayLogEntry, XrayLogRequest, XrayMetric, XrayMetricsState, XrayObservatoryEntry,
12};
13use crate::{Client, Error, Result, client::AuthenticationScope, response::ApiResponse};
14
15const ROOT: &str = "panel/api/server";
16
17/// Server status, Xray lifecycle, maintenance, and utility endpoints.
18#[derive(Clone, Copy, Debug)]
19pub struct ServerApi<'client> {
20    client: &'client Client,
21}
22
23impl<'client> ServerApi<'client> {
24    pub(crate) const fn new(client: &'client Client) -> Self {
25        Self { client }
26    }
27
28    /// Returns the latest cached machine and Xray status snapshot.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error when the request or response contract fails.
33    pub async fn status(self) -> Result<ServerStatus> {
34        self.get_object("status").await
35    }
36
37    /// Returns CPU history through the legacy `{t, cpu}` endpoint.
38    ///
39    /// Prefer [`Self::system_history`] for the uniform `{t, v}` shape.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error when retrieval or decoding fails.
44    pub async fn legacy_cpu_history(self, bucket: HistoryBucket) -> Result<Vec<LegacyCpuPoint>> {
45        self.get_object(&format!("cpuHistory/{}", bucket.seconds()))
46            .await
47    }
48
49    /// Returns aggregated history for one host metric.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error when retrieval or decoding fails.
54    pub async fn system_history(
55        self,
56        metric: SystemMetric,
57        bucket: HistoryBucket,
58    ) -> Result<Vec<MetricPoint>> {
59        self.get_object(&format!("history/{}/{}", metric.as_str(), bucket.seconds()))
60            .await
61    }
62
63    /// Returns Xray metrics discovery and collection state.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error when retrieval or decoding fails.
68    pub async fn xray_metrics_state(self) -> Result<XrayMetricsState> {
69        self.get_object("xrayMetricsState").await
70    }
71
72    /// Returns aggregated history for one Xray runtime metric.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error when retrieval or decoding fails.
77    pub async fn xray_metrics_history(
78        self,
79        metric: XrayMetric,
80        bucket: HistoryBucket,
81    ) -> Result<Vec<MetricPoint>> {
82        self.get_object(&format!(
83            "xrayMetricsHistory/{}/{}",
84            metric.as_str(),
85            bucket.seconds()
86        ))
87        .await
88    }
89
90    /// Returns the latest Xray observatory snapshot.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error when retrieval or decoding fails.
95    pub async fn xray_observatory(self) -> Result<Vec<XrayObservatoryEntry>> {
96        self.get_object("xrayObservatory").await
97    }
98
99    /// Returns observatory delay history for one outbound tag.
100    ///
101    /// The tag is encoded as exactly one URL path segment.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error when retrieval or decoding fails.
106    pub async fn xray_observatory_history(
107        self,
108        tag: &str,
109        bucket: HistoryBucket,
110    ) -> Result<Vec<MetricPoint>> {
111        self.get_object(&format!(
112            "xrayObservatoryHistory/{}/{}",
113            segment(tag),
114            bucket.seconds()
115        ))
116        .await
117    }
118
119    /// Lists Xray versions available for installation.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error when lookup or decoding fails.
124    pub async fn xray_versions(self) -> Result<Vec<String>> {
125        self.get_object("getXrayVersion").await
126    }
127
128    /// Returns current and latest panel release information.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error when lookup or decoding fails.
133    pub async fn panel_update_info(self) -> Result<PanelUpdateInfo> {
134        self.get_object("getPanelUpdateInfo").await
135    }
136
137    /// Returns the latest panel self-update status.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error when retrieval or decoding fails.
142    pub async fn panel_update_status(self) -> Result<PanelUpdateStatus> {
143        self.get_object("getUpdateStatus").await
144    }
145
146    /// Returns the complete assembled Xray configuration currently in use.
147    ///
148    /// Xray configuration is intentionally represented as open-ended JSON because
149    /// its schema depends on the installed xray-core version and enabled protocols.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error when retrieval or decoding fails.
154    pub async fn xray_config(self) -> Result<XrayConfig> {
155        self.get_object("getConfigJson").await
156    }
157
158    /// Downloads a database backup without writing it to disk.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error when the download fails.
163    pub async fn download_database(self) -> Result<DatabaseFile> {
164        self.download("getDb").await
165    }
166
167    /// Downloads the cross-engine migration artifact without writing it to disk.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error when the download fails.
172    pub async fn download_migration(self) -> Result<DatabaseFile> {
173        self.download("getMigration").await
174    }
175
176    /// Restores a database upload and lets the panel restart Xray.
177    ///
178    /// This is destructive. The SDK sends a genuine multipart part named `db` and
179    /// never reads from or writes to the local filesystem itself.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error when upload, validation, import, or restart fails.
184    pub async fn import_database(self, filename: &str, database_bytes: &[u8]) -> Result<()> {
185        let path = format!("{ROOT}/importDB");
186        self.client
187            .execute_multipart::<Value>(
188                Method::POST,
189                &path,
190                "db",
191                filename,
192                database_bytes,
193                AuthenticationScope::PanelApi,
194            )
195            .await?;
196        Ok(())
197    }
198
199    /// Generates a UUID v4 using the panel helper.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error when generation or decoding fails.
204    pub async fn generate_uuid(self) -> Result<String> {
205        #[derive(serde::Deserialize)]
206        struct UuidObject {
207            uuid: String,
208        }
209
210        Ok(self.get_object::<UuidObject>("getNewUUID").await?.uuid)
211    }
212
213    /// Returns the panel web certificate and private-key paths.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error when retrieval or decoding fails.
218    pub async fn web_certificate_files(self) -> Result<WebCertificateFiles> {
219        self.get_object("getWebCertFiles").await
220    }
221
222    /// Returns read-only summaries of nodes directly managed by this panel.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error when retrieval or decoding fails.
227    pub async fn descendants(self) -> Result<Vec<NodeSummary>> {
228        self.get_object("descendants").await
229    }
230
231    /// Generates an X25519 keypair for REALITY.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error when generation or decoding fails.
236    pub async fn generate_x25519(self) -> Result<X25519KeyPair> {
237        self.get_object("getNewX25519Cert").await
238    }
239
240    /// Generates ML-DSA-65 signing material.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error when generation or decoding fails.
245    pub async fn generate_mldsa65(self) -> Result<Mldsa65KeyPair> {
246        self.get_object("getNewmldsa65").await
247    }
248
249    /// Generates ML-KEM-768 key material.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error when generation or decoding fails.
254    pub async fn generate_mlkem768(self) -> Result<Mlkem768KeyPair> {
255        self.get_object("getNewmlkem768").await
256    }
257
258    /// Generates VLESS encryption/decryption choices.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error when generation or decoding fails.
263    pub async fn generate_vless_encryption(self) -> Result<VlessEncryptionOptions> {
264        self.get_object("getNewVlessEnc").await
265    }
266
267    /// Generates ECH server keys and a public ECH config list for an SNI.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error when generation or decoding fails.
272    pub async fn generate_ech(self, sni: &str) -> Result<EchKeyPair> {
273        #[derive(Serialize)]
274        struct Form<'a> {
275            sni: &'a str,
276        }
277
278        self.post_form_object("getNewEchCert", &Form { sni }).await
279    }
280
281    /// Computes SHA-256 hashes for a certificate referenced by panel file path.
282    ///
283    /// The server accepts only certificate files already referenced by its own
284    /// configuration.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error when validation, hashing, or decoding fails.
289    pub async fn certificate_file_hashes(self, certificate_file: &str) -> Result<Vec<String>> {
290        self.certificate_hashes(certificate_file, "").await
291    }
292
293    /// Computes SHA-256 hashes for inline PEM or DER certificate content.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error when parsing, hashing, or decoding fails.
298    pub async fn certificate_content_hashes(
299        self,
300        certificate_content: &str,
301    ) -> Result<Vec<String>> {
302        self.certificate_hashes("", certificate_content).await
303    }
304
305    /// Fetches the live leaf certificate hash from a remote TLS server.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error when probing, hashing, or decoding fails.
310    pub async fn remote_certificate_hashes(self, server: &str) -> Result<Vec<String>> {
311        #[derive(Serialize)]
312        struct Form<'a> {
313            server: &'a str,
314        }
315
316        self.post_form_object("getRemoteCertHash", &Form { server })
317            .await
318    }
319
320    /// Probes one REALITY target and returns its feasibility verdict.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error when the live probe or decoding fails.
325    pub async fn scan_reality_target(
326        self,
327        request: &RealityScanRequest,
328    ) -> Result<RealityScanResult> {
329        #[derive(Serialize)]
330        struct Form<'a> {
331            target: &'a str,
332            xver: i32,
333        }
334
335        self.post_form_object(
336            "scanRealityTarget",
337            &Form {
338                target: &request.target,
339                xver: request.xray_version,
340            },
341        )
342        .await
343    }
344
345    /// Probes comma-separated REALITY targets, or the panel seed list when empty.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error when live probes or decoding fail.
350    pub async fn scan_reality_targets(
351        self,
352        targets: &[impl AsRef<str>],
353    ) -> Result<Vec<RealityScanResult>> {
354        #[derive(Serialize)]
355        struct Form {
356            targets: String,
357        }
358
359        self.post_form_object(
360            "scanRealityTargets",
361            &Form {
362                targets: targets
363                    .iter()
364                    .map(AsRef::as_ref)
365                    .collect::<Vec<_>>()
366                    .join(","),
367            },
368        )
369        .await
370    }
371
372    /// Probes the panel's built-in REALITY target seed list.
373    ///
374    /// This is the unambiguous convenience form of
375    /// [`Self::scan_reality_targets`] for the upstream empty-list behavior.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error when live probes or decoding fail.
380    pub async fn scan_default_reality_targets(self) -> Result<Vec<RealityScanResult>> {
381        self.scan_reality_targets(&[] as &[&str]).await
382    }
383
384    /// Returns the cluster-wide recently observed client IP table.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error when retrieval or decoding fails.
389    pub async fn client_ips(self) -> Result<Vec<ClientIpRecord>> {
390        self.get_object("clientIps").await
391    }
392
393    /// Merges client IP observations into the panel's cluster-wide table.
394    ///
395    /// # Errors
396    ///
397    /// Returns an error when validation or merge fails.
398    pub async fn merge_client_ips(self, records: &[ClientIpRecord]) -> Result<()> {
399        self.post_empty("clientIps", Some(records)).await
400    }
401
402    /// Reports whether Fail2ban-backed per-client IP limits are usable.
403    ///
404    /// # Errors
405    ///
406    /// Returns an error when retrieval or decoding fails.
407    pub async fn fail2ban_status(self) -> Result<Fail2banStatus> {
408        self.get_object("fail2banStatus").await
409    }
410
411    /// Stops Xray immediately.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error when the panel cannot stop Xray.
416    pub async fn stop_xray(self) -> Result<()> {
417        self.post_empty::<()>("stopXrayService", None).await
418    }
419
420    /// Restarts Xray with the current configuration.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error when validation or restart fails.
425    pub async fn restart_xray(self) -> Result<()> {
426        self.post_empty::<()>("restartXrayService", None).await
427    }
428
429    /// Downloads and installs an Xray release tag, or `latest`.
430    ///
431    /// The version is encoded as exactly one URL path segment.
432    ///
433    /// # Errors
434    ///
435    /// Returns an error when download or installation fails.
436    pub async fn install_xray(self, version: &str) -> Result<()> {
437        self.post_empty::<()>(&format!("installXray/{}", segment(version)), None)
438            .await
439    }
440
441    /// Starts a panel self-update and returns its run identifier.
442    ///
443    /// `dev_override` selects the channel for this run when present; `None` uses
444    /// the panel's configured update channel.
445    ///
446    /// # Errors
447    ///
448    /// Returns an error when the updater cannot be started or decoded.
449    pub async fn update_panel(self, dev_override: Option<bool>) -> Result<PanelUpdateRun> {
450        #[derive(Serialize)]
451        struct Form {
452            #[serde(skip_serializing_if = "Option::is_none")]
453            dev: Option<bool>,
454        }
455
456        self.post_form_object("updatePanel", &Form { dev: dev_override })
457            .await
458    }
459
460    /// Selects stable (`false`) or rolling dev (`true`) for future panel updates.
461    ///
462    /// # Errors
463    ///
464    /// Returns an error when the setting cannot be changed.
465    pub async fn set_update_channel(self, dev: bool) -> Result<()> {
466        #[derive(Serialize)]
467        struct Form {
468            dev: bool,
469        }
470
471        self.post_form_empty("setUpdateChannel", &Form { dev })
472            .await
473    }
474
475    /// Refreshes the panel's default `GeoIP` and `GeoSite` files.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error when the update fails.
480    pub async fn update_geofiles(self) -> Result<()> {
481        self.post_empty::<()>("updateGeofile", None).await
482    }
483
484    /// Refreshes one named Xray geo file.
485    ///
486    /// The filename is encoded as one path segment; the server additionally
487    /// enforces its v3.8.5 safe-filename allowlist.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error when validation or update fails.
492    pub async fn update_geofile(self, filename: &str) -> Result<()> {
493        self.post_empty::<()>(&format!("updateGeofile/{}", segment(filename)), None)
494            .await
495    }
496
497    /// Returns filtered trailing lines from the panel log.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error when retrieval or decoding fails.
502    pub async fn panel_logs(self, request: &PanelLogRequest) -> Result<Vec<String>> {
503        #[derive(Serialize)]
504        #[serde(rename_all = "camelCase")]
505        struct Form<'a> {
506            level: &'a str,
507            syslog: bool,
508        }
509
510        self.post_form_list(
511            &format!("logs/{}", request.count),
512            &Form {
513                level: request.level.as_str(),
514                syslog: request.syslog,
515            },
516        )
517        .await
518    }
519
520    /// Returns filtered trailing lines from the Xray process log.
521    ///
522    /// # Errors
523    ///
524    /// Returns an error when retrieval or decoding fails.
525    pub async fn xray_logs(self, request: &XrayLogRequest) -> Result<Vec<XrayLogEntry>> {
526        #[derive(Serialize)]
527        #[serde(rename_all = "camelCase")]
528        struct Form<'a> {
529            filter: &'a str,
530            show_direct: bool,
531            show_blocked: bool,
532            show_proxy: bool,
533        }
534
535        self.post_form_list(
536            &format!("xraylogs/{}", request.count),
537            &Form {
538                filter: &request.filter,
539                show_direct: request.show_direct,
540                show_blocked: request.show_blocked,
541                show_proxy: request.show_proxy,
542            },
543        )
544        .await
545    }
546
547    /// Returns live `AmneziaWG` peer activity and recent lifecycle log lines.
548    ///
549    /// The server caps invalid or out-of-range counts to 100 and applies the
550    /// case-insensitive filter to both peer metadata and event text.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error when retrieval or response decoding fails.
555    pub async fn amneziawg_logs(self, count: u32, filter: &str) -> Result<AmneziaWgLogs> {
556        #[derive(Serialize)]
557        struct Form<'a> {
558            filter: &'a str,
559        }
560
561        self.post_form_object(&format!("amneziawglogs/{count}"), &Form { filter })
562            .await
563    }
564
565    async fn certificate_hashes(
566        self,
567        certificate_file: &str,
568        certificate_content: &str,
569    ) -> Result<Vec<String>> {
570        #[derive(Serialize)]
571        #[serde(rename_all = "camelCase")]
572        struct Form<'a> {
573            cert_file: &'a str,
574            cert_content: &'a str,
575        }
576
577        self.post_form_object(
578            "getCertHash",
579            &Form {
580                cert_file: certificate_file,
581                cert_content: certificate_content,
582            },
583        )
584        .await
585    }
586
587    async fn get_object<T>(self, suffix: &str) -> Result<T>
588    where
589        T: DeserializeOwned,
590    {
591        let path = format!("{ROOT}/{suffix}");
592        let envelope = self
593            .client
594            .execute::<T, ()>(Method::GET, &path, None, AuthenticationScope::PanelApi)
595            .await?;
596        self.required_object(Method::GET, &path, envelope)
597    }
598
599    async fn post_form_object<T, B>(self, suffix: &str, form: &B) -> Result<T>
600    where
601        T: DeserializeOwned,
602        B: Serialize + ?Sized,
603    {
604        let path = format!("{ROOT}/{suffix}");
605        let envelope = self
606            .client
607            .execute_form(Method::POST, &path, form, AuthenticationScope::PanelApi)
608            .await?;
609        self.required_object(Method::POST, &path, envelope)
610    }
611
612    /// Posts a panel-authenticated form and decodes its collection response.
613    /// A successful envelope with a null or absent object represents an empty list;
614    /// transport, API, and decoding errors propagate to the caller.
615    async fn post_form_list<T: DeserializeOwned, B: Serialize + ?Sized>(
616        self,
617        suffix: &str,
618        form: &B,
619    ) -> Result<Vec<T>> {
620        let path = format!("{ROOT}/{suffix}");
621        let envelope = self
622            .client
623            .execute_form::<Vec<T>, _>(Method::POST, &path, form, AuthenticationScope::PanelApi)
624            .await?;
625        Ok(envelope.obj.unwrap_or_default())
626    }
627
628    /// Posts a panel-authenticated form for an action without a typed result.
629    /// Validates the response envelope and propagates transport and API errors.
630    async fn post_form_empty<B>(self, suffix: &str, form: &B) -> Result<()>
631    where
632        B: Serialize + ?Sized,
633    {
634        let path = format!("{ROOT}/{suffix}");
635        self.client
636            .execute_form::<Value, _>(Method::POST, &path, form, AuthenticationScope::PanelApi)
637            .await?;
638        Ok(())
639    }
640
641    async fn post_empty<B>(self, suffix: &str, body: Option<&B>) -> Result<()>
642    where
643        B: Serialize + ?Sized,
644    {
645        let path = format!("{ROOT}/{suffix}");
646        self.client
647            .execute::<Value, B>(Method::POST, &path, body, AuthenticationScope::PanelApi)
648            .await?;
649        Ok(())
650    }
651
652    async fn download(self, suffix: &str) -> Result<DatabaseFile> {
653        let path = format!("{ROOT}/{suffix}");
654        let (headers, bytes) = self
655            .client
656            .execute_bytes(Method::GET, &path, AuthenticationScope::PanelApi)
657            .await?;
658        let content_type = headers
659            .get(header::CONTENT_TYPE)
660            .and_then(|value| value.to_str().ok())
661            .map(str::to_owned);
662        if content_type
663            .as_deref()
664            .is_some_and(|value| value.starts_with("application/json"))
665        {
666            let url = self.client.endpoint(&path)?;
667            let envelope: ApiResponse<Value> =
668                serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
669                    method: Method::GET,
670                    url: Box::new(url.clone()),
671                    source,
672                })?;
673            if !envelope.success {
674                return Err(Error::Api {
675                    method: Method::GET,
676                    url: Box::new(url),
677                    message: if envelope.msg.is_empty() {
678                        "database download failed".to_owned()
679                    } else {
680                        envelope.msg
681                    },
682                });
683            }
684        }
685        Ok(DatabaseFile {
686            filename: attachment_filename(&headers),
687            content_type,
688            bytes,
689        })
690    }
691
692    fn required_object<T>(self, method: Method, path: &str, envelope: ApiResponse<T>) -> Result<T> {
693        let url = self.client.endpoint(path)?;
694        envelope.obj.ok_or_else(|| Error::MissingObject {
695            method,
696            url: Box::new(url),
697        })
698    }
699}
700
701fn segment(value: &str) -> String {
702    utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
703}
704
705fn attachment_filename(headers: &header::HeaderMap) -> Option<String> {
706    let disposition = headers.get(header::CONTENT_DISPOSITION)?.to_str().ok()?;
707    disposition.split(';').skip(1).find_map(|parameter| {
708        let (name, value) = parameter.trim().split_once('=')?;
709        name.eq_ignore_ascii_case("filename")
710            .then(|| value.trim().trim_matches('"').to_owned())
711    })
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn path_segments_encode_reserved_and_unicode_characters() {
720        assert_eq!(segment("v1/latest"), "v1%2Flatest");
721        assert_eq!(segment("узел 1"), "%D1%83%D0%B7%D0%B5%D0%BB%201");
722    }
723
724    #[test]
725    fn extracts_quoted_attachment_filename() {
726        let mut headers = header::HeaderMap::new();
727        headers.insert(
728            header::CONTENT_DISPOSITION,
729            header::HeaderValue::from_static("attachment; filename=\"x-ui.db\""),
730        );
731        assert_eq!(attachment_filename(&headers).as_deref(), Some("x-ui.db"));
732    }
733}