diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index 491b2a98..c97780cf 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -235,17 +235,17 @@ impl BootManager { ))); } - // Download SPAM filters if missing + // Download Spam filter rules if missing if config .value("version.spam-filter") .filter(|v| !v.is_empty()) .is_none() { - match manager.fetch_config_resource("spam-filter").await { + match manager.fetch_spam_rules().await { Ok(external_config) => { trc::event!( Config(trc::ConfigEvent::ImportExternal), - Version = external_config.version, + Version = external_config.version.to_string(), Id = "spam-filter" ); insert_keys.extend(external_config.keys); @@ -362,6 +362,20 @@ impl BootManager { } } + // Spam filter auto-update + if config + .property_or_default::("spam-filter.auto-update", "false") + .unwrap_or_default() + { + if let Err(err) = core.storage.config.update_spam_rules(false).await { + trc::event!( + Resource(trc::ResourceEvent::Error), + Details = "Failed to update spam-filter", + CausedBy = err + ); + } + } + // Build shared inner let (ipc, ipc_rxs) = build_ipc(); let inner = Arc::new(Inner { diff --git a/crates/common/src/manager/config.rs b/crates/common/src/manager/config.rs index 3bdcc82c..f1daf1a5 100644 --- a/crates/common/src/manager/config.rs +++ b/crates/common/src/manager/config.rs @@ -20,6 +20,7 @@ use trc::AddContext; use utils::{ config::{Config, ConfigKey}, glob::GlobPattern, + Semver, }; #[derive(Default)] @@ -50,9 +51,8 @@ pub enum MatchType { All, } -pub(crate) struct ExternalConfig { - pub id: String, - pub version: String, +pub(crate) struct ExternalSpamRules { + pub version: Semver, pub keys: Vec, } @@ -329,73 +329,69 @@ impl ConfigManager { }) } - pub async fn update_config_resource( - &self, - resource_id: &str, - overwrite: bool, - ) -> trc::Result> { - let external = self - .fetch_config_resource(resource_id) - .await - .map_err(|reason| { - trc::EventType::Config(trc::ConfigEvent::FetchError) - .caused_by(trc::location!()) - .details("Failed to fetch external configuration") - .ctx(trc::Key::Reason, reason) - })?; + pub async fn update_spam_rules(&self, overwrite: bool) -> trc::Result> { + let external = self.fetch_spam_rules().await.map_err(|reason| { + trc::EventType::Config(trc::ConfigEvent::FetchError) + .caused_by(trc::location!()) + .details("Failed to update spam filter rules") + .ctx(trc::Key::Reason, reason) + })?; + + if self.get("version.spam-filter").await?.map_or(true, |v| { + v.as_str().try_into().map_or(true, |v| external.version > v) + }) { + // Delete previous STWT_* rules + for prefix in [ + "spam-filter.rule.stwt_", + "spam-filter.dnsbl.server.stwt_", + "http-lookup.stwt_", + ] { + self.clear_prefix(prefix).await?; + } - if self - .get(&external.id) - .await? - .map_or(true, |v| v != external.version) - { self.set(external.keys, overwrite).await?; trc::event!( Config(trc::ConfigEvent::ImportExternal), - Version = external.version.clone(), - Id = resource_id.to_string(), + Version = external.version.to_string(), + Id = "spam-filter", ); Ok(Some(external.version)) } else { trc::event!( Config(trc::ConfigEvent::AlreadyUpToDate), - Version = external.version, - Id = resource_id.to_string(), + Version = external.version.to_string(), + Id = "spam-filter", ); Ok(None) } } - pub(crate) async fn fetch_config_resource( - &self, - resource_id: &str, - ) -> Result { - let config = String::from_utf8(self.fetch_resource(resource_id).await?) + pub(crate) async fn fetch_spam_rules(&self) -> Result { + let config = String::from_utf8(self.fetch_resource("spam-filter").await?) .map_err(|err| format!("Configuration file has invalid UTF-8: {err}"))?; let config = Config::new(config) .map_err(|err| format!("Failed to parse external configuration: {err}"))?; // Import configuration - let mut external = ExternalConfig { - id: String::new(), - version: String::new(), + let mut external = ExternalSpamRules { + version: Semver::default(), keys: Vec::new(), }; + let mut required_semver = Semver::default(); + let server_semver: Semver = env!("CARGO_PKG_VERSION").try_into().unwrap(); for (key, value) in config.keys { - if key.starts_with("version.") { - external.id.clone_from(&key); - external.version.clone_from(&value); + if key == "version.spam-filter" { + external.version = value.as_str().try_into().unwrap_or_default(); external.keys.push(ConfigKey::from((key, value))); + } else if key == "version.server" { + required_semver = value.as_str().try_into().unwrap_or_default(); } else if key.starts_with("spam-filter.") || key.starts_with("http-lookup.") || (key.starts_with("lookup.") && !key.starts_with("lookup.default.")) || key.starts_with("server.asn.") - || key.starts_with("queue.quota.") - || key.starts_with("queue.throttle.") - || key.starts_with("session.throttle.") { external.keys.push(ConfigKey::from((key, value))); } else { @@ -403,15 +399,21 @@ impl ConfigManager { Config(trc::ConfigEvent::ExternalKeyIgnored), Key = key, Value = value, - Id = resource_id.to_string(), + Id = "spam-filter", ); } } - if !external.version.is_empty() { + if !required_semver.is_valid() { + Err("External spam filter rules do not contain a valid server version".to_string()) + } else if required_semver > server_semver { + Err(format!( + "External spam filter rules require server version {required_semver}, but this is version {server_semver}", + )) + } else if external.version.is_valid() { Ok(external) } else { - Err("External configuration file does not contain a version key".to_string()) + Err("External spam filter rules do not contain a version key".to_string()) } } diff --git a/crates/jmap/src/api/management/reload.rs b/crates/jmap/src/api/management/reload.rs index 8fa811f5..1040d863 100644 --- a/crates/jmap/src/api/management/reload.rs +++ b/crates/jmap/src/api/management/reload.rs @@ -129,12 +129,13 @@ impl ManageReload for Server { let overwrite = UrlParams::new(req.uri().query()).has_key("overwrite"); Ok(JsonResponse::new(json!({ - "data": self + "data": self .core .storage .config - .update_config_resource("spam-filter", overwrite) - .await?, + .update_spam_rules(overwrite) + .await? + .map(|v| v.to_string()), })) .into_http_response()) } diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 5b39c6c4..89b0fb0b 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -135,6 +135,7 @@ impl OAuthApiHandler for Server { "data": { "code": client_code, "permissions": access_token.permissions(), + "version": env!("CARGO_PKG_VERSION"), "isEnterprise": is_enterprise, }, }) diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index decb0860..c5232311 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::sync::Arc; +use std::{fmt::Display, sync::Arc}; pub mod cache; pub mod codec; @@ -122,6 +122,75 @@ impl HttpLimitResponse for Response { } } +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct Semver(u64); + +impl Semver { + pub fn new(major: u16, minor: u16, patch: u16) -> Self { + let mut version: u64 = 0; + version |= (major as u64) << 32; + version |= (minor as u64) << 16; + version |= patch as u64; + Semver(version) + } + + pub fn unpack(&self) -> (u16, u16, u16) { + let version = self.0; + let major = ((version >> 32) & 0xFFFF) as u16; + let minor = ((version >> 16) & 0xFFFF) as u16; + let patch = (version & 0xFFFF) as u16; + (major, minor, patch) + } + + pub fn major(&self) -> u16 { + (self.0 >> 32) as u16 + } + + pub fn minor(&self) -> u16 { + (self.0 >> 16) as u16 + } + + pub fn patch(&self) -> u16 { + self.0 as u16 + } + + pub fn is_valid(&self) -> bool { + self.0 > 0 + } +} + +impl AsRef for Semver { + fn as_ref(&self) -> &u64 { + &self.0 + } +} + +impl From for Semver { + fn from(value: u64) -> Self { + Semver(value) + } +} + +impl TryFrom<&str> for Semver { + type Error = (); + + fn try_from(value: &str) -> Result { + let mut parts = value.splitn(3, '.'); + let major = parts.next().ok_or(())?.parse().map_err(|_| ())?; + let minor = parts.next().ok_or(())?.parse().map_err(|_| ())?; + let patch = parts.next().ok_or(())?.parse().map_err(|_| ())?; + Ok(Semver::new(major, minor, patch)) + } +} + +impl Display for Semver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (major, minor, patch) = self.unpack(); + write!(f, "{major}.{minor}.{patch}") + } +} + pub trait UnwrapFailure { fn failed(self, action: &str) -> T; }