Skip to main content

minecraft_java_rs_core/net/
client.rs

1//! Centralised reqwest client construction and transport-error description.
2
3use std::net::{IpAddr, SocketAddr};
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::dns::{Addrs, Name, Resolve, Resolving};
8use serde::Deserialize;
9
10/// A DNS resolver that returns only IPv4 addresses.
11///
12/// On networks that advertise IPv6 (AAAA records) but whose IPv6 route is
13/// broken, reqwest/hyper does not fall back to IPv4 the way browsers do (it has
14/// no Happy Eyeballs), so connections hang or fail with the opaque "error
15/// sending request for url". This is a classic "works in the browser and over a
16/// VPN, fails in the app" symptom. Filtering DNS results to IPv4 before reqwest
17/// ever attempts a connection sidesteps the broken IPv6 path entirely.
18#[derive(Debug, Default)]
19struct Ipv4OnlyResolver;
20
21impl Resolve for Ipv4OnlyResolver {
22    fn resolve(&self, name: Name) -> Resolving {
23        let host = name.as_str().to_owned();
24        Box::pin(async move {
25            // Port is irrelevant here — reqwest overwrites it with the real one.
26            let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
27            let v4: Vec<SocketAddr> = addrs.filter(SocketAddr::is_ipv4).collect();
28            Ok(Box::new(v4.into_iter()) as Addrs)
29        })
30    }
31}
32
33/// A DNS-over-HTTPS resolver pointed at a fixed resolver IP (e.g. `1.1.1.1`).
34///
35/// Names are resolved with JSON DoH queries to `https://<ip>/dns-query`,
36/// connecting to the resolver by its *literal IP* so no system DNS lookup is
37/// needed to bootstrap. This bypasses both ISP DNS hijacking/poisoning **and**
38/// port-53 blocking — the failure modes behind "works over a VPN, fails on this
39/// network" that a plain change of nameserver cannot fix.
40///
41/// When `ipv4_only` is set, only A records are requested (composes with
42/// `force_ipv4`).
43#[derive(Debug)]
44struct DohResolver {
45    /// Bootstrap client. It MUST NOT use a custom resolver itself: it connects
46    /// to the DoH endpoint by literal IP, so wiring it through `DohResolver`
47    /// would recurse forever.
48    client: reqwest::Client,
49    /// Fully-formed endpoint, e.g. `https://1.1.1.1/dns-query`.
50    endpoint: String,
51    /// Resolver IP, kept for human-readable diagnostics (`MJRS_DNS_DEBUG`).
52    resolver: IpAddr,
53    ipv4_only: bool,
54}
55
56/// Subset of the Cloudflare/Google JSON DoH response we care about.
57#[derive(Deserialize)]
58struct DohResponse {
59    #[serde(rename = "Answer", default)]
60    answer: Vec<DohAnswer>,
61}
62
63#[derive(Deserialize)]
64struct DohAnswer {
65    /// Record payload. For A/AAAA it is an IP literal; for CNAME etc. it is a
66    /// hostname, which simply fails to parse and is skipped.
67    data: String,
68}
69
70impl DohResolver {
71    async fn query(
72        client: reqwest::Client,
73        endpoint: String,
74        host: String,
75        rtype: &'static str,
76    ) -> Result<Vec<IpAddr>, Box<dyn std::error::Error + Send + Sync>> {
77        let resp = client
78            .get(&endpoint)
79            .query(&[("name", host.as_str()), ("type", rtype)])
80            .header("accept", "application/dns-json")
81            .send()
82            .await?
83            .error_for_status()?
84            .json::<DohResponse>()
85            .await?;
86        Ok(resp
87            .answer
88            .into_iter()
89            .filter_map(|a| a.data.parse::<IpAddr>().ok())
90            .collect())
91    }
92}
93
94impl Resolve for DohResolver {
95    fn resolve(&self, name: Name) -> Resolving {
96        let client = self.client.clone();
97        let endpoint = self.endpoint.clone();
98        let resolver = self.resolver;
99        let ipv4_only = self.ipv4_only;
100        let host = name.as_str().to_owned();
101        Box::pin(async move {
102            // Opt-in diagnostics: silent unless MJRS_DNS_DEBUG is set. Lets users
103            // (and support) confirm resolution actually flows through DoH without
104            // a packet capture.
105            let debug = std::env::var_os("MJRS_DNS_DEBUG").is_some();
106
107            let mut ips =
108                match DohResolver::query(client.clone(), endpoint.clone(), host.clone(), "A").await
109                {
110                    Ok(v) => v,
111                    Err(e) => {
112                        if debug {
113                            eprintln!("[dns] DoH via {resolver} → {host} = ERROR ({e})");
114                        }
115                        return Err(e);
116                    }
117                };
118            if !ipv4_only {
119                // A missing/failing AAAA lookup is non-fatal: most hosts that
120                // resolve over IPv4 simply have no IPv6 record.
121                if let Ok(v6) = DohResolver::query(client, endpoint, host.clone(), "AAAA").await {
122                    ips.extend(v6);
123                }
124            }
125            if debug {
126                eprintln!("[dns] DoH via {resolver} → {host} = {ips:?}");
127            }
128            let addrs: Vec<SocketAddr> = ips.into_iter().map(|ip| SocketAddr::new(ip, 0)).collect();
129            Ok(Box::new(addrs.into_iter()) as Addrs)
130        })
131    }
132}
133
134/// Build a reqwest client with the shared launcher configuration.
135///
136/// DNS behaviour, in precedence order:
137/// - `dns = Some(ip)` → resolve every name via DNS-over-HTTPS against that
138///   resolver IP (see [`DohResolver`]); honours `force_ipv4` by requesting only
139///   A records.
140/// - `dns = None` and `force_ipv4 = true` → use the system resolver but keep
141///   only IPv4 results (see [`Ipv4OnlyResolver`]).
142/// - otherwise → reqwest's default system resolver.
143pub fn build_client(
144    timeout_secs: u64,
145    force_ipv4: bool,
146    dns: Option<IpAddr>,
147) -> reqwest::Result<reqwest::Client> {
148    let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(timeout_secs));
149    match dns {
150        Some(ip) => {
151            let boot = reqwest::Client::builder()
152                .timeout(Duration::from_secs(timeout_secs))
153                .build()?;
154            let endpoint = match ip {
155                IpAddr::V4(v4) => format!("https://{v4}/dns-query"),
156                IpAddr::V6(v6) => format!("https://[{v6}]/dns-query"),
157            };
158            builder = builder.dns_resolver(Arc::new(DohResolver {
159                client: boot,
160                endpoint,
161                resolver: ip,
162                ipv4_only: force_ipv4,
163            }));
164        }
165        None if force_ipv4 => {
166            builder = builder.dns_resolver(Arc::new(Ipv4OnlyResolver));
167        }
168        None => {}
169    }
170    builder.build()
171}
172
173/// Describe a `reqwest::Error`, surfacing the underlying transport cause when
174/// the request never reached the server (no HTTP status).
175///
176/// reqwest's own `Display` for these cases is the unhelpful "error sending
177/// request for url (…)". This walks the error source chain down to the root
178/// cause (e.g. "Network is unreachable", "Temporary failure in name
179/// resolution", "Connection reset by peer") — the information actually needed
180/// to tell a DNS problem from a broken IPv6 route from an ISP reset.
181pub fn describe_reqwest_error(err: &reqwest::Error) -> String {
182    if let Some(status) = err.status() {
183        let reason = status.canonical_reason().unwrap_or("unknown");
184        return format!("HTTP {status} {reason}");
185    }
186
187    let kind = if err.is_timeout() {
188        "connection timed out"
189    } else if err.is_connect() {
190        "could not establish connection"
191    } else if err.is_request() {
192        "request could not be sent"
193    } else {
194        "network error"
195    };
196
197    // Walk to the deepest cause in the source chain — that is where the real
198    // OS-level reason (DNS / unreachable / reset) lives.
199    let mut root: Option<String> = None;
200    let mut src = std::error::Error::source(err);
201    while let Some(e) = src {
202        root = Some(e.to_string());
203        src = e.source();
204    }
205
206    match root {
207        Some(cause) => format!("{kind} ({cause})"),
208        None => kind.to_string(),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn build_client_succeeds_in_all_modes() {
218        assert!(build_client(10, false, None).is_ok());
219        assert!(build_client(10, true, None).is_ok());
220        assert!(build_client(10, false, Some("1.1.1.1".parse().unwrap())).is_ok());
221        assert!(build_client(10, true, Some("1.1.1.1".parse().unwrap())).is_ok());
222    }
223
224    /// Live check that the DoH path resolves real names end-to-end: rustls must
225    /// accept Cloudflare's cert when connecting to the literal IP `1.1.1.1`, and
226    /// the resolver must hand reqwest at least one usable address.
227    #[tokio::test]
228    #[ignore = "requires internet: queries Cloudflare DoH at 1.1.1.1"]
229    async fn doh_resolver_resolves_real_host() {
230        // Also exercises the MJRS_DNS_DEBUG diagnostics path; run with
231        // `--ignored --nocapture` to see the `[dns] DoH via …` line.
232        std::env::set_var("MJRS_DNS_DEBUG", "1");
233
234        let resolver = DohResolver {
235            client: reqwest::Client::builder().build().unwrap(),
236            endpoint: "https://1.1.1.1/dns-query".to_owned(),
237            resolver: "1.1.1.1".parse().unwrap(),
238            ipv4_only: true,
239        };
240        let name: Name = "resources.download.minecraft.net".parse().unwrap();
241        let addrs: Vec<SocketAddr> = resolver.resolve(name).await.unwrap().collect();
242        assert!(!addrs.is_empty(), "DoH returned no addresses");
243        assert!(addrs.iter().all(SocketAddr::is_ipv4), "ipv4_only honoured");
244
245        std::env::remove_var("MJRS_DNS_DEBUG");
246    }
247}