cratefield_runtime_native/ports/
mod.rs1mod blob;
7mod clock;
8mod defer;
9mod http;
10mod kv;
11mod rate_limit;
12mod realtime;
13
14pub use blob::DirBlob;
15pub use clock::TokioClock;
16pub use defer::SpawnDefer;
17pub use http::{OutboundOptions, ReqwestClient, vet_ip_ok};
18pub use kv::{RedisBundle, RedisKv, RedisPortError, redis_from_env};
19pub use rate_limit::RedisRateLimiter;
20pub use realtime::{Connection, InProcessRealtime};
21
22use axum::http::{HeaderMap, HeaderName};
23use std::net::IpAddr;
24use std::str::FromStr as _;
25
26use cratefield_core::Config;
27
28pub const CLIENT_IP_HEADER: &str = "cf-connecting-ip";
35
36const STRIPPED: [&str; 4] = [
42 "cf-connecting-ip",
43 "x-forwarded-for",
44 "x-real-ip",
45 "forwarded",
46];
47
48#[must_use]
58pub(crate) fn trusted_hosts(config: &dyn Config) -> Vec<String> {
59 config
60 .get("TRUSTED_HOSTS")
61 .map(|raw| {
62 raw.split(',')
63 .map(|entry| entry.trim().to_ascii_lowercase())
64 .filter(|entry| !entry.is_empty())
65 .collect()
66 })
67 .unwrap_or_default()
68}
69
70#[must_use]
74pub(crate) fn venture_hosts(venture: &cratefield_core::Venture) -> Vec<String> {
75 let domain = venture.domain.trim().to_ascii_lowercase();
76 let mut hosts = Vec::new();
77 if !domain.is_empty() {
78 hosts.push(format!("api.{domain}"));
79 hosts.push(domain);
80 }
81 if let Some(host) = venture
82 .public_url
83 .split("://")
84 .nth(1)
85 .and_then(|rest| rest.split('/').next())
86 .map(|host| host.trim().to_ascii_lowercase())
87 .filter(|host| !host.is_empty())
88 {
89 let host = host_without_port(&host);
90 if !hosts.iter().any(|known| known == &host) {
91 hosts.push(host);
92 }
93 }
94 hosts
95}
96
97#[must_use]
100pub(crate) fn host_without_port(authority: &str) -> String {
101 let authority = authority.trim().to_ascii_lowercase();
102 if let Some(rest) = authority.strip_prefix('[') {
103 return match rest.split_once(']') {
104 Some((inner, _)) => format!("[{inner}]"),
105 None => authority,
106 };
107 }
108 match authority.rsplit_once(':') {
109 Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host.to_owned(),
110 _ => authority,
111 }
112}
113
114#[must_use]
117pub(crate) fn is_loopback_host(host: &str) -> bool {
118 let host = host_without_port(host);
119 host == "localhost"
120 || host.ends_with(".localhost")
121 || host == "[::1]"
122 || host
123 .parse::<std::net::IpAddr>()
124 .is_ok_and(|ip| ip.is_loopback())
125}
126
127#[must_use]
140pub fn trusted_proxy_headers(config: &dyn Config) -> Vec<String> {
141 config
142 .get("TRUSTED_PROXY_HEADERS")
143 .map(|raw| {
144 raw.split(',')
145 .map(str::trim)
146 .filter(|entry| !entry.is_empty())
147 .map(str::to_ascii_lowercase)
148 .filter(|name| HeaderName::from_str(name).is_ok())
149 .collect::<Vec<_>>()
150 })
151 .unwrap_or_default()
152}
153
154#[must_use]
169pub fn client_ip(headers: &HeaderMap, trusted: &[String], peer: Option<IpAddr>) -> Option<IpAddr> {
170 for name in trusted {
171 let parsed = headers
172 .get(name.as_str())
173 .and_then(|value| value.to_str().ok())
174 .and_then(|list| list.split(',').next())
175 .map(str::trim)
176 .and_then(|first| first.parse::<IpAddr>().ok());
177 if let Some(ip) = parsed {
178 return Some(ip);
179 }
180 }
181 peer
182}
183
184pub(crate) fn normalize_headers(headers: &mut HeaderMap, resolved: Option<IpAddr>) {
189 for name in STRIPPED {
190 headers.remove(HeaderName::from_static(name));
191 }
192 if let Some(ip) = resolved
193 && let Ok(value) = axum::http::HeaderValue::from_str(&ip.to_string())
194 {
195 headers.insert(HeaderName::from_static(CLIENT_IP_HEADER), value);
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use axum::http::HeaderValue;
203 use cratefield_core::MapConfig;
204
205 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
206 use std::str::FromStr as _;
207 let mut map = HeaderMap::new();
208 for (name, value) in pairs {
209 map.insert(
210 HeaderName::from_str(name).expect("test header names are valid"),
211 HeaderValue::from_str(value).expect("valid header value"),
212 );
213 }
214 map
215 }
216
217 #[test]
218 fn trusted_list_defaults_to_empty() {
219 assert!(trusted_proxy_headers(&MapConfig::default()).is_empty());
220 let cfg = MapConfig::from_pairs([("TRUSTED_PROXY_HEADERS", " ")]);
221 assert!(trusted_proxy_headers(&cfg).is_empty());
222 }
223
224 #[test]
225 fn trusted_list_parses_lowercased_and_drops_invalid() {
226 let cfg = MapConfig::from_pairs([(
227 "TRUSTED_PROXY_HEADERS",
228 "X-Forwarded-For, x-real-ip, bad name,,cf-connecting-ip",
229 )]);
230 assert_eq!(
231 trusted_proxy_headers(&cfg),
232 ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"]
233 );
234 }
235
236 #[test]
237 fn unconfigured_trusts_no_header_only_the_peer() {
238 let headers = headers(&[
239 ("x-forwarded-for", "6.6.6.6"),
240 ("cf-connecting-ip", "7.7.7.7"),
241 ("x-real-ip", "8.8.8.8"),
242 ]);
243 let peer: IpAddr = "203.0.113.7".parse().unwrap();
244 assert_eq!(
245 client_ip(&headers, &[], Some(peer)),
246 Some(peer),
247 "with no trusted headers, forwarding headers are decoration"
248 );
249 }
250
251 #[test]
252 fn trusted_header_wins_in_config_order() {
253 let headers = headers(&[
254 ("x-real-ip", "198.51.100.2"),
255 ("x-forwarded-for", "198.51.100.1, 10.0.0.1"),
256 ]);
257 let trusted = vec!["x-forwarded-for".to_owned(), "x-real-ip".to_owned()];
258 let resolved = client_ip(&headers, &trusted, Some("203.0.113.7".parse().unwrap()));
259 assert_eq!(
260 resolved.map(|ip| ip.to_string()).as_deref(),
261 Some("198.51.100.1")
262 );
263 }
264
265 #[test]
266 fn garbage_in_trusted_header_falls_through_to_next_then_peer() {
267 let headers = headers(&[("x-real-ip", "not-an-ip")]);
268 let peer: IpAddr = "203.0.113.7".parse().unwrap();
269 let trusted = vec!["x-real-ip".to_owned()];
270 assert_eq!(client_ip(&headers, &trusted, Some(peer)), Some(peer));
271 }
272
273 #[test]
274 fn normalize_strips_everything_and_sets_the_verdict() {
275 let mut headers = headers(&[
276 ("x-forwarded-for", "6.6.6.6"),
277 ("forwarded", "for=7.7.7.7"),
278 ("x-real-ip", "8.8.8.8"),
279 ("cf-connecting-ip", "9.9.9.9"),
280 ]);
281 normalize_headers(&mut headers, Some("203.0.113.7".parse::<IpAddr>().unwrap()));
282 assert_eq!(headers.get("x-forwarded-for"), None);
284 assert_eq!(headers.get("forwarded"), None);
285 assert_eq!(headers.get("x-real-ip"), None);
286 assert_eq!(
287 headers.get(CLIENT_IP_HEADER).and_then(|v| v.to_str().ok()),
288 Some("203.0.113.7")
289 );
290 assert_eq!(
292 cratefield_core::client_ip(&headers).as_deref(),
293 Some("203.0.113.7")
294 );
295 }
296}
297
298#[cfg(test)]
299mod host_tests {
300 use super::{host_without_port, is_loopback_host, trusted_hosts, venture_hosts};
301 use cratefield_core::{MapConfig, Venture};
302
303 #[test]
304 fn a_venture_answers_to_its_own_domain_and_api_subdomain() {
305 let venture = Venture::new("cratefield-waitlist", "cratefield.com")
306 .public_url("https://cratefield.com");
307 let hosts = venture_hosts(&venture);
308 assert!(hosts.contains(&"cratefield.com".to_owned()), "{hosts:?}");
309 assert!(
310 hosts.contains(&"api.cratefield.com".to_owned()),
311 "{hosts:?}"
312 );
313 let split =
315 Venture::new("auth", "factory0.ventures").public_url("https://auth.factory0.ventures/");
316 let hosts = venture_hosts(&split);
317 assert!(
318 hosts.contains(&"auth.factory0.ventures".to_owned()),
319 "{hosts:?}"
320 );
321 }
322
323 #[test]
324 fn ports_are_not_part_of_the_comparison_and_ipv6_keeps_its_brackets() {
325 assert_eq!(host_without_port("cratefield.com:8443"), "cratefield.com");
326 assert_eq!(host_without_port("CrateField.com"), "cratefield.com");
327 assert_eq!(host_without_port("[::1]:8080"), "[::1]");
328 assert_eq!(host_without_port("[fd00::1]"), "[fd00::1]");
329 assert_eq!(host_without_port("host:notaport"), "host:notaport");
331 }
332
333 #[test]
334 fn loopback_is_recognized_in_every_spelling_local_work_uses() {
335 for host in [
336 "localhost",
337 "localhost:8080",
338 "127.0.0.1",
339 "127.0.0.1:8080",
340 "127.9.9.9",
341 "[::1]",
342 "[::1]:8080",
343 "app.localhost",
344 ] {
345 assert!(is_loopback_host(host), "{host} is loopback");
346 }
347 for host in ["cratefield.com", "10.0.0.1", "[fd00::1]", "evil.example"] {
348 assert!(!is_loopback_host(host), "{host} is not loopback");
349 }
350 }
351
352 #[test]
353 fn the_extra_host_list_is_parsed_like_the_trusted_proxy_list() {
354 assert!(trusted_hosts(&MapConfig::default()).is_empty());
355 let cfg = MapConfig::from_pairs([("TRUSTED_HOSTS", " Api.Example.com , , other.test ")]);
356 assert_eq!(trusted_hosts(&cfg), ["api.example.com", "other.test"]);
357 }
358}