Skip to main content

sbe_proxy/
server.rs

1use std::{net::SocketAddr, sync::Arc};
2
3use tokio::{
4    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
5    net::{TcpListener, TcpStream},
6    sync::watch,
7};
8use tracing::{debug, info, warn};
9
10use crate::{allowlist::DomainAllowlist, error::ProxyError};
11
12/// A domain-filtering HTTP CONNECT proxy server.
13///
14/// Binds to `127.0.0.1` on an ephemeral port. Sandboxed processes connect through
15/// this proxy via `HTTP_PROXY`/`HTTPS_PROXY` env vars. The proxy checks the target
16/// domain against an allowlist before establishing the upstream tunnel.
17pub struct ProxyServer {
18    listener: TcpListener,
19    allowlist: Arc<DomainAllowlist>,
20    shutdown_rx: watch::Receiver<bool>,
21}
22
23impl ProxyServer {
24    /// Create and bind a new proxy server. Returns the server and its bound port.
25    pub async fn bind(
26        allowlist: DomainAllowlist,
27        shutdown_rx: watch::Receiver<bool>,
28    ) -> Result<(Self, u16), ProxyError> {
29        let listener = TcpListener::bind("127.0.0.1:0")
30            .await
31            .map_err(ProxyError::Bind)?;
32        let port = listener.local_addr().map_err(ProxyError::Bind)?.port();
33
34        info!(port, "sbe proxy listening");
35
36        Ok((
37            Self {
38                listener,
39                allowlist: Arc::new(allowlist),
40                shutdown_rx,
41            },
42            port,
43        ))
44    }
45
46    /// Run the proxy server until shutdown is signaled.
47    pub async fn run(self) -> Result<(), ProxyError> {
48        let mut shutdown = self.shutdown_rx;
49
50        loop {
51            tokio::select! {
52                result = self.listener.accept() => {
53                    let (stream, addr): (TcpStream, SocketAddr) = result.map_err(ProxyError::Accept)?;
54                    let allowlist = Arc::clone(&self.allowlist);
55                    tokio::spawn(async move {
56                        if let Err(e) = handle_connection(stream, addr, &allowlist).await {
57                            debug!(error = %e, "proxy connection error");
58                        }
59                    });
60                }
61                _ = shutdown.changed() => {
62                    info!("sbe proxy shutting down");
63                    break;
64                }
65            }
66        }
67        Ok(())
68    }
69}
70
71/// Handle a single proxy connection.
72///
73/// Reads the HTTP request line, determines the method, and dispatches accordingly.
74/// Only CONNECT is supported (for HTTPS tunneling). Other methods are rejected.
75async fn handle_connection(
76    client: TcpStream,
77    addr: SocketAddr,
78    allowlist: &DomainAllowlist,
79) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
80    // Wrap in BufReader to read request line and headers
81    let mut client = BufReader::new(client);
82
83    // Read the request line
84    let mut request_line = String::new();
85    client.read_line(&mut request_line).await?;
86    let request_line = request_line.trim().to_owned();
87
88    if request_line.is_empty() {
89        return Ok(());
90    }
91
92    let parts: Vec<&str> = request_line.split_whitespace().collect();
93    if parts.len() < 2 {
94        client
95            .write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n")
96            .await?;
97        return Ok(());
98    }
99
100    let method = parts[0].to_uppercase();
101    let target = parts[1].to_owned();
102
103    if method == "CONNECT" {
104        handle_connect(client, addr, &target, allowlist).await
105    } else {
106        client
107            .write_all(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n")
108            .await?;
109        warn!(method = %method, addr = %addr, "rejected non-CONNECT request");
110        Ok(())
111    }
112}
113
114/// Handle an HTTP CONNECT tunnel request.
115async fn handle_connect(
116    mut client: BufReader<TcpStream>,
117    addr: SocketAddr,
118    target: &str,
119    allowlist: &DomainAllowlist,
120) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
121    // Parse host:port
122    let (host, port) = parse_host_port(target)?;
123
124    // Consume remaining request headers (until empty line)
125    let mut header_line = String::new();
126    loop {
127        header_line.clear();
128        client.read_line(&mut header_line).await?;
129        if header_line.trim().is_empty() {
130            break;
131        }
132    }
133
134    // Check domain against allowlist
135    if !allowlist.is_allowed(&host) {
136        warn!(
137            host = %host,
138            port = port,
139            client = %addr,
140            "blocked connection to non-allowed domain"
141        );
142        let response = format!(
143            "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nsbe: \
144             domain '{host}' is not in the allowlist\n"
145        );
146        client.write_all(response.as_bytes()).await?;
147        return Ok(());
148    }
149
150    // Connect to upstream
151    let upstream_addr = format!("{host}:{port}");
152    let mut upstream = TcpStream::connect(&upstream_addr).await.map_err(|e| {
153        Box::new(ProxyError::UpstreamConnect {
154            host: host.clone(),
155            port,
156            source: e,
157        })
158    })?;
159
160    // Send 200 Connection Established
161    client
162        .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
163        .await?;
164    client.flush().await?;
165
166    debug!(host = %host, port = port, client = %addr, "tunnel established");
167
168    // Forward any buffered data to upstream
169    let buffered = client.buffer().to_vec();
170    if !buffered.is_empty() {
171        upstream.write_all(&buffered).await?;
172    }
173
174    // Unwrap the BufReader to get the underlying TcpStream for bidirectional copy
175    let mut client_stream = client.into_inner();
176
177    // Bidirectional copy until either side closes
178    let _ = tokio::io::copy_bidirectional(&mut client_stream, &mut upstream).await;
179
180    Ok(())
181}
182
183/// Parse "host:port" from a CONNECT target string.
184fn parse_host_port(
185    target: &str,
186) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
187    // Handle [ipv6]:port
188    if let Some(bracket_end) = target.find("]:") {
189        let host = target[1..bracket_end].to_owned();
190        let port: u16 = target[bracket_end + 2..].parse()?;
191        return Ok((host, port));
192    }
193
194    let mut parts = target.rsplitn(2, ':');
195    let port_str = parts.next().ok_or("missing port")?;
196    let host = parts.next().ok_or("missing host")?;
197    let port: u16 = port_str.parse()?;
198    Ok((host.to_owned(), port))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_should_parse_host_port() {
207        let (host, port) = parse_host_port("registry.npmjs.org:443").unwrap();
208        assert_eq!(host, "registry.npmjs.org");
209        assert_eq!(port, 443);
210    }
211
212    #[test]
213    fn test_should_parse_host_port_8000() {
214        let (host, port) = parse_host_port("evil.com:8000").unwrap();
215        assert_eq!(host, "evil.com");
216        assert_eq!(port, 8000);
217    }
218}