1use 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
28const MAX_HEAD_SIZE: usize = 64 * 1024;
31
32#[derive(Clone, Debug, Error, PartialEq, Eq)]
34pub enum HttpConnectError {
35 #[error("HTTP CONNECT failed: proxy refused the tunnel with status {0}")]
37 Refused(u16),
38 #[error("HTTP CONNECT failed: malformed status line")]
40 MalformedStatus,
41 #[error("HTTP CONNECT failed: proxy response head too large")]
43 HeadTooLarge,
44}
45
46#[derive(Clone)]
53pub struct HttpCredentials {
54 username: String,
55 password: String,
56}
57
58impl HttpCredentials {
59 pub fn new(username: &str, password: &str) -> HttpCredentials {
61 HttpCredentials {
62 username: username.to_string(),
63 password: password.to_string(),
64 }
65 }
66
67 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#[derive(Debug)]
85enum State {
86 SendRequest,
88 ReadHead,
90 Done,
92}
93
94#[derive(Debug)]
96pub struct HttpConnect {
97 request: Vec<u8>,
98 head: Vec<u8>,
99 state: State,
100}
101
102impl HttpConnect {
103 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 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 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 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 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}