mod blob;
mod clock;
mod defer;
mod http;
mod kv;
mod rate_limit;
mod realtime;
pub use blob::DirBlob;
pub use clock::TokioClock;
pub use defer::SpawnDefer;
pub use http::{OutboundOptions, ReqwestClient, vet_ip_ok};
pub use kv::{RedisBundle, RedisKv, RedisPortError, redis_from_env};
pub use rate_limit::RedisRateLimiter;
pub use realtime::{Connection, InProcessRealtime};
use axum::http::{HeaderMap, HeaderName};
use std::net::IpAddr;
use std::str::FromStr as _;
use cratefield_core::Config;
pub const CLIENT_IP_HEADER: &str = "cf-connecting-ip";
const STRIPPED: [&str; 4] = [
"cf-connecting-ip",
"x-forwarded-for",
"x-real-ip",
"forwarded",
];
#[must_use]
pub(crate) fn trusted_hosts(config: &dyn Config) -> Vec<String> {
config
.get("TRUSTED_HOSTS")
.map(|raw| {
raw.split(',')
.map(|entry| entry.trim().to_ascii_lowercase())
.filter(|entry| !entry.is_empty())
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub(crate) fn venture_hosts(venture: &cratefield_core::Venture) -> Vec<String> {
let domain = venture.domain.trim().to_ascii_lowercase();
let mut hosts = Vec::new();
if !domain.is_empty() {
hosts.push(format!("api.{domain}"));
hosts.push(domain);
}
if let Some(host) = venture
.public_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.map(|host| host.trim().to_ascii_lowercase())
.filter(|host| !host.is_empty())
{
let host = host_without_port(&host);
if !hosts.iter().any(|known| known == &host) {
hosts.push(host);
}
}
hosts
}
#[must_use]
pub(crate) fn host_without_port(authority: &str) -> String {
let authority = authority.trim().to_ascii_lowercase();
if let Some(rest) = authority.strip_prefix('[') {
return match rest.split_once(']') {
Some((inner, _)) => format!("[{inner}]"),
None => authority,
};
}
match authority.rsplit_once(':') {
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host.to_owned(),
_ => authority,
}
}
#[must_use]
pub(crate) fn is_loopback_host(host: &str) -> bool {
let host = host_without_port(host);
host == "localhost"
|| host.ends_with(".localhost")
|| host == "[::1]"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[must_use]
pub fn trusted_proxy_headers(config: &dyn Config) -> Vec<String> {
config
.get("TRUSTED_PROXY_HEADERS")
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_ascii_lowercase)
.filter(|name| HeaderName::from_str(name).is_ok())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
#[must_use]
pub fn client_ip(headers: &HeaderMap, trusted: &[String], peer: Option<IpAddr>) -> Option<IpAddr> {
for name in trusted {
let parsed = headers
.get(name.as_str())
.and_then(|value| value.to_str().ok())
.and_then(|list| list.split(',').next())
.map(str::trim)
.and_then(|first| first.parse::<IpAddr>().ok());
if let Some(ip) = parsed {
return Some(ip);
}
}
peer
}
pub(crate) fn normalize_headers(headers: &mut HeaderMap, resolved: Option<IpAddr>) {
for name in STRIPPED {
headers.remove(HeaderName::from_static(name));
}
if let Some(ip) = resolved
&& let Ok(value) = axum::http::HeaderValue::from_str(&ip.to_string())
{
headers.insert(HeaderName::from_static(CLIENT_IP_HEADER), value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use cratefield_core::MapConfig;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
use std::str::FromStr as _;
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
HeaderName::from_str(name).expect("test header names are valid"),
HeaderValue::from_str(value).expect("valid header value"),
);
}
map
}
#[test]
fn trusted_list_defaults_to_empty() {
assert!(trusted_proxy_headers(&MapConfig::default()).is_empty());
let cfg = MapConfig::from_pairs([("TRUSTED_PROXY_HEADERS", " ")]);
assert!(trusted_proxy_headers(&cfg).is_empty());
}
#[test]
fn trusted_list_parses_lowercased_and_drops_invalid() {
let cfg = MapConfig::from_pairs([(
"TRUSTED_PROXY_HEADERS",
"X-Forwarded-For, x-real-ip, bad name,,cf-connecting-ip",
)]);
assert_eq!(
trusted_proxy_headers(&cfg),
["x-forwarded-for", "x-real-ip", "cf-connecting-ip"]
);
}
#[test]
fn unconfigured_trusts_no_header_only_the_peer() {
let headers = headers(&[
("x-forwarded-for", "6.6.6.6"),
("cf-connecting-ip", "7.7.7.7"),
("x-real-ip", "8.8.8.8"),
]);
let peer: IpAddr = "203.0.113.7".parse().unwrap();
assert_eq!(
client_ip(&headers, &[], Some(peer)),
Some(peer),
"with no trusted headers, forwarding headers are decoration"
);
}
#[test]
fn trusted_header_wins_in_config_order() {
let headers = headers(&[
("x-real-ip", "198.51.100.2"),
("x-forwarded-for", "198.51.100.1, 10.0.0.1"),
]);
let trusted = vec!["x-forwarded-for".to_owned(), "x-real-ip".to_owned()];
let resolved = client_ip(&headers, &trusted, Some("203.0.113.7".parse().unwrap()));
assert_eq!(
resolved.map(|ip| ip.to_string()).as_deref(),
Some("198.51.100.1")
);
}
#[test]
fn garbage_in_trusted_header_falls_through_to_next_then_peer() {
let headers = headers(&[("x-real-ip", "not-an-ip")]);
let peer: IpAddr = "203.0.113.7".parse().unwrap();
let trusted = vec!["x-real-ip".to_owned()];
assert_eq!(client_ip(&headers, &trusted, Some(peer)), Some(peer));
}
#[test]
fn normalize_strips_everything_and_sets_the_verdict() {
let mut headers = headers(&[
("x-forwarded-for", "6.6.6.6"),
("forwarded", "for=7.7.7.7"),
("x-real-ip", "8.8.8.8"),
("cf-connecting-ip", "9.9.9.9"),
]);
normalize_headers(&mut headers, Some("203.0.113.7".parse::<IpAddr>().unwrap()));
assert_eq!(headers.get("x-forwarded-for"), None);
assert_eq!(headers.get("forwarded"), None);
assert_eq!(headers.get("x-real-ip"), None);
assert_eq!(
headers.get(CLIENT_IP_HEADER).and_then(|v| v.to_str().ok()),
Some("203.0.113.7")
);
assert_eq!(
cratefield_core::client_ip(&headers).as_deref(),
Some("203.0.113.7")
);
}
}
#[cfg(test)]
mod host_tests {
use super::{host_without_port, is_loopback_host, trusted_hosts, venture_hosts};
use cratefield_core::{MapConfig, Venture};
#[test]
fn a_venture_answers_to_its_own_domain_and_api_subdomain() {
let venture = Venture::new("cratefield-waitlist", "cratefield.com")
.public_url("https://cratefield.com");
let hosts = venture_hosts(&venture);
assert!(hosts.contains(&"cratefield.com".to_owned()), "{hosts:?}");
assert!(
hosts.contains(&"api.cratefield.com".to_owned()),
"{hosts:?}"
);
let split =
Venture::new("auth", "factory0.ventures").public_url("https://auth.factory0.ventures/");
let hosts = venture_hosts(&split);
assert!(
hosts.contains(&"auth.factory0.ventures".to_owned()),
"{hosts:?}"
);
}
#[test]
fn ports_are_not_part_of_the_comparison_and_ipv6_keeps_its_brackets() {
assert_eq!(host_without_port("cratefield.com:8443"), "cratefield.com");
assert_eq!(host_without_port("CrateField.com"), "cratefield.com");
assert_eq!(host_without_port("[::1]:8080"), "[::1]");
assert_eq!(host_without_port("[fd00::1]"), "[fd00::1]");
assert_eq!(host_without_port("host:notaport"), "host:notaport");
}
#[test]
fn loopback_is_recognized_in_every_spelling_local_work_uses() {
for host in [
"localhost",
"localhost:8080",
"127.0.0.1",
"127.0.0.1:8080",
"127.9.9.9",
"[::1]",
"[::1]:8080",
"app.localhost",
] {
assert!(is_loopback_host(host), "{host} is loopback");
}
for host in ["cratefield.com", "10.0.0.1", "[fd00::1]", "evil.example"] {
assert!(!is_loopback_host(host), "{host} is not loopback");
}
}
#[test]
fn the_extra_host_list_is_parsed_like_the_trusted_proxy_list() {
assert!(trusted_hosts(&MapConfig::default()).is_empty());
let cfg = MapConfig::from_pairs([("TRUSTED_HOSTS", " Api.Example.com , , other.test ")]);
assert_eq!(trusted_hosts(&cfg), ["api.example.com", "other.test"]);
}
}