/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use super::*; use crate::expr::{if_block::IfBlock, tokenizer::TokenMap}; use ahash::AHashSet; use std::{hash::Hasher, time::Duration}; use utils::config::{Config, Rate, http::parse_http_headers, utils::ParseValue}; use xxhash_rust::xxh3::Xxh3Builder; #[derive(Clone)] pub struct Network { pub node_id: u64, pub roles: ClusterRoles, pub server_name: String, pub report_domain: String, pub security: Security, pub contact_form: Option, pub http_response_url: IfBlock, pub http_allowed_endpoint: IfBlock, pub asn_geo_lookup: AsnGeoLookupConfig, } #[derive(Clone)] pub struct ContactForm { pub rcpt_to: Vec, pub max_size: usize, pub rate: Option, pub validate_domain: bool, pub from_email: FieldOrDefault, pub from_subject: FieldOrDefault, pub from_name: FieldOrDefault, pub field_honey_pot: Option, } #[derive(Clone, Default)] pub struct ClusterRoles { pub purge_stores: ClusterRole, pub purge_accounts: ClusterRole, pub push_notifications: ClusterRole, pub fts_indexing: ClusterRole, pub bayes_training: ClusterRole, pub imip_processing: ClusterRole, pub merge_threads: ClusterRole, pub calendar_alerts: ClusterRole, pub renew_acme: ClusterRole, pub calculate_metrics: ClusterRole, pub push_metrics: ClusterRole, } #[derive(Clone, Copy, Default)] pub enum ClusterRole { #[default] Enabled, Disabled, Sharded { shard_id: u32, total_shards: u32, }, } #[derive(Clone, Default)] pub enum AsnGeoLookupConfig { Resource { expires: Duration, timeout: Duration, max_size: usize, headers: HeaderMap, asn_resources: Vec, geo_resources: Vec, }, Dns { zone_ipv4: String, zone_ipv6: String, separator: String, index_asn: usize, index_asn_name: Option, index_country: Option, }, #[default] Disabled, } #[derive(Clone)] pub struct FieldOrDefault { pub field: Option, pub default: String, } pub(crate) const HTTP_VARS: &[u32; 11] = &[ V_LISTENER, V_REMOTE_IP, V_REMOTE_PORT, V_LOCAL_IP, V_LOCAL_PORT, V_PROTOCOL, V_TLS, V_URL, V_URL_PATH, V_HEADERS, V_METHOD, ]; impl Default for Network { fn default() -> Self { Self { security: Default::default(), contact_form: None, node_id: 1, http_response_url: IfBlock::new::<()>( "http.url", [], "protocol + '://' + config_get('server.hostname') + ':' + local_port", ), http_allowed_endpoint: IfBlock::new::<()>("http.allowed-endpoint", [], "200"), asn_geo_lookup: AsnGeoLookupConfig::Disabled, server_name: Default::default(), report_domain: Default::default(), roles: ClusterRoles::default(), } } } impl ContactForm { pub fn parse(config: &mut Config) -> Option { if !config .property_or_default::("form.enable", "false") .unwrap_or_default() { return None; } let form = ContactForm { rcpt_to: config .values("form.deliver-to") .filter_map(|(_, addr)| { if addr.contains('@') && addr.contains('.') { Some(addr.trim().to_lowercase()) } else { None } }) .collect(), max_size: config.property("form.max-size").unwrap_or(100 * 1024), validate_domain: config .property_or_default::("form.validate-domain", "true") .unwrap_or(true), from_email: FieldOrDefault::parse(config, "form.email", "postmaster@localhost"), from_subject: FieldOrDefault::parse(config, "form.subject", "Contact form submission"), from_name: FieldOrDefault::parse(config, "form.name", "Anonymous"), field_honey_pot: config.value("form.honey-pot.field").map(|v| v.into()), rate: config .property_or_default::>("form.rate-limit", "5/1h") .unwrap_or_default(), }; if !form.rcpt_to.is_empty() { Some(form) } else { config.new_build_error("form.deliver-to", "No valid email addresses found"); None } } } impl FieldOrDefault { pub fn parse(config: &mut Config, key: &str, default: &str) -> Self { FieldOrDefault { field: config.value((key, "field")).map(|s| s.to_string()), default: config .value((key, "default")) .unwrap_or(default) .to_string(), } } } impl Network { pub fn parse(config: &mut Config) -> Self { let server_name = config .value("server.hostname") .map(|v| v.to_string()) .or_else(|| { config .value("lookup.default.hostname") .map(|v| v.to_lowercase()) }) .unwrap_or_else(|| { hostname::get() .map(|v| v.to_string_lossy().to_lowercase()) .unwrap_or_else(|_| "localhost".to_string()) }); let report_domain = config .value("report.domain") .map(|v| v.to_lowercase()) .or_else(|| { config .value("lookup.default.domain") .map(|v| v.to_lowercase()) }) .unwrap_or_else(|| { psl::domain_str(&server_name) .unwrap_or(server_name.as_str()) .to_string() }); let mut network = Network { node_id: config.property("cluster.node-id").unwrap_or(1), report_domain, server_name, security: Security::parse(config), contact_form: ContactForm::parse(config), asn_geo_lookup: AsnGeoLookupConfig::parse(config).unwrap_or_default(), ..Default::default() }; let token_map = &TokenMap::default().with_variables(HTTP_VARS); // Node roles for (value, key) in [ ( &mut network.roles.purge_stores, "cluster.roles.purge.stores", ), ( &mut network.roles.purge_accounts, "cluster.roles.purge.accounts", ), (&mut network.roles.renew_acme, "cluster.roles.acme.renew"), ( &mut network.roles.calculate_metrics, "cluster.roles.metrics.calculate", ), ( &mut network.roles.push_metrics, "cluster.roles.metrics.push", ), ( &mut network.roles.push_notifications, "cluster.roles.push-notifications", ), ( &mut network.roles.fts_indexing, "cluster.roles.fts-indexing", ), ( &mut network.roles.bayes_training, "cluster.roles.bayes-training", ), ( &mut network.roles.imip_processing, "cluster.roles.imip-processing", ), ( &mut network.roles.calendar_alerts, "cluster.roles.calendar-alerts", ), ( &mut network.roles.merge_threads, "cluster.roles.merge-threads", ), ] { let shards = config .properties::(key) .into_iter() .map(|(_, v)| v) .collect::>(); let shard_size = shards.len() as u32; let mut found_node = false; for (shard_id, shard) in shards.iter().enumerate() { if shard.0.contains(&network.node_id) { if shard_size > 1 { *value = ClusterRole::Sharded { shard_id: shard_id as u32, total_shards: shard_size, }; } found_node = true; break; } } if !shards.is_empty() && !found_node { *value = ClusterRole::Disabled; } } for (value, key) in [ (&mut network.http_response_url, "http.url"), (&mut network.http_allowed_endpoint, "http.allowed-endpoint"), ] { if let Some(if_block) = IfBlock::try_parse(config, key, token_map) { *value = if_block; } } network } } struct NodeList(AHashSet); impl ParseValue for NodeList { fn parse_value(value: &str) -> utils::config::Result { value .split(',') .map(|s| s.trim().parse::().map_err(|e| e.to_string())) .collect::, String>>() .map(NodeList) } } impl AsnGeoLookupConfig { pub fn parse(config: &mut Config) -> Option { match config.value("asn.type")? { "dns" => AsnGeoLookupConfig::Dns { zone_ipv4: config.value_require_non_empty("asn.zone.ipv4")?.to_string(), zone_ipv6: config.value_require_non_empty("asn.zone.ipv6")?.to_string(), separator: config.value_require_non_empty("asn.separator")?.to_string(), index_asn: config.property_require("asn.index.asn")?, index_asn_name: config.property("asn.index.asn-name"), index_country: config.property("asn.index.country"), } .into(), "resource" => { let asn_resources = config .values("asn.urls.asn") .map(|(_, v)| v.to_string()) .collect::>(); let geo_resources = config .values("asn.urls.geo") .map(|(_, v)| v.to_string()) .collect::>(); if asn_resources.is_empty() && geo_resources.is_empty() { config.new_build_error("asn.urls", "No resources found"); return None; } AsnGeoLookupConfig::Resource { headers: parse_http_headers(config, "asn"), expires: config.property_or_default::("asn.expires", "1d")?, timeout: config.property_or_default::("asn.timeout", "5m")?, max_size: config.property("asn.max-size").unwrap_or(100 * 1024 * 1024), asn_resources, geo_resources, } .into() } "disable" | "disabled" | "none" | "false" => AsnGeoLookupConfig::Disabled.into(), _ => { config.new_build_error("asn.type", "Invalid value"); None } } } } impl ClusterRole { pub fn is_enabled_or_sharded(&self) -> bool { matches!(self, ClusterRole::Enabled | ClusterRole::Sharded { .. }) } pub fn is_enabled_for_account(&self, account_id: u32) -> bool { match self { ClusterRole::Enabled => true, ClusterRole::Disabled => false, ClusterRole::Sharded { shard_id, total_shards, } => (account_id % total_shards) == *shard_id, } } pub fn is_enabled_for_hash(&self, item: &impl std::hash::Hash) -> bool { match self { ClusterRole::Enabled => true, ClusterRole::Disabled => false, ClusterRole::Sharded { shard_id, total_shards, } => { let mut hasher = Xxh3Builder::new().with_seed(191179).build(); item.hash(&mut hasher); hasher.finish() % (*total_shards as u64) == *shard_id as u64 } } } }