nautilus_network/websocket/
proxy.rs1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
43use rustls::{ClientConfig, RootCertStore, pki_types::ServerName};
44use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
45use tokio_rustls::{TlsConnector, client::TlsStream};
46use url::Url;
47
48use crate::{net::TcpStream, transport::TransportError};
49
50const MAX_PROXY_RESPONSE_BYTES: usize = 16 * 1024;
55
56#[derive(Debug)]
64pub enum ProxiedStream {
65 Plain(TcpStream),
67 PlainOverTlsProxy(Box<TlsStream<TcpStream>>),
69 Tls(Box<TlsStream<TcpStream>>),
71 TlsOverTlsProxy(Box<TlsStream<TlsStream<TcpStream>>>),
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct WsTarget {
78 pub host: String,
80 pub port: u16,
82 pub is_tls: bool,
84}
85
86impl WsTarget {
87 pub fn parse(url: &str) -> Result<Self, TransportError> {
94 let parsed =
95 Url::parse(url).map_err(|e| TransportError::InvalidUrl(format!("{url}: {e}")))?;
96
97 let is_tls = match parsed.scheme() {
98 "ws" => false,
99 "wss" => true,
100 other => {
101 return Err(TransportError::InvalidUrl(format!(
102 "expected ws:// or wss:// scheme, was {other}"
103 )));
104 }
105 };
106
107 let raw_host = parsed
108 .host_str()
109 .ok_or_else(|| TransportError::InvalidUrl("missing hostname".to_string()))?;
110
111 let host = if raw_host.starts_with('[') && raw_host.ends_with(']') {
114 raw_host[1..raw_host.len() - 1].to_string()
115 } else {
116 raw_host.to_string()
117 };
118
119 let port = parsed.port().unwrap_or(if is_tls { 443 } else { 80 });
120
121 Ok(Self { host, port, is_tls })
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum ProxyKind {
133 Http(ProxyTarget),
135 Unsupported {
137 scheme: String,
139 },
140}
141
142impl ProxyKind {
143 pub fn parse(url: &str) -> Result<Self, TransportError> {
151 let parsed =
152 Url::parse(url).map_err(|e| TransportError::InvalidUrl(format!("{url}: {e}")))?;
153
154 match parsed.scheme() {
155 "http" | "https" => ProxyTarget::parse(url).map(ProxyKind::Http),
156 scheme @ ("socks5" | "socks5h" | "socks4" | "socks4a") => {
157 if parsed.host_str().is_none_or(str::is_empty) {
162 return Err(TransportError::InvalidUrl(format!(
163 "proxy URL '{url}' is missing a host (did you mean {scheme}://...)?"
164 )));
165 }
166 Ok(Self::Unsupported {
167 scheme: scheme.to_string(),
168 })
169 }
170 other => Err(TransportError::InvalidUrl(format!(
171 "unsupported proxy scheme '{other}'; expected http:// or https://"
172 ))),
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ProxyTarget {
180 pub host: String,
183 pub port: u16,
185 pub is_tls: bool,
187 pub auth_header: Option<String>,
190}
191
192impl ProxyTarget {
193 pub fn parse(url: &str) -> Result<Self, TransportError> {
204 let parsed =
205 Url::parse(url).map_err(|e| TransportError::InvalidUrl(format!("{url}: {e}")))?;
206
207 let is_tls = match parsed.scheme() {
208 "http" => false,
209 "https" => true,
210 "socks5" | "socks5h" | "socks4" | "socks4a" => {
211 return Err(TransportError::InvalidUrl(format!(
212 "SOCKS proxy scheme '{}' is not yet supported for WebSocket connections; \
213 use an http:// or https:// proxy",
214 parsed.scheme()
215 )));
216 }
217 other => {
218 return Err(TransportError::InvalidUrl(format!(
219 "unsupported proxy scheme '{other}'; expected http:// or https://"
220 )));
221 }
222 };
223
224 let raw_host = parsed
225 .host_str()
226 .ok_or_else(|| TransportError::InvalidUrl("proxy URL missing hostname".to_string()))?;
227
228 let host = if raw_host.starts_with('[') && raw_host.ends_with(']') {
232 raw_host[1..raw_host.len() - 1].to_string()
233 } else {
234 raw_host.to_string()
235 };
236
237 let port = parsed.port().unwrap_or(if is_tls { 443 } else { 80 });
238
239 let auth_header = if parsed.username().is_empty() {
240 None
241 } else {
242 let username = decode_userinfo(parsed.username());
243 let password = decode_userinfo(parsed.password().unwrap_or(""));
244 let credentials = format!("{username}:{password}");
245 Some(format!("Basic {}", BASE64.encode(credentials)))
246 };
247
248 Ok(Self {
249 host,
250 port,
251 is_tls,
252 auth_header,
253 })
254 }
255}
256
257fn decode_userinfo(value: &str) -> String {
261 let bytes = nautilus_core::string::urlencoding::decode_bytes(value.as_bytes());
262 String::from_utf8_lossy(&bytes).into_owned()
263}
264
265pub async fn tunnel_via_proxy(
281 target: &WsTarget,
282 proxy: &ProxyTarget,
283) -> Result<ProxiedStream, TransportError> {
284 let tcp = TcpStream::connect((proxy.host.as_str(), proxy.port))
285 .await
286 .map_err(TransportError::Io)?;
287
288 if let Err(e) = tcp.set_nodelay(true) {
289 log::warn!("Failed to enable TCP_NODELAY on proxy connection: {e:?}");
290 }
291
292 if proxy.is_tls {
293 let proxy_tls = wrap_tls(tcp, &proxy.host).await?;
294 let tunneled = send_connect(proxy_tls, target, proxy).await?;
295 if target.is_tls {
296 let upstream = wrap_tls(tunneled, &target.host).await?;
297 Ok(ProxiedStream::TlsOverTlsProxy(Box::new(upstream)))
298 } else {
299 Ok(ProxiedStream::PlainOverTlsProxy(Box::new(tunneled)))
300 }
301 } else {
302 let tunneled = send_connect(tcp, target, proxy).await?;
303 if target.is_tls {
304 let upstream = wrap_tls(tunneled, &target.host).await?;
305 Ok(ProxiedStream::Tls(Box::new(upstream)))
306 } else {
307 Ok(ProxiedStream::Plain(tunneled))
308 }
309 }
310}
311
312async fn send_connect<S>(
316 mut stream: S,
317 target: &WsTarget,
318 proxy: &ProxyTarget,
319) -> Result<S, TransportError>
320where
321 S: AsyncRead + AsyncWrite + Unpin,
322{
323 let host_header = format_host_header(&target.host, target.port);
324 let mut request = format!(
325 "CONNECT {host_header} HTTP/1.1\r\n\
326 Host: {host_header}\r\n\
327 Proxy-Connection: Keep-Alive\r\n"
328 );
329
330 if let Some(auth) = &proxy.auth_header {
331 request.push_str("Proxy-Authorization: ");
332 request.push_str(auth);
333 request.push_str("\r\n");
334 }
335 request.push_str("\r\n");
336
337 stream
338 .write_all(request.as_bytes())
339 .await
340 .map_err(TransportError::Io)?;
341 stream.flush().await.map_err(TransportError::Io)?;
342
343 read_connect_response(&mut stream).await?;
344 Ok(stream)
345}
346
347fn format_host_header(host: &str, port: u16) -> String {
348 if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
349 format!("[{host}]:{port}")
350 } else {
351 format!("{host}:{port}")
352 }
353}
354
355async fn read_connect_response<S>(stream: &mut S) -> Result<(), TransportError>
358where
359 S: AsyncRead + Unpin,
360{
361 let mut buf = Vec::with_capacity(512);
362 let mut byte = [0u8; 1];
363
364 loop {
365 let n = stream.read(&mut byte).await.map_err(TransportError::Io)?;
366 if n == 0 {
367 return Err(TransportError::Handshake(
368 "proxy closed connection before sending CONNECT response".to_string(),
369 ));
370 }
371
372 buf.push(byte[0]);
373
374 if buf.ends_with(b"\r\n\r\n") {
375 break;
376 }
377
378 if buf.len() > MAX_PROXY_RESPONSE_BYTES {
379 return Err(TransportError::Handshake(format!(
380 "proxy CONNECT response exceeded {MAX_PROXY_RESPONSE_BYTES} bytes without terminator"
381 )));
382 }
383 }
384
385 let text = std::str::from_utf8(&buf).map_err(|_| {
386 TransportError::Handshake("proxy CONNECT response was not valid UTF-8".to_string())
387 })?;
388
389 let status_line = text.lines().next().ok_or_else(|| {
390 TransportError::Handshake("proxy CONNECT response missing status line".to_string())
391 })?;
392
393 let mut parts = status_line.splitn(3, ' ');
395 let _version = parts.next().ok_or_else(|| {
396 TransportError::Handshake(format!("malformed status line: {status_line}"))
397 })?;
398 let status_code = parts
399 .next()
400 .ok_or_else(|| TransportError::Handshake(format!("malformed status line: {status_line}")))?
401 .parse::<u16>()
402 .map_err(|_| TransportError::Handshake(format!("non-numeric status: {status_line}")))?;
403
404 if !(200..300).contains(&status_code) {
405 return Err(TransportError::Handshake(format!(
406 "proxy refused CONNECT: {status_line}"
407 )));
408 }
409
410 Ok(())
411}
412
413async fn wrap_tls<S>(stream: S, server_name: &str) -> Result<TlsStream<S>, TransportError>
415where
416 S: AsyncRead + AsyncWrite + Unpin,
417{
418 let mut root_store = RootCertStore::empty();
419 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
420
421 let config = ClientConfig::builder()
422 .with_root_certificates(root_store)
423 .with_no_client_auth();
424
425 let connector = TlsConnector::from(std::sync::Arc::new(config));
426 let domain = ServerName::try_from(server_name.to_string())
427 .map_err(|e| TransportError::Tls(format!("invalid DNS name '{server_name}': {e}")))?;
428
429 connector
430 .connect(domain, stream)
431 .await
432 .map_err(TransportError::Io)
433}
434
435#[cfg(test)]
436#[cfg(not(feature = "turmoil"))] mod tests {
438 use std::net::SocketAddr;
439
440 use rstest::rstest;
441 use tokio::net::TcpListener;
442
443 use super::*;
444
445 #[rstest]
446 fn ws_target_parses_wss() {
447 let target = WsTarget::parse("wss://stream.binance.com:9443/ws/btcusdt@trade").unwrap();
448 assert_eq!(target.host, "stream.binance.com");
449 assert_eq!(target.port, 9443);
450 assert!(target.is_tls);
451 }
452
453 #[rstest]
454 fn ws_target_default_ports() {
455 let plain = WsTarget::parse("ws://example.com/path").unwrap();
456 assert_eq!(plain.port, 80);
457 assert!(!plain.is_tls);
458
459 let tls = WsTarget::parse("wss://example.com/path").unwrap();
460 assert_eq!(tls.port, 443);
461 assert!(tls.is_tls);
462 }
463
464 #[rstest]
465 fn ws_target_strips_ipv6_brackets() {
466 let target = WsTarget::parse("wss://[::1]:9443/ws").unwrap();
467 assert_eq!(target.host, "::1");
468 assert_eq!(target.port, 9443);
469 }
470
471 #[rstest]
472 fn ws_target_rejects_non_ws_scheme() {
473 let err = WsTarget::parse("https://example.com").unwrap_err();
474 assert!(matches!(err, TransportError::InvalidUrl(_)));
475 }
476
477 #[rstest]
478 fn proxy_target_parses_http() {
479 let proxy = ProxyTarget::parse("http://127.0.0.1:9999").unwrap();
480 assert_eq!(proxy.host, "127.0.0.1");
481 assert_eq!(proxy.port, 9999);
482 assert!(!proxy.is_tls);
483 assert!(proxy.auth_header.is_none());
484 }
485
486 #[rstest]
487 fn proxy_target_default_ports() {
488 let plain = ProxyTarget::parse("http://proxy.example.com").unwrap();
489 assert_eq!(plain.port, 80);
490 let tls = ProxyTarget::parse("https://proxy.example.com").unwrap();
491 assert_eq!(tls.port, 443);
492 assert!(tls.is_tls);
493 }
494
495 #[rstest]
496 fn proxy_target_basic_auth() {
497 let proxy =
498 ProxyTarget::parse("http://proxytest:fixture42@proxy.example.com:8080").unwrap();
499 assert_eq!(
501 proxy.auth_header.unwrap(),
502 "Basic cHJveHl0ZXN0OmZpeHR1cmU0Mg=="
503 );
504 }
505
506 #[rstest]
507 fn proxy_target_basic_auth_decodes_percent_encoded() {
508 let proxy = ProxyTarget::parse("http://us%2Fer:p%40ss@proxy.example.com:8080").unwrap();
510 let header = proxy.auth_header.unwrap();
511 assert_eq!(header, "Basic dXMvZXI6cEBzcw==");
513 }
514
515 #[tokio::test]
516 async fn send_connect_includes_proxy_authorization() {
517 let (client, mut server) = tokio::io::duplex(1024);
518 let target = WsTarget::parse("ws://example.com:80/path").unwrap();
519 let proxy =
520 ProxyTarget::parse("http://proxytest:fixture42@proxy.example.com:8080").unwrap();
521
522 let server_task = tokio::spawn(async move {
523 let mut request = Vec::new();
524 let mut chunk = [0; 64];
525 loop {
526 let n = server.read(&mut chunk).await.unwrap();
527 request.extend_from_slice(&chunk[..n]);
528 if request.ends_with(b"\r\n\r\n") {
529 break;
530 }
531 }
532
533 let request = String::from_utf8(request).unwrap();
534 assert_eq!(
535 request,
536 "CONNECT example.com:80 HTTP/1.1\r\n\
537 Host: example.com:80\r\n\
538 Proxy-Connection: Keep-Alive\r\n\
539 Proxy-Authorization: Basic cHJveHl0ZXN0OmZpeHR1cmU0Mg==\r\n\r\n"
540 );
541 server
542 .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
543 .await
544 });
545
546 let _stream = send_connect(client, &target, &proxy).await.unwrap();
547 server_task.await.unwrap().unwrap();
548 }
549
550 #[rstest]
551 fn proxy_target_strips_ipv6_brackets() {
552 let proxy = ProxyTarget::parse("http://[::1]:8080").unwrap();
553 assert_eq!(proxy.host, "::1");
554 assert_eq!(proxy.port, 8080);
555 }
556
557 #[rstest]
558 fn proxy_target_rejects_socks() {
559 let err = ProxyTarget::parse("socks5://127.0.0.1:1080").unwrap_err();
560 let TransportError::InvalidUrl(msg) = err else {
561 panic!("expected InvalidUrl");
562 };
563 assert!(msg.contains("SOCKS"));
564 }
565
566 #[rstest]
567 fn proxy_kind_classifies_http() {
568 let kind = ProxyKind::parse("http://127.0.0.1:9999").unwrap();
569 assert!(matches!(kind, ProxyKind::Http(_)));
570 }
571
572 #[rstest]
573 fn proxy_kind_classifies_socks_as_unsupported() {
574 let kind = ProxyKind::parse("socks5://127.0.0.1:1080").unwrap();
575 let ProxyKind::Unsupported { scheme } = kind else {
576 panic!("expected Unsupported");
577 };
578 assert_eq!(scheme, "socks5");
579 }
580
581 #[rstest]
582 fn proxy_kind_rejects_garbage() {
583 assert!(ProxyKind::parse("ftp://x").is_err());
584 assert!(ProxyKind::parse("").is_err());
585 }
586
587 #[rstest]
588 fn proxy_kind_rejects_socks_without_authority() {
589 let err = ProxyKind::parse("socks5:127.0.0.1:1080").unwrap_err();
592 assert!(matches!(err, TransportError::InvalidUrl(_)));
593 }
594
595 #[rstest]
596 fn proxy_target_rejects_unknown_scheme() {
597 let err = ProxyTarget::parse("ftp://proxy.example.com").unwrap_err();
598 assert!(matches!(err, TransportError::InvalidUrl(_)));
599 }
600
601 #[rstest]
602 fn proxy_target_rejects_empty() {
603 let err = ProxyTarget::parse("").unwrap_err();
604 assert!(matches!(err, TransportError::InvalidUrl(_)));
605 }
606
607 #[rstest]
608 fn host_header_brackets_ipv6() {
609 assert_eq!(format_host_header("example.com", 443), "example.com:443");
610 assert_eq!(format_host_header("::1", 443), "[::1]:443");
611 assert_eq!(format_host_header("[::1]", 443), "[::1]:443");
612 }
613
614 async fn spawn_fake_proxy(response: &'static [u8]) -> SocketAddr {
617 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
618 let addr = listener.local_addr().unwrap();
619 tokio::spawn(async move {
620 let (mut stream, _) = listener.accept().await.unwrap();
621 let mut buf = vec![0u8; 1024];
622 loop {
624 let n = AsyncReadExt::read(&mut stream, &mut buf).await.unwrap();
625 if n == 0 {
626 break;
627 }
628
629 if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
630 break;
631 }
632 }
633 stream.write_all(response).await.unwrap();
634 stream.flush().await.unwrap();
635 });
636 addr
637 }
638
639 #[tokio::test]
640 async fn read_connect_response_accepts_2xx() {
641 let addr = spawn_fake_proxy(b"HTTP/1.1 200 Connection established\r\n\r\n").await;
642 let mut stream = TcpStream::connect(addr).await.unwrap();
643 stream
644 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
645 .await
646 .unwrap();
647 stream.flush().await.unwrap();
648 read_connect_response(&mut stream).await.unwrap();
649 }
650
651 #[tokio::test]
652 async fn read_connect_response_rejects_403() {
653 let addr = spawn_fake_proxy(b"HTTP/1.1 403 Forbidden\r\n\r\n").await;
654 let mut stream = TcpStream::connect(addr).await.unwrap();
655 stream
656 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
657 .await
658 .unwrap();
659 stream.flush().await.unwrap();
660 let err = read_connect_response(&mut stream).await.unwrap_err();
661 let TransportError::Handshake(msg) = err else {
662 panic!("expected Handshake error");
663 };
664 assert!(msg.contains("403"));
665 }
666
667 #[rstest]
672 #[case::status_300(&b"HTTP/1.1 300 Multiple Choices\r\n\r\n"[..], "300")]
673 #[case::status_407(
674 &b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n"[..],
675 "407",
676 )]
677 #[case::malformed_status(&b"HTTP/1.1 abc Boom\r\n\r\n"[..], "non-numeric")]
678 #[tokio::test]
679 async fn read_connect_response_rejects_non_2xx(
680 #[case] response: &'static [u8],
681 #[case] expected_msg_substring: &'static str,
682 ) {
683 let addr = spawn_fake_proxy(response).await;
684 let mut stream = TcpStream::connect(addr).await.unwrap();
685 stream
686 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
687 .await
688 .unwrap();
689 stream.flush().await.unwrap();
690 let err = read_connect_response(&mut stream).await.unwrap_err();
691 let TransportError::Handshake(msg) = err else {
692 panic!("expected Handshake error, was {err:?}");
693 };
694 assert!(
695 msg.contains(expected_msg_substring),
696 "expected error message to contain {expected_msg_substring:?}, was {msg:?}"
697 );
698 }
699
700 #[tokio::test]
703 async fn read_connect_response_rejects_eof_before_terminator() {
704 let addr = spawn_fake_proxy(b"HTTP/1.1 200 OK\r\n").await;
706 let mut stream = TcpStream::connect(addr).await.unwrap();
707 stream
708 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
709 .await
710 .unwrap();
711 stream.flush().await.unwrap();
712 let err = read_connect_response(&mut stream).await.unwrap_err();
713 let TransportError::Handshake(msg) = err else {
714 panic!("expected Handshake error, was {err:?}");
715 };
716 assert!(
717 msg.contains("closed connection"),
718 "unexpected handshake error: {msg}"
719 );
720 }
721
722 #[tokio::test]
725 async fn read_connect_response_rejects_oversize_headers() {
726 let mut response = b"HTTP/1.1 200 OK\r\n".to_vec();
727 while response.len() <= MAX_PROXY_RESPONSE_BYTES {
728 response.extend_from_slice(b"X-Pad: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n");
729 }
730 let leaked: &'static [u8] = response.leak();
731 let addr = spawn_fake_proxy(leaked).await;
732 let mut stream = TcpStream::connect(addr).await.unwrap();
733 stream
734 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
735 .await
736 .unwrap();
737 stream.flush().await.unwrap();
738 let err = read_connect_response(&mut stream).await.unwrap_err();
739 let TransportError::Handshake(msg) = err else {
740 panic!("expected Handshake error, was {err:?}");
741 };
742 assert!(
743 msg.contains("exceeded"),
744 "unexpected handshake error: {msg}"
745 );
746 }
747
748 #[tokio::test]
752 async fn read_connect_response_preserves_trailing_bytes() {
753 let addr = spawn_fake_proxy(b"HTTP/1.1 200 Connection established\r\n\r\nLEFTOVER").await;
754 let mut stream = TcpStream::connect(addr).await.unwrap();
755 stream
756 .write_all(b"CONNECT host:443 HTTP/1.1\r\nHost: host:443\r\n\r\n")
757 .await
758 .unwrap();
759 stream.flush().await.unwrap();
760 read_connect_response(&mut stream).await.unwrap();
761
762 let mut tail = [0u8; b"LEFTOVER".len()];
763 AsyncReadExt::read_exact(&mut stream, &mut tail)
764 .await
765 .unwrap();
766 assert_eq!(&tail, b"LEFTOVER");
767 }
768}