Skip to main content

subscription_proxy_pool/
node.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use url::Url;
6
7use crate::{Error, Result};
8
9/// Proxy transports supported directly by the HTTP client.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ProxyKind {
13    /// HTTP forward proxy (including HTTPS targets via CONNECT).
14    Http,
15    /// TLS connection to the forward proxy itself.
16    Https,
17    /// SOCKS5 with local target DNS resolution.
18    Socks5,
19    /// SOCKS5 with target DNS resolution by the proxy.
20    Socks5h,
21}
22
23impl ProxyKind {
24    fn scheme(self) -> &'static str {
25        match self {
26            Self::Http => "http",
27            Self::Https => "https",
28            Self::Socks5 => "socks5",
29            Self::Socks5h => "socks5h",
30        }
31    }
32}
33
34/// A validated proxy endpoint. Debug output omits credentials and display names.
35///
36/// Serialization includes credentials so caches can restore functioning clients.
37/// Treat serialized nodes and [`Self::url`] as secrets.
38#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct ProxyNode {
40    name: String,
41    endpoint: String,
42}
43
44impl ProxyNode {
45    /// Parse an HTTP(S) or SOCKS5(H) URL. URI fragments become the display name.
46    pub fn from_url(value: &str) -> Result<Self> {
47        let mut url = Url::parse(value).map_err(|_| Error::Config("invalid proxy URL"))?;
48        Self::validate_url(&url)?;
49        let name = url.fragment().unwrap_or("proxy").to_owned();
50        url.set_fragment(None);
51        Ok(Self {
52            name,
53            endpoint: url.into(),
54        })
55    }
56
57    fn validate_url(url: &Url) -> Result<()> {
58        if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") {
59            return Err(Error::Config("unsupported proxy protocol"));
60        }
61        if url.host_str().is_none_or(str::is_empty) || url.port_or_known_default().unwrap_or(0) == 0
62        {
63            return Err(Error::Config("proxy host and a nonzero port are required"));
64        }
65        if !matches!(url.path(), "" | "/") || url.query().is_some() {
66            return Err(Error::Config("proxy URLs cannot contain a path or query"));
67        }
68        Ok(())
69    }
70
71    /// Validate a node restored using Serde.
72    pub fn validate(&self) -> Result<()> {
73        let url = Url::parse(&self.endpoint).map_err(|_| Error::Config("invalid proxy URL"))?;
74        Self::validate_url(&url)?;
75        if url.fragment().is_some() {
76            return Err(Error::Config("stored proxy URL cannot contain a fragment"));
77        }
78        Ok(())
79    }
80
81    /// Override the human-readable name; it has no effect on node identity.
82    pub fn with_name(mut self, name: impl Into<String>) -> Self {
83        self.name = name.into();
84        self
85    }
86
87    /// Human-readable name supplied by the subscription.
88    pub fn name(&self) -> &str {
89        &self.name
90    }
91
92    /// Stable endpoint identity, including credentials but excluding its name.
93    pub fn id(&self) -> String {
94        format!("{:x}", Sha256::digest(self.endpoint.as_bytes()))
95    }
96
97    /// Full endpoint URL, including any credentials. Do not log it.
98    pub fn url(&self) -> &str {
99        &self.endpoint
100    }
101
102    /// The node's transport protocol.
103    pub fn kind(&self) -> ProxyKind {
104        match self.endpoint.split(':').next() {
105            Some("https") => ProxyKind::Https,
106            Some("socks5") => ProxyKind::Socks5,
107            Some("socks5h") => ProxyKind::Socks5h,
108            _ => ProxyKind::Http,
109        }
110    }
111}
112
113impl fmt::Debug for ProxyNode {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter
116            .debug_struct("ProxyNode")
117            .field("id", &self.id())
118            .field("protocol", &self.kind().scheme())
119            .finish_non_exhaustive()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn identity_ignores_name_but_includes_credentials() {
129        let first = ProxyNode::from_url("http://user:secret@localhost:8080#one").unwrap();
130        let renamed = first.clone().with_name("two");
131        assert_eq!(first.id(), renamed.id());
132        assert_ne!(
133            first.id(),
134            ProxyNode::from_url("http://user:changed@localhost:8080")
135                .unwrap()
136                .id()
137        );
138        assert!(!format!("{first:?}").contains("secret"));
139    }
140
141    #[test]
142    fn validates_protocol_ports_and_endpoint_shape() {
143        for value in [
144            "ss://host:10",
145            "socks5://host",
146            "http://host:0",
147            "http://host/path",
148            "http://host?key=secret",
149        ] {
150            assert!(ProxyNode::from_url(value).is_err(), "{value}");
151        }
152        for value in [
153            "http://localhost",
154            "https://localhost",
155            "socks5h://[::1]:1080",
156        ] {
157            assert!(ProxyNode::from_url(value).is_ok(), "{value}");
158        }
159    }
160}