Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions data/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/app/core/geoip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,17 @@ pub struct LiwanGeoIP {
impl LiwanGeoIP {
pub fn try_new(config: crate::config::Config) -> Result<Self> {
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"));
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub struct GeoIpConfig {
pub maxmind_license_key: Option<String>,
#[serde(default = "default_maxmind_edition")]
pub maxmind_edition: String,
#[serde(default)]
pub cloudflare: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down
77 changes: 77 additions & 0 deletions src/utils/ip_headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub city: Option<String>,
}

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<IpAddr>,
Expand Down Expand Up @@ -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());
}
}
51 changes: 38 additions & 13 deletions src/web/routes/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,6 +146,7 @@ static EXISTING_ENTITIES: LazyLock<quick_cache::sync::Cache<String, ()>> =
async fn event_handler(
state: State<RouterState>,
ClientIp(ip): ClientIp,
cf_geo: CloudflareGeo,
TypedHeader(user_agent): TypedHeader<headers::UserAgent>,
Json(event): Json<EventRequest>,
) -> ApiResult<impl IntoApiResponse> {
Expand All @@ -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)?;

Expand All @@ -177,6 +179,7 @@ fn process_event(
event: EventRequest,
mut url: Url,
ip: Option<IpAddr>,
cf_geo: CloudflareGeo,
user_agent: headers::UserAgent,
) -> Result<Option<Event>> {
let referrer = match process_referer(event.referrer.as_deref()) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<IpAddr>,
cf_geo: CloudflareGeo,
) -> (Option<String>, Option<String>) {
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))
}
Expand Down
27 changes: 26 additions & 1 deletion src/web/webext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -283,3 +283,28 @@ impl FromRequestParts<RouterState> for ClientIp {
Ok(ClientIp(public_ip(peer_ip)))
}
}

impl OperationInput for CloudflareGeo {}

impl FromRequestParts<RouterState> for CloudflareGeo {
type Rejection = Infallible;

async fn from_request_parts(
parts: &mut http::request::Parts,
state: &RouterState,
) -> Result<Self, Self::Rejection> {
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::<SocketAddr>::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())
}
}