pub mod config;
mod fetch;
pub use config::{AutoDownloadConfig, GeoIpConfig, GeoIpProvider};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tracing::{debug, warn};
use crate::http_client::{HttpClientError, HttpError};
use crate::sensitive::SensitiveString;
use fetch::{Archive, Credential, Transfer};
const SECS_PER_DAY: u64 = 86_400;
const DOWNLOAD_TIMEOUT_SECS: u64 = 600;
#[derive(Debug, thiserror::Error)]
pub enum GeoIpDownloadError {
#[error("HTTP request failed: {0}")]
Http(#[from] HttpError),
#[error("HTTP client build failed: {0}")]
HttpClient(#[from] HttpClientError),
#[error("download of {url} returned HTTP {status}")]
UnexpectedStatus { url: String, status: u16 },
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("decompression task failed: {0}")]
Join(#[from] tokio::task::JoinError),
#[error("provider {provider} requires {field} but it was not configured")]
MissingCredential {
provider: &'static str,
field: &'static str,
},
#[error("{member} not found in the downloaded archive")]
ArchiveMemberMissing {
member: &'static str,
},
#[error("no {kind} database available for provider {provider}")]
NoDatabases {
provider: String,
kind: &'static str,
},
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DatabasePaths {
pub city: Option<PathBuf>,
pub asn: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
City,
Asn,
}
impl Kind {
const fn label(self) -> &'static str {
match self {
Self::City => "city",
Self::Asn => "asn",
}
}
}
pub async fn ensure_databases(config: &GeoIpConfig) -> Result<DatabasePaths, GeoIpDownloadError> {
if !config.enabled {
debug!("GeoIP provisioning disabled by config");
return Ok(DatabasePaths::default());
}
if config.provider == GeoIpProvider::Custom
|| config.city_db_path.is_some()
|| config.asn_db_path.is_some()
{
return Ok(DatabasePaths {
city: config.city_db_path.clone(),
asn: config.asn_db_path.clone(),
});
}
let auto = &config.auto_download;
let (city_file, asn_file) = provider_filenames(config.provider);
let city_path = city_file.map(|f| auto.data_dir.join(f));
let asn_path = asn_file.map(|f| auto.data_dir.join(f));
if !auto.enabled {
return Ok(DatabasePaths {
city: city_path.filter(|p| p.exists()),
asn: asn_path.filter(|p| p.exists()),
});
}
let max_age_secs = u64::from(auto.max_age_days) * SECS_PER_DAY;
Ok(DatabasePaths {
city: resolve(Kind::City, city_path.as_deref(), config, max_age_secs).await,
asn: resolve(Kind::Asn, asn_path.as_deref(), config, max_age_secs).await,
})
}
async fn resolve(
kind: Kind,
path: Option<&Path>,
config: &GeoIpConfig,
max_age_secs: u64,
) -> Option<PathBuf> {
let path = path?;
if is_fresh(path, max_age_secs) {
debug!(kind = kind.label(), path = %path.display(), "GeoIP database is fresh");
return Some(path.to_path_buf());
}
match download(kind, config).await {
Ok(downloaded) => Some(downloaded),
Err(e) => {
warn!(
kind = kind.label(),
error = %e,
provider = ?config.provider,
"GeoIP database download failed"
);
if path.exists() {
warn!(kind = kind.label(), path = %path.display(), "using stale GeoIP database");
Some(path.to_path_buf())
} else {
None
}
}
}
}
fn provider_filenames(provider: GeoIpProvider) -> (Option<&'static str>, Option<&'static str>) {
match provider {
GeoIpProvider::DbIpLite => (Some("dbip-city-lite.mmdb"), Some("dbip-asn-lite.mmdb")),
GeoIpProvider::MaxMindGeoLite2 => (Some("GeoLite2-City.mmdb"), Some("GeoLite2-ASN.mmdb")),
GeoIpProvider::IpLocate => (None, Some("iplocate-asn.mmdb")),
GeoIpProvider::IpInfoLite => (Some("ipinfo-lite.mmdb"), None),
GeoIpProvider::Sapics => (None, Some("sapics-asn-country.mmdb")),
GeoIpProvider::Custom => (None, None),
}
}
fn is_fresh(path: &Path, max_age_secs: u64) -> bool {
let Ok(metadata) = fs::metadata(path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
let Ok(age) = SystemTime::now().duration_since(modified) else {
return false;
};
age.as_secs() < max_age_secs
}
async fn download(kind: Kind, config: &GeoIpConfig) -> Result<PathBuf, GeoIpDownloadError> {
plan(kind, config)?.run().await
}
fn plan(kind: Kind, config: &GeoIpConfig) -> Result<Transfer, GeoIpDownloadError> {
let auto = &config.auto_download;
let dir = &auto.data_dir;
let unavailable = || GeoIpDownloadError::NoDatabases {
provider: format!("{:?}", config.provider),
kind: kind.label(),
};
let transfer = match (config.provider, kind) {
(GeoIpProvider::DbIpLite, _) => {
let month = chrono::Utc::now().format("%Y-%m");
let (slug, file) = match kind {
Kind::City => ("city", "dbip-city-lite.mmdb"),
Kind::Asn => ("asn", "dbip-asn-lite.mmdb"),
};
Transfer {
url: format!("https://download.db-ip.com/free/dbip-{slug}-lite-{month}.mmdb.gz"),
dest: dir.join(file),
archive: Archive::Gzip,
credential: Credential::None,
}
}
(GeoIpProvider::MaxMindGeoLite2, _) => {
let edition = match kind {
Kind::City => "GeoLite2-City",
Kind::Asn => "GeoLite2-ASN",
};
let member = match kind {
Kind::City => "GeoLite2-City.mmdb",
Kind::Asn => "GeoLite2-ASN.mmdb",
};
Transfer {
url: format!(
"https://download.maxmind.com/geoip/databases/{edition}/download?suffix=tar.gz"
),
dest: dir.join(member),
archive: Archive::TarGz { member },
credential: Credential::Basic {
username: require(
auto.maxmind_account_id.as_ref(),
"MaxMindGeoLite2",
"auto_download.maxmind_account_id",
)?,
password: require(
auto.maxmind_license_key.as_ref(),
"MaxMindGeoLite2",
"auto_download.maxmind_license_key",
)?,
},
}
}
(GeoIpProvider::IpInfoLite, Kind::City) => Transfer {
url: "https://ipinfo.io/data/ipinfo_lite.mmdb".to_string(),
dest: dir.join("ipinfo-lite.mmdb"),
archive: Archive::Raw,
credential: Credential::QueryToken {
name: "token",
value: require(
auto.ipinfo_token.as_ref(),
"IpInfoLite",
"auto_download.ipinfo_token",
)?,
},
},
(GeoIpProvider::IpLocate, Kind::Asn) => Transfer {
url: "https://github.com/sapics/ip-location-db/raw/main/dbip-asn/dbip-asn.mmdb"
.to_string(),
dest: dir.join("iplocate-asn.mmdb"),
archive: Archive::Raw,
credential: Credential::None,
},
(GeoIpProvider::Sapics, Kind::Asn) => Transfer {
url: "https://github.com/sapics/ip-location-db/raw/main/geo-whois-asn-country/geo-whois-asn-country.mmdb"
.to_string(),
dest: dir.join("sapics-asn-country.mmdb"),
archive: Archive::Raw,
credential: Credential::None,
},
(GeoIpProvider::IpInfoLite, Kind::Asn)
| (GeoIpProvider::IpLocate | GeoIpProvider::Sapics, Kind::City)
| (GeoIpProvider::Custom, _) => return Err(unavailable()),
};
Ok(transfer)
}
fn require(
value: Option<&SensitiveString>,
provider: &'static str,
field: &'static str,
) -> Result<SensitiveString, GeoIpDownloadError> {
value
.cloned()
.ok_or(GeoIpDownloadError::MissingCredential { provider, field })
}
#[cfg(test)]
mod tests;