Skip to main content

xui_rs/
proxy.rs

1//! Explicit outbound proxy configuration shared by every SDK transport.
2
3use std::{fmt, io, str::FromStr};
4
5use reqwest::StatusCode;
6use secrecy::{ExposeSecret, SecretString};
7use thiserror::Error;
8use url::Url;
9
10use crate::{Error, Result};
11
12/// Proxy protocol and DNS behavior.
13#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
14#[non_exhaustive]
15pub enum ProxyScheme {
16    /// Plain HTTP proxy. TLS targets use an HTTP CONNECT tunnel.
17    Http,
18    /// TLS-protected HTTP proxy. TLS targets use CONNECT inside proxy TLS.
19    Https,
20    /// SOCKS5 with target hostnames resolved locally.
21    Socks5,
22    /// SOCKS5 with target hostnames resolved by the proxy.
23    Socks5h,
24}
25
26impl ProxyScheme {
27    /// Returns the exact URL scheme.
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::Http => "http",
31            Self::Https => "https",
32            Self::Socks5 => "socks5",
33            Self::Socks5h => "socks5h",
34        }
35    }
36
37    pub(crate) const fn default_port(self) -> u16 {
38        match self {
39            Self::Http => 80,
40            Self::Https => 443,
41            Self::Socks5 | Self::Socks5h => 1080,
42        }
43    }
44
45    pub(crate) const fn resolves_remotely(self) -> bool {
46        matches!(self, Self::Socks5h)
47    }
48}
49
50impl fmt::Display for ProxyScheme {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        formatter.write_str(self.as_str())
53    }
54}
55
56/// Explicit proxy used by panel HTTP, WebSocket, and subscription requests.
57///
58/// Proxy URLs must not contain credentials. Add username/password separately
59/// with [`ProxyConfig::with_basic_auth`] so ordinary URL and `Debug` handling
60/// cannot expose them.
61#[derive(Clone)]
62#[must_use]
63pub struct ProxyConfig {
64    url: Url,
65    scheme: ProxyScheme,
66    credentials: Option<ProxyCredentials>,
67}
68
69#[derive(Clone)]
70struct ProxyCredentials {
71    username: SecretString,
72    password: SecretString,
73}
74
75impl ProxyConfig {
76    /// Parses an HTTP, HTTPS, SOCKS5, or `SOCKS5h` proxy URL.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`Error::Configuration`] for an unsupported/relative URL,
81    /// embedded credentials, or a path, query, or fragment.
82    pub fn new(url: impl AsRef<str>) -> Result<Self> {
83        let url = Url::parse(url.as_ref())
84            .map_err(|error| Error::Configuration(format!("invalid proxy URL: {error}")))?;
85        let scheme = match url.scheme() {
86            "http" => ProxyScheme::Http,
87            "https" => ProxyScheme::Https,
88            "socks5" => ProxyScheme::Socks5,
89            "socks5h" => ProxyScheme::Socks5h,
90            _ => {
91                return Err(Error::Configuration(
92                    "proxy URL scheme must be http, https, socks5, or socks5h".to_owned(),
93                ));
94            }
95        };
96        if url.host_str().is_none() {
97            return Err(Error::Configuration(
98                "proxy URL must contain a host".to_owned(),
99            ));
100        }
101        if !url.username().is_empty() || url.password().is_some() {
102            return Err(Error::Configuration(
103                "proxy URL must not contain credentials; use with_basic_auth".to_owned(),
104            ));
105        }
106        if !matches!(url.path(), "" | "/") || url.query().is_some() || url.fragment().is_some() {
107            return Err(Error::Configuration(
108                "proxy URL must not contain a path, query, or fragment".to_owned(),
109            ));
110        }
111        Ok(Self {
112            url,
113            scheme,
114            credentials: None,
115        })
116    }
117
118    /// Adds HTTP Basic or SOCKS5 username/password authentication.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`Error::Configuration`] when the username is empty/contains a
123    /// colon or SOCKS5's one-byte username/password length limit is exceeded.
124    pub fn with_basic_auth(
125        mut self,
126        username: impl Into<String>,
127        password: impl Into<String>,
128    ) -> Result<Self> {
129        let username = username.into();
130        let password = password.into();
131        if username.is_empty() {
132            return Err(Error::Configuration(
133                "proxy username must not be empty".to_owned(),
134            ));
135        }
136        if username.contains(':') {
137            return Err(Error::Configuration(
138                "proxy username must not contain ':'".to_owned(),
139            ));
140        }
141        if matches!(self.scheme, ProxyScheme::Socks5 | ProxyScheme::Socks5h)
142            && (username.len() > usize::from(u8::MAX) || password.len() > usize::from(u8::MAX))
143        {
144            return Err(Error::Configuration(
145                "SOCKS5 username and password must each be at most 255 bytes".to_owned(),
146            ));
147        }
148        self.credentials = Some(ProxyCredentials {
149            username: SecretString::from(username),
150            password: SecretString::from(password),
151        });
152        Ok(self)
153    }
154
155    /// Returns the selected proxy protocol and DNS behavior.
156    pub const fn scheme(&self) -> ProxyScheme {
157        self.scheme
158    }
159
160    /// Returns the credential-free proxy endpoint URL.
161    pub const fn url(&self) -> &Url {
162        &self.url
163    }
164
165    /// Returns whether proxy authentication is configured.
166    pub const fn has_basic_auth(&self) -> bool {
167        self.credentials.is_some()
168    }
169
170    pub(crate) fn host(&self) -> &str {
171        self.url
172            .host_str()
173            .expect("ProxyConfig construction requires a host")
174    }
175
176    pub(crate) fn port(&self) -> u16 {
177        self.url
178            .port()
179            .unwrap_or_else(|| self.scheme.default_port())
180    }
181
182    pub(crate) fn credentials(&self) -> Option<(&str, &str)> {
183        self.credentials.as_ref().map(|credentials| {
184            (
185                credentials.username.expose_secret(),
186                credentials.password.expose_secret(),
187            )
188        })
189    }
190
191    pub(crate) fn reqwest_proxy(&self) -> Result<reqwest::Proxy> {
192        let proxy = reqwest::Proxy::all(self.url.as_str())
193            .map_err(|error| Error::Configuration(format!("invalid proxy: {error}")))?;
194        Ok(match self.credentials() {
195            Some((username, password)) => proxy.basic_auth(username, password),
196            None => proxy,
197        })
198    }
199}
200
201impl fmt::Debug for ProxyConfig {
202    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
203        formatter
204            .debug_struct("ProxyConfig")
205            .field("scheme", &self.scheme)
206            .field("endpoint", &"[REDACTED]")
207            .field("authenticated", &self.has_basic_auth())
208            .finish_non_exhaustive()
209    }
210}
211
212impl FromStr for ProxyConfig {
213    type Err = Error;
214
215    fn from_str(value: &str) -> Result<Self> {
216        Self::new(value)
217    }
218}
219
220impl TryFrom<&str> for ProxyConfig {
221    type Error = Error;
222
223    fn try_from(value: &str) -> Result<Self> {
224        Self::new(value)
225    }
226}
227
228impl TryFrom<String> for ProxyConfig {
229    type Error = Error;
230
231    fn try_from(value: String) -> Result<Self> {
232        Self::new(value)
233    }
234}
235
236/// Failure while opening a WebSocket tunnel through an explicit proxy.
237#[derive(Debug, Error)]
238#[non_exhaustive]
239pub enum ProxyError {
240    /// Proxy TCP or TLS transport failed.
241    #[error("proxy transport failed: {0}")]
242    Io(#[source] io::Error),
243    /// SOCKS5 negotiation or target connection failed.
244    #[error("SOCKS5 negotiation failed: {0}")]
245    Socks5(#[source] Box<tokio_socks::Error>),
246    /// HTTP proxy rejected CONNECT.
247    #[error("HTTP CONNECT returned {0}")]
248    HttpStatus(StatusCode),
249    /// HTTP proxy returned a malformed CONNECT response.
250    #[error("HTTP proxy returned an invalid CONNECT response")]
251    InvalidHttpResponse,
252    /// HTTP proxy headers exceeded the SDK's bounded parser limit.
253    #[error("HTTP proxy CONNECT response exceeded {limit} bytes")]
254    HttpResponseTooLarge {
255        /// Maximum accepted CONNECT response header size.
256        limit: usize,
257    },
258    /// Native TLS trust roots could not be initialized.
259    #[error("proxy TLS configuration failed: {0}")]
260    TlsConfiguration(String),
261}
262
263impl ProxyError {
264    pub(crate) fn is_timeout(&self) -> bool {
265        match self {
266            Self::Io(error) => error.kind() == io::ErrorKind::TimedOut,
267            Self::Socks5(error) => {
268                matches!(error.as_ref(), tokio_socks::Error::Io(error) if error.kind() == io::ErrorKind::TimedOut)
269            }
270            _ => false,
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn schemes_define_ports_dns_and_display_values() {
281        let cases = [
282            (ProxyScheme::Http, "http", 80, false),
283            (ProxyScheme::Https, "https", 443, false),
284            (ProxyScheme::Socks5, "socks5", 1080, false),
285            (ProxyScheme::Socks5h, "socks5h", 1080, true),
286        ];
287        for (scheme, text, port, remote_dns) in cases {
288            assert_eq!(scheme.as_str(), text);
289            assert_eq!(scheme.to_string(), text);
290            assert_eq!(scheme.default_port(), port);
291            assert_eq!(scheme.resolves_remotely(), remote_dns);
292        }
293    }
294
295    #[test]
296    fn configuration_rejects_ambiguous_or_leaky_urls() {
297        for value in [
298            "relative",
299            "ftp://proxy.example.com",
300            "http://user:password@proxy.example.com",
301            "http://proxy.example.com/path",
302            "http://proxy.example.com?query=yes",
303            "http://proxy.example.com#fragment",
304        ] {
305            assert!(ProxyConfig::new(value).is_err(), "accepted {value}");
306        }
307        assert!(
308            ProxyConfig::new("http://proxy.example.com")
309                .unwrap()
310                .with_basic_auth("", "password")
311                .is_err()
312        );
313        assert!(
314            ProxyConfig::new("http://proxy.example.com")
315                .unwrap()
316                .with_basic_auth("user:name", "password")
317                .is_err()
318        );
319        let oversized = "x".repeat(256);
320        assert!(
321            ProxyConfig::new("socks5://proxy.example.com")
322                .unwrap()
323                .with_basic_auth(&oversized, "password")
324                .is_err()
325        );
326        assert!(
327            ProxyConfig::new("socks5://proxy.example.com")
328                .unwrap()
329                .with_basic_auth("user", oversized)
330                .is_err()
331        );
332    }
333
334    #[test]
335    fn conversions_and_credentials_keep_endpoint_secret_free() {
336        let plain: ProxyConfig = "http://proxy.example.com".parse().unwrap();
337        assert_eq!(plain.host(), "proxy.example.com");
338        assert_eq!(plain.port(), 80);
339        assert!(!plain.has_basic_auth());
340        assert!(plain.credentials().is_none());
341        plain.reqwest_proxy().unwrap();
342
343        let authenticated = ProxyConfig::try_from("https://proxy.example.com:8443")
344            .unwrap()
345            .with_basic_auth("service", "proxy-password-secret")
346            .unwrap();
347        assert_eq!(authenticated.port(), 8443);
348        assert_eq!(
349            authenticated.credentials(),
350            Some(("service", "proxy-password-secret"))
351        );
352        authenticated.reqwest_proxy().unwrap();
353        let output = format!("{authenticated:?}");
354        assert!(!output.contains("proxy.example.com"));
355        assert!(!output.contains("proxy-password-secret"));
356        assert!(output.contains("authenticated: true"));
357
358        let owned = String::from("socks5h://proxy.example.com");
359        assert_eq!(
360            ProxyConfig::try_from(owned).unwrap().scheme(),
361            ProxyScheme::Socks5h
362        );
363    }
364
365    #[test]
366    fn proxy_timeout_detection_handles_io_and_socks_errors() {
367        assert!(ProxyError::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")).is_timeout());
368        assert!(
369            ProxyError::Socks5(Box::new(tokio_socks::Error::Io(io::Error::new(
370                io::ErrorKind::TimedOut,
371                "timeout",
372            ))))
373            .is_timeout()
374        );
375        assert!(!ProxyError::HttpStatus(StatusCode::BAD_GATEWAY).is_timeout());
376    }
377}