diff --git a/.gitignore b/.gitignore index dc836fe..a3d025c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target !/configs/domain.conf.sample /configs +/mmdb diff --git a/Cargo.lock b/Cargo.lock index b439e11..710cc99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,13 +127,14 @@ checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "dns-geolocation-checker" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "futures", "hickory-client", "hickory-proto", "hickory-resolver", + "maxminddb", "rand", "reqwest", "serde", @@ -600,6 +601,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.11" @@ -664,6 +674,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + [[package]] name = "memchr" version = "2.7.4" diff --git a/Cargo.toml b/Cargo.toml index ba0eccd..6f9a816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dns-geolocation-checker" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "A tool to check the geolocation of a domain based on its DNS records." homepage = "https://github.com/single9/dns-geolocation-checker" @@ -10,12 +10,18 @@ readme = "README.md" keywords = ["tool"] [[bin]] -name = "geo-checker" -path = "src/bin/geo_checker.rs" +name = "dns-geo-checker" +path = "src/bin/dns_geo_checker.rs" + +[features] +default = ["mmdb"] +full = ["ip-api", "mmdb"] +ip-api = ["reqwest"] +mmdb = ["maxminddb"] [dependencies] anyhow = "1.0.86" -reqwest = { version = "0.12.5", features = ["json"] } +reqwest = { version = "0.12.5", features = ["json"], optional = true} serde = { version = "1.0.204", features = ["serde_derive"] } serde_json = "1.0.120" tokio = { version = "1.38.0", features = ["full"] } @@ -25,3 +31,4 @@ hickory-client = "0.24.1" hickory-proto = "0.24.1" rand = "0.8.5" futures = "0.3.30" +maxminddb = { version = "0.24.0", optional = true } diff --git a/README.md b/README.md index 35653af..055ee5f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ To get started with this project, clone the repository and ensure you have Rust ### Installation +#### Cargo + +Binary releases are available on [crates.io](https://crates.io/crates/dns-geolocation-checker). You can install the DNS Geolocation Checker using the following command: + +```sh +cargo install dns-geolocation-checker +``` + +#### Manually + 1. Clone the repository: ```sh @@ -28,6 +38,25 @@ cargo build ## Usage +### Feature Flags + +The DNS Geolocation Checker supports the following feature flags: + +- `ip-api`: Enables the IP Geolocation API provider. +- `mmdb`: Enables the MaxMind GeoLite2 database provider. + +To enable a feature flag, use the following command: + +```sh +cargo build -F ip-api +``` + +To enable multiple feature flags, use the following command: + +```sh +cargo build -F full +``` + ### Configuration You can configure the DNS Geolocation Checker by modifying the `config.toml` file. The configuration file contains the following sections: @@ -52,12 +81,40 @@ geo_routing = ["sg", "us"] Put the file `config.toml` in the `configs` directory of the project. Or you can specify the path to the configuration file using the `CONFIG_PATH` environment variable when running the application. +### IP Geolocation Providers + +#### MMDB + +This is the default IP geolocation provider. + +If you want to use the MaxMind GeoLite2 database, you need to download the database from the [MaxMind website](https://dev.maxmind.com/geoip/geoip2/geolite2/). After downloading the database, you need to specify the path to the database in the `config.toml` file: + +```toml +ip_geo_provider = "mmdb" + +# Set the path to the MMDB file +# Default: "./mmdb/GeoLite2-City.mmdb" +mmdb_path = "/path/to/GeoLite2-City.mmdb" +``` + +The default path is `./mmdb/GeoLite2-City.mmdb`. + +#### IP-API + +The [IP Geolocation API](https://ip-api.com/) is a free service that provides geolocation information for IP addresses. + +If you want to use the IP Geolocation API service, you need to specify the provider in the `config.toml` file: + +```toml +ip_geo_provider = "ip-api" +``` + ### Run To run the DNS Geolocation Checker, use the following command: ```sh -cargo run --bin geo-checker +cargo run --bin dns-geo-checker ``` When you run the DNS Geolocation Checker, it will query the DNS records for each domain and check the geolocation of the IP addresses returned. If the IP address falls within one of the subnets specified in the `test_subnets` section, the geolocation will be considered a match. @@ -80,11 +137,13 @@ To run the tests for this project, execute: cargo test --verbose ``` -## Notice - -This application use the [IP Geolocation API](https://ip-api.com/) to get the geolocation of IP addresses. - ## TODO - [ ] CLI mode +- [X] Support multiple IP geolocation providers - [ ] Support IPv6 addresses +- [ ] Map IP addresses to geographical locations + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/src/bin/dns_geo_checker.rs b/src/bin/dns_geo_checker.rs new file mode 100644 index 0000000..aa18ad5 --- /dev/null +++ b/src/bin/dns_geo_checker.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use dns_geolocation_checker::{ + configs_parser::ConfigParser, + ip_geo_checker::{IpGeoChecker, IpGeoCheckerTestedData}, + ip_geo_client::IpGeoProviderType, +}; +use std::env; + +#[cfg(feature = "ip-api")] +use dns_geolocation_checker::ip_geo_client::ip_api_client::IpApiClient; +#[cfg(feature = "mmdb")] +use dns_geolocation_checker::ip_geo_client::mmdb_client::MMDBClient; + +fn print_tested_data(data: Vec) { + data.clone() + .into_iter() + .filter(|r| r.is_ok()) + .for_each(|r| { + println!( + "[Matched] {}, ip: {}, subnet: {}, expected: {}, actual: {}", + r.host, r.ip, r.subnet, r.expected, r.actual + ); + }); + + data.clone() + .into_iter() + .filter(|r: &IpGeoCheckerTestedData| r.is_err()) + .for_each(|r| { + eprintln!( + "[Mismatched] {}, ip: {}, subnet: {}, expected: {}, actual: {}, error: {:?}", + r.host, + r.ip, + r.subnet, + r.expected, + r.actual, + r.err() + ); + }); +} + +#[tokio::main] +async fn main() -> Result<()> { + let path = env::var("CONFIG_PATH").unwrap_or("./configs/config.toml".to_string()); + let parser = ConfigParser::new_with_path(path); + let config = parser.config(); + let geo_ip_provider = config.ip_geo_provider.clone(); + let data = match geo_ip_provider { + #[cfg(feature = "ip-api")] + IpGeoProviderType::IpApi => { + IpGeoChecker::::new() + .config(&config) + .with_ip_api_client() + .check() + .await + } + #[cfg(feature = "mmdb")] + IpGeoProviderType::MMDB => { + IpGeoChecker::::new() + .config(&config) + .with_mmdb_client() + .check() + .await + } + _ => panic!( + "[Error] Invalid IP Geo Provider. Please add a valid provider in the config file." + ), + }; + + print_tested_data(data); + + Ok(()) +} diff --git a/src/bin/geo_checker.rs b/src/bin/geo_checker.rs deleted file mode 100644 index 032a1de..0000000 --- a/src/bin/geo_checker.rs +++ /dev/null @@ -1,42 +0,0 @@ -use anyhow::Result; -use dns_geolocation_checker::{ - configs_parser::ConfigParser, - ip_geo_checker::{IpGeoChecker, IpGeoCheckerTestedData}, -}; -use std::env; - -#[tokio::main] -async fn main() -> Result<()> { - let path = env::var("CONFIG_PATH").unwrap_or("./configs/config.toml".to_string()); - let parser = ConfigParser::new_with_path(path); - let config = parser.config(); - let res = IpGeoChecker::new(reqwest::Client::new()) - .config(&config) - .build() - .check() - .await; - - res.clone().into_iter().filter(|r| r.is_ok()).for_each(|r| { - println!( - "[Matched] {}, ip: {}, subnet: {}, expected: {}, actual: {}", - r.host, r.ip, r.subnet, r.expected, r.actual - ); - }); - - res.clone() - .into_iter() - .filter(|r: &IpGeoCheckerTestedData| r.is_err()) - .for_each(|r| { - eprintln!( - "[Mismatched] {}, ip: {}, subnet: {}, expected: {}, actual: {}, error: {:?}", - r.host, - r.ip, - r.subnet, - r.expected, - r.actual, - r.err() - ); - }); - - Ok(()) -} diff --git a/src/configs_parser.rs b/src/configs_parser.rs index 6d1834b..fd9077e 100644 --- a/src/configs_parser.rs +++ b/src/configs_parser.rs @@ -3,15 +3,23 @@ use serde::Deserialize; use std::{collections::HashMap, fs}; +use crate::ip_geo_client::IpGeoProviderType; + /// A struct to hold the parsed config #[derive(Default, Debug, Clone, Deserialize)] pub struct Config { + /// The IP geo provider + #[serde(default)] + pub ip_geo_provider: IpGeoProviderType, + #[serde(default)] + pub mmdb_path: Option, /// A map of country codes to their respective subnets pub test_subnets: HashMap, /// A list of domains and their respective geo routing pub domain: Vec, } +/// A struct to hold the domain config #[derive(Default, Debug, Clone, Deserialize)] pub struct DomainConfig { /// The host of the domain diff --git a/src/ip_geo_checker.rs b/src/ip_geo_checker.rs index 122da68..43b6ddf 100644 --- a/src/ip_geo_checker.rs +++ b/src/ip_geo_checker.rs @@ -2,18 +2,20 @@ use serde::Deserialize; use std::net::IpAddr; -use Result; use crate::configs_parser::{Config, DomainConfig}; use crate::dns_client::DnsResolver; +use crate::ip_geo_client::{GetGeoIpInfo, IpGeoClient, IpGeoProvider}; -const IP_API_BATCH: &'static str = "http://ip-api.com/batch"; +#[cfg(feature = "ip-api")] +use crate::ip_geo_client::ip_api_client::IpApiClient; +#[cfg(feature = "mmdb")] +use crate::ip_geo_client::mmdb_client::MMDBClient; -/// A struct to hold the response from the ip-api.com API +/// A struct to hold the response for the Geo IP API #[derive(Default, Debug, Clone, Deserialize)] -pub struct IpApiResponse { +pub struct GeoIpResponse { pub query: String, - pub status: String, pub country: String, #[serde(rename = "countryCode")] pub country_code: String, @@ -21,7 +23,6 @@ pub struct IpApiResponse { #[serde(rename = "regionName")] pub region_name: String, pub city: String, - pub zip: String, pub lat: f64, pub lon: f64, } @@ -34,7 +35,7 @@ pub struct IpGeoCheckerTestedData { /// The IP address pub ip: IpAddr, /// The response from the ip-api.com API - pub geoip: IpApiResponse, + pub geoip: GeoIpResponse, /// The subnet pub subnet: String, /// The expected country code @@ -50,7 +51,7 @@ impl Default for IpGeoCheckerTestedData { Self { host: "".to_string(), ip: "0.0.0.0".parse().unwrap(), - geoip: IpApiResponse::default(), + geoip: GeoIpResponse::default(), subnet: "".to_string(), expected: "".to_string(), actual: "".to_string(), @@ -70,7 +71,7 @@ impl IpGeoCheckerTestedData { self } - pub fn set_geoip(&mut self, geoip: IpApiResponse) -> &mut Self { + pub fn set_geoip(&mut self, geoip: GeoIpResponse) -> &mut Self { self.geoip = geoip; self } @@ -120,21 +121,19 @@ impl IpGeoCheckerTestedData { #[derive(Default, Clone, Debug)] pub struct IpGeoCheckerResult { pub domain: DomainConfig, - pub geoip: Vec, + pub geoip: Vec, pub expected: String, pub actual: bool, } pub struct IpGeoCheckerBuilder { - client: reqwest::Client, dns_resolver: DnsResolver, config: Config, } impl IpGeoCheckerBuilder { - pub fn new(client: reqwest::Client) -> Self { + pub fn new() -> Self { Self { - client, dns_resolver: DnsResolver::Google, config: Config::default(), } @@ -150,9 +149,19 @@ impl IpGeoCheckerBuilder { self } - pub fn build(&mut self) -> IpGeoChecker { + #[cfg(feature = "ip-api")] + pub fn with_ip_api_client(&mut self) -> IpGeoChecker { IpGeoChecker { - client: self.client.clone(), + client: IpGeoClient::with_provider::(&self.config), + dns_resolver: self.dns_resolver.clone(), + config: self.config.clone(), + } + } + + #[cfg(feature = "mmdb")] + pub fn with_mmdb_client(&mut self) -> IpGeoChecker { + IpGeoChecker { + client: IpGeoClient::with_provider::(&self.config), dns_resolver: self.dns_resolver.clone(), config: self.config.clone(), } @@ -160,17 +169,19 @@ impl IpGeoCheckerBuilder { } #[derive(Clone)] -pub struct IpGeoChecker { - client: reqwest::Client, +pub struct IpGeoChecker { + client: IpGeoProvider, dns_resolver: DnsResolver, config: Config, } -impl IpGeoChecker { - pub fn new(client: reqwest::Client) -> IpGeoCheckerBuilder { - IpGeoCheckerBuilder::new(client) +impl IpGeoChecker { + /// Create a new instance of the IpGeoChecker + pub fn new() -> IpGeoCheckerBuilder { + IpGeoCheckerBuilder::new() } + /// Check the Geo IP of the domains pub async fn check(&self) -> Vec { let resolver = self.dns_resolver.connect().await; let test_subnets = self.config.test_subnets.clone(); @@ -198,6 +209,7 @@ impl IpGeoChecker { .unwrap(); let geoip_results = self + .client .batch_get_ip_info(&ips) .await .unwrap() @@ -226,20 +238,6 @@ impl IpGeoChecker { .flatten() .collect() } - - async fn batch_get_ip_info( - &self, - ips: &Vec, - ) -> Result, reqwest::Error> { - let ips = ips.iter().map(|a| a.to_string()).collect::>(); - self.client - .post(IP_API_BATCH) - .json(&ips) - .send() - .await? - .json::>() - .await - } } #[cfg(test)] @@ -265,7 +263,7 @@ mod tests { #[test] fn test_ip_geo_checker_tested_data_set_geoip() { let mut data = IpGeoCheckerTestedData::default(); - let geoip = IpApiResponse::default(); + let geoip = GeoIpResponse::default(); data.set_geoip(geoip.clone()); assert!(data.geoip.query == geoip.query); } diff --git a/src/ip_geo_client/ip_api_client.rs b/src/ip_geo_client/ip_api_client.rs new file mode 100644 index 0000000..52fcf10 --- /dev/null +++ b/src/ip_geo_client/ip_api_client.rs @@ -0,0 +1,46 @@ +use std::net::IpAddr; + +use crate::{configs_parser::Config, ip_geo_checker::GeoIpResponse}; + +use super::{GetGeoIpInfo, NewProvider}; + +#[derive(Clone)] +pub struct IpApiClient { + api_base: String, + client: reqwest::Client, +} + +impl NewProvider for IpApiClient { + fn new(_: &Config) -> Self { + Self { + api_base: "http://ip-api.com".to_string(), + client: reqwest::Client::new(), + } + } +} + +impl GetGeoIpInfo for IpApiClient { + #[allow(refining_impl_trait)] + async fn get_geoip_info(&self, ip: IpAddr) -> Result { + let ip = ip.to_string(); + let url = format!("{}/json/{}", self.api_base, ip); + let res = self.client.get(url).send().await?; + res.json::().await + } + + #[allow(refining_impl_trait)] + async fn batch_get_ip_info( + &self, + ips: &Vec, + ) -> Result, reqwest::Error> { + let ips = ips.iter().map(|a| a.to_string()).collect::>(); + let url = format!("{}/batch", self.api_base); + self.client + .post(url) + .json(&ips) + .send() + .await? + .json::>() + .await + } +} diff --git a/src/ip_geo_client/mmdb_client.rs b/src/ip_geo_client/mmdb_client.rs new file mode 100644 index 0000000..bcaea09 --- /dev/null +++ b/src/ip_geo_client/mmdb_client.rs @@ -0,0 +1,110 @@ +use std::{env, net::IpAddr, sync::Arc}; + +use crate::{configs_parser::Config, ip_geo_checker::GeoIpResponse}; + +use super::{GetGeoIpInfo, NewProvider}; + +#[derive(Clone)] +pub struct MMDBClient { + reader: Arc>>, +} + +impl NewProvider for MMDBClient { + fn new(config: &Config) -> Self { + let mmdb_path = config + .mmdb_path + .clone() + .unwrap_or(env::var("MMDB_PATH").unwrap_or("./mmdb/GeoLite2-City.mmdb".to_string())); + let reader = maxminddb::Reader::open_readfile(mmdb_path).unwrap(); + Self { + reader: Arc::new(reader), + } + } +} + +impl GetGeoIpInfo for MMDBClient { + #[allow(refining_impl_trait)] + async fn get_geoip_info(&self, ip: IpAddr) -> Result { + let ip = ip.to_string(); + let record: maxminddb::geoip2::City = self.reader.lookup(ip.parse().unwrap())?; + Ok(GeoIpResponse { + query: ip, + country: record + .country + .clone() + .unwrap() + .iso_code + .unwrap() + .to_string(), + country_code: record + .country + .clone() + .unwrap() + .iso_code + .unwrap() + .to_string(), + region: record + .subdivisions + .clone() + .unwrap() + .get(0) + .unwrap() + .iso_code + .unwrap() + .to_string(), + region_name: "".to_string(), + city: record + .city + .unwrap() + .names + .unwrap() + .get("en") + .unwrap() + .to_string(), + lat: record.location.clone().unwrap().latitude.unwrap(), + lon: record.location.clone().unwrap().longitude.unwrap(), + }) + } + + #[allow(refining_impl_trait)] + async fn batch_get_ip_info( + &self, + ips: &Vec, + ) -> Result, maxminddb::MaxMindDBError> { + let ips = ips.iter().map(|a| a.to_string()).collect::>(); + let mut results = vec![]; + for ip in ips.iter() { + let record: maxminddb::geoip2::City = self.reader.lookup(ip.parse().unwrap())?; + results.push(GeoIpResponse { + query: ip.to_string(), + country: record + .country + .clone() + .unwrap() + .iso_code + .unwrap() + .to_string(), + country_code: record + .country + .clone() + .unwrap() + .iso_code + .unwrap() + .to_string(), + region: "".to_string(), + region_name: "".to_string(), + city: record + .city + .unwrap() + .names + .unwrap() + .get("en") + .unwrap() + .to_string(), + lat: record.location.clone().unwrap().latitude.unwrap(), + lon: record.location.clone().unwrap().longitude.unwrap(), + }); + } + Ok(results) + } +} diff --git a/src/ip_geo_client/mod.rs b/src/ip_geo_client/mod.rs new file mode 100644 index 0000000..72b1ae2 --- /dev/null +++ b/src/ip_geo_client/mod.rs @@ -0,0 +1,82 @@ +use std::{error::Error, net::IpAddr}; + +use serde::Deserialize; + +use crate::{configs_parser::Config, ip_geo_checker::GeoIpResponse}; + +#[cfg(feature = "ip-api")] +pub mod ip_api_client; +#[cfg(feature = "mmdb")] +pub mod mmdb_client; + +pub trait NewProvider { + fn new(config: &Config) -> Self; +} + +pub trait GetGeoIpInfo { + fn get_geoip_info( + &self, + ip: IpAddr, + ) -> impl std::future::Future> + Send; + fn batch_get_ip_info( + &self, + ips: &Vec, + ) -> impl std::future::Future, impl Error>> + Send; +} + +#[derive(Clone, Debug, Deserialize)] +pub enum IpGeoProviderType { + #[cfg(feature = "ip-api")] + #[serde(alias = "ip-api")] + IpApi, + #[cfg(feature = "mmdb")] + #[serde(alias = "mmdb")] + MMDB, + None, +} + +impl Default for IpGeoProviderType { + fn default() -> Self { + Self::MMDB + } +} + +#[derive(Clone, Default, Debug)] +pub struct IpGeoProvider(pub T); + +impl IpGeoProvider +where + T: GetGeoIpInfo + NewProvider + Clone, +{ + pub fn new(provider: T) -> Self { + Self(provider) + } +} + +impl GetGeoIpInfo for IpGeoProvider { + fn get_geoip_info( + &self, + ip: IpAddr, + ) -> impl std::future::Future> + Send { + self.0.get_geoip_info(ip) + } + + fn batch_get_ip_info( + &self, + ips: &Vec, + ) -> impl std::future::Future, impl Error>> + Send { + self.0.batch_get_ip_info(ips) + } +} + +#[derive(Default, Clone)] +pub struct IpGeoClient; + +impl IpGeoClient { + pub fn with_provider(config: &Config) -> IpGeoProvider + where + T: GetGeoIpInfo + NewProvider + Clone, + { + IpGeoProvider(T::new(config)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 68e3185..a4ddb2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod configs_parser; pub mod dns_client; pub mod ip_geo_checker; +pub mod ip_geo_client;