Skip to main content

http_stat/
http_request.rs

1// Copyright 2025 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// This file implements HTTP request functionality with support for HTTP/1.1, HTTP/2, and HTTP/3
16// It includes features like DNS resolution, TLS handshake, and request/response handling
17
18use super::error::{Error, Result};
19use super::stats::{HttpStat, ALPN_HTTP1, ALPN_HTTP2};
20use bytes::Bytes;
21use http::request::Builder;
22use http::HeaderValue;
23use http::Request;
24use http::Uri;
25use http::{HeaderMap, Method};
26use http_body_util::Full;
27use std::net::IpAddr;
28use std::str::FromStr;
29use std::time::Duration;
30use std::time::Instant;
31
32// Version information from Cargo.toml
33const VERSION: &str = env!("CARGO_PKG_VERSION");
34
35// Handle request error and update statistics
36pub(crate) fn finish_with_error(
37    mut stat: HttpStat,
38    error: impl ToString,
39    start: Instant,
40) -> HttpStat {
41    stat.error = Some(error.to_string());
42    stat.total = Some(start.elapsed());
43    stat
44}
45
46/// A single `--connect-to HOST1:PORT1:HOST2:PORT2` entry.
47///
48/// When the request target matches `(src_host, src_port)`, the TCP connection is
49/// established to `(dst_host, dst_port)`. TLS SNI and the HTTP `Host` header still
50/// use the original hostname — only the actual TCP destination changes.
51///
52/// Empty `src_host` / `src_port` act as wildcards. Empty `dst_host` / absent `dst_port`
53/// keep the original value.
54#[derive(Debug, Clone)]
55pub struct ConnectTo {
56    src_host: String,
57    src_port: Option<u16>,
58    pub dst_host: String,
59    pub dst_port: Option<u16>,
60}
61
62fn parse_host_segment(s: &str) -> (String, &str) {
63    if let Some(rest) = s.strip_prefix('[') {
64        // IPv6 bracketed: [addr]...
65        if let Some(end) = rest.find(']') {
66            return (rest[..end].to_string(), &rest[end + 1..]);
67        }
68    }
69    // Plain host: take up to first ':'
70    let colon = s.find(':').unwrap_or(s.len());
71    (s[..colon].to_string(), &s[colon..])
72}
73
74impl ConnectTo {
75    /// Parse `HOST1:PORT1:HOST2:PORT2`. Any field may be empty; IPv6 uses `[addr]`.
76    pub fn parse(s: &str) -> Option<Self> {
77        let (src_host, rest) = parse_host_segment(s);
78        let rest = rest.strip_prefix(':')?; // require separator after HOST1
79
80        // PORT1 up to next ':'
81        let colon = rest.find(':')?;
82        let src_port = if rest[..colon].is_empty() {
83            None
84        } else {
85            Some(rest[..colon].parse().ok()?)
86        };
87        let rest = &rest[colon + 1..];
88
89        // HOST2
90        let (dst_host, rest) = parse_host_segment(rest);
91
92        // Optional ':PORT2'
93        let port2_str = rest.strip_prefix(':').unwrap_or(rest);
94        let dst_port = if port2_str.is_empty() {
95            None
96        } else {
97            Some(port2_str.parse().ok()?)
98        };
99
100        Some(ConnectTo {
101            src_host,
102            src_port,
103            dst_host,
104            dst_port,
105        })
106    }
107
108    /// Returns `true` if this entry applies to the given `(host, port)`.
109    pub fn matches(&self, host: &str, port: u16) -> bool {
110        let host_ok = self.src_host.is_empty() || self.src_host.eq_ignore_ascii_case(host);
111        let port_ok = self.src_port.is_none() || self.src_port == Some(port);
112        host_ok && port_ok
113    }
114}
115
116// HttpRequest struct to hold request configuration
117#[derive(Default, Debug, Clone)]
118pub struct HttpRequest {
119    pub uri: Uri,                                // Target URI
120    pub method: Option<String>,                  // HTTP method (GET, POST, etc.)
121    pub alpn_protocols: Vec<String>,             // Supported ALPN protocols
122    pub resolve: Option<IpAddr>,                 // Custom DNS resolution
123    pub headers: Option<HeaderMap<HeaderValue>>, // Custom HTTP headers
124    pub ip_version: Option<i32>,                 // IP version (4 for IPv4, 6 for IPv6)
125    pub skip_verify: bool,                       // Skip TLS certificate verification
126    pub body: Option<Bytes>,                     // Request body
127    pub dns_servers: Option<Vec<String>>,        // DNS servers
128    pub dns_timeout: Option<Duration>,           // DNS resolution timeout
129    pub tcp_timeout: Option<Duration>,           // TCP connection timeout
130    pub tls_timeout: Option<Duration>,           // TLS handshake timeout
131    pub request_timeout: Option<Duration>,       // HTTP request timeout
132    pub quic_timeout: Option<Duration>,          // QUIC connection timeout
133    pub client_cert: Option<Vec<u8>>,            // PEM-encoded client certificate (mTLS)
134    pub client_key: Option<Vec<u8>>,             // PEM-encoded client private key (mTLS)
135    pub proxy: Option<String>,                   // Proxy URL (http://, https://, socks5://)
136    pub use_absolute_uri: bool,                  // Send absolute URI (HTTP forward proxy)
137    pub connect_to: Vec<String>,                 // --connect-to HOST1:PORT1:HOST2:PORT2 overrides
138    pub bind_addr: Option<IpAddr>,               // Local source IP to bind before connecting
139}
140
141impl HttpRequest {
142    pub fn get_port(&self) -> u16 {
143        let schema = if let Some(scheme) = self.uri.scheme() {
144            scheme.to_string()
145        } else {
146            "".to_string()
147        };
148
149        let default_port = if ["https", "grpcs"].contains(&schema.as_str()) {
150            443
151        } else {
152            80
153        };
154        self.uri.port_u16().unwrap_or(default_port)
155    }
156    // Build HTTP request with proper headers
157    pub fn builder(&self, is_http1: bool) -> Builder {
158        let uri = &self.uri;
159        let method = if let Some(method) = &self.method {
160            Method::from_str(method).unwrap_or(Method::GET)
161        } else {
162            Method::GET
163        };
164        let mut builder = if is_http1 && !self.use_absolute_uri {
165            if let Some(value) = uri.path_and_query() {
166                Request::builder().uri(value.to_string())
167            } else {
168                Request::builder().uri(uri)
169            }
170        } else {
171            Request::builder().uri(uri)
172        };
173        builder = builder.method(method);
174        let mut set_host = false;
175        let mut set_user_agent = false;
176
177        // Add custom headers if provided
178        if let Some(headers) = &self.headers {
179            for (key, value) in headers.iter() {
180                builder = builder.header(key, value);
181                match key.to_string().to_lowercase().as_str() {
182                    "host" => set_host = true,
183                    "user-agent" => set_user_agent = true,
184                    _ => {}
185                }
186            }
187        }
188
189        // Set default Host header if not provided
190        if !set_host {
191            if let Some(host) = uri.host() {
192                let port = self.get_port();
193                if port != 80 && port != 443 {
194                    builder = builder.header("Host", format!("{host}:{port}"));
195                } else {
196                    builder = builder.header("Host", host);
197                }
198            }
199        }
200
201        // Set default User-Agent if not provided
202        if !set_user_agent {
203            builder = builder.header("User-Agent", format!("httpstat.rs/{VERSION}"));
204        }
205        builder
206    }
207}
208
209// Convert string URL to HttpRequest
210impl TryFrom<&str> for HttpRequest {
211    type Error = Error;
212
213    fn try_from(url: &str) -> Result<Self> {
214        let prefixes = ["http://", "https://", "grpc://", "grpcs://"];
215
216        let value = if prefixes.iter().any(|prefix| url.starts_with(prefix)) {
217            url.to_string()
218        } else {
219            format!("http://{url}")
220        };
221        let uri = value.parse::<Uri>().map_err(|e| Error::Uri { source: e })?;
222        Ok(Self {
223            uri,
224            alpn_protocols: vec![ALPN_HTTP2.to_string(), ALPN_HTTP1.to_string()],
225            ..Default::default()
226        })
227    }
228}
229
230// Convert HttpRequest to hyper Request
231impl TryFrom<&HttpRequest> for Request<Full<Bytes>> {
232    type Error = Error;
233    fn try_from(req: &HttpRequest) -> Result<Self> {
234        req.builder(true)
235            .body(Full::new(req.body.clone().unwrap_or_default()))
236            .map_err(|e| Error::Http { source: e })
237    }
238}
239
240pub(crate) fn build_http_request(
241    req: &HttpRequest,
242    is_http1: bool,
243) -> Result<Request<Full<Bytes>>> {
244    req.builder(is_http1)
245        .body(Full::new(req.body.clone().unwrap_or_default()))
246        .map_err(|e| Error::Http { source: e })
247}