diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9396b26cc..76d089e29 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1152,10 +1152,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.23" +version = "1.2.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -2323,6 +2324,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "findshlibs" version = "0.10.2" @@ -3532,6 +3539,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "http 1.3.1", + "http-body-util", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "socket-factory", + "tokio", + "tokio-rustls", + "tracing", + "webpki-roots 1.0.4", +] + [[package]] name = "http-test-server" version = "0.1.0" @@ -3563,13 +3589,14 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http 1.3.1", "http-body", @@ -3577,6 +3604,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -3615,13 +3643,14 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http 1.3.1", "http-body", @@ -3630,7 +3659,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -6113,7 +6142,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.0", + "webpki-roots 1.0.4", ] [[package]] @@ -6296,9 +6325,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "web-time", "zeroize", @@ -6498,9 +6527,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.9.1", "core-foundation 0.10.0", @@ -6511,9 +6540,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -8056,9 +8085,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -9083,14 +9112,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.0", + "webpki-roots 1.0.4", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1f9e1a4dc..be12244c3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,6 +8,7 @@ members = [ "connlib/dns-over-tcp", "connlib/dns-types", "connlib/etherparse-ext", + "connlib/http-client", "connlib/ip-packet", "connlib/l3-tcp", "connlib/l3-udp-dns-client", @@ -94,7 +95,12 @@ hex = "0.4.3" hex-display = "0.3.0" hex-literal = "1.0.0" hickory-resolver = "0.25.2" +http = "1.3.1" +http-body-util = "0.1.3" +http-client = { path = "connlib/http-client" } humantime = "2.3" +hyper = "1.7.0" +hyper-util = "0.1.17" ip-packet = { path = "connlib/ip-packet" } ip_network = { version = "0.4", default-features = false } ip_network_table = { version = "0.2", default-features = false } @@ -144,6 +150,7 @@ roxmltree = "0.20" rpassword = "7.4.0" rtnetlink = { version = "0.18.1", default-features = false, features = ["tokio_socket"] } rustls = { version = "0.23.31", default-features = false, features = ["ring"] } +rustls-pki-types = "1.13.0" sadness-generator = "0.6.0" sd-notify = "0.4.5" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 secrecy = "0.10" @@ -188,6 +195,7 @@ test-strategy = "0.4.3" thiserror = "2.0.17" time = "0.3.43" tokio = "1.48.0" +tokio-rustls = { version = "0.26.4", default-features = false } tokio-stream = "0.1.17" tokio-tungstenite = "0.28.0" tokio-util = "0.7.16" @@ -205,6 +213,7 @@ tun = { path = "connlib/tun" } uniffi = "0.29.4" url = "2.5.2" uuid = "1.18.1" +webpki-roots = "1.0.4" which = "4.4.2" windows = "0.61.3" windows-core = "0.61.1" diff --git a/rust/connlib/http-client/Cargo.toml b/rust/connlib/http-client/Cargo.toml new file mode 100644 index 000000000..aa92b7e0f --- /dev/null +++ b/rust/connlib/http-client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "http-client" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } + +[lib] +path = "lib.rs" + +[dependencies] +anyhow = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true, features = ["client", "http2"] } +hyper-util = { workspace = true, features = ["tokio"] } +rustls = { workspace = true } +rustls-pki-types = { workspace = true } +socket-factory = { workspace = true } +tokio = { workspace = true } +tokio-rustls = { workspace = true } +tracing = { workspace = true } +webpki-roots = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/rust/connlib/http-client/lib.rs b/rust/connlib/http-client/lib.rs new file mode 100644 index 000000000..1aae4b369 --- /dev/null +++ b/rust/connlib/http-client/lib.rs @@ -0,0 +1,248 @@ +#![cfg_attr(test, allow(clippy::unwrap_used))] + +use std::{ + collections::{HashMap, hash_map}, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use rustls::ClientConfig; +use socket_factory::{SocketFactory, TcpSocket, TcpStream}; +use tokio::task::JoinSet; +use tokio_rustls::TlsConnector; + +pub struct HttpClient { + sf: Arc>, + + client_tls_config: Arc, + clients: HashMap>>, + connections: JoinSet<()>, + + dns_records: HashMap>, +} + +impl Default for HttpClient { + fn default() -> Self { + Self::new() + } +} + +impl HttpClient { + pub fn new() -> Self { + // TODO: Use `rustls-platform-verifier` instead. + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let mut config = rustls::ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"http/1.0".to_vec()]; + + Self { + sf: Arc::new(socket_factory::tcp), + clients: HashMap::default(), + dns_records: HashMap::default(), + connections: JoinSet::new(), + client_tls_config: Arc::new(config), + } + } + + pub fn set_socket_factory(&mut self, sf: impl SocketFactory) { + self.sf = Arc::new(sf); + + self.clients.clear(); + self.connections.abort_all(); + } + + pub fn set_dns_records(&mut self, domain: String, addresses: Vec) { + self.dns_records.insert(domain, addresses); + } + + pub async fn send_request( + &mut self, + request: http::Request, + ) -> Result> { + let host = request + .uri() + .host() + .context("Missing host in request URI")? + .to_owned(); + let scheme = request + .uri() + .scheme_str() + .context("Missing scheme in request URI")?; + let port = match scheme { + "http" => request.uri().port_u16().unwrap_or(80), + "https" => request.uri().port_u16().unwrap_or(443), + other => bail!("Unsupported scheme '{other}'"), + }; + + let mut client = match self.clients.entry(host.clone()) { + hash_map::Entry::Occupied(o) if !o.get().is_closed() => o.remove(), // We remove the Client such that it is discarded on any error. + hash_map::Entry::Occupied(_) | hash_map::Entry::Vacant(_) => { + let addresses = self + .dns_records + .get(&host) + .with_context(|| format!("No DNS records for '{host}'"))? + .clone(); + + let (client, conn) = connect( + addresses, + port, + host.clone(), + self.client_tls_config.clone(), + self.sf.clone(), + ) + .await?; + + self.connections.spawn({ + let host = host.clone(); + + async move { + match conn.await.context("HTTP2 connection failed") { + Ok(()) => tracing::debug!(%host, "HTTP2 connection finished"), + Err(e) => tracing::debug!(%host, "{e:#}"), + } + } + }); + + client + } + }; + + client + .ready() + .await + .context("Failed to await readiness of HTTP2 client")?; + + let (parts, body) = request.into_parts(); + let request = http::Request::from_parts(parts, Full::new(body)); + + let response = client + .send_request(request) + .await + .context("Failed to send HTTP request")?; + + let (parts, incoming) = response.into_parts(); + + let body = incoming + .collect() + .await + .context("Failed to receive HTTP response body")?; + + self.clients.insert(host.clone(), client); + + Ok(http::Response::from_parts(parts, body.to_bytes())) + } +} + +async fn connect( + addresses: Vec, + port: u16, + domain: String, + tls_config: Arc, + sf: Arc>, +) -> Result<( + hyper::client::conn::http2::SendRequest>, + hyper::client::conn::http2::Connection< + hyper_util::rt::TokioIo>, + Full, + hyper_util::rt::TokioExecutor, + >, +)> { + tracing::debug!(?addresses, %domain, "Creating new HTTP2 connection"); + + for address in addresses { + let socket = SocketAddr::new(address, port); + + match connect_one(socket, domain.clone(), tls_config.clone(), sf.clone()).await { + Ok((client, conn)) => { + tracing::debug!(%socket, %domain, "Created new HTTP2 connection"); + + return Ok((client, conn)); + } + Err(e) => { + tracing::debug!(%socket, %domain, "Failed to create HTTP2 client: {e:#}"); + continue; + } + } + } + + anyhow::bail!("Failed to connect to '{domain}'"); +} + +async fn connect_one( + socket: SocketAddr, + domain: String, + tls_client_config: Arc, + sf: Arc>, +) -> Result<( + hyper::client::conn::http2::SendRequest>, + hyper::client::conn::http2::Connection< + hyper_util::rt::TokioIo>, + Full, + hyper_util::rt::TokioExecutor, + >, +)> { + let stream = sf + .bind(socket) + .context("Failed to create TCP socket")? + .connect(socket) + .await + .context("Failed to connect TCP stream")?; + + let connector = TlsConnector::from(tls_client_config.clone()); + let tls_domain = rustls_pki_types::ServerName::try_from(domain)?; + + let stream = connector.connect(tls_domain, stream).await?; + + let mut builder = + hyper::client::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new()); + builder.timer(hyper_util::rt::TokioTimer::default()); + builder.keep_alive_timeout(Duration::from_secs(1)); + builder.keep_alive_while_idle(true); + builder.keep_alive_interval(Some(Duration::from_secs(5))); + + let (client, connection) = builder + .handshake(hyper_util::rt::TokioIo::new(stream)) + .await + .context("Failed to handshake HTTP2 connection")?; + + Ok((client, connection)) +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::*; + + #[tokio::test] + #[ignore = "Requires Internet"] + async fn doh_query() { + rustls::crypto::ring::default_provider() + .install_default() + .unwrap(); + + let mut http_client = HttpClient::new(); + http_client.set_dns_records( + "one.one.one.one".to_owned(), + vec![IpAddr::from([1, 1, 1, 1])], + ); + + let query = http::Request::builder() + .uri("https://one.one.one.one/dns-query?name=example.com") + .method("GET") + .header("Accept", "application/dns-json") + .body(Bytes::new()) + .unwrap(); + + let response = http_client.send_request(query).await.unwrap(); + + assert!(response.status().is_success()); + } +}