Skip to main content

shared/
net.rs

1use hickory_resolver::{
2    TokioResolver,
3    config::LookupIpStrategy,
4    lookup_ip::{LookupIp, LookupIpIter},
5};
6use reqwest::dns::{Addrs, Name, Resolve, Resolving};
7use std::{
8    net::SocketAddr,
9    str::FromStr,
10    sync::{Arc, OnceLock},
11};
12
13const MAX_REDIRECTS: usize = 10;
14
15pub fn unbracket(host: &str) -> &str {
16    host.strip_prefix('[')
17        .and_then(|h| h.strip_suffix(']'))
18        .unwrap_or(host)
19}
20
21pub fn host_to_ip(host: &str) -> Option<std::net::IpAddr> {
22    std::net::IpAddr::from_str(unbracket(host)).ok()
23}
24
25pub fn is_blocked_ip(cidrs: &[cidr::IpCidr], ip: &std::net::IpAddr) -> bool {
26    let ip = ip.to_canonical();
27
28    cidrs.iter().any(|cidr| cidr.contains(&ip))
29}
30
31#[derive(Clone)]
32pub struct BlockedIpResolver {
33    env: Arc<crate::env::Env>,
34    context: &'static str,
35    state: Arc<TokioResolver>,
36}
37
38fn tokio_resolver() -> TokioResolver {
39    let mut builder =
40        TokioResolver::builder_tokio().expect("failed to create TokioResolver builder");
41    builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
42
43    builder.build().expect("failed to build TokioResolver")
44}
45
46impl BlockedIpResolver {
47    pub fn new(env: &Arc<crate::env::Env>, context: &'static str) -> Self {
48        Self {
49            env: Arc::clone(env),
50            context,
51            state: Arc::new(tokio_resolver()),
52        }
53    }
54}
55
56impl Resolve for BlockedIpResolver {
57    fn resolve(&self, name: Name) -> Resolving {
58        let resolver = self.clone();
59
60        Box::pin(async move {
61            let lookup = resolver.state.lookup_ip(name.as_str()).await?;
62            let addrs: Addrs = Box::new(SocketAddrs::new(
63                Arc::clone(&resolver.env),
64                resolver.context,
65                lookup,
66                |l| l.iter(),
67            ));
68
69            Ok(addrs)
70        })
71    }
72}
73
74#[ouroboros::self_referencing]
75struct SocketAddrs {
76    env: Arc<crate::env::Env>,
77    context: &'static str,
78    lookup: LookupIp,
79
80    #[borrows(mut lookup)]
81    #[covariant]
82    iter: LookupIpIter<'this>,
83}
84
85impl Iterator for SocketAddrs {
86    type Item = SocketAddr;
87
88    fn next(&mut self) -> Option<Self::Item> {
89        let next = self
90            .with_iter_mut(|iter| iter.next())
91            .map(|ip_addr| SocketAddr::new(ip_addr, 0))?;
92
93        if is_blocked_ip(&self.borrow_env().app_blocked_cidrs, &next.ip()) {
94            tracing::warn!(
95                "blocking internal IP address in {}: {}",
96                self.borrow_context(),
97                next.ip()
98            );
99
100            return self.next();
101        }
102
103        Some(next)
104    }
105}
106
107static RESOLVER: OnceLock<TokioResolver> = OnceLock::new();
108
109/// Resolves `host` and drops every address covered by `APP_BLOCKED_CIDRS`, for connections that
110/// cannot be routed through [`outbound_client`] and have to dial the returned addresses directly.
111pub async fn resolve_allowed_addresses(
112    env: &Arc<crate::env::Env>,
113    host: &str,
114    port: u16,
115    context: &'static str,
116) -> Result<Vec<SocketAddr>, anyhow::Error> {
117    if let Some(ip) = host_to_ip(host) {
118        if is_blocked_ip(&env.app_blocked_cidrs, &ip) {
119            tracing::warn!("blocking internal IP address in {}: {}", context, ip);
120
121            return Err(anyhow::anyhow!("IP address {ip} is blocked"));
122        }
123
124        return Ok(vec![SocketAddr::new(ip, port)]);
125    }
126
127    let lookup = RESOLVER.get_or_init(tokio_resolver).lookup_ip(host).await?;
128
129    let addresses: Vec<SocketAddr> = lookup
130        .iter()
131        .filter(|ip| {
132            if is_blocked_ip(&env.app_blocked_cidrs, ip) {
133                tracing::warn!("blocking internal IP address in {}: {}", context, ip);
134
135                false
136            } else {
137                true
138            }
139        })
140        .map(|ip| SocketAddr::new(ip, port))
141        .collect();
142
143    if addresses.is_empty() {
144        return Err(anyhow::anyhow!(
145            "{host} does not resolve to any allowed IP address"
146        ));
147    }
148
149    Ok(addresses)
150}
151
152static OUTBOUND_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
153
154/// A [`reqwest::Client`] for requests to user-provided urls, refusing to connect to any resolved
155/// address or redirect covered by `APP_BLOCKED_CIDRS`. IP-literal urls skip resolution, so start
156/// requests with [`outbound_request`] to refuse those as well.
157pub fn outbound_client(env: &Arc<crate::env::Env>) -> &'static reqwest::Client {
158    OUTBOUND_CLIENT.get_or_init(|| {
159        let redirect_env = Arc::clone(env);
160
161        reqwest::Client::builder()
162            .user_agent(format!("github.com/calagopus/panel {}", crate::VERSION))
163            .connect_timeout(std::time::Duration::from_secs(10))
164            .read_timeout(std::time::Duration::from_secs(30))
165            .no_proxy()
166            .dns_resolver(Arc::new(BlockedIpResolver::new(env, "outbound request")))
167            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
168                if attempt.previous().len() >= MAX_REDIRECTS {
169                    return attempt.error(anyhow::anyhow!("too many redirects"));
170                }
171
172                if let Some(host) = attempt.url().host_str()
173                    && let Some(ip) = host_to_ip(host)
174                    && is_blocked_ip(&redirect_env.app_blocked_cidrs, &ip)
175                {
176                    tracing::warn!(
177                        "blocking redirect to internal IP address in outbound request: {}",
178                        ip
179                    );
180
181                    return attempt.error(anyhow::anyhow!("IP address {ip} is blocked"));
182                }
183
184                attempt.follow()
185            }))
186            .build()
187            .expect("Failed to create HTTP client")
188    })
189}
190
191/// Starts a request on [`outbound_client`], refusing a url whose host is an IP address covered by
192/// `APP_BLOCKED_CIDRS` before anything is connected.
193pub fn outbound_request(
194    env: &Arc<crate::env::Env>,
195    method: reqwest::Method,
196    url: reqwest::Url,
197) -> Result<reqwest::RequestBuilder, anyhow::Error> {
198    if let Some(host) = url.host_str()
199        && let Some(ip) = host_to_ip(host)
200        && is_blocked_ip(&env.app_blocked_cidrs, &ip)
201    {
202        tracing::warn!("blocking internal IP address in outbound request: {}", ip);
203
204        return Err(anyhow::anyhow!("IP address {ip} is blocked"));
205    }
206
207    Ok(outbound_client(env).request(method, url))
208}