1use crate::audit;
11use crate::config::ExternalProxyConfig;
12use crate::error::{ProxyError, Result};
13use crate::filter::ProxyFilter;
14use crate::token;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::net::TcpStream;
17use tracing::debug;
18use zeroize::Zeroizing;
19
20pub async fn connect_via_proxy(
34 proxy_addr: &str,
35 target_host: &str,
36 target_port: u16,
37 proxy_auth_header: Option<&str>,
38) -> Result<TcpStream> {
39 let mut proxy_stream = TcpStream::connect(proxy_addr).await.map_err(|e| {
40 ProxyError::ExternalProxy(format!(
41 "cannot connect to external proxy {}: {}",
42 proxy_addr, e
43 ))
44 })?;
45
46 let mut connect_req = format!(
47 "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
48 target_host, target_port, target_host, target_port
49 );
50 if let Some(auth) = proxy_auth_header {
51 connect_req.push_str(&format!("Proxy-Authorization: {}\r\n", auth));
52 }
53 connect_req.push_str("\r\n");
54
55 proxy_stream
56 .write_all(connect_req.as_bytes())
57 .await
58 .map_err(|e| {
59 ProxyError::ExternalProxy(format!("failed to send CONNECT to external proxy: {}", e))
60 })?;
61
62 let mut buf_reader = BufReader::new(&mut proxy_stream);
63 let mut response_line = String::new();
64 buf_reader
65 .read_line(&mut response_line)
66 .await
67 .map_err(|e| {
68 ProxyError::ExternalProxy(format!(
69 "failed to read response from external proxy: {}",
70 e
71 ))
72 })?;
73
74 let status = parse_status_code(&response_line)?;
75 if status != 200 {
76 return Err(ProxyError::ExternalProxy(format!(
77 "enterprise proxy rejected CONNECT to {}:{} with status {}",
78 target_host, target_port, status
79 )));
80 }
81
82 loop {
84 let mut line = String::new();
85 buf_reader.read_line(&mut line).await.map_err(|e| {
86 ProxyError::ExternalProxy(format!("failed to drain proxy response headers: {}", e))
87 })?;
88 if line.trim().is_empty() {
89 break;
90 }
91 }
92 drop(buf_reader);
93 Ok(proxy_stream)
94}
95
96#[derive(Debug, Clone)]
102pub struct BypassMatcher {
103 exact: Vec<String>,
105 suffixes: Vec<String>,
107}
108
109impl BypassMatcher {
110 #[must_use]
119 pub fn new(hosts: &[String]) -> Self {
120 let mut exact = Vec::new();
121 let mut suffixes = Vec::new();
122
123 for host in hosts {
124 let lower = host.to_lowercase();
125 if let Some(suffix) = lower.strip_prefix("*.") {
126 if !suffix.is_empty() {
128 suffixes.push(format!(".{suffix}"));
129 }
130 } else {
132 exact.push(lower);
133 }
134 }
135
136 Self { exact, suffixes }
137 }
138
139 #[must_use]
141 pub fn matches(&self, host: &str) -> bool {
142 let lower = host.to_lowercase();
143
144 if self.exact.contains(&lower) {
146 return true;
147 }
148
149 for suffix in &self.suffixes {
151 if lower.ends_with(suffix.as_str()) && lower.len() > suffix.len() {
152 return true;
153 }
154 }
155
156 false
157 }
158
159 #[must_use]
161 pub fn is_empty(&self) -> bool {
162 self.exact.is_empty() && self.suffixes.is_empty()
163 }
164}
165
166pub async fn handle_external_proxy(
175 first_line: &str,
176 stream: &mut TcpStream,
177 remaining_header: &[u8],
178 filter: &ProxyFilter,
179 session_token: &Zeroizing<String>,
180 external_config: &ExternalProxyConfig,
181 audit_log: Option<&audit::SharedAuditLog>,
182) -> Result<()> {
183 let (host, port) = parse_connect_target(first_line)?;
185 debug!("External proxy CONNECT to {}:{}", host, port);
186
187 validate_proxy_auth(remaining_header, session_token)?;
189
190 let check = filter.check_host(&host, port).await?;
193 if !check.result.is_allowed() {
194 let reason = check.result.reason();
195 audit::log_denied(
196 audit_log,
197 audit::ProxyMode::External,
198 &audit::EventContext {
199 auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
200 auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
201 denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
202 ..audit::EventContext::default()
203 },
204 &host,
205 port,
206 &reason,
207 );
208 send_response(stream, 403, &format!("Forbidden: {}", reason)).await?;
209 return Err(ProxyError::HostDenied { host, reason });
210 }
211
212 if external_config.auth.is_some() {
216 return Err(ProxyError::ExternalProxy(
217 "external proxy authentication is configured but not yet implemented; \
218 remove the auth section from the external proxy config or wait for \
219 a future release"
220 .to_string(),
221 ));
222 }
223
224 let mut proxy_stream = match connect_via_proxy(&external_config.address, &host, port, None)
227 .await
228 {
229 Ok(s) => s,
230 Err(ProxyError::ExternalProxy(msg)) if msg.contains("rejected CONNECT") => {
231 audit::log_denied(
235 audit_log,
236 audit::ProxyMode::External,
237 &audit::EventContext {
238 auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
239 auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
240 denial_category: Some(
241 nono::undo::NetworkAuditDenialCategory::ExternalProxyRejected,
242 ),
243 ..audit::EventContext::default()
244 },
245 &host,
246 port,
247 &msg,
248 );
249 send_response(stream, 502, "Bad Gateway").await?;
250 return Err(ProxyError::ExternalProxy(msg));
251 }
252 Err(e) => {
253 audit::log_denied(
254 audit_log,
255 audit::ProxyMode::External,
256 &audit::EventContext {
257 auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
258 auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
259 denial_category: Some(
260 nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed,
261 ),
262 ..audit::EventContext::default()
263 },
264 &host,
265 port,
266 &e.to_string(),
267 );
268 send_response(stream, 502, "Bad Gateway").await?;
269 return Err(e);
270 }
271 };
272
273 send_response(stream, 200, "Connection Established").await?;
275 audit::log_allowed(
276 audit_log,
277 audit::ProxyMode::External,
278 &audit::EventContext {
279 auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
280 auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
281 ..audit::EventContext::default()
282 },
283 &host,
284 port,
285 "CONNECT",
286 );
287
288 let result = tokio::io::copy_bidirectional(stream, &mut proxy_stream).await;
290 debug!(
291 "External proxy tunnel closed for {}:{}: {:?}",
292 host, port, result
293 );
294
295 Ok(())
296}
297
298fn parse_connect_target(line: &str) -> Result<(String, u16)> {
300 let parts: Vec<&str> = line.split_whitespace().collect();
301 if parts.len() < 2 {
302 return Err(ProxyError::HttpParse(format!(
303 "malformed CONNECT line: {}",
304 line
305 )));
306 }
307
308 let authority = parts[1];
309 if let Some((host, port_str)) = authority.rsplit_once(':') {
310 let port = port_str.parse::<u16>().map_err(|_| {
311 ProxyError::HttpParse(format!("invalid port in CONNECT: {}", authority))
312 })?;
313 Ok((host.to_string(), port))
314 } else {
315 Ok((authority.to_string(), 443))
316 }
317}
318
319fn validate_proxy_auth(header_bytes: &[u8], session_token: &Zeroizing<String>) -> Result<()> {
324 token::validate_proxy_auth(header_bytes, session_token)
325}
326
327fn parse_status_code(line: &str) -> Result<u16> {
329 let parts: Vec<&str> = line.split_whitespace().collect();
330 if parts.len() < 2 {
331 return Err(ProxyError::HttpParse(format!(
332 "malformed HTTP response: {}",
333 line
334 )));
335 }
336 parts[1]
337 .parse::<u16>()
338 .map_err(|_| ProxyError::HttpParse(format!("invalid status code in response: {}", line)))
339}
340
341async fn send_response(stream: &mut TcpStream, status: u16, reason: &str) -> Result<()> {
343 let response = format!("HTTP/1.1 {} {}\r\n\r\n", status, reason);
344 stream.write_all(response.as_bytes()).await?;
345 stream.flush().await?;
346 Ok(())
347}
348
349#[cfg(test)]
350#[allow(clippy::unwrap_used)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn test_parse_connect_target() {
356 let (host, port) = parse_connect_target("CONNECT api.openai.com:443 HTTP/1.1").unwrap();
357 assert_eq!(host, "api.openai.com");
358 assert_eq!(port, 443);
359 }
360
361 #[test]
362 fn test_parse_status_code_200() {
363 assert_eq!(
364 parse_status_code("HTTP/1.1 200 Connection Established\r\n").unwrap(),
365 200
366 );
367 }
368
369 #[test]
370 fn test_parse_status_code_403() {
371 assert_eq!(
372 parse_status_code("HTTP/1.1 403 Forbidden\r\n").unwrap(),
373 403
374 );
375 }
376
377 #[test]
378 fn test_parse_status_code_malformed() {
379 assert!(parse_status_code("garbage").is_err());
380 }
381
382 #[test]
383 fn test_bypass_matcher_exact() {
384 let matcher = BypassMatcher::new(&["internal.corp".to_string()]);
385 assert!(matcher.matches("internal.corp"));
386 assert!(!matcher.matches("other.corp"));
387 }
388
389 #[test]
390 fn test_bypass_matcher_case_insensitive() {
391 let matcher = BypassMatcher::new(&["Internal.Corp".to_string()]);
392 assert!(matcher.matches("internal.corp"));
393 assert!(matcher.matches("INTERNAL.CORP"));
394 }
395
396 #[test]
397 fn test_bypass_matcher_wildcard() {
398 let matcher = BypassMatcher::new(&["*.internal.corp".to_string()]);
399 assert!(matcher.matches("app.internal.corp"));
400 assert!(matcher.matches("deep.sub.internal.corp"));
401 assert!(!matcher.matches("internal.corp"));
403 }
404
405 #[test]
406 fn test_bypass_matcher_wildcard_case_insensitive() {
407 let matcher = BypassMatcher::new(&["*.Internal.Corp".to_string()]);
408 assert!(matcher.matches("APP.INTERNAL.CORP"));
409 }
410
411 #[test]
412 fn test_bypass_matcher_no_match() {
413 let matcher =
414 BypassMatcher::new(&["internal.corp".to_string(), "*.private.net".to_string()]);
415 assert!(!matcher.matches("api.openai.com"));
416 assert!(!matcher.matches("evil.com"));
417 }
418
419 #[test]
420 fn test_bypass_matcher_empty() {
421 let matcher = BypassMatcher::new(&[]);
422 assert!(matcher.is_empty());
423 assert!(!matcher.matches("anything.com"));
424 }
425
426 #[test]
427 fn test_bypass_matcher_mixed() {
428 let matcher =
429 BypassMatcher::new(&["exact.host.com".to_string(), "*.wildcard.com".to_string()]);
430 assert!(matcher.matches("exact.host.com"));
431 assert!(matcher.matches("sub.wildcard.com"));
432 assert!(!matcher.matches("wildcard.com"));
433 assert!(!matcher.matches("other.com"));
434 }
435
436 #[test]
437 fn test_bypass_matcher_bare_star_is_not_wildcard() {
438 let matcher = BypassMatcher::new(&["*".to_string()]);
441 assert!(!matcher.matches("anything.com"));
442 assert!(!matcher.matches("internal.corp"));
443 }
444
445 #[test]
446 fn test_bypass_matcher_star_without_dot_is_literal() {
447 let matcher = BypassMatcher::new(&["*corp".to_string()]);
450 assert!(!matcher.matches("internal.corp"));
451 assert!(!matcher.matches("subcorp"));
452 assert!(matcher.matches("*corp"));
454 }
455
456 #[test]
457 fn test_bypass_matcher_star_dot_only_is_ignored() {
458 let matcher = BypassMatcher::new(&["*.".to_string()]);
460 assert!(matcher.is_empty());
461 assert!(!matcher.matches("anything.com"));
462 }
463}