/* * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use std::{borrow::Cow, net::IpAddr, sync::Arc}; use arc_swap::ArcSwap; use config::{ imap::ImapConfig, jmap::settings::JmapConfig, scripts::Scripting, server::ServerProtocol, smtp::{ auth::{ArcSealer, DkimSigner}, queue::RelayHost, SmtpConfig, }, storage::Storage, tracers::{OtelTracer, Tracer, Tracers}, }; use directory::{ core::secret::verify_secret_hash, Directory, DirectoryError, Principal, QueryBy, Type, }; use expr::if_block::IfBlock; use listener::{ blocked::{AllowedIps, BlockedIps}, tls::TlsManager, }; use mail_send::Credentials; use opentelemetry::KeyValue; use opentelemetry_sdk::{ trace::{self, Sampler}, Resource, }; use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION}; #[cfg(feature = "enterprise")] use se_licensing::license::LicenseKey; use sieve::Sieve; use store::LookupStore; use tokio::sync::{mpsc, oneshot}; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{ layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, Registry, }; use utils::{config::Config, BlobHash}; use webhooks::{manager::WebhookEvent, WebhookPayload, WebhookType, Webhooks}; pub mod addresses; pub mod config; pub mod expr; pub mod listener; pub mod manager; pub mod scripts; pub mod webhooks; pub static USER_AGENT: &str = concat!("Stalwart/", env!("CARGO_PKG_VERSION"),); pub static DAEMON_NAME: &str = concat!("Stalwart Mail Server v", env!("CARGO_PKG_VERSION"),); pub const IPC_CHANNEL_BUFFER: usize = 1024; pub type SharedCore = Arc>; #[derive(Clone, Default)] pub struct Core { pub storage: Storage, pub sieve: Scripting, pub network: Network, pub tls: TlsManager, pub smtp: SmtpConfig, pub jmap: JmapConfig, pub imap: ImapConfig, pub web_hooks: Webhooks, #[cfg(feature = "enterprise")] pub enterprise: Option, } #[derive(Clone)] pub struct Network { pub blocked_ips: BlockedIps, pub allowed_ips: AllowedIps, pub url: IfBlock, } // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd // SPDX-License-Identifier: LicenseRef-SEL #[cfg(feature = "enterprise")] #[derive(Clone)] pub struct Enterprise { pub license: LicenseKey, pub undelete_period: Option, } // SPDX-SnippetEnd pub enum AuthResult { Success(T), Failure(AuthFailureReason), } pub enum AuthFailureReason { InvalidCredentials, MissingTotp, Banned, InternalError(DirectoryError), } #[derive(Debug)] pub enum DeliveryEvent { Ingest { message: IngestMessage, result_tx: oneshot::Sender>, }, Stop, } pub struct Ipc { pub delivery_tx: mpsc::Sender, pub webhook_tx: mpsc::Sender, } #[derive(Debug)] pub struct IngestMessage { pub sender_address: String, pub recipients: Vec, pub message_blob: BlobHash, pub message_size: usize, } #[derive(Debug, Clone)] pub enum DeliveryResult { Success, TemporaryFailure { reason: Cow<'static, str>, }, PermanentFailure { code: [u8; 3], reason: Cow<'static, str>, }, } pub trait IntoString: Sized { fn into_string(self) -> String; } impl IntoString for Vec { fn into_string(self) -> String { String::from_utf8(self) .unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned()) } } impl Core { pub fn get_directory(&self, name: &str) -> Option<&Arc> { self.storage.directories.get(name) } pub fn get_directory_or_default(&self, name: &str) -> &Arc { self.storage.directories.get(name).unwrap_or_else(|| { tracing::debug!( context = "get_directory", event = "error", directory = name, "Directory not found, using default." ); &self.storage.directory }) } pub fn get_lookup_store(&self, name: &str) -> &LookupStore { self.storage.lookups.get(name).unwrap_or_else(|| { tracing::debug!( context = "get_lookup_store", event = "error", directory = name, "Store not found, using default." ); &self.storage.lookup }) } pub fn get_arc_sealer(&self, name: &str) -> Option<&ArcSealer> { self.smtp .mail_auth .sealers .get(name) .map(|s| s.as_ref()) .or_else(|| { tracing::warn!( context = "get_arc_sealer", event = "error", name = name, "Arc sealer not found." ); None }) } pub fn get_dkim_signer(&self, name: &str) -> Option<&DkimSigner> { self.smtp .mail_auth .signers .get(name) .map(|s| s.as_ref()) .or_else(|| { tracing::warn!( context = "get_dkim_signer", event = "error", name = name, "DKIM signer not found." ); None }) } pub fn get_sieve_script(&self, name: &str) -> Option<&Arc> { self.sieve.scripts.get(name).or_else(|| { tracing::warn!( context = "get_sieve_script", event = "error", name = name, "Sieve script not found." ); None }) } pub fn get_relay_host(&self, name: &str) -> Option<&RelayHost> { self.smtp.queue.relay_hosts.get(name).or_else(|| { tracing::warn!( context = "get_relay_host", event = "error", name = name, "Remote host not found." ); None }) } pub async fn authenticate( &self, directory: &Directory, ipc: &Ipc, credentials: &Credentials, remote_ip: IpAddr, protocol: ServerProtocol, return_member_of: bool, ) -> directory::Result>> { // First try to authenticate the user against the default directory let result = match directory .query(QueryBy::Credentials(credentials), return_member_of) .await { Ok(Some(principal)) => { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthSuccess) { ipc.send_webhook( WebhookType::AuthSuccess, WebhookPayload::Authentication { login: credentials.login().to_string(), protocol, remote_ip, typ: principal.typ.into(), as_master: None, }, ) .await; } return Ok(AuthResult::Success(principal)); } Ok(None) => Ok(()), Err(DirectoryError::MissingTotpCode) => { return Ok(AuthResult::Failure(AuthFailureReason::MissingTotp)) } Err(err) => Err(err), }; // Then check if the credentials match the fallback admin or master user match ( &self.jmap.fallback_admin, &self.jmap.master_user, credentials, ) { (Some((fallback_admin, fallback_pass)), _, Credentials::Plain { username, secret }) if username == fallback_admin => { if verify_secret_hash(fallback_pass, secret).await { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthSuccess) { ipc.send_webhook( WebhookType::AuthSuccess, WebhookPayload::Authentication { login: username.to_string(), protocol, remote_ip, typ: Type::Superuser.into(), as_master: None, }, ) .await; } return Ok(AuthResult::Success(Principal::fallback_admin( fallback_pass, ))); } } (_, Some((master_user, master_pass)), Credentials::Plain { username, secret }) if username.ends_with(master_user) => { if verify_secret_hash(master_pass, secret).await { let username = username.strip_suffix(master_user).unwrap(); let username = username.strip_suffix('%').unwrap_or(username); return Ok( if let Some(principal) = directory .query(QueryBy::Name(username), return_member_of) .await? { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthSuccess) { ipc.send_webhook( WebhookType::AuthSuccess, WebhookPayload::Authentication { login: username.to_string(), protocol, remote_ip, typ: principal.typ.into(), as_master: true.into(), }, ) .await; } AuthResult::Success(principal) } else { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthFailure) { ipc.send_webhook( WebhookType::AuthFailure, WebhookPayload::Authentication { login: username.to_string(), protocol, remote_ip, typ: None, as_master: true.into(), }, ) .await; } AuthResult::Failure(AuthFailureReason::InvalidCredentials) }, ); } } _ => {} } if let Err(err) = result { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthError) { ipc.send_webhook( WebhookType::AuthError, WebhookPayload::Error { message: err.to_string(), }, ) .await; } Err(err) } else if self.has_fail2ban() { let login = credentials.login(); if self.is_fail2banned(remote_ip, login.to_string()).await? { tracing::info!( context = "directory", event = "fail2ban", remote_ip = ?remote_ip, login = ?login, "IP address blocked after too many failed login attempts", ); // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthBanned) { ipc.send_webhook( WebhookType::AuthBanned, WebhookPayload::Authentication { login: credentials.login().to_string(), protocol, remote_ip, typ: None, as_master: None, }, ) .await; } Ok(AuthResult::Failure(AuthFailureReason::Banned)) } else { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthFailure) { ipc.send_webhook( WebhookType::AuthFailure, WebhookPayload::Authentication { login: credentials.login().to_string(), protocol, remote_ip, typ: None, as_master: None, }, ) .await; } Ok(AuthResult::Failure(AuthFailureReason::InvalidCredentials)) } } else { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthFailure) { ipc.send_webhook( WebhookType::AuthFailure, WebhookPayload::Authentication { login: credentials.login().to_string(), protocol, remote_ip, typ: None, as_master: None, }, ) .await; } Ok(AuthResult::Failure(AuthFailureReason::InvalidCredentials)) } } } impl Tracers { pub fn enable(self, config: &mut Config) -> Option> { let mut layers: Option + Sync + Send>> = None; let mut guards = Vec::new(); for tracer in self.tracers { let (Tracer::Stdout { level, .. } | Tracer::Log { level, .. } | Tracer::Journal { level } | Tracer::Otel { level, .. }) = tracer; let filter = match EnvFilter::builder().parse(format!( "smtp={level},imap={level},jmap={level},pop3={level},store={level},common={level},utils={level},directory={level},se_common={level}" )) { Ok(filter) => { filter } Err(err) => { config.new_build_error("tracer", format!("Failed to set env filter: {err}")); continue; } }; let layer = match tracer { Tracer::Stdout { ansi, .. } => tracing_subscriber::fmt::layer() .with_ansi(ansi) .with_filter(filter) .boxed(), Tracer::Log { appender, ansi, .. } => { let (non_blocking, guard) = tracing_appender::non_blocking(appender); guards.push(guard); tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_ansi(ansi) .with_filter(filter) .boxed() } Tracer::Otel { tracer, .. } => { let tracer = match tracer { OtelTracer::Gprc(exporter) => opentelemetry_otlp::new_pipeline() .tracing() .with_exporter(exporter), OtelTracer::Http(exporter) => opentelemetry_otlp::new_pipeline() .tracing() .with_exporter(exporter), } .with_trace_config( trace::config() .with_resource(Resource::new(vec![ KeyValue::new(SERVICE_NAME, "stalwart-mail".to_string()), KeyValue::new( SERVICE_VERSION, env!("CARGO_PKG_VERSION").to_string(), ), ])) .with_sampler(Sampler::AlwaysOn), ) .install_batch(opentelemetry_sdk::runtime::Tokio); match tracer { Ok(tracer) => tracing_opentelemetry::layer() .with_tracer(tracer) .with_filter(filter) .boxed(), Err(err) => { config.new_build_error( "tracer", format!("Failed to start OpenTelemetry: {err}"), ); continue; } } } Tracer::Journal { .. } => { #[cfg(unix)] { match tracing_journald::layer() { Ok(layer) => layer.with_filter(filter).boxed(), Err(err) => { config.new_build_error( "tracer", format!("Failed to start Journald: {err}"), ); continue; } } } #[cfg(not(unix))] { config.new_build_error( "tracer", "Journald is only available on Unix systems.", ); continue; } } }; layers = Some(match layers { Some(layers) => layers.and_then(layer).boxed(), None => layer, }); } match tracing_subscriber::registry().with(layers?).try_init() { Ok(_) => Some(guards), Err(err) => { config.new_build_error("tracer", format!("Failed to start tracing: {err}")); None } } } } trait CredentialsUsername { fn login(&self) -> &str; } impl CredentialsUsername for Credentials { fn login(&self) -> &str { match self { Credentials::Plain { username, .. } | Credentials::XOauth2 { username, .. } | Credentials::OAuthBearer { token: username } => username, } } }