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
109pub 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
154pub 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
191pub 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}