Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:51:05.428Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

proxy_handshake.rs

394 lines · 15.5 KB · rust
1//! Sans-IO client handshakes for HTTP CONNECT and SOCKS proxies.
2//!
3//! Speaks the client side of the protocols needed to tunnel a TCP connection
4//! through a proxy — HTTP CONNECT (RFC 9110 §9.3.6), SOCKS4/4a, and SOCKS5
5//! (RFC 1928, with RFC 1929 username/password authentication) — as a pure
6//! state machine that never touches a socket. Callers own all I/O: resolving
7//! and connecting to the proxy, wrapping the connection in TLS when the proxy
8//! requires it, and pumping bytes between the [`Handshake`] and their
9//! transport. The `futures-io` and `tokio` features add ready-made drivers
10//! for the corresponding I/O traits.
11//!
12//! ```
13//! use proxy_handshake::{Handshake, ProxySpec, Step, Target, Url};
14//!
15//! let url: Url = "http://localhost:8888".parse().unwrap();
16//! let spec = ProxySpec::parse(&url).unwrap();
17//! let target = Target::Domain("example.com".into(), 443);
18//! let mut handshake = Handshake::new(&spec, &target).unwrap();
19//!
20//! // The sans-IO loop: write `Send` bytes to the proxy, feed bytes the proxy
21//! // sent back through `advance`, and start using the stream for the
22//! // tunneled protocol once `Done` is returned.
23//! let Ok(Step::Send(connect_request)) = handshake.advance(&[]) else {
24//!     unreachable!()
25//! };
26//! assert!(connect_request.starts_with(b"CONNECT example.com:443 HTTP/1.1\r\n"));
27//! ```
28
29mod handshake;
30
31#[cfg(feature = "futures-io")]
32pub mod futures_io;
33#[cfg(feature = "tokio")]
34pub mod tokio;
35
36use std::net::SocketAddr;
37
38pub use handshake::{Handshake, Step};
39pub use url::Url;
40
41/// How to reach and authenticate with a proxy: the validated interpretation
42/// of a proxy [`Url`].
43///
44/// A raw [`Url`] can hold any scheme, leaves default ports implicit, and
45/// carries credentials percent-encoded. Parsing into a `ProxySpec` settles
46/// all of that once — unsupported schemes are rejected, scheme-specific
47/// default ports are applied, and credentials are decoded — so the handshake
48/// and its callers never re-derive them.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ProxySpec {
51    pub scheme: ProxyScheme,
52    /// The proxy's host: a domain name or an IP address literal (without
53    /// brackets for IPv6).
54    pub host: String,
55    pub port: u16,
56    pub credentials: Option<Credentials>,
57}
58
59/// The protocol the proxy speaks, taken from the proxy URL's scheme.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ProxyScheme {
62    /// An HTTP CONNECT proxy (`http://` or `https://`). `tls` is whether the
63    /// connection *to the proxy itself* is wrapped in TLS (an `https://`
64    /// proxy URL); it is independent of any TLS the tunneled protocol layers
65    /// inside the tunnel.
66    Http { tls: bool },
67    /// A SOCKS4 proxy (`socks4://` or `socks4a://`).
68    Socks4 { remote_dns: bool },
69    /// A SOCKS5 proxy (`socks5://` or `socks5h://`).
70    Socks5 { remote_dns: bool },
71}
72
73/// Proxy credentials, percent-decoded from the URL's userinfo component.
74#[derive(Clone, PartialEq, Eq)]
75pub struct Credentials {
76    pub username: String,
77    pub password: String,
78}
79
80/// Redacts both fields so credentials can't leak through debug formatting of
81/// any containing type. Implemented manually (rather than leaving `Debug`
82/// underived) so a future `#[derive(Debug)]` on this struct is a conscious
83/// choice instead of an accident.
84impl std::fmt::Debug for Credentials {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("Credentials")
87            .field("username", &"<redacted>")
88            .field("password", &"<redacted>")
89            .finish()
90    }
91}
92
93/// The destination to tunnel to.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Target {
96    /// An unresolved host name and port: DNS resolution is delegated to the
97    /// proxy. With SOCKS4 proxies this is encoded using the SOCKS4a
98    /// extension, which not every SOCKS4 proxy supports.
99    ///
100    /// The host must be a name, not arbitrary text: strings containing ASCII
101    /// control bytes are rejected by [`Handshake::new`], since they could
102    /// otherwise forge protocol structure in the messages the host is
103    /// embedded in.
104    Domain(String, u16),
105    /// An already-resolved socket address. Use this when
106    /// [`ProxySpec::remote_dns`] is false, resolving the host locally first.
107    Address(SocketAddr),
108}
109
110/// The size cap on an HTTP CONNECT response head; a proxy that sends more
111/// than this before the blank line is misbehaving.
112pub const MAX_HTTP_RESPONSE_LENGTH: usize = 8192;
113
114#[derive(Debug, thiserror::Error)]
115pub enum ProxyError {
116    #[error(
117        "unsupported proxy scheme `{0}` (expected http, https, socks4, socks4a, socks5, or socks5h)"
118    )]
119    UnsupportedScheme(String),
120    #[error("proxy URL is missing a host")]
121    MissingHost,
122    #[error("proxy credentials are not valid UTF-8 after percent-decoding")]
123    InvalidCredentials,
124    #[error("SOCKS5 usernames and passwords are limited to 255 bytes")]
125    CredentialsTooLong,
126    #[error("SOCKS target domains are limited to 255 bytes")]
127    DomainTooLong,
128    #[error("target domain contains ASCII control bytes")]
129    DomainContainsControlBytes,
130    #[error("SOCKS4 proxies only support IPv4 targets")]
131    Socks4Ipv4Only,
132    #[error("SOCKS4 user ids cannot contain NUL bytes")]
133    Socks4UserIdContainsNul,
134    #[error("proxy sent a malformed HTTP response: {0}")]
135    MalformedHttpResponse(String),
136    #[error("proxy refused CONNECT with HTTP status {0}")]
137    HttpConnectRefused(u16),
138    #[error("HTTP response from proxy exceeded {MAX_HTTP_RESPONSE_LENGTH} bytes")]
139    HttpResponseTooLarge,
140    #[error("proxy replied with SOCKS version {0:#04x}")]
141    UnexpectedSocksVersion(u8),
142    #[error("SOCKS5 proxy rejected the offered authentication method (selected {selected:#04x})")]
143    AuthMethodRejected { selected: u8 },
144    #[error("SOCKS5 proxy rejected the username/password")]
145    AuthenticationFailed,
146    #[error("SOCKS5 proxy refused the connection: {}", socks5_refusal_message(*.0))]
147    Socks5ConnectRefused(u8),
148    #[error("SOCKS5 proxy sent unknown address type {0:#04x}")]
149    UnknownAddressType(u8),
150    #[error("SOCKS4 proxy rejected the connection (code {0:#04x})")]
151    Socks4ConnectRefused(u8),
152}
153
154/// Errors from a driver establishing a tunnel: either the proxy conversation
155/// failed or the transport did.
156#[cfg(any(feature = "futures-io", feature = "tokio"))]
157#[derive(Debug, thiserror::Error)]
158pub enum EstablishError {
159    #[error(transparent)]
160    Proxy(#[from] ProxyError),
161    #[error(transparent)]
162    Io(#[from] std::io::Error),
163}
164
165impl ProxySpec {
166    /// Parses a proxy URL with an `http`, `https`, `socks4`, `socks4a`,
167    /// `socks5`, or `socks5h` scheme. Missing ports default by scheme (80,
168    /// 443, or 1080). Credentials in the userinfo component are
169    /// percent-decoded, per RFC 3986 §3.2.1.
170    pub fn parse(url: &Url) -> Result<Self, ProxyError> {
171        let scheme = match url.scheme() {
172            "http" => ProxyScheme::Http { tls: false },
173            "https" => ProxyScheme::Http { tls: true },
174            "socks4" => ProxyScheme::Socks4 { remote_dns: false },
175            "socks4a" => ProxyScheme::Socks4 { remote_dns: true },
176            "socks5" => ProxyScheme::Socks5 { remote_dns: false },
177            "socks5h" => ProxyScheme::Socks5 { remote_dns: true },
178            other => return Err(ProxyError::UnsupportedScheme(other.to_string())),
179        };
180
181        let host = match url.host().ok_or(ProxyError::MissingHost)? {
182            url::Host::Domain(domain) => domain.to_string(),
183            url::Host::Ipv4(ip) => ip.to_string(),
184            url::Host::Ipv6(ip) => ip.to_string(),
185        };
186        let default_port = match scheme {
187            ProxyScheme::Http { tls: false } => 80,
188            ProxyScheme::Http { tls: true } => 443,
189            ProxyScheme::Socks4 { .. } | ProxyScheme::Socks5 { .. } => 1080,
190        };
191        let port = url.port().unwrap_or(default_port);
192
193        let credentials = if url.username().is_empty() && url.password().is_none() {
194            None
195        } else {
196            Some(Credentials {
197                username: decode_userinfo(url.username())?,
198                password: decode_userinfo(url.password().unwrap_or_default())?,
199            })
200        };
201
202        Ok(Self {
203            scheme,
204            host,
205            port,
206            credentials,
207        })
208    }
209
210    /// Whether the target should be handed to the proxy as a host name
211    /// ([`Target::Domain`]) instead of being resolved locally. True for HTTP
212    /// CONNECT and the `socks4a`/`socks5h` schemes, matching curl's scheme
213    /// semantics.
214    pub fn remote_dns(&self) -> bool {
215        match self.scheme {
216            ProxyScheme::Http { .. } => true,
217            ProxyScheme::Socks4 { remote_dns } | ProxyScheme::Socks5 { remote_dns } => remote_dns,
218        }
219    }
220
221    /// Whether the connection to the proxy itself must be wrapped in TLS
222    /// before the handshake starts (an `https://` proxy URL).
223    pub fn tls(&self) -> bool {
224        matches!(self.scheme, ProxyScheme::Http { tls: true })
225    }
226}
227
228/// Decodes one component (username or password) of a URL's userinfo, which
229/// RFC 3986 §3.2.1 defines as percent-encoded: the proxy must be sent the
230/// decoded form.
231fn decode_userinfo(component: &str) -> Result<String, ProxyError> {
232    percent_encoding::percent_decode_str(component)
233        .decode_utf8()
234        .map(|decoded| decoded.into_owned())
235        .map_err(|_| ProxyError::InvalidCredentials)
236}
237
238/// Matches `host` against a `NO_PROXY`-style exclusion list: comma-separated
239/// entries that are `*`, an exact host, or a domain suffix (bare, with a
240/// leading dot, or with a leading `*.`). Entries may carry a `:port`, which
241/// is ignored — exclusion is by host. CIDR ranges are not supported.
242pub fn no_proxy_matches(no_proxy: &str, host: &str) -> bool {
243    no_proxy
244        .split(',')
245        .map(str::trim)
246        .filter(|entry| !entry.is_empty())
247        .any(|entry| {
248            if entry == "*" {
249                return true;
250            }
251            // Bare IPv6 literals also contain colons, so only strip a port
252            // when there is exactly one colon and the suffix parses as one.
253            let entry = match entry.rsplit_once(':') {
254                Some((prefix, suffix))
255                    if !prefix.contains(':') && suffix.parse::<u16>().is_ok() =>
256                {
257                    prefix
258                }
259                _ => entry,
260            };
261            let entry = entry.strip_prefix("*.").unwrap_or(entry);
262            let entry = entry.strip_prefix('.').unwrap_or(entry);
263            host.eq_ignore_ascii_case(entry)
264                || (host.len() > entry.len()
265                    && host.as_bytes()[host.len() - entry.len() - 1] == b'.'
266                    && host[host.len() - entry.len()..].eq_ignore_ascii_case(entry))
267        })
268}
269
270/// Human-readable descriptions of the SOCKS5 reply field's error codes,
271/// verbatim from RFC 1928 §6.
272fn socks5_refusal_message(code: u8) -> String {
273    match code {
274        0x01 => "general SOCKS server failure".to_string(),
275        0x02 => "connection not allowed by ruleset".to_string(),
276        0x03 => "network unreachable".to_string(),
277        0x04 => "host unreachable".to_string(),
278        0x05 => "connection refused".to_string(),
279        0x06 => "TTL expired".to_string(),
280        0x07 => "command not supported".to_string(),
281        0x08 => "address type not supported".to_string(),
282        other => format!("unknown error code {other:#04x}"),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn parse_applies_default_ports_by_scheme() {
292        assert_eq!(parse("http://proxy.example.com").port, 80);
293        assert_eq!(parse("https://proxy.example.com").port, 443);
294        assert_eq!(parse("socks4://proxy.example.com").port, 1080);
295        assert_eq!(parse("socks5://proxy.example.com").port, 1080);
296        assert_eq!(parse("http://proxy.example.com:3128").port, 3128);
297    }
298
299    #[test]
300    fn parse_maps_schemes() {
301        assert_eq!(parse("http://p").scheme, ProxyScheme::Http { tls: false });
302        assert_eq!(parse("https://p").scheme, ProxyScheme::Http { tls: true });
303        assert_eq!(
304            parse("socks4://p").scheme,
305            ProxyScheme::Socks4 { remote_dns: false }
306        );
307        assert_eq!(
308            parse("socks4a://p").scheme,
309            ProxyScheme::Socks4 { remote_dns: true }
310        );
311        assert_eq!(
312            parse("socks5://p").scheme,
313            ProxyScheme::Socks5 { remote_dns: false }
314        );
315        assert_eq!(
316            parse("socks5h://p").scheme,
317            ProxyScheme::Socks5 { remote_dns: true }
318        );
319    }
320
321    #[test]
322    fn parse_rejects_unknown_scheme() {
323        let url: Url = "ftp://proxy.example.com".parse().unwrap();
324        let Err(error) = ProxySpec::parse(&url) else {
325            panic!("expected parsing to fail");
326        };
327        assert!(matches!(error, ProxyError::UnsupportedScheme(scheme) if scheme == "ftp"));
328    }
329
330    #[test]
331    fn parse_percent_decodes_credentials() {
332        let spec = parse("http://user:p%40ss@proxy.example.com:8080");
333        let credentials = spec.credentials.unwrap();
334        assert_eq!(credentials.username, "user");
335        assert_eq!(credentials.password, "p@ss");
336    }
337
338    #[test]
339    fn parse_supports_username_only_credentials() {
340        let spec = parse("socks4://userid@proxy.example.com");
341        let credentials = spec.credentials.unwrap();
342        assert_eq!(credentials.username, "userid");
343        assert_eq!(credentials.password, "");
344    }
345
346    #[test]
347    fn parse_unbrackets_ipv6_hosts() {
348        assert_eq!(parse("http://[::1]:8080").host, "::1");
349    }
350
351    #[test]
352    fn remote_dns_follows_scheme() {
353        assert!(parse("http://p").remote_dns());
354        assert!(parse("https://p").remote_dns());
355        assert!(!parse("socks4://p").remote_dns());
356        assert!(parse("socks4a://p").remote_dns());
357        assert!(!parse("socks5://p").remote_dns());
358        assert!(parse("socks5h://p").remote_dns());
359    }
360
361    #[test]
362    fn credentials_debug_output_is_redacted() {
363        let credentials = Credentials {
364            username: "user".to_string(),
365            password: "hunter2".to_string(),
366        };
367        let debug_output = format!("{credentials:?}");
368        assert!(!debug_output.contains("hunter2"));
369        assert!(debug_output.contains("<redacted>"));
370    }
371
372    #[test]
373    fn no_proxy_matching() {
374        assert!(no_proxy_matches("example.com", "example.com"));
375        assert!(no_proxy_matches("example.com", "sub.example.com"));
376        assert!(no_proxy_matches(".example.com", "sub.example.com"));
377        assert!(no_proxy_matches("*.example.com", "sub.example.com"));
378        assert!(no_proxy_matches("other.org, example.com", "example.com"));
379        assert!(no_proxy_matches("example.com:443", "example.com"));
380        assert!(no_proxy_matches("*", "anything.at.all"));
381        assert!(no_proxy_matches("EXAMPLE.com", "example.COM"));
382        assert!(no_proxy_matches("::1", "::1"));
383
384        assert!(!no_proxy_matches("example.com", "notexample.com"));
385        assert!(!no_proxy_matches("example.com", "example.org"));
386        assert!(!no_proxy_matches("", "example.com"));
387        assert!(!no_proxy_matches("sub.example.com", "example.com"));
388    }
389
390    fn parse(url: &str) -> ProxySpec {
391        ProxySpec::parse(&url.parse().unwrap()).unwrap()
392    }
393}
394
Served at tenant.openagents/omega Member data and write actions are omitted.