Skip to main content

discord_proxy/
proxy.rs

1use anyhow::{Context, Result, bail};
2use base64::{Engine, engine::general_purpose::STANDARD};
3use percent_encoding::percent_decode_str;
4use std::{fmt, str::FromStr};
5use url::Url;
6
7#[derive(Debug, Clone, Copy, Eq, PartialEq)]
8pub enum ProxyScheme {
9    Http,
10    Socks5,
11}
12
13impl ProxyScheme {
14    pub fn as_str(self) -> &'static str {
15        match self {
16            Self::Http => "http",
17            Self::Socks5 => "socks5",
18        }
19    }
20}
21
22#[derive(Debug, Clone, Eq, PartialEq)]
23pub struct UpstreamProxy {
24    scheme: ProxyScheme,
25    host: String,
26    port: u16,
27    username: Option<String>,
28    password: Option<String>,
29}
30
31impl UpstreamProxy {
32    pub fn parse(raw: &str) -> Result<Self> {
33        raw.parse()
34    }
35
36    pub fn scheme(&self) -> ProxyScheme {
37        self.scheme
38    }
39
40    pub fn host(&self) -> &str {
41        &self.host
42    }
43
44    pub fn port(&self) -> u16 {
45        self.port
46    }
47
48    pub fn username(&self) -> Option<&str> {
49        self.username.as_deref()
50    }
51
52    pub fn password(&self) -> Option<&str> {
53        self.password.as_deref()
54    }
55
56    pub fn has_auth(&self) -> bool {
57        self.username.is_some() || self.password.is_some()
58    }
59
60    pub fn needs_bridge(&self) -> bool {
61        self.scheme != ProxyScheme::Http || self.has_auth()
62    }
63
64    pub fn command_line_url(&self) -> String {
65        format!(
66            "{}://{}:{}",
67            self.scheme.as_str(),
68            format_host_for_url(&self.host),
69            self.port
70        )
71    }
72
73    pub fn authority(&self) -> String {
74        format!("{}:{}", self.host, self.port)
75    }
76
77    pub fn basic_proxy_authorization(&self) -> Option<String> {
78        let username = self.username.as_ref()?;
79        let password = self.password.as_deref().unwrap_or_default();
80        let token = STANDARD.encode(format!("{username}:{password}"));
81        Some(format!("Basic {token}"))
82    }
83}
84
85impl FromStr for UpstreamProxy {
86    type Err = anyhow::Error;
87
88    fn from_str(raw: &str) -> Result<Self> {
89        let trimmed = raw.trim().trim_matches('"');
90        if trimmed.is_empty() {
91            bail!("proxy URL cannot be empty");
92        }
93
94        let normalized = if trimmed.contains("://") {
95            trimmed.to_string()
96        } else {
97            format!("http://{trimmed}")
98        };
99
100        let url =
101            Url::parse(&normalized).with_context(|| format!("invalid proxy URL: {trimmed}"))?;
102
103        let scheme = match url.scheme().to_ascii_lowercase().as_str() {
104            "http" | "https" => ProxyScheme::Http,
105            "socks" | "socks5" => ProxyScheme::Socks5,
106            other => bail!("unsupported proxy scheme: {other}"),
107        };
108
109        let host = url
110            .host_str()
111            .context("proxy URL is missing a host")?
112            .to_string();
113        let port = url
114            .port_or_known_default()
115            .or_else(|| default_port(scheme))
116            .context("proxy URL is missing a port")?;
117
118        let username = decode_non_empty(url.username());
119        let password = url.password().and_then(decode_non_empty);
120
121        Ok(Self {
122            scheme,
123            host,
124            port,
125            username,
126            password,
127        })
128    }
129}
130
131impl fmt::Display for ProxyScheme {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter.write_str(self.as_str())
134    }
135}
136
137fn default_port(scheme: ProxyScheme) -> Option<u16> {
138    Some(match scheme {
139        ProxyScheme::Http => 80,
140        ProxyScheme::Socks5 => 1080,
141    })
142}
143
144fn decode_non_empty(value: &str) -> Option<String> {
145    if value.is_empty() {
146        return None;
147    }
148
149    Some(percent_decode_str(value).decode_utf8_lossy().into_owned())
150}
151
152fn format_host_for_url(host: &str) -> String {
153    if host.contains(':') && !host.starts_with('[') {
154        format!("[{host}]")
155    } else {
156        host.to_string()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn parses_http_proxy_without_scheme() {
166        let proxy = UpstreamProxy::parse("127.0.0.1:7890").unwrap();
167
168        assert_eq!(proxy.scheme(), ProxyScheme::Http);
169        assert_eq!(proxy.host(), "127.0.0.1");
170        assert_eq!(proxy.port(), 7890);
171        assert!(!proxy.needs_bridge());
172        assert_eq!(proxy.command_line_url(), "http://127.0.0.1:7890");
173    }
174
175    #[test]
176    fn parses_socks_alias_with_auth() {
177        let proxy = UpstreamProxy::parse("socks://user:p%40ss@localhost:1080").unwrap();
178
179        assert_eq!(proxy.scheme(), ProxyScheme::Socks5);
180        assert_eq!(proxy.username(), Some("user"));
181        assert_eq!(proxy.password(), Some("p@ss"));
182        assert!(proxy.needs_bridge());
183        assert_eq!(proxy.command_line_url(), "socks5://localhost:1080");
184    }
185
186    #[test]
187    fn authenticated_http_proxy_needs_bridge() {
188        let proxy = UpstreamProxy::parse("http://user:pass@example.com:8080").unwrap();
189
190        assert!(proxy.needs_bridge());
191        assert!(proxy.basic_proxy_authorization().is_some());
192        assert_eq!(proxy.command_line_url(), "http://example.com:8080");
193    }
194}