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