use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::Secret;
const DEFAULT_DATA_DIR: &str = "/var/lib/geoip";
const DEFAULT_MAX_AGE_DAYS: u32 = 30;
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_READ_TIMEOUT_SECS: u64 = 60;
const DEFAULT_MIN_SIZE_PERCENT: u8 = 50;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum GeoIpProvider {
#[default]
DbIp,
MaxMind,
IpInfo,
SapicsOriginAsn,
SapicsIpToAsn,
Custom,
}
impl GeoIpProvider {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::DbIp => "DbIp",
Self::MaxMind => "MaxMind",
Self::IpInfo => "IpInfo",
Self::SapicsOriginAsn => "SapicsOriginAsn",
Self::SapicsIpToAsn => "SapicsIpToAsn",
Self::Custom => "Custom",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderTier {
#[default]
Free,
Paid,
}
impl ProviderTier {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Free => "free",
Self::Paid => "paid",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProviderChoice {
pub provider: GeoIpProvider,
pub tier: ProviderTier,
}
impl From<GeoIpProvider> for ProviderChoice {
fn from(provider: GeoIpProvider) -> Self {
Self {
provider,
tier: ProviderTier::Free,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderSelection {
pub city: ProviderChoice,
pub asn: ProviderChoice,
}
impl Default for ProviderSelection {
fn default() -> Self {
Self {
city: ProviderChoice::from(GeoIpProvider::DbIp),
asn: ProviderChoice::from(GeoIpProvider::SapicsOriginAsn),
}
}
}
impl From<GeoIpProvider> for ProviderSelection {
fn from(provider: GeoIpProvider) -> Self {
Self::from(ProviderChoice::from(provider))
}
}
impl From<ProviderChoice> for ProviderSelection {
fn from(choice: ProviderChoice) -> Self {
Self {
city: choice,
asn: choice,
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum ChoiceWire {
Bare(GeoIpProvider),
Tiered(TieredWire),
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct TieredWire {
provider: GeoIpProvider,
#[serde(default)]
tier: ProviderTier,
}
impl Serialize for ProviderChoice {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if self.tier == ProviderTier::Free {
self.provider.serialize(serializer)
} else {
TieredWire {
provider: self.provider,
tier: self.tier,
}
.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for ProviderChoice {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(match ChoiceWire::deserialize(deserializer)? {
ChoiceWire::Bare(provider) => Self::from(provider),
ChoiceWire::Tiered(TieredWire { provider, tier }) => Self { provider, tier },
})
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum ProviderWire {
Uniform(ProviderChoice),
PerKind(PerKindWire),
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct PerKindWire {
city: Option<ProviderChoice>,
asn: Option<ProviderChoice>,
}
impl Serialize for ProviderSelection {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if self.city == self.asn {
self.city.serialize(serializer)
} else {
PerKindWire {
city: Some(self.city),
asn: Some(self.asn),
}
.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for ProviderSelection {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(match ProviderWire::deserialize(deserializer)? {
ProviderWire::Uniform(choice) => Self::from(choice),
ProviderWire::PerKind(PerKindWire { city, asn }) => {
let fallback = Self::default();
Self {
city: city.unwrap_or(fallback.city),
asn: asn.unwrap_or(fallback.asn),
}
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AutoDownloadConfig {
pub enabled: bool,
pub data_dir: PathBuf,
pub maxmind_account_id: Option<Secret>,
pub maxmind_license_key: Option<Secret>,
pub ipinfo_token: Option<Secret>,
pub max_age_days: u32,
pub connect_timeout_secs: u64,
pub read_timeout_secs: u64,
pub verify_content: bool,
pub min_size_percent: u8,
pub age_from_source: bool,
pub resident_max_bytes: u64,
}
impl Default for AutoDownloadConfig {
fn default() -> Self {
Self {
enabled: true,
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
maxmind_account_id: None,
maxmind_license_key: None,
ipinfo_token: None,
max_age_days: DEFAULT_MAX_AGE_DAYS,
connect_timeout_secs: DEFAULT_CONNECT_TIMEOUT_SECS,
read_timeout_secs: DEFAULT_READ_TIMEOUT_SECS,
verify_content: true,
min_size_percent: DEFAULT_MIN_SIZE_PERCENT,
age_from_source: true,
resident_max_bytes: super::DEFAULT_RESIDENT_MAX_BYTES,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GeoIpConfig {
pub enabled: bool,
pub provider: ProviderSelection,
pub city_db_path: Option<PathBuf>,
pub asn_db_path: Option<PathBuf>,
pub auto_download: AutoDownloadConfig,
#[serde(skip)]
pub http_client: Option<reqwest::Client>,
}
impl Default for GeoIpConfig {
fn default() -> Self {
Self {
enabled: true,
provider: ProviderSelection::default(),
city_db_path: None,
asn_db_path: None,
auto_download: AutoDownloadConfig::default(),
http_client: None,
}
}
}
impl PartialEq for GeoIpConfig {
fn eq(&self, other: &Self) -> bool {
self.enabled == other.enabled
&& self.provider == other.provider
&& self.city_db_path == other.city_db_path
&& self.asn_db_path == other.asn_db_path
&& self.auto_download == other.auto_download
}
}
impl Eq for GeoIpConfig {}
impl GeoIpConfig {
#[must_use]
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_default_is_dbip() {
assert_eq!(GeoIpProvider::default(), GeoIpProvider::DbIp);
assert_eq!(ProviderTier::default(), ProviderTier::Free);
}
#[test]
fn provider_variants_compare_distinctly() {
assert_eq!(GeoIpProvider::DbIp, GeoIpProvider::DbIp);
assert_ne!(GeoIpProvider::DbIp, GeoIpProvider::MaxMind);
assert_ne!(GeoIpProvider::Custom, GeoIpProvider::SapicsOriginAsn);
assert_ne!(GeoIpProvider::SapicsOriginAsn, GeoIpProvider::IpInfo);
}
#[test]
fn a_tier_is_part_of_the_choice_not_of_the_provider() {
let free = ProviderChoice::from(GeoIpProvider::MaxMind);
let paid = ProviderChoice {
provider: GeoIpProvider::MaxMind,
tier: ProviderTier::Paid,
};
assert_eq!(free.tier, ProviderTier::Free);
assert_ne!(free, paid);
assert_eq!(free.provider, paid.provider);
}
#[test]
fn a_changed_credential_compares_unequal() {
let mut changed = GeoIpConfig::default();
changed.auto_download.ipinfo_token = Some(Secret::from("a-token"));
assert_ne!(GeoIpConfig::default(), changed);
assert_eq!(changed, changed.clone());
}
#[test]
fn an_injected_client_is_not_a_config_change() {
let plain = GeoIpConfig::default();
let injected = GeoIpConfig::default().with_http_client(reqwest::Client::new());
assert_eq!(plain, injected);
assert!(injected.http_client.is_some());
}
#[test]
fn provider_round_trips_through_snake_case() {
for (provider, wire) in [
(GeoIpProvider::DbIp, "db_ip"),
(GeoIpProvider::MaxMind, "max_mind"),
(GeoIpProvider::IpInfo, "ip_info"),
(GeoIpProvider::SapicsOriginAsn, "sapics_origin_asn"),
(GeoIpProvider::SapicsIpToAsn, "sapics_ip_to_asn"),
(GeoIpProvider::Custom, "custom"),
] {
let quoted = format!("\"{wire}\"");
assert_eq!(serde_json::to_string(&provider).unwrap(), quoted);
let decoded: GeoIpProvider = serde_json::from_str("ed).unwrap();
assert_eq!(decoded, provider);
}
}
#[test]
fn a_single_provider_applies_to_both_kinds() {
let selection: ProviderSelection = serde_json::from_str("\"max_mind\"").unwrap();
assert_eq!(selection.city, ProviderChoice::from(GeoIpProvider::MaxMind));
assert_eq!(selection.asn, ProviderChoice::from(GeoIpProvider::MaxMind));
assert_eq!(serde_json::to_string(&selection).unwrap(), "\"max_mind\"");
}
#[test]
fn a_per_kind_map_selects_each_provider() {
let selection: ProviderSelection =
serde_json::from_str(r#"{"city": "ip_info", "asn": "sapics_origin_asn"}"#).unwrap();
assert_eq!(selection.city, ProviderChoice::from(GeoIpProvider::IpInfo));
assert_eq!(
selection.asn,
ProviderChoice::from(GeoIpProvider::SapicsOriginAsn)
);
let dumped = serde_json::to_string(&selection).unwrap();
assert_eq!(
serde_json::from_str::<ProviderSelection>(&dumped).unwrap(),
selection
);
}
#[test]
fn a_tier_is_named_in_the_config_not_inferred() {
let selection: ProviderSelection =
serde_json::from_str(r#"{"provider": "max_mind", "tier": "paid"}"#).unwrap();
assert_eq!(selection.city.provider, GeoIpProvider::MaxMind);
assert_eq!(selection.city.tier, ProviderTier::Paid);
assert_eq!(selection.asn.tier, ProviderTier::Paid);
let dumped = serde_json::to_string(&selection).unwrap();
assert_eq!(dumped, r#"{"provider":"max_mind","tier":"paid"}"#);
assert_eq!(
serde_json::from_str::<ProviderSelection>(&dumped).unwrap(),
selection
);
}
#[test]
fn one_kind_may_be_paid_and_the_other_free() {
let selection: ProviderSelection = serde_json::from_str(
r#"{"city": {"provider": "max_mind", "tier": "paid"}, "asn": "sapics_origin_asn"}"#,
)
.unwrap();
assert_eq!(selection.city.tier, ProviderTier::Paid);
assert_eq!(selection.asn.tier, ProviderTier::Free);
assert_eq!(
serde_json::from_str::<ProviderSelection>(&serde_json::to_string(&selection).unwrap())
.unwrap(),
selection
);
}
#[test]
fn an_omitted_tier_is_the_free_line() {
let choice: ProviderChoice = serde_json::from_str(r#"{"provider": "max_mind"}"#).unwrap();
assert_eq!(choice.tier, ProviderTier::Free);
assert_eq!(choice, ProviderChoice::from(GeoIpProvider::MaxMind));
}
#[test]
fn an_omitted_kind_takes_that_kinds_default() {
let selection: ProviderSelection =
serde_json::from_str(r#"{"asn": "sapics_ip_to_asn"}"#).unwrap();
assert_eq!(selection.city, ProviderSelection::default().city);
assert_eq!(
selection.asn,
ProviderChoice::from(GeoIpProvider::SapicsIpToAsn)
);
let city_only: ProviderSelection = serde_json::from_str(r#"{"city": "db_ip"}"#).unwrap();
assert_eq!(
city_only.asn,
ProviderChoice::from(GeoIpProvider::SapicsOriginAsn)
);
}
#[test]
fn the_two_wire_forms_of_one_selection_compare_equal() {
let uniform: ProviderSelection = serde_json::from_str("\"db_ip\"").unwrap();
let spelt_out: ProviderSelection =
serde_json::from_str(r#"{"city": "db_ip", "asn": {"provider": "db_ip"}}"#).unwrap();
assert_eq!(uniform, spelt_out);
}
#[test]
fn a_misspelt_key_is_rejected_at_every_level() {
assert!(serde_json::from_str::<GeoIpConfig>(r#"{"enabledd": true}"#).is_err());
assert!(serde_json::from_str::<AutoDownloadConfig>(r#"{"max_age_dayz": 7}"#).is_err(),);
}
#[test]
fn a_misspelt_kind_is_rejected() {
let err = serde_json::from_str::<ProviderSelection>(r#"{"citty": "db_ip"}"#);
assert!(err.is_err(), "{err:?}");
}
#[test]
fn a_misspelt_tier_key_is_rejected() {
let err =
serde_json::from_str::<ProviderChoice>(r#"{"provider": "max_mind", "teir": "paid"}"#);
assert!(err.is_err(), "{err:?}");
}
#[test]
fn auto_download_defaults() {
let auto = AutoDownloadConfig::default();
assert!(auto.enabled);
assert_eq!(auto.data_dir, PathBuf::from("/var/lib/geoip"));
assert_eq!(auto.max_age_days, 30);
assert!(auto.maxmind_account_id.is_none());
assert!(auto.maxmind_license_key.is_none());
assert!(auto.ipinfo_token.is_none());
assert_eq!(auto.connect_timeout_secs, 30);
assert_eq!(auto.read_timeout_secs, 60);
assert_eq!(auto.resident_max_bytes, 128 * 1024 * 1024);
}
#[test]
fn the_resident_ceiling_is_operator_tunable() {
let auto: AutoDownloadConfig =
serde_json::from_str(r#"{"resident_max_bytes": 0}"#).unwrap();
assert_eq!(auto.resident_max_bytes, 0);
assert_eq!(auto.max_age_days, 30);
let dumped = serde_json::to_string(&auto).unwrap();
assert_eq!(
serde_json::from_str::<AutoDownloadConfig>(&dumped).unwrap(),
auto
);
}
#[test]
fn the_timeouts_are_operator_tunable() {
let json = r#"{"connect_timeout_secs": 5, "read_timeout_secs": 120}"#;
let auto: AutoDownloadConfig = serde_json::from_str(json).unwrap();
assert_eq!(auto.connect_timeout_secs, 5);
assert_eq!(auto.read_timeout_secs, 120);
assert_eq!(auto.max_age_days, 30);
assert!(auto.enabled);
}
#[test]
fn geoip_defaults() {
let config = GeoIpConfig::default();
assert!(config.enabled);
assert_eq!(
config.provider.city,
ProviderChoice::from(GeoIpProvider::DbIp)
);
assert_eq!(
config.provider.asn,
ProviderChoice::from(GeoIpProvider::SapicsOriginAsn)
);
assert!(config.city_db_path.is_none());
assert!(config.asn_db_path.is_none());
assert!(config.http_client.is_none());
assert!(config.auto_download.enabled);
}
#[test]
fn deserialises_with_partial_keys() {
let json = r#"{
"provider": "max_mind",
"auto_download": {
"data_dir": "/srv/geoip",
"max_age_days": 7,
"maxmind_account_id": "123456",
"maxmind_license_key": "secret-key"
}
}"#;
let config: GeoIpConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.provider.city.provider, GeoIpProvider::MaxMind);
assert_eq!(config.provider.asn.provider, GeoIpProvider::MaxMind);
assert_eq!(config.provider.city.tier, ProviderTier::Free);
assert_eq!(config.auto_download.data_dir, PathBuf::from("/srv/geoip"));
assert_eq!(config.auto_download.max_age_days, 7);
assert_eq!(
config
.auto_download
.maxmind_license_key
.as_ref()
.map(Secret::expose),
Some("secret-key")
);
assert!(config.enabled);
assert!(config.auto_download.enabled);
}
#[test]
fn credentials_are_redacted_in_debug() {
let config = GeoIpConfig {
auto_download: AutoDownloadConfig {
maxmind_account_id: Some("account-1234".into()),
maxmind_license_key: Some("licence-abcd".into()),
ipinfo_token: Some("token-wxyz".into()),
..Default::default()
},
..Default::default()
};
let rendered = format!("{config:?}");
assert!(!rendered.contains("account-1234"), "{rendered}");
assert!(!rendered.contains("licence-abcd"), "{rendered}");
assert!(!rendered.contains("token-wxyz"), "{rendered}");
assert!(rendered.contains("REDACTED"), "{rendered}");
}
}