Skip to repository content394 lines · 15.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:51:05.428Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
proxy_handshake.rs
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