hpr_net/http.rs
1//! The HTTP transport: blocking HTTP/1.1 through `ureq`, TLS through rustls.
2
3use std::io::Read;
4use std::time::Duration;
5
6use ureq::{Proxy, ProxyProtocol};
7
8use crate::Transport;
9
10/// How an [`Http`] transport behaves. Start from [`HttpConfig::default`] and change fields.
11#[non_exhaustive]
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct HttpConfig {
14 /// The time a whole request may take: finding the host, connecting, every redirect and
15 /// reading the body. Longer than [`Http::MAX_TIMEOUT`] counts as that. Default 60 s.
16 pub timeout: Duration,
17 /// The longest body accepted, counted after any gzip unpacking; one byte more is refused.
18 /// Default 64 MiB, room for a forecast grid and far past any JSON answer.
19 pub max_body_bytes: u64,
20 /// Whether to use the proxy the environment names (`ALL_PROXY`, `HTTPS_PROXY` or
21 /// `HTTP_PROXY`, the first set, in either case, for every URL, bar hosts in `NO_PROXY`).
22 /// Default `true`.
23 pub proxy_from_env: bool,
24}
25
26impl Default for HttpConfig {
27 fn default() -> Self {
28 Self {
29 timeout: Duration::from_secs(60),
30 max_body_bytes: 64 * 1024 * 1024,
31 proxy_from_env: true,
32 }
33 }
34}
35
36/// A [`Transport`] that fetches over HTTP and HTTPS, behind the crate's `http` feature.
37///
38/// TLS is rustls with Mozilla's root certificates compiled in (the `webpki-roots` crate), so it
39/// needs no OpenSSL and ignores the operating system's certificate store. It follows up to ten
40/// redirects and sends `Accept-Encoding: gzip`, unpacking gzip bodies. Any status but 2xx is an
41/// error, as are a timeout and a body that unpacks to more than the limit. An HTTP or HTTPS proxy
42/// from the environment is used. A SOCKS one is refused with an error rather than bypassed, since
43/// this build cannot speak SOCKS; hosts `NO_PROXY` exempts are fetched directly, with no redirect
44/// followed, since the next address might not be exempt.
45///
46/// Cloning is cheap and the clones share one pool of connections.
47///
48/// ```no_run
49/// use hpr_net::{Cache, Client, Http, Mode, Source};
50///
51/// let dir = Cache::platform_dir().expect("a home or local app data folder");
52/// let client = Client::new(Http::new(), Cache::new(dir), Mode::Online);
53/// let source = Source {
54/// name: "Example".into(),
55/// attribution: "Example data".into(),
56/// ttl_s: 3600,
57/// };
58/// let now_s = std::time::SystemTime::now()
59/// .duration_since(std::time::UNIX_EPOCH)
60/// .map_or(0, |d| d.as_secs());
61/// let answer = client.fetch(&source, "https://example.com/", now_s)?;
62/// println!("{} bytes, {:?}", answer.body.len(), answer.freshness);
63/// # Ok::<(), hpr_net::NetError>(())
64/// ```
65#[derive(Debug, Clone)]
66pub struct Http {
67 agent: ureq::Agent,
68 max_body_bytes: u64,
69 /// A SOCKS proxy the environment names, which every URL it covers is refused under.
70 socks: Option<Proxy>,
71}
72
73impl Http {
74 /// The `User-Agent` header sent: the crate's name and version and the project's address, so a
75 /// data provider can tell who is calling.
76 pub const USER_AGENT: &str = concat!(
77 "hpr-sim/",
78 env!("CARGO_PKG_VERSION"),
79 " (+https://github.com/nrdptel/hpr-sim)"
80 );
81 /// The longest timeout used: 30 days. A longer one, such as [`Duration::MAX`] for "none",
82 /// counts as this, since a deadline past the clock's range would overflow.
83 pub const MAX_TIMEOUT: Duration = Duration::from_secs(30 * 24 * 3600);
84
85 /// A transport with [`HttpConfig::default`].
86 pub fn new() -> Self {
87 Self::with_config(HttpConfig::default())
88 }
89
90 /// A transport with `config`.
91 pub fn with_config(config: HttpConfig) -> Self {
92 let proxy = if config.proxy_from_env {
93 Proxy::try_from_env()
94 } else {
95 None
96 };
97 Self::with_proxy(&config, proxy)
98 }
99
100 fn with_proxy(config: &HttpConfig, proxy: Option<Proxy>) -> Self {
101 let socks = proxy
102 .clone()
103 .filter(|p| !matches!(p.protocol(), ProxyProtocol::Http | ProxyProtocol::Https));
104 // Under a SOCKS proxy a redirect is refused, not followed: `NO_PROXY` could exempt the
105 // first address and not the next, which ureq would then reach directly.
106 let redirects = if socks.is_some() { 0 } else { 10 };
107 let agent = ureq::Agent::config_builder()
108 .timeout_global(Some(config.timeout.min(Self::MAX_TIMEOUT)))
109 .user_agent(Self::USER_AGENT)
110 .max_redirects(redirects)
111 .proxy(proxy)
112 .build()
113 .new_agent();
114 Self {
115 agent,
116 max_body_bytes: config.max_body_bytes,
117 socks,
118 }
119 }
120}
121
122impl Http {
123 /// Why `url` is refused, when the environment's proxy is SOCKS and `NO_PROXY` doesn't exempt
124 /// it. Without ureq's SOCKS support, ureq would warn and connect directly, around the proxy.
125 fn socks_refusal(&self, url: &str) -> Option<String> {
126 let socks = self.socks.as_ref()?;
127 let uri = url.parse::<ureq::http::Uri>().ok()?;
128 if socks.is_no_proxy(&uri) {
129 return None;
130 }
131 // The protocol alone: the address may carry a user name and password, and one with an
132 // unescaped `/` or `#` in the password ends early, leaving the user name as the "host".
133 Some(format!(
134 "the environment's proxy ({:?}) is SOCKS, which hpr-net cannot use; unset the \
135 variable naming it (ALL_PROXY, HTTPS_PROXY or HTTP_PROXY), add the host to NO_PROXY, \
136 or turn off HttpConfig::proxy_from_env",
137 socks.protocol(),
138 ))
139 }
140}
141
142impl Default for Http {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl Transport for Http {
149 fn get(&self, url: &str) -> Result<Vec<u8>, String> {
150 if let Some(refusal) = self.socks_refusal(url) {
151 return Err(refusal);
152 }
153 let mut response = self.agent.get(url).call().map_err(|e| e.to_string())?;
154 // ureq errors on 4xx and 5xx only; a 304 or an unfollowed 3xx would read as an empty body.
155 let status = response.status();
156 if !status.is_success() {
157 return Err(format!("http status: {}", status.as_u16()));
158 }
159 // The limit is on the unpacked body, read one byte past it to tell "at" from "over".
160 // ureq's own limit counts bytes on the wire, inside its gzip decoder, so a small compressed
161 // body could unpack past it. Wire bytes that unpack to nothing are bounded by the timeout.
162 let unpacked = response.body_mut().with_config().limit(u64::MAX).reader();
163 let mut body = Vec::new();
164 unpacked
165 .take(self.max_body_bytes.saturating_add(1))
166 .read_to_end(&mut body)
167 .map_err(|e| e.to_string())?;
168 if body.len() as u64 > self.max_body_bytes {
169 return Err(format!(
170 "the body is longer than the {} bytes allowed",
171 self.max_body_bytes
172 ));
173 }
174 Ok(body)
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn under(proxy: Proxy) -> Http {
183 Http::with_proxy(&HttpConfig::default(), Some(proxy))
184 }
185
186 #[test]
187 fn a_socks_proxy_is_refused_not_bypassed() {
188 let http = under(Proxy::new("socks5://127.0.0.1:9").unwrap());
189 let reason = http.get("http://127.0.0.1:1/x").unwrap_err();
190 assert!(reason.contains("is SOCKS"), "{reason}");
191 }
192
193 #[test]
194 fn the_refusal_never_repeats_the_proxys_credentials() {
195 // Well formed, and with an unescaped `/` that ends the address inside the password.
196 for url in [
197 "socks5://alice:s3cret@proxy.example:1080",
198 "socks5://alice:12/cret@proxy.example:1080",
199 ] {
200 let reason = under(Proxy::new(url).unwrap())
201 .socks_refusal("https://example.test/x")
202 .unwrap();
203 assert!(
204 !reason.contains("cret") && !reason.contains("alice"),
205 "{reason}"
206 );
207 }
208 }
209
210 #[test]
211 fn no_proxy_exempts_a_host_from_the_refusal() {
212 let proxy = Proxy::builder(ProxyProtocol::Socks5)
213 .host("proxy.example")
214 .no_proxy("near.example")
215 .build()
216 .unwrap();
217 let http = under(proxy);
218 assert_eq!(http.socks_refusal("https://near.example/x"), None);
219 assert!(http.socks_refusal("https://far.example/x").is_some());
220 }
221
222 #[test]
223 fn under_a_socks_proxy_redirects_are_not_followed() {
224 // A request can't be made here: ureq panics on a SOCKS proxy not read from the
225 // environment, and only the environment makes one. With no redirects followed, a 3xx
226 // comes back as the response and fails as any status but 2xx does (`tests/http.rs`, 304).
227 let socks = under(Proxy::new("socks5://127.0.0.1:9").unwrap());
228 assert_eq!(socks.agent.config().max_redirects(), 0);
229 let http = under(Proxy::new("http://127.0.0.1:9").unwrap());
230 assert_eq!(http.agent.config().max_redirects(), 10);
231 }
232
233 #[test]
234 fn an_http_proxy_is_used_not_refused() {
235 for url in ["http://127.0.0.1:9", "https://127.0.0.1:9"] {
236 let http = under(Proxy::new(url).unwrap());
237 assert_eq!(http.socks_refusal("https://example.test/x"), None, "{url}");
238 }
239 }
240}