|
1 | | -//! The proxy module. For now it holds only the upstream-proxy configuration |
2 | | -//! type; the proxy server (listener, connection handling) lands in a later |
3 | | -//! PR. |
| 1 | +//! The proxy itself: listener, connection handlers, upstream chaining. |
| 2 | +//! |
| 3 | +//! All synchronous, thread-per-connection. `ProxyHandle::spawn` binds a |
| 4 | +//! `std::net::TcpListener` on `127.0.0.1:0` and returns once the listener |
| 5 | +//! is bound and the listener thread has been spawned. Drop the handle to |
| 6 | +//! shut everything down — the listener thread stops accepting new |
| 7 | +//! connections; in-flight connection threads finish on their own when |
| 8 | +//! either side closes. |
| 9 | +//! |
| 10 | +//! See the crate-level docs for trust assumptions and the "no proxy here" |
| 11 | +//! principle. |
4 | 12 |
|
| 13 | +mod connection; |
5 | 14 | mod upstream; |
6 | 15 |
|
| 16 | +use crate::allowlist::Allowlist; |
| 17 | +use anyhow::{Context, Result}; |
| 18 | +use futures::channel::mpsc; |
| 19 | +use std::net::{Ipv4Addr, TcpListener, TcpStream}; |
| 20 | +use std::sync::Arc; |
| 21 | +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; |
| 22 | +use std::thread; |
| 23 | + |
| 24 | +/// Cap on concurrently handled connections. Each connection costs the |
| 25 | +/// editor process two threads and two pump buffers; the cap keeps a |
| 26 | +/// runaway (or malicious) sandboxed command from exhausting the editor's |
| 27 | +/// thread/fd budget. Well above what parallel package managers open. |
| 28 | +const MAX_CONCURRENT_CONNECTIONS: usize = 256; |
| 29 | + |
7 | 30 | pub use upstream::UpstreamProxy; |
| 31 | + |
| 32 | +/// Configuration for spawning a proxy. |
| 33 | +#[derive(Debug, Clone)] |
| 34 | +pub struct ProxyConfig { |
| 35 | + /// Hosts the proxy will allow to be reached. |
| 36 | + pub allowlist: Allowlist, |
| 37 | + /// Optional upstream HTTP proxy to chain through, with `NO_PROXY`-style |
| 38 | + /// bypasses for hosts that should connect direct. |
| 39 | + pub upstream: Option<UpstreamProxy>, |
| 40 | + /// Where the proxy reports per-connection events. Use |
| 41 | + /// [`mpsc::unbounded`] so connection threads (which are sync) never |
| 42 | + /// block on send. The receiver is async-friendly so `gpui` / `tokio` |
| 43 | + /// callers can poll it from their executor of choice. |
| 44 | + pub events: mpsc::UnboundedSender<ProxyEvent>, |
| 45 | +} |
| 46 | + |
| 47 | +/// A request method seen by the proxy. |
| 48 | +/// |
| 49 | +/// Either a CONNECT (HTTPS tunnel) or an HTTP forward request. |
| 50 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 51 | +pub enum RequestMethod { |
| 52 | + Connect, |
| 53 | + Http(String), |
| 54 | +} |
| 55 | + |
| 56 | +impl RequestMethod { |
| 57 | + pub fn as_str(&self) -> &str { |
| 58 | + match self { |
| 59 | + RequestMethod::Connect => "CONNECT", |
| 60 | + RequestMethod::Http(method) => method.as_str(), |
| 61 | + } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/// Outcome of a single connection's policy decision. |
| 66 | +#[derive(Debug, Clone)] |
| 67 | +pub enum RequestOutcome { |
| 68 | + Allowed, |
| 69 | + Denied { reason: DenyReason }, |
| 70 | +} |
| 71 | + |
| 72 | +/// Why an attempted connection was denied. |
| 73 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 74 | +pub enum DenyReason { |
| 75 | + /// Hostname (in punycode form on the wire) wasn't in the allowlist. |
| 76 | + HostNotInAllowlist { host: String }, |
| 77 | + /// CONNECT or HTTP request targeted an IP literal. Denied unless the |
| 78 | + /// allowlist allows any host. |
| 79 | + IpLiteralRejected { target: String }, |
| 80 | + /// The hostname resolved only to loopback / private / link-local |
| 81 | + /// addresses, which the sandbox policy never reaches via the allowlist |
| 82 | + /// (DNS-rebinding protection). Not applied when the allowlist allows |
| 83 | + /// any host. |
| 84 | + ResolvedToForbiddenIp { host: String }, |
| 85 | +} |
| 86 | + |
| 87 | +impl DenyReason { |
| 88 | + pub(crate) fn proxy_status_error(&self) -> &'static str { |
| 89 | + match self { |
| 90 | + DenyReason::HostNotInAllowlist { .. } => "destination_ip_prohibited", |
| 91 | + DenyReason::IpLiteralRejected { .. } => "destination_ip_prohibited", |
| 92 | + DenyReason::ResolvedToForbiddenIp { .. } => "destination_ip_prohibited", |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + pub(crate) fn human_explanation(&self) -> String { |
| 97 | + match self { |
| 98 | + DenyReason::HostNotInAllowlist { host } => { |
| 99 | + format!("host '{host}' is not in this conversation's network allowlist") |
| 100 | + } |
| 101 | + DenyReason::IpLiteralRejected { target } => format!( |
| 102 | + "target '{target}' is an IP literal; only hostnames are permitted by sandbox policy" |
| 103 | + ), |
| 104 | + DenyReason::ResolvedToForbiddenIp { host } => format!( |
| 105 | + "host '{host}' resolves only to loopback/private/link-local addresses, \ |
| 106 | + which sandbox policy blocks" |
| 107 | + ), |
| 108 | + } |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +/// Events emitted by the proxy as it handles connections. |
| 113 | +#[derive(Debug, Clone)] |
| 114 | +pub enum ProxyEvent { |
| 115 | + /// Sent once after the listener is bound. Always the first event for |
| 116 | + /// a given proxy instance. |
| 117 | + Ready { port: u16 }, |
| 118 | + |
| 119 | + /// Emitted at policy-decision time, before bytes flow to the upstream. |
| 120 | + RequestAttempt { |
| 121 | + host: String, |
| 122 | + port: u16, |
| 123 | + method: RequestMethod, |
| 124 | + outcome: RequestOutcome, |
| 125 | + }, |
| 126 | + |
| 127 | + /// Emitted after an `Allowed` connection finishes. Carries throughput |
| 128 | + /// totals for diagnostics. Not emitted for denied connections. |
| 129 | + RequestCompleted { |
| 130 | + host: String, |
| 131 | + port: u16, |
| 132 | + method: RequestMethod, |
| 133 | + bytes_to_remote: u64, |
| 134 | + bytes_from_remote: u64, |
| 135 | + duration_ms: u64, |
| 136 | + }, |
| 137 | +} |
| 138 | + |
| 139 | +/// Handle to a running proxy. Drop to stop the listener; in-flight |
| 140 | +/// connection threads finish on their own as soon as either side closes. |
| 141 | +pub struct ProxyHandle { |
| 142 | + port: u16, |
| 143 | + /// Listener thread sees this flip to `true` after `accept` returns and |
| 144 | + /// then exits. |
| 145 | + shutdown: Arc<AtomicBool>, |
| 146 | + /// Joined on drop to make shutdown deterministic in tests; ignored if |
| 147 | + /// the listener has already exited. |
| 148 | + listener_thread: Option<thread::JoinHandle<()>>, |
| 149 | +} |
| 150 | + |
| 151 | +impl ProxyHandle { |
| 152 | + /// Spawns the proxy: binds a listener on `127.0.0.1:0`, spawns the |
| 153 | + /// listener thread, sends a `Ready` event, and returns. The returned |
| 154 | + /// port is what callers should use for `HTTPS_PROXY`/`HTTP_PROXY` env |
| 155 | + /// vars and for the seatbelt rule narrowing `localhost:<port>`. |
| 156 | + pub fn spawn(config: ProxyConfig) -> Result<ProxyHandle> { |
| 157 | + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) |
| 158 | + .context("failed to bind proxy listener on 127.0.0.1:0")?; |
| 159 | + let port = listener |
| 160 | + .local_addr() |
| 161 | + .context("failed to read proxy local addr")? |
| 162 | + .port(); |
| 163 | + |
| 164 | + // Inform the parent the proxy is ready before starting the accept |
| 165 | + // loop. Send is fire-and-forget on an unbounded channel — never |
| 166 | + // blocks, never errors meaningfully. |
| 167 | + let _ = config.events.unbounded_send(ProxyEvent::Ready { port }); |
| 168 | + |
| 169 | + let shutdown = Arc::new(AtomicBool::new(false)); |
| 170 | + let runtime_state = Arc::new(RuntimeState { |
| 171 | + allowlist: config.allowlist, |
| 172 | + upstream: config.upstream, |
| 173 | + events: config.events, |
| 174 | + active_connections: AtomicUsize::new(0), |
| 175 | + }); |
| 176 | + |
| 177 | + let listener_thread = thread::Builder::new() |
| 178 | + .name("http-proxy-listener".to_string()) |
| 179 | + // Listener thread does almost nothing on its stack — accept, |
| 180 | + // spawn, loop. 128 KiB is plenty. |
| 181 | + .stack_size(128 * 1024) |
| 182 | + .spawn({ |
| 183 | + let shutdown = shutdown.clone(); |
| 184 | + move || run_listener(listener, runtime_state, shutdown) |
| 185 | + }) |
| 186 | + .context("failed to spawn proxy listener thread")?; |
| 187 | + |
| 188 | + Ok(ProxyHandle { |
| 189 | + port, |
| 190 | + shutdown, |
| 191 | + listener_thread: Some(listener_thread), |
| 192 | + }) |
| 193 | + } |
| 194 | + |
| 195 | + /// The bound port. Stable for the lifetime of this handle. |
| 196 | + pub fn port(&self) -> u16 { |
| 197 | + self.port |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +impl Drop for ProxyHandle { |
| 202 | + fn drop(&mut self) { |
| 203 | + self.shutdown.store(true, Ordering::SeqCst); |
| 204 | + // The listener is blocked in `accept()`. Waking it up cleanly via |
| 205 | + // a flag alone isn't possible with `std::net::TcpListener` — there's |
| 206 | + // no way to interrupt the syscall. Connect to ourselves: the |
| 207 | + // listener wakes up, accepts the connection, sees the shutdown |
| 208 | + // flag, breaks the loop. The accepted connection's worker thread |
| 209 | + // will read the empty stream and exit too. |
| 210 | + let _ = TcpStream::connect((Ipv4Addr::LOCALHOST, self.port)); |
| 211 | + |
| 212 | + if let Some(thread) = self.listener_thread.take() { |
| 213 | + // Give the listener a chance to clean up. A join error means the |
| 214 | + // listener thread panicked; there's nothing to recover, but it |
| 215 | + // shouldn't pass unnoticed. |
| 216 | + if thread.join().is_err() { |
| 217 | + log::warn!("[http_proxy] listener thread panicked"); |
| 218 | + } |
| 219 | + } |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +/// State shared across all connection threads for a single proxy instance. |
| 224 | +pub(crate) struct RuntimeState { |
| 225 | + pub(crate) allowlist: Allowlist, |
| 226 | + pub(crate) upstream: Option<UpstreamProxy>, |
| 227 | + pub(crate) events: mpsc::UnboundedSender<ProxyEvent>, |
| 228 | + active_connections: AtomicUsize, |
| 229 | +} |
| 230 | + |
| 231 | +/// Decrements the active-connection count when a connection thread finishes |
| 232 | +/// (normally or by panic). |
| 233 | +struct ConnectionSlot(Arc<RuntimeState>); |
| 234 | + |
| 235 | +impl Drop for ConnectionSlot { |
| 236 | + fn drop(&mut self) { |
| 237 | + self.0.active_connections.fetch_sub(1, Ordering::SeqCst); |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +fn run_listener(listener: TcpListener, state: Arc<RuntimeState>, shutdown: Arc<AtomicBool>) { |
| 242 | + for stream in listener.incoming() { |
| 243 | + if shutdown.load(Ordering::SeqCst) { |
| 244 | + log::debug!("[http_proxy] listener stopping (shutdown signaled)"); |
| 245 | + break; |
| 246 | + } |
| 247 | + match stream { |
| 248 | + Ok(stream) => { |
| 249 | + let previous = state.active_connections.fetch_add(1, Ordering::SeqCst); |
| 250 | + if previous >= MAX_CONCURRENT_CONNECTIONS { |
| 251 | + state.active_connections.fetch_sub(1, Ordering::SeqCst); |
| 252 | + log::warn!( |
| 253 | + "[http_proxy] dropping connection: {MAX_CONCURRENT_CONNECTIONS} \ |
| 254 | + connections already active" |
| 255 | + ); |
| 256 | + drop(stream); |
| 257 | + continue; |
| 258 | + } |
| 259 | + let slot = ConnectionSlot(state.clone()); |
| 260 | + let state = state.clone(); |
| 261 | + let result = thread::Builder::new() |
| 262 | + .name("http-proxy-conn".to_string()) |
| 263 | + // Connection workers do bidir copy with a 64 KiB buffer |
| 264 | + // and a few syscall stack frames. 128 KiB is plenty. |
| 265 | + .stack_size(128 * 1024) |
| 266 | + .spawn(move || { |
| 267 | + let _slot = slot; |
| 268 | + if let Err(e) = connection::handle(stream, state) { |
| 269 | + log::debug!("[http_proxy] connection handler error: {e}"); |
| 270 | + } |
| 271 | + }); |
| 272 | + if let Err(e) = result { |
| 273 | + log::warn!("[http_proxy] failed to spawn connection thread: {e}"); |
| 274 | + } |
| 275 | + } |
| 276 | + Err(e) => { |
| 277 | + // EMFILE / per-process fd exhaustion is the realistic |
| 278 | + // failure here. Log and keep going — accept errors are |
| 279 | + // usually transient. |
| 280 | + log::warn!("[http_proxy] accept failed: {e}"); |
| 281 | + } |
| 282 | + } |
| 283 | + } |
| 284 | +} |
0 commit comments