pub(crate) mod fetch;
pub(crate) mod source;
pub(crate) mod verify;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use super::config::{GeoIpConfig, ProviderChoice, ProviderSelection};
use fetch::Transfer;
use source::SourceSpec;
use verify::Guard;
pub use source::{SourceTerms, source_terms};
#[cfg(test)]
use super::config::{GeoIpProvider, ProviderTier};
#[cfg(test)]
use fetch::{Archive, Credential};
pub(crate) const SECS_PER_DAY: u64 = 86_400;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GeoIpDownloadError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[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("{url} expanded past the {limit} byte ceiling")]
TooLarge {
url: String,
limit: u64,
},
#[error("no {kind} database available for provider {provider}")]
NoDatabases {
provider: String,
kind: &'static str,
},
#[error("the {tier} tier of provider {provider} is not modelled")]
UnsupportedTier {
provider: String,
tier: &'static str,
},
#[error("download does not match the digest at {url}: expected {expected}, got {actual}")]
ChecksumMismatch {
url: String,
expected: String,
actual: String,
},
#[error("the digest at {url} is not a sha256")]
MalformedChecksum {
url: String,
},
#[error("{url} did not deliver a MaxMind DB")]
NotADatabase {
url: String,
},
#[error("{path} is being fetched by another process")]
Busy {
path: String,
},
#[error(
"{url} accepted the credential and refused the database; the account has no entitlement to it, so check the tier selected against the products the account holds"
)]
NotEntitled {
url: String,
},
#[error("{path} does not parse as the table it states: {detail}")]
Unparseable {
path: String,
detail: String,
},
#[error("{url} rejected the credential with HTTP {status}; check {fields}")]
CredentialRejected {
url: String,
status: u16,
fields: &'static str,
},
#[error("{url} is rate limiting this client")]
RateLimited {
url: String,
retry_after_secs: Option<u64>,
},
#[error("{path} resolved no {field} for any probe address")]
Unpopulated {
path: String,
field: &'static str,
},
#[error(
"{path} is {actual} bytes against the {existing} on disk, under the {floor_percent}% floor"
)]
Undersized {
path: String,
actual: u64,
existing: u64,
floor_percent: u8,
},
#[error("{url} delivered {actual} of {expected} bytes")]
Truncated {
url: String,
expected: u64,
actual: u64,
},
}
impl GeoIpDownloadError {
#[must_use]
pub const fn is_permanent(&self) -> bool {
match self {
Self::CredentialRejected { .. }
| Self::MissingCredential { .. }
| Self::NoDatabases { .. }
| Self::NotEntitled { .. }
| Self::UnsupportedTier { .. } => true,
Self::UnexpectedStatus { status, .. } => {
*status >= 400 && *status < 500 && *status != 408
}
_ => false,
}
}
#[must_use]
pub const fn outcome(&self) -> &'static str {
match self {
Self::Busy { .. } => "busy",
Self::NotEntitled { .. } => "unentitled",
Self::ChecksumMismatch { .. }
| Self::MalformedChecksum { .. }
| Self::NotADatabase { .. }
| Self::Unparseable { .. }
| Self::Unpopulated { .. }
| Self::Undersized { .. }
| Self::Truncated { .. }
| Self::ArchiveMemberMissing { .. } => "refused",
_ => "failed",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DatabaseFormat {
#[default]
Mmdb,
Csv,
Json,
}
impl DatabaseFormat {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Mmdb => "MaxMind DB",
Self::Csv => "CSV",
Self::Json => "JSON",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Database {
pub format: DatabaseFormat,
pub files: Vec<PathBuf>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Databases {
pub city: Option<Database>,
pub asn: Option<Database>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DatabaseSpec {
format: DatabaseFormat,
names: &'static [&'static str],
}
impl DatabaseSpec {
fn at(self, data_dir: &Path) -> Database {
Database {
format: self.format,
files: self.names.iter().map(|name| data_dir.join(name)).collect(),
}
}
}
#[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",
}
}
const fn choice(self, selection: ProviderSelection) -> ProviderChoice {
match self {
Self::City => selection.city,
Self::Asn => selection.asn,
}
}
fn explicit_path(self, config: &GeoIpConfig) -> Option<&PathBuf> {
match self {
Self::City => config.city_db_path.as_ref(),
Self::Asn => config.asn_db_path.as_ref(),
}
}
#[cfg(test)]
fn spec(self, choice: ProviderChoice) -> Result<Option<DatabaseSpec>, GeoIpDownloadError> {
Ok(SourceSpec::select(choice, self)?.map(SourceSpec::database))
}
}
pub async fn ensure_databases(config: &GeoIpConfig) -> Result<Databases, GeoIpDownloadError> {
if !config.enabled {
debug!("GeoIP provisioning disabled by config");
return Ok(Databases::default());
}
Ok(Databases {
city: resolve(Kind::City, config).await,
asn: resolve(Kind::Asn, config).await,
})
}
async fn resolve(kind: Kind, config: &GeoIpConfig) -> Option<Database> {
let age = |files: &[PathBuf]| {
telemetry::age(kind.label(), files, config.auto_download.age_from_source);
};
if let Some(path) = kind.explicit_path(config) {
let files = vec![path.clone()];
age(&files);
return Some(Database {
format: DatabaseFormat::Mmdb,
files,
});
}
let choice = kind.choice(config.provider);
let source = match SourceSpec::select(choice, kind) {
Ok(source) => source?,
Err(e) => {
warn!(kind = kind.label(), error = %e, "GeoIP provider tier is not modelled");
return None;
}
};
let database = source.database().at(&config.auto_download.data_dir);
if !config.auto_download.enabled {
if !database.files.iter().all(|file| file.exists()) {
return None;
}
age(&database.files);
return Some(database);
}
let max_age_secs = source.staleness_window(&config.auto_download).as_secs();
if database
.files
.iter()
.all(|file| is_fresh(file, max_age_secs))
{
debug!(kind = kind.label(), files = ?database.files, "GeoIP database is fresh");
age(&database.files);
return Some(database);
}
match download(kind, config).await {
Ok(files) => {
telemetry::attempt(kind.label(), "ok");
age(&files);
Some(Database {
format: database.format,
files,
})
}
Err(e) => {
telemetry::attempt(kind.label(), e.outcome());
warn!(
kind = kind.label(),
error = %e,
provider = ?choice,
"GeoIP database download failed"
);
if database.files.iter().all(|file| file.exists()) {
warn!(kind = kind.label(), files = ?database.files, "using stale GeoIP database");
age(&database.files);
Some(database)
} else {
None
}
}
}
}
#[cfg(test)]
fn provider_files(
choice: ProviderChoice,
) -> Result<(Option<DatabaseSpec>, Option<DatabaseSpec>), GeoIpDownloadError> {
Ok((Kind::City.spec(choice)?, Kind::Asn.spec(choice)?))
}
pub fn validate(config: &GeoIpConfig) -> Result<(), GeoIpDownloadError> {
if !config.enabled || !config.auto_download.enabled {
return Ok(());
}
for kind in [Kind::City, Kind::Asn] {
if kind.explicit_path(config).is_some() {
continue;
}
match plan(kind, config) {
Ok(_) | Err(GeoIpDownloadError::NoDatabases { .. }) => {}
Err(e) => return Err(e),
}
}
Ok(())
}
pub(crate) 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<Vec<PathBuf>, GeoIpDownloadError> {
let transfers = plan(kind, config)?;
let client = fetch::client(
config.http_client.as_ref(),
Duration::from_secs(config.auto_download.connect_timeout_secs),
Duration::from_secs(config.auto_download.read_timeout_secs),
)?;
let guard = Guard::new(kind, &config.auto_download);
let mut files = Vec::with_capacity(transfers.len());
for transfer in transfers {
files.push(transfer.run_guarded(&client, guard).await?);
}
Ok(files)
}
fn plan(kind: Kind, config: &GeoIpConfig) -> Result<Vec<Transfer>, GeoIpDownloadError> {
let choice = kind.choice(config.provider);
let Some(source) = SourceSpec::select(choice, kind)? else {
return Err(GeoIpDownloadError::NoDatabases {
provider: choice.provider.label().to_string(),
kind: kind.label(),
});
};
Ok(vec![source.transfer(&config.auto_download)?])
}
#[cfg(feature = "metrics")]
mod telemetry {
use std::path::Path;
use std::time::SystemTime;
const TYPE: &str = "geoip";
pub(super) fn attempt(kind: &'static str, result: &'static str) {
metrics::counter!(
"enrichment_download_total",
"type" => TYPE,
"kind" => kind,
"result" => result,
)
.increment(1);
}
pub(super) fn age(kind: &'static str, files: &[std::path::PathBuf], from_source: bool) {
let seconds = files
.iter()
.filter_map(|file| age_of(file, from_source))
.max_by(f64::total_cmp);
let Some(seconds) = seconds else {
return;
};
metrics::gauge!("enrichment_database_age_seconds", "type" => TYPE, "kind" => kind)
.set(seconds);
}
fn age_of(file: &Path, from_source: bool) -> Option<f64> {
let stamp = from_source
.then(|| built_at(file))
.flatten()
.or_else(|| written_at(file))?;
SystemTime::now()
.duration_since(stamp)
.ok()
.map(|elapsed| elapsed.as_secs_f64())
}
#[cfg(feature = "geoip-lookup")]
fn built_at(file: &Path) -> Option<SystemTime> {
crate::geoip::enricher::open_reader(file)
.ok()?
.metadata()
.build_time()
.ok()
}
#[cfg(not(feature = "geoip-lookup"))]
const fn built_at(_file: &Path) -> Option<SystemTime> {
None
}
fn written_at(file: &Path) -> Option<SystemTime> {
std::fs::metadata(file).ok()?.modified().ok()
}
}
#[cfg(not(feature = "metrics"))]
mod telemetry {
pub(super) const fn attempt(_kind: &'static str, _result: &'static str) {}
pub(super) const fn age(
_kind: &'static str,
_files: &[std::path::PathBuf],
_from_source: bool,
) {
}
}
#[cfg(test)]
pub(crate) mod testkit;
#[cfg(test)]
mod tests;