Skip to main content

io_proxy/http/
connect.rs

1//! I/O-free coroutine running the HTTP `CONNECT` tunnel handshake
2//! ([RFC 9110 §9.3.6]).
3//!
4//! Sends `CONNECT host:port HTTP/1.1` in authority-form and treats any
5//! `2xx` response as a live tunnel. The response head is read one byte at
6//! a time up to the blank-line terminator, so no tunnel payload (a TLS
7//! ServerHello, an IMAP/SMTP greeting) past the head is ever consumed:
8//! the socket is left positioned exactly at the tunnel start.
9//!
10//! Only tunnelling is implemented; plaintext HTTP forward proxying
11//! (absolute-URI request lines) and non-Basic proxy authentication
12//! (Digest, NTLM, Negotiate) are out of scope.
13//!
14//! [RFC 9110 §9.3.6]: https://www.rfc-editor.org/rfc/rfc9110#section-9.3.6
15
16use alloc::{
17    string::{String, ToString},
18    vec::Vec,
19};
20use core::fmt;
21
22use base64::{Engine, engine::general_purpose::STANDARD};
23use log::{debug, trace};
24use thiserror::Error;
25
26use crate::coroutine::{ProxyCoroutine, ProxyCoroutineState, ProxyYield};
27
28/// Cap on the proxy response head, to bound memory against a proxy that
29/// never sends the blank-line terminator.
30const MAX_HEAD_SIZE: usize = 64 * 1024;
31
32/// Failure causes during the HTTP `CONNECT` handshake.
33#[derive(Clone, Debug, Error, PartialEq, Eq)]
34pub enum HttpConnectError {
35    /// The proxy refused the tunnel with a non-2xx status.
36    #[error("HTTP CONNECT failed: proxy refused the tunnel with status {0}")]
37    Refused(u16),
38    /// The proxy status line could not be parsed.
39    #[error("HTTP CONNECT failed: malformed status line")]
40    MalformedStatus,
41    /// The response head exceeded the internal cap without terminating.
42    #[error("HTTP CONNECT failed: proxy response head too large")]
43    HeadTooLarge,
44}
45
46/// `Proxy-Authorization: Basic` credentials ([RFC 7617]).
47///
48/// The password is redacted from the [`Debug`] output. Per RFC 7617 the
49/// username must not contain a colon; that is the caller's responsibility.
50///
51/// [RFC 7617]: https://www.rfc-editor.org/rfc/rfc7617
52#[derive(Clone)]
53pub struct HttpCredentials {
54    username: String,
55    password: String,
56}
57
58impl HttpCredentials {
59    /// Builds Basic credentials from a username and password.
60    pub fn new(username: &str, password: &str) -> HttpCredentials {
61        HttpCredentials {
62            username: username.to_string(),
63            password: password.to_string(),
64        }
65    }
66
67    /// Renders the `Proxy-Authorization` header value: `Basic <base64>`.
68    fn header_value(&self) -> String {
69        let token = STANDARD.encode(format!("{}:{}", self.username, self.password));
70        format!("Basic {token}")
71    }
72}
73
74impl fmt::Debug for HttpCredentials {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_struct("HttpCredentials")
77            .field("username", &self.username)
78            .field("password", &"***")
79            .finish()
80    }
81}
82
83/// Handshake step.
84#[derive(Debug)]
85enum State {
86    /// Emit the `CONNECT` request.
87    SendRequest,
88    /// Read the response head, one byte at a time, up to `\r\n\r\n`.
89    ReadHead,
90    /// Handshake complete.
91    Done,
92}
93
94/// I/O-free HTTP `CONNECT` handshake coroutine.
95#[derive(Debug)]
96pub struct HttpConnect {
97    request: Vec<u8>,
98    head: Vec<u8>,
99    state: State,
100}
101
102impl HttpConnect {
103    /// Creates a coroutine tunnelling to `host:port`, sending
104    /// `Proxy-Authorization: Basic` when `credentials` are provided.
105    pub fn new(host: &str, port: u16, credentials: Option<HttpCredentials>) -> Self {
106        let mut request = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
107        if let Some(credentials) = &credentials {
108            request.push_str(&format!(
109                "Proxy-Authorization: {}\r\n",
110                credentials.header_value()
111            ));
112        }
113        request.push_str("\r\n");
114
115        debug!("prepare http connect handshake");
116        Self {
117            request: request.into_bytes(),
118            head: Vec::new(),
119            state: State::SendRequest,
120        }
121    }
122
123    /// Parses the status code from the accumulated head's first line.
124    fn status_code(&self) -> Result<u16, HttpConnectError> {
125        let head = String::from_utf8_lossy(&self.head);
126        let line = head.lines().next().unwrap_or_default();
127        line.split_whitespace()
128            .nth(1)
129            .and_then(|code| code.parse::<u16>().ok())
130            .ok_or(HttpConnectError::MalformedStatus)
131    }
132}
133
134impl ProxyCoroutine for HttpConnect {
135    type Yield = ProxyYield;
136    type Return = Result<(), HttpConnectError>;
137
138    fn resume(&mut self, arg: Option<&[u8]>) -> ProxyCoroutineState<Self::Yield, Self::Return> {
139        use ProxyCoroutineState::{Complete, Yielded};
140
141        match self.state {
142            State::SendRequest => {
143                trace!("requesting connect tunnel");
144                self.state = State::ReadHead;
145                Yielded(ProxyYield::WantsWrite(core::mem::take(&mut self.request)))
146            }
147
148            State::ReadHead => {
149                if let Some(data) = arg {
150                    self.head.extend_from_slice(data);
151                }
152
153                if self.head.ends_with(b"\r\n\r\n") {
154                    self.state = State::Done;
155                    let code = match self.status_code() {
156                        Ok(code) => code,
157                        Err(err) => return Complete(Err(err)),
158                    };
159                    if (200..300).contains(&code) {
160                        debug!("http tunnel established");
161                        return Complete(Ok(()));
162                    }
163                    return Complete(Err(HttpConnectError::Refused(code)));
164                }
165
166                if self.head.len() > MAX_HEAD_SIZE {
167                    self.state = State::Done;
168                    return Complete(Err(HttpConnectError::HeadTooLarge));
169                }
170
171                // one byte at a time so the terminator is never overshot
172                // into tunnel payload
173                Yielded(ProxyYield::WantsRead(1))
174            }
175
176            State::Done => panic!("HttpConnect resumed after completion"),
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn wants_write(cor: &mut HttpConnect, arg: Option<&[u8]>) -> Vec<u8> {
186        match cor.resume(arg) {
187            ProxyCoroutineState::Yielded(ProxyYield::WantsWrite(bytes)) => bytes,
188            state => panic!("expected WantsWrite, got {state:?}"),
189        }
190    }
191
192    fn complete_ok(cor: &mut HttpConnect, arg: Option<&[u8]>) {
193        match cor.resume(arg) {
194            ProxyCoroutineState::Complete(Ok(())) => {}
195            state => panic!("expected Complete(Ok), got {state:?}"),
196        }
197    }
198
199    fn complete_err(cor: &mut HttpConnect, arg: Option<&[u8]>) -> HttpConnectError {
200        match cor.resume(arg) {
201            ProxyCoroutineState::Complete(Err(err)) => err,
202            state => panic!("expected Complete(Err), got {state:?}"),
203        }
204    }
205
206    #[test]
207    fn request_authority_form_no_auth() {
208        let mut cor = HttpConnect::new("imap.example.com", 993, None);
209        let req = wants_write(&mut cor, None);
210        assert_eq!(
211            req,
212            b"CONNECT imap.example.com:993 HTTP/1.1\r\nHost: imap.example.com:993\r\n\r\n"
213        );
214    }
215
216    #[test]
217    fn request_includes_basic_auth() {
218        let creds = HttpCredentials::new("user", "pass");
219        let mut cor = HttpConnect::new("h", 1, Some(creds));
220        let req = wants_write(&mut cor, None);
221        let req = String::from_utf8(req).unwrap();
222        // base64("user:pass") = dXNlcjpwYXNz
223        assert!(req.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n"));
224    }
225
226    #[test]
227    fn established_on_2xx() {
228        let mut cor = HttpConnect::new("h", 1, None);
229        wants_write(&mut cor, None);
230        // request WantsRead(1) first; feeding the whole head at once still
231        // completes because the coroutine scans for the terminator.
232        assert!(matches!(
233            cor.resume(None),
234            ProxyCoroutineState::Yielded(ProxyYield::WantsRead(1))
235        ));
236        complete_ok(
237            &mut cor,
238            Some(b"HTTP/1.1 200 Connection established\r\n\r\n"),
239        );
240    }
241
242    #[test]
243    fn accepts_http10_and_bare_200() {
244        let mut cor = HttpConnect::new("h", 1, None);
245        wants_write(&mut cor, None);
246        complete_ok(&mut cor, Some(b"HTTP/1.0 200 OK\r\n\r\n"));
247    }
248
249    #[test]
250    fn refused_on_non_2xx() {
251        let mut cor = HttpConnect::new("h", 1, None);
252        wants_write(&mut cor, None);
253        let err = complete_err(&mut cor, Some(b"HTTP/1.1 403 Forbidden\r\n\r\n"));
254        assert_eq!(err, HttpConnectError::Refused(403));
255    }
256
257    #[test]
258    fn malformed_status_line() {
259        let mut cor = HttpConnect::new("h", 1, None);
260        wants_write(&mut cor, None);
261        let err = complete_err(&mut cor, Some(b"garbage-without-code\r\n\r\n"));
262        assert_eq!(err, HttpConnectError::MalformedStatus);
263    }
264
265    #[test]
266    fn debug_redacts_password() {
267        let creds = HttpCredentials::new("alice", "secret");
268        let rendered = format!("{creds:?}");
269        assert!(rendered.contains("alice"));
270        assert!(!rendered.contains("secret"));
271    }
272}