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
108pub 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
153pub 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}