diff --git a/data/config.example.toml b/data/config.example.toml index 918b3f7..cf380a3 100644 --- a/data/config.example.toml +++ b/data/config.example.toml @@ -39,6 +39,13 @@ listen=9042 # The port to listen on (http) # # Otherwise, the database will be downloaded automatically and stored in the data_dir # maxmind_db_path="./GeoLite2-City.mmdb" +# # Trust and use the geolocation headers set by Cloudflare (cf-ipcountry, cf-ipcity), taking +# # precedence over the MaxMind database if set. Will not be used if the request is not from a +# # trusted proxy. +# # Note: Currently, cf-ipcountry is always sent, but the city header (cf-ipcity) requires +# # enabling the "Add visitor location headers" managed transform in the Cloudflare dashboard. +# cloudflare=false + [duckdb] # See https://liwan.dev/guides/duckdb # threads=2 diff --git a/src/app/core/geoip.rs b/src/app/core/geoip.rs index 04eb8a7..f20b708 100644 --- a/src/app/core/geoip.rs +++ b/src/app/core/geoip.rs @@ -32,11 +32,17 @@ pub struct LiwanGeoIP { impl LiwanGeoIP { pub fn try_new(config: crate::config::Config) -> Result { let geoip = config.geoip; + if geoip.cloudflare { + tracing::info!("Cloudflare geolocation support enabled") + } if geoip.maxmind_account_id.is_none() && geoip.maxmind_license_key.is_none() && geoip.maxmind_db_path.is_none() { - tracing::trace!("GeoIP support disabled, skipping..."); + tracing::trace!("MaxMind GeoIP support disabled, skipping..."); return Ok(Self::noop()); } + if geoip.cloudflare { + tracing::info!("MaxMind GeoIP also enabled, will fall back to it if Cloudflare geolocation is not available") + } let edition = &geoip.maxmind_edition; let default_path = PathBuf::from(config.data_dir.clone()).join(format!("./geoip/{edition}.mmdb")); diff --git a/src/config.rs b/src/config.rs index f6de03d..808b5f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,6 +72,8 @@ pub struct GeoIpConfig { pub maxmind_license_key: Option, #[serde(default = "default_maxmind_edition")] pub maxmind_edition: String, + #[serde(default)] + pub cloudflare: bool, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/utils/ip_headers.rs b/src/utils/ip_headers.rs index 3caef8a..e8bbc43 100644 --- a/src/utils/ip_headers.rs +++ b/src/utils/ip_headers.rs @@ -195,6 +195,41 @@ pub fn parse_header_ip(parts: &http::request::Parts, header: &TrustedHeader) -> } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudflareGeo { + present: bool, + pub country_code: Option, + pub city: Option, +} + +impl CloudflareGeo { + /// Parse Cloudflare's geolocation headers (`cf-ipcountry`, `cf-ipcity`). + /// + /// A missing or invalid `cf-ipcountry` header means Cloudflare geolocation is not active, + /// in which case [`CloudflareGeo::is_present`] returns false. + pub fn from_headers(parts: &http::request::Parts) -> Self { + let header = |name: &str| { + parts.headers.get(name).and_then(|value| value.to_str().ok()).map(str::trim).filter(|value| !value.is_empty()) + }; + + let Some(country) = header("cf-ipcountry") else { + return Self::default(); + }; + + // Cloudflare uses "XX" for unknown locations and "T1" for Tor exit nodes. + let country_code = (!country.eq_ignore_ascii_case("XX") && !country.eq_ignore_ascii_case("T1")) + .then(|| country.to_uppercase()); + let city = header("cf-ipcity").map(str::to_owned); + + Self { present: true, country_code, city } + } + + /// Whether Cloudflare provided authoritative geolocation for this request. + pub fn is_present(&self) -> bool { + self.present + } +} + pub fn should_trust_forwarded_headers( use_forward_headers: bool, peer_ip: Option, @@ -267,4 +302,46 @@ mod tests { assert_eq!(public_ip(Some("127.0.0.1".parse().unwrap())), None); assert_eq!(public_ip(Some("::1".parse().unwrap())), None); } + + fn cloudflare_geo(headers: &[(&str, &str)]) -> CloudflareGeo { + let mut builder = http::Request::builder(); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let (parts, _) = builder.body(()).unwrap().into_parts(); + CloudflareGeo::from_headers(&parts) + } + + #[test] + fn cloudflare_geo_parses_country_and_city() { + let geo = cloudflare_geo(&[("cf-ipcountry", "ca"), ("cf-ipcity", "Ottawa")]); + assert!(geo.is_present()); + assert_eq!(geo.country_code.as_deref(), Some("CA")); + assert_eq!(geo.city.as_deref(), Some("Ottawa")); + } + + #[test] + fn cloudflare_geo_country_only() { + let geo = cloudflare_geo(&[("cf-ipcountry", "US")]); + assert!(geo.is_present()); + assert_eq!(geo.country_code.as_deref(), Some("US")); + assert_eq!(geo.city, None); + } + + #[test] + fn cloudflare_geo_unknown_country_is_present_without_fallback() { + for code in ["XX", "xx", "T1", "t1"] { + let geo = cloudflare_geo(&[("cf-ipcountry", code)]); + assert!(geo.is_present(), "expected present for {code}"); + assert_eq!(geo.country_code, None, "expected no country for {code}"); + assert_eq!(geo.city, None); + } + } + + #[test] + fn cloudflare_geo_absent_when_header_missing_or_empty() { + assert_eq!(cloudflare_geo(&[]), CloudflareGeo::default()); + assert!(!cloudflare_geo(&[]).is_present()); + assert!(!cloudflare_geo(&[("cf-ipcountry", " ")]).is_present()); + } } diff --git a/src/web/routes/event.rs b/src/web/routes/event.rs index f773cad..51dde3f 100644 --- a/src/web/routes/event.rs +++ b/src/web/routes/event.rs @@ -3,6 +3,7 @@ use crate::app::models::{ }; use crate::app::{Liwan, models::Event}; use crate::utils::hash::{visitor_group_id, visitor_group_id_cidr, visitor_group_id_fallback}; +use crate::utils::ip_headers::CloudflareGeo; use crate::utils::referrer::{Referrer, process_referer}; use crate::utils::useragent; use crate::web::RouterState; @@ -145,6 +146,7 @@ static EXISTING_ENTITIES: LazyLock> = async fn event_handler( state: State, ClientIp(ip): ClientIp, + cf_geo: CloudflareGeo, TypedHeader(user_agent): TypedHeader, Json(event): Json, ) -> ApiResult { @@ -154,7 +156,7 @@ async fn event_handler( event.validate().context("invalid event").http_err("invalid event", StatusCode::BAD_REQUEST)?; // run the event processing in the background - let res = tokio::task::spawn_blocking(move || process_event(app, event, url, ip, user_agent)) + let res = tokio::task::spawn_blocking(move || process_event(app, event, url, ip, cf_geo, user_agent)) .await .http_status(StatusCode::INTERNAL_SERVER_ERROR)?; @@ -177,6 +179,7 @@ fn process_event( event: EventRequest, mut url: Url, ip: Option, + cf_geo: CloudflareGeo, user_agent: headers::UserAgent, ) -> Result> { let referrer = match process_referer(event.referrer.as_deref()) { @@ -215,20 +218,13 @@ fn process_event( resolve_visitor_group_id(&settings, ip, user_agent.as_str(), &app.events.get_salt()?, &event.entity_id); #[cfg(feature = "geoip")] - let (country, city) = match settings.track_geo { - GeoDetail::None => (None, None), - GeoDetail::Country => ip - .and_then(|ip| app.geoip.lookup(&ip).ok()) - .map(|lookup| (lookup.country_code, None)) - .unwrap_or((None, None)), - GeoDetail::City => ip - .and_then(|ip| app.geoip.lookup(&ip).ok()) - .map(|lookup| (lookup.country_code, lookup.city)) - .unwrap_or((None, None)), - }; + let (country, city) = resolve_geo(&app, settings.track_geo, ip, cf_geo); #[cfg(not(feature = "geoip"))] - let (country, city) = (None, None); + let (country, city) = { + let _ = cf_geo; + (None, None) + }; let utm = if settings.track_utm_params { extract_utm(&mut url) } else { Utm::default() }; url.set_query(None); @@ -265,6 +261,35 @@ fn process_event( Ok(Some(event)) } +/// Resolve the country/city for an event, preferring Cloudflare-provided geolocation +/// and falling back to the MaxMind database lookup for any missing field. +#[cfg(feature = "geoip")] +fn resolve_geo( + app: &Liwan, + track_geo: GeoDetail, + ip: Option, + cf_geo: CloudflareGeo, +) -> (Option, Option) { + match (track_geo, cf_geo.is_present()) { + (GeoDetail::None, _) => (None, None), + + // If Cloudflare provided geolocation, never fall back to MaxMind even + // if part of it is missing. + (GeoDetail::Country, true) => (cf_geo.country_code, None), + (GeoDetail::City, true) => (cf_geo.country_code, cf_geo.city), + + // Otherwise, look the IP up in the MaxMind database. + (GeoDetail::Country, false) => { + let country = ip.and_then(|ip| app.geoip.lookup(&ip).ok()).and_then(|lookup| lookup.country_code); + (country, None) + } + (GeoDetail::City, false) => ip + .and_then(|ip| app.geoip.lookup(&ip).ok()) + .map(|lookup| (lookup.country_code, lookup.city)) + .unwrap_or((None, None)), + } +} + fn ingest_drop_rule_matches(event: &Event, rule: &IngestDropRule) -> bool { !rule.filters.is_empty() && rule.filters.iter().all(|filter| ingest_filter_matches(event, filter)) } diff --git a/src/web/webext.rs b/src/web/webext.rs index 6e3f693..7757f16 100644 --- a/src/web/webext.rs +++ b/src/web/webext.rs @@ -5,7 +5,7 @@ use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; use std::task::{Context, Poll}; -use crate::utils::ip_headers::{parse_header_ip, public_ip, should_trust_forwarded_headers}; +use crate::utils::ip_headers::{CloudflareGeo, parse_header_ip, public_ip, should_trust_forwarded_headers}; use crate::web::Files; use crate::web::RouterState; use aide::axum::IntoApiResponse; @@ -283,3 +283,28 @@ impl FromRequestParts for ClientIp { Ok(ClientIp(public_ip(peer_ip))) } } + +impl OperationInput for CloudflareGeo {} + +impl FromRequestParts for CloudflareGeo { + type Rejection = Infallible; + + async fn from_request_parts( + parts: &mut http::request::Parts, + state: &RouterState, + ) -> Result { + if !state.config.geoip.cloudflare { + return Ok(CloudflareGeo::default()); + } + + // Only trust the geolocation headers if the request comes from a trusted proxy, + // like the forwarded IP headers. + let peer_ip = + ConnectInfo::::from_request_parts(parts, state).await.ok().map(|ConnectInfo(addr)| addr.ip()); + if should_trust_forwarded_headers(state.config.use_forward_headers, peer_ip, &state.config.trusted_proxies) { + return Ok(CloudflareGeo::from_headers(parts)); + } + + Ok(CloudflareGeo::default()) + } +}