#[cfg(feature = "geo-matching")]
use crate::error::FlowGuardError;
use dashmap::DashMap;
use maxminddb::{geoip2, Reader};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::path::Path;
use std::sync::Arc;
use tracing::{debug, info, instrument, warn};
#[cfg(feature = "geo-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GeoInfo {
pub country_code: Option<String>,
pub country_name: Option<String>,
pub city: Option<String>,
pub continent: Option<String>,
pub longitude: Option<f64>,
pub latitude: Option<f64>,
pub timezone: Option<String>,
}
impl GeoInfo {
pub fn empty() -> Self {
Self {
country_code: None,
country_name: None,
city: None,
continent: None,
longitude: None,
latitude: None,
timezone: None,
}
}
pub fn is_empty(&self) -> bool {
self.country_code.is_none()
&& self.country_name.is_none()
&& self.city.is_none()
&& self.continent.is_none()
}
pub fn description(&self) -> String {
match (&self.city, &self.country_name) {
(Some(city), Some(country)) => format!("{}, {}", city, country),
(Some(city), None) => city.clone(),
(None, Some(country)) => country.clone(),
(None, None) => "Unknown".to_string(),
}
}
}
impl Default for GeoInfo {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "geo-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GeoCondition {
pub countries: Vec<String>,
pub cities: Vec<String>,
pub continents: Vec<String>,
}
impl GeoCondition {
pub fn empty() -> Self {
Self {
countries: vec![],
cities: vec![],
continents: vec![],
}
}
pub fn countries(countries: Vec<String>) -> Self {
Self {
countries,
cities: vec![],
continents: vec![],
}
}
pub fn cities(cities: Vec<String>) -> Self {
Self {
countries: vec![],
cities,
continents: vec![],
}
}
pub fn continents(continents: Vec<String>) -> Self {
Self {
countries: vec![],
cities: vec![],
continents,
}
}
pub fn is_empty(&self) -> bool {
self.countries.is_empty() && self.cities.is_empty() && self.continents.is_empty()
}
pub fn matches(&self, info: &GeoInfo) -> bool {
if self.is_empty() {
return true;
}
if !self.countries.is_empty() {
if let Some(country_code) = &info.country_code {
if self.countries.contains(country_code) {
return true;
}
}
return false;
}
if !self.cities.is_empty() {
if let Some(city) = &info.city {
if self.cities.contains(city) {
return true;
}
}
return false;
}
if !self.continents.is_empty() {
if let Some(continent) = &info.continent {
if self.continents.contains(continent) {
return true;
}
}
return false;
}
false
}
}
impl Default for GeoCondition {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "geo-matching")]
pub struct GeoMatcher {
reader: Arc<Reader<Vec<u8>>>,
cache: Arc<DashMap<IpAddr, GeoInfo>>,
cache_size_limit: usize,
}
impl GeoMatcher {
#[instrument(skip(db_path))]
pub async fn new<P: AsRef<Path>>(db_path: P) -> Result<Self, FlowGuardError> {
let db_path = db_path.as_ref();
if !db_path.exists() {
return Err(FlowGuardError::ConfigError(format!(
"GeoLite2数据库文件不存在: {}。请从MaxMind官网下载GeoLite2-City.mmdb文件",
db_path.display()
)));
}
info!("加载GeoLite2数据库: {}", db_path.display());
let metadata = tokio::fs::metadata(db_path)
.await
.map_err(FlowGuardError::IoError)?;
const MIN_DB_SIZE: u64 = 50 * 1024 * 1024; const MAX_DB_SIZE: u64 = 500 * 1024 * 1024;
let file_size = metadata.len();
if file_size < MIN_DB_SIZE {
return Err(FlowGuardError::ConfigError(format!(
"GeoLite2数据库文件大小异常({} bytes),可能已损坏或不是完整文件。最小要求: {} \
bytes",
file_size, MIN_DB_SIZE
)));
}
if file_size > MAX_DB_SIZE {
warn!(
"GeoLite2数据库文件过大({} bytes),可能不是标准文件",
file_size
);
}
let db_content = tokio::fs::read(db_path)
.await
.map_err(FlowGuardError::IoError)?;
if db_content.len() as u64 != file_size {
return Err(FlowGuardError::ConfigError(
"GeoLite2数据库文件读取不完整,可能被截断".to_string(),
));
}
info!("GeoLite2数据库加载成功,大小: {} bytes", db_content.len());
if db_content.len() < 4 {
return Err(FlowGuardError::ConfigError(
"GeoLite2数据库文件过短,无法读取文件头".to_string(),
));
}
let header = &db_content[0..4];
let is_valid_header = header == [0x00, 0x00, 0x02, 0x00] || header == [0x00, 0x00, 0x00, 0x00] || header == [0x00, 0x00, 0x03, 0x00];
if !is_valid_header {
warn!("GeoLite2数据库文件头格式异常: {:02X?}", header);
}
let reader = Reader::from_source(db_content)
.map_err(|e| FlowGuardError::ConfigError(format!("无效的GeoLite2数据库文件: {}", e)))?;
info!(
"GeoLite2数据库元数据: 版本={}, 构建日期={}, 记录数={}",
reader.metadata.binary_format_major_version,
reader.metadata.build_epoch,
reader.metadata.node_count
);
let matcher = Self {
reader: Arc::new(reader),
cache: Arc::new(DashMap::new()),
cache_size_limit: 10_000,
};
info!("GeoMatcher创建成功");
Ok(matcher)
}
#[instrument(skip(db_path))]
pub async fn with_cache_limit<P: AsRef<Path>>(
db_path: P,
cache_size_limit: usize,
) -> Result<Self, FlowGuardError> {
let mut matcher = Self::new(db_path).await?;
matcher.cache_size_limit = cache_size_limit;
Ok(matcher)
}
#[instrument(skip(self))]
pub fn lookup(&self, ip: IpAddr) -> Result<GeoInfo, FlowGuardError> {
if let Some(cached) = self.cache.get(&ip) {
debug!("缓存命中: {}", ip);
return Ok(cached.clone());
}
debug!("查询IP地理位置: {}", ip);
let city: geoip2::City = self
.reader
.lookup(ip)
.map_err(|e| FlowGuardError::ConfigError(format!("IP查询失败: {}", e)))?;
let info = self.extract_geo_info(&city);
if self.cache.len() >= self.cache_size_limit {
let remove_count = self.cache_size_limit / 10;
let keys_to_remove: Vec<_> = self
.cache
.iter()
.take(remove_count)
.map(|k| *k.key())
.collect();
for key in keys_to_remove {
self.cache.remove(&key);
}
debug!("缓存清理完成,移除 {} 条记录", remove_count);
}
self.cache.insert(ip, info.clone());
debug!("IP查询成功: {} -> {}", ip, info.description());
Ok(info)
}
#[instrument(skip(self, ips))]
pub fn batch_lookup(&self, ips: &[IpAddr]) -> Vec<Result<GeoInfo, FlowGuardError>> {
ips.iter().map(|ip| self.lookup(*ip)).collect()
}
#[instrument(skip(self, condition))]
pub fn matches_ip(&self, ip: IpAddr, condition: &GeoCondition) -> Result<bool, FlowGuardError> {
let info = self.lookup(ip)?;
Ok(condition.matches(&info))
}
pub fn matches(&self, info: &GeoInfo, condition: &GeoCondition) -> bool {
condition.matches(info)
}
#[instrument(skip(self))]
pub fn clear_cache(&self) {
let size = self.cache.len();
self.cache.clear();
info!("缓存已清空,移除 {} 条记录", size);
}
pub fn cache_stats(&self) -> GeoCacheStats {
GeoCacheStats {
size: self.cache.len(),
limit: self.cache_size_limit,
hit_rate: 0.0, }
}
fn extract_geo_info(&self, city: &geoip2::City) -> GeoInfo {
let country_code = city
.country
.as_ref()
.and_then(|c| c.iso_code.map(|s| s.to_string()));
let country_name = city
.country
.as_ref()
.and_then(|c| c.names.as_ref())
.and_then(|names| names.get("en").map(|s| s.to_string()));
let city_name = city
.city
.as_ref()
.and_then(|c| c.names.as_ref())
.and_then(|names| names.get("en").map(|s| s.to_string()));
let continent = city
.continent
.as_ref()
.and_then(|c| c.names.as_ref())
.and_then(|names| names.get("en").map(|s| s.to_string()));
let location = city.location.as_ref();
let longitude = location.and_then(|l| l.longitude);
let latitude = location.and_then(|l| l.latitude);
let timezone = location.and_then(|l| l.time_zone.map(|s| s.to_string()));
GeoInfo {
country_code,
country_name,
city: city_name,
continent,
longitude,
latitude,
timezone,
}
}
}
#[cfg(feature = "geo-matching")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoCacheStats {
pub size: usize,
pub limit: usize,
pub hit_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_geo_info_empty() {
let info = GeoInfo::empty();
assert!(info.is_empty());
assert_eq!(info.description(), "Unknown");
}
#[test]
fn test_geo_info_description() {
let info1 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: Some("Beijing".to_string()),
continent: Some("Asia".to_string()),
longitude: Some(116.4),
latitude: Some(39.9),
timezone: Some("Asia/Shanghai".to_string()),
};
assert_eq!(info1.description(), "Beijing, China");
let info2 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: None,
continent: Some("Asia".to_string()),
longitude: None,
latitude: None,
timezone: None,
};
assert_eq!(info2.description(), "China");
let info3 = GeoInfo {
country_code: None,
country_name: None,
city: Some("Beijing".to_string()),
continent: None,
longitude: None,
latitude: None,
timezone: None,
};
assert_eq!(info3.description(), "Beijing");
}
#[test]
fn test_geo_condition_empty() {
let condition = GeoCondition::empty();
assert!(condition.is_empty());
let info = GeoInfo::empty();
assert!(condition.matches(&info));
}
#[test]
fn test_geo_condition_countries() {
let condition = GeoCondition::countries(vec!["CN".to_string(), "US".to_string()]);
let info1 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: None,
continent: None,
longitude: None,
latitude: None,
timezone: None,
};
assert!(condition.matches(&info1));
let info2 = GeoInfo {
country_code: Some("JP".to_string()),
country_name: Some("Japan".to_string()),
city: None,
continent: None,
longitude: None,
latitude: None,
timezone: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_geo_condition_cities() {
let condition = GeoCondition::cities(vec!["Beijing".to_string(), "Shanghai".to_string()]);
let info1 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: Some("Beijing".to_string()),
continent: None,
longitude: None,
latitude: None,
timezone: None,
};
assert!(condition.matches(&info1));
let info2 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: Some("Shenzhen".to_string()),
continent: None,
longitude: None,
latitude: None,
timezone: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_geo_condition_continents() {
let condition = GeoCondition::continents(vec!["Asia".to_string()]);
let info1 = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: None,
continent: Some("Asia".to_string()),
longitude: None,
latitude: None,
timezone: None,
};
assert!(condition.matches(&info1));
let info2 = GeoInfo {
country_code: Some("US".to_string()),
country_name: Some("United States".to_string()),
city: None,
continent: Some("North America".to_string()),
longitude: None,
latitude: None,
timezone: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_geo_condition_default() {
let condition = GeoCondition::default();
assert!(condition.is_empty());
}
#[test]
fn test_geo_cache_stats() {
let cache_stats = GeoCacheStats {
size: 0,
limit: 10000,
hit_rate: 0.0,
};
assert_eq!(cache_stats.size, 0);
assert_eq!(cache_stats.limit, 10000);
assert_eq!(cache_stats.hit_rate, 0.0);
}
#[test]
#[ignore] fn test_geo_matcher_lookup() {
}
#[test]
fn test_geo_info_serialization() {
let info = GeoInfo {
country_code: Some("CN".to_string()),
country_name: Some("China".to_string()),
city: Some("Beijing".to_string()),
continent: Some("Asia".to_string()),
longitude: Some(116.4),
latitude: Some(39.9),
timezone: Some("Asia/Shanghai".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
let deserialized: GeoInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info, deserialized);
}
#[test]
fn test_geo_condition_serialization() {
let condition = GeoCondition {
countries: vec!["CN".to_string(), "US".to_string()],
cities: vec!["Beijing".to_string()],
continents: vec!["Asia".to_string()],
};
let json = serde_json::to_string(&condition).unwrap();
let deserialized: GeoCondition = serde_json::from_str(&json).unwrap();
assert_eq!(condition, deserialized);
}
}