Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use calcard::vcard::VCardVersion;
use registry::schema::{
enums::VCardVersion as RegistryVCardVersion,
structs::{
AddressBook, Calendar, CalendarAlarm, CalendarScheduling, DataRetention, FileStorage,
Sharing, SystemSettings, WebDav,
},
};
use std::str::FromStr;
use store::registry::bootstrap::Bootstrap;
use utils::template::Template;
#[derive(Debug, Clone, Default)]
pub struct GroupwareConfig {
// DAV settings
pub max_request_size: usize,
pub dead_property_size: Option<usize>,
pub live_property_size: usize,
pub max_lock_timeout: u64,
pub max_locks_per_user: usize,
pub max_results: usize,
pub assisted_discovery: bool,
// Calendar settings
pub max_ical_size: usize,
pub max_ical_instances: usize,
pub max_ical_attendees_per_instance: usize,
pub default_calendar_name: Option<String>,
pub default_calendar_display_name: Option<String>,
pub alarms_enabled: bool,
pub alarms_minimum_interval: i64,
pub alarms_allow_external_recipients: bool,
pub alarms_from_name: String,
pub alarms_from_email: Option<String>,
pub alarms_template: Template<CalendarTemplateVariable>,
pub itip_enabled: bool,
pub itip_auto_add: bool,
pub itip_inbound_max_ical_size: usize,
pub itip_outbound_max_recipients: usize,
pub itip_http_rsvp_url: Option<String>,
pub itip_http_rsvp_expiration: u64,
pub itip_inbox_auto_expunge: Option<u64>,
pub itip_template: Template<CalendarTemplateVariable>,
// Addressbook settings
pub max_vcard_size: usize,
pub vcard_version: VCardVersion,
pub default_addressbook_name: Option<String>,
pub default_addressbook_display_name: Option<String>,
// File storage settings
pub max_file_size: usize,
// Sharing settings
pub max_shares_per_item: usize,
pub allow_directory_query: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
pub enum CalendarTemplateVariable {
#[default]
PageTitle,
Lang,
Dir,
Header,
Footer,
EventTitle,
EventDescription,
EventDetails,
Actions,
ActionUrl,
ActionName,
AttendeesTitle,
Attendees,
Key,
Color,
Changed,
Value,
Link,
LogoCid,
OldValue,
Rsvp,
}
impl GroupwareConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let calendar = bp.setting_infallible::<Calendar>().await;
let alarm = bp.setting_infallible::<CalendarAlarm>().await;
let sched = bp.setting_infallible::<CalendarScheduling>().await;
let book = bp.setting_infallible::<AddressBook>().await;
let dav = bp.setting_infallible::<WebDav>().await;
let file = bp.setting_infallible::<FileStorage>().await;
let share = bp.setting_infallible::<Sharing>().await;
let dr = bp.setting_infallible::<DataRetention>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
GroupwareConfig {
max_request_size: dav.request_max_size as usize,
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
live_property_size: dav.live_property_max_size as usize,
assisted_discovery: dav.enable_assisted_discovery,
max_lock_timeout: dav.max_lock_timeout.into_inner().as_secs(),
max_locks_per_user: dav.max_locks as usize,
max_results: dav.max_results as usize,
default_calendar_name: calendar.default_href_name,
default_calendar_display_name: calendar.default_display_name,
default_addressbook_name: book.default_href_name,
default_addressbook_display_name: book.default_display_name,
max_ical_size: calendar.max_i_calendar_size as usize,
max_ical_instances: calendar.max_recurrence_expansions as usize,
max_ical_attendees_per_instance: calendar.max_attendees as usize,
max_vcard_size: book.max_v_card_size as usize,
vcard_version: match book.v_card_version {
RegistryVCardVersion::V3 => VCardVersion::V3_0,
RegistryVCardVersion::V4 => VCardVersion::V4_0,
},
max_file_size: file.max_size as usize,
alarms_enabled: alarm.enable,
alarms_minimum_interval: alarm.min_trigger_interval.into_inner().as_secs() as i64,
alarms_allow_external_recipients: alarm.allow_external_rcpts,
alarms_from_name: alarm.from_name,
alarms_from_email: alarm.from_email,
alarms_template: Template::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-alarm.html.min"
)))
.expect("Failed to parse calendar template"),
itip_enabled: sched.enable,
itip_auto_add: sched.auto_add_invitations,
itip_inbound_max_ical_size: sched.itip_max_size as usize,
itip_outbound_max_recipients: sched.max_recipients as usize,
itip_inbox_auto_expunge: dr
.expunge_scheduling_inbox_after
.map(|d| d.into_inner().as_secs()),
itip_http_rsvp_url: if sched.http_rsvp_enable {
if let Some(url) = sched
.http_rsvp_url
.as_deref()
.map(|v| v.trim().trim_end_matches('/'))
.filter(|v| !v.is_empty())
{
Some(url.to_string())
} else {
Some(format!("https://{}/calendar/rsvp", system.default_hostname))
}
} else {
None
},
max_shares_per_item: share.max_shares as usize,
allow_directory_query: share.allow_directory_queries,
itip_http_rsvp_expiration: sched.http_rsvp_link_expiry.into_inner().as_secs(),
itip_template: Template::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-invite.html.min"
)))
.expect("Failed to parse calendar template"),
}
}
}
impl FromStr for CalendarTemplateVariable {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"page_title" => Ok(CalendarTemplateVariable::PageTitle),
"lang" => Ok(CalendarTemplateVariable::Lang),
"dir" => Ok(CalendarTemplateVariable::Dir),
"header" => Ok(CalendarTemplateVariable::Header),
"footer" => Ok(CalendarTemplateVariable::Footer),
"event_title" => Ok(CalendarTemplateVariable::EventTitle),
"event_description" => Ok(CalendarTemplateVariable::EventDescription),
"event_details" => Ok(CalendarTemplateVariable::EventDetails),
"action_url" => Ok(CalendarTemplateVariable::ActionUrl),
"action_name" => Ok(CalendarTemplateVariable::ActionName),
"attendees" => Ok(CalendarTemplateVariable::Attendees),
"attendees_title" => Ok(CalendarTemplateVariable::AttendeesTitle),
"key" => Ok(CalendarTemplateVariable::Key),
"value" => Ok(CalendarTemplateVariable::Value),
"link" => Ok(CalendarTemplateVariable::Link),
"logo_cid" => Ok(CalendarTemplateVariable::LogoCid),
"actions" => Ok(CalendarTemplateVariable::Actions),
"changed" => Ok(CalendarTemplateVariable::Changed),
"old_value" => Ok(CalendarTemplateVariable::OldValue),
"rsvp" => Ok(CalendarTemplateVariable::Rsvp),
"color" => Ok(CalendarTemplateVariable::Color),
_ => Err(format!("Unknown calendar template variable: {}", s)),
}
}
}
#[cfg(test)]
mod tests {
use super::CalendarTemplateVariable;
use utils::template::Template;
const TEMPLATES: [(&str, &str, &str); 2] = [
(
"calendar-invite.html",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-invite.html"
)),
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-invite.html.min"
)),
),
(
"calendar-alarm.html",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-alarm.html"
)),
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-alarm.html.min"
)),
),
];
// Every `{{...}}` token in a template, in order of appearance
fn tokens(contents: &str) -> Vec<&str> {
let mut tokens = Vec::new();
let mut rest = contents;
while let Some((_, after)) = rest.split_once("{{") {
match after.split_once("}}") {
Some((token, tail)) => {
tokens.push(token.trim());
rest = tail;
}
None => break,
}
}
tokens
}
#[test]
fn shipped_calendar_templates_parse() {
for (name, source, minified) in TEMPLATES {
Template::<CalendarTemplateVariable>::parse(source)
.unwrap_or_else(|err| panic!("{name} failed to parse: {err}"));
Template::<CalendarTemplateVariable>::parse(minified)
.unwrap_or_else(|err| panic!("{name}.min failed to parse: {err}"));
}
}
#[test]
fn minified_calendar_templates_are_in_sync() {
for (name, source, minified) in TEMPLATES {
let source = tokens(source);
assert!(source.len() > 10, "{name} yielded no tokens to compare");
assert_eq!(
source,
tokens(minified),
"{name}.min is stale, re-run resources/scripts/minify_html.sh"
);
}
}
#[test]
fn calendar_template_tokens_are_single_line() {
// A newline inside `{{...}}` makes the parser reject the block
for (name, source, minified) in TEMPLATES {
for (suffix, contents) in [("", source), (".min", minified)] {
for token in tokens(contents) {
assert!(
!token.contains('\n') && !token.contains('\r'),
"{name}{suffix} has a multi-line token: {token:?}"
);
}
}
}
}
}
+249
View File
@@ -0,0 +1,249 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::server::tls::build_self_signed_cert;
use crate::{
Caches, Data, DavResource, DavResources, MailboxCache, MessageStoreCache, MessageUidCache,
TlsConnectors,
auth::{AccessTokenInner, AccountCache, DomainCache, MailingListCache, RoleCache, TenantCache},
config::{
mailstore::spamfilter::SpamClassifier,
server::tls::parse_certificates,
smtp::{
auth::DkimSigners,
resolver::{Policy, Tlsa},
},
},
manager::application::WebApplications,
network::security::BlockedIps,
};
use ahash::{AHashMap, AHashSet};
use arc_swap::ArcSwap;
use mail_auth::{MX, Parameters, RecordSet, Txt};
use parking_lot::RwLock;
use registry::schema::{prelude::ObjectType, structs};
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
sync::Arc,
};
use store::{LookupStores, registry::bootstrap::Bootstrap};
use utils::{
UnwrapFailure,
cache::{Cache, CacheWithTtl},
snowflake::{MAX_NODE_ID, SnowflakeIdGenerator},
tls::build_tls_connector,
};
impl Data {
pub async fn parse(bp: &mut Bootstrap) -> Self {
// Parse certificates
let mut certificates = AHashMap::new();
let mut subject_names = AHashSet::new();
parse_certificates(bp, &mut certificates, &mut subject_names).await;
if subject_names.is_empty() {
subject_names.insert("localhost".into());
}
// Build and test snowflake id generator
let node_id = bp.node_id();
if node_id > MAX_NODE_ID {
panic!("Node id {node_id} exceeds {MAX_NODE_ID}, panicking to avoid data corruption");
}
SnowflakeIdGenerator::set_node_id(node_id as u64);
let id_generator = SnowflakeIdGenerator::new();
if !id_generator.is_valid() {
panic!("Invalid system time, panicking to avoid data corruption");
}
// Initialize apps
let applications = WebApplications::new();
applications.reload(bp).await;
let blocked_ips = BlockedIps::parse(bp).await;
let lookup_stores = LookupStores::build(bp).await;
Data {
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
tls_certificates: ArcSwap::from_pointee(certificates),
tls_self_signed_cert: build_self_signed_cert(
subject_names
.into_iter()
.map(Into::into)
.collect::<Vec<_>>(),
)
.or_else(|err| {
bp.build_error(
ObjectType::Certificate.singleton(),
format!("Failed to build self-signed TLS certificate: {err}"),
);
build_self_signed_cert(vec!["localhost".to_string()])
})
.ok()
.map(Arc::new),
lookup_stores: ArcSwap::from_pointee(lookup_stores.stores),
blocked_ips: RwLock::new(blocked_ips),
jmap_id_gen: id_generator.clone(),
queue_id_gen: id_generator.clone(),
registry_id_gen: id_generator.clone(),
span_id_gen: id_generator,
queue_status: true.into(),
applications,
logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
asn_geo_data: Default::default(),
}
}
}
impl Caches {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let cache = bp.setting_infallible::<structs::Cache>().await;
Caches {
access_tokens: Cache::new_single_shard(
cache.access_tokens,
(std::mem::size_of::<AccessTokenInner>() + 255) as u64,
),
http_auth: Cache::new(cache.http_auth, (50 + std::mem::size_of::<u32>()) as u64),
messages: Cache::new_single_shard(
cache.messages,
(std::mem::size_of::<u32>()
+ std::mem::size_of::<Arc<MessageStoreCache>>()
+ (1024 * std::mem::size_of::<MessageUidCache>())
+ (15 * (std::mem::size_of::<MailboxCache>() + 60))) as u64,
),
files: Cache::new_single_shard(
cache.files,
(std::mem::size_of::<DavResources>() + (500 * std::mem::size_of::<DavResource>()))
as u64,
),
events: Cache::new_single_shard(
cache.events,
(std::mem::size_of::<DavResources>() + (500 * std::mem::size_of::<DavResource>()))
as u64,
),
contacts: Cache::new_single_shard(
cache.contacts,
(std::mem::size_of::<DavResources>() + (500 * std::mem::size_of::<DavResource>()))
as u64,
),
scheduling: Cache::new_single_shard(
cache.scheduling,
(std::mem::size_of::<DavResources>() + (500 * std::mem::size_of::<DavResource>()))
as u64,
),
emails: Cache::new(cache.email_addresses, 255u64),
emails_negative: CacheWithTtl::new(
cache.email_addresses_negative,
(std::mem::size_of::<DomainCache>() + 255) as u64,
),
domain_names: Cache::new(
cache.domain_names,
(std::mem::size_of::<DomainCache>() + 255) as u64,
),
domain_names_negative: CacheWithTtl::new(
cache.domain_names_negative,
(std::mem::size_of::<DomainCache>() + 255) as u64,
),
domains: Cache::new(
cache.domains,
(std::mem::size_of::<DomainCache>() + 255) as u64,
),
accounts: Cache::new(
cache.accounts,
(std::mem::size_of::<AccountCache>() + 255) as u64,
),
roles: Cache::new(cache.roles, (std::mem::size_of::<RoleCache>() + 255) as u64),
tenants: Cache::new(
cache.tenants,
(std::mem::size_of::<TenantCache>() + 255) as u64,
),
lists: Cache::new(
cache.mailing_lists,
(std::mem::size_of::<MailingListCache>() + 255) as u64,
),
dkim_signers: Cache::new(
cache.dkim_signatures,
(std::mem::size_of::<DkimSigners>() + 255) as u64,
),
dns_txt: CacheWithTtl::new(cache.dns_txt, (std::mem::size_of::<Txt>() + 255) as u64),
dns_mx: CacheWithTtl::new(cache.dns_mx, ((std::mem::size_of::<MX>() + 255) * 2) as u64),
dns_ptr: CacheWithTtl::new(cache.dns_ptr, (std::mem::size_of::<IpAddr>() + 255) as u64),
dns_ipv4: CacheWithTtl::new(
cache.dns_ipv4,
((std::mem::size_of::<Ipv4Addr>() + 255) * 2) as u64,
),
dns_ipv6: CacheWithTtl::new(
cache.dns_ipv6,
((std::mem::size_of::<Ipv6Addr>() + 255) * 2) as u64,
),
dns_tlsa: CacheWithTtl::new(cache.dns_tlsa, (std::mem::size_of::<Tlsa>() + 255) as u64),
dns_mta_sts: CacheWithTtl::new(
cache.dns_mta_sts,
(std::mem::size_of::<Policy>() + 255) as u64,
),
dns_rbl: CacheWithTtl::new(
cache.dns_rbl,
((std::mem::size_of::<Ipv4Addr>() + 255) * 2) as u64,
),
negative_cache_ttl: cache.negative_ttl.into_inner(),
}
}
#[allow(clippy::type_complexity)]
#[inline(always)]
pub fn build_auth_parameters<T>(
&self,
params: T,
) -> Parameters<
'_,
T,
CacheWithTtl<Box<str>, Txt>,
CacheWithTtl<Box<str>, RecordSet<MX>>,
CacheWithTtl<Box<str>, RecordSet<Ipv4Addr>>,
CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
CacheWithTtl<IpAddr, RecordSet<Box<str>>>,
> {
Parameters {
params,
cache_txt: Some(&self.dns_txt),
cache_mx: Some(&self.dns_mx),
cache_ptr: Some(&self.dns_ptr),
cache_ipv4: Some(&self.dns_ipv4),
cache_ipv6: Some(&self.dns_ipv6),
}
}
}
impl Default for Data {
fn default() -> Self {
Self {
spam_classifier: Default::default(),
tls_certificates: Default::default(),
tls_self_signed_cert: Default::default(),
blocked_ips: Default::default(),
jmap_id_gen: Default::default(),
queue_id_gen: Default::default(),
span_id_gen: Default::default(),
registry_id_gen: Default::default(),
queue_status: true.into(),
applications: WebApplications::new(),
logos: Default::default(),
smtp_connectors: TlsConnectors::try_new().unwrap(),
asn_geo_data: Default::default(),
lookup_stores: Default::default(),
}
}
}
impl TlsConnectors {
fn try_new() -> Result<Self, String> {
Ok(TlsConnectors {
pki_verify: build_tls_connector(false)?,
dummy_verify: build_tls_connector(true)?,
})
}
}
@@ -0,0 +1,323 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::config::mailstore::jmap::JmapConfig;
use ahash::AHashSet;
use calcard::icalendar::ICalendarDuration;
use jmap_proto::{
object::{email::EmailComparator, file_node::FileNodeComparator},
request::capability::{
BlobCapabilities, CalendarCapabilities, Capabilities, Capability, ContactsCapabilities,
CoreCapabilities, EmptyCapabilities, FileNodeCapabilities, MailCapabilities,
PrincipalAvailabilityCapabilities, PrincipalCapabilities, SieveAccountCapabilities,
SieveSessionCapabilities, SubmissionCapabilities, WebPushCapabilities,
},
types::date::UTCDate,
};
use registry::{
schema::structs::{Calendar, Email, SieveUserInterpreter},
types::EnumImpl,
};
use store::registry::bootstrap::Bootstrap;
use types::type_state::DataType;
use utils::map::vec_map::VecMap;
impl JmapConfig {
pub async fn add_capabilities(&mut self, bp: &mut Bootstrap) {
// Add core capabilities
self.capabilities.session.append(
Capability::Core,
Capabilities::Core(CoreCapabilities {
max_size_upload: self.upload_max_size as u64,
max_concurrent_upload: self.upload_max_concurrent.unwrap_or(u32::MAX as u64),
max_size_request: self.request_max_size as u64,
max_concurrent_requests: self.request_max_concurrent.unwrap_or(u32::MAX as u64),
max_calls_in_request: self.request_max_calls as u64,
max_objects_in_get: self.get_max_objects as u64,
max_objects_in_set: self.set_max_objects as u64,
collation_algorithms: vec![
"i;ascii-numeric".to_string(),
"i;ascii-casemap".to_string(),
"i;unicode-casemap".to_string(),
],
}),
);
// Add email capabilities
let email = bp.setting_infallible::<Email>().await;
self.capabilities.session.append(
Capability::Mail,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Mail,
Capabilities::Mail(MailCapabilities {
max_mailboxes_per_email: None,
max_mailbox_depth: email.max_mailbox_depth,
max_size_mailbox_name: email.max_mailbox_name_length,
max_size_attachments_per_email: email.max_attachment_size,
email_query_sort_options: vec![
EmailComparator::ReceivedAt,
EmailComparator::Size,
EmailComparator::From,
EmailComparator::To,
EmailComparator::Subject,
EmailComparator::SentAt,
EmailComparator::HasKeyword(Default::default()),
EmailComparator::AllInThreadHaveKeyword(Default::default()),
EmailComparator::SomeInThreadHaveKeyword(Default::default()),
],
may_create_top_level_mailbox: true,
}),
);
// Add calendar capabilities
self.capabilities.session.append(
Capability::Calendars,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Calendars,
Capabilities::Calendar(CalendarCapabilities {
max_calendars_per_event: None,
min_date_time: UTCDate {
year: 1,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_before_gmt: false,
tz_hour: 0,
tz_minute: 0,
},
max_date_time: UTCDate {
year: 9999,
month: 12,
day: 31,
hour: 23,
minute: 59,
second: 59,
tz_before_gmt: false,
tz_hour: 0,
tz_minute: 0,
},
max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365)
.to_string(),
max_participants_per_event: bp
.setting_infallible::<Calendar>()
.await
.max_attendees
.into(),
may_create_calendar: true,
}),
);
self.capabilities.session.append(
Capability::CalendarsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::CalendarsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add contacts capabilities
self.capabilities.session.append(
Capability::Contacts,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Contacts,
Capabilities::Contacts(ContactsCapabilities {
max_address_books_per_card: None,
may_create_address_book: true,
}),
);
self.capabilities.session.append(
Capability::ContactsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::ContactsParse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add file node capabilities
self.capabilities.session.append(
Capability::FileNode,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::FileNode,
Capabilities::FileNode(FileNodeCapabilities {
max_file_node_depth: None,
max_size_file_node_name: 255,
forbidden_name_chars: Some("/<>:\"\\|?*".to_string()),
forbidden_node_names: Some(
[
".", "..", "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3",
"COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT0", "LPT1", "LPT2",
"LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
]
.into_iter()
.map(str::to_string)
.collect(),
),
file_node_query_sort_options: vec![
FileNodeComparator::Name,
FileNodeComparator::Size,
FileNodeComparator::NodeType,
],
may_create_top_level_file_node: true,
case_insensitive_names: false,
web_trash_url: None,
web_url_template: None,
web_write_url_template: None,
}),
);
// Add principal capabilities
self.capabilities.session.append(
Capability::Principals,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Principals,
Capabilities::Principals(PrincipalCapabilities {
current_user_principal_id: None,
}),
);
self.capabilities.session.append(
Capability::PrincipalsAvailability,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::PrincipalsAvailability,
Capabilities::PrincipalsAvailability(PrincipalAvailabilityCapabilities {
max_availability_duration: ICalendarDuration::from_seconds(86400 * 365).to_string(),
}),
);
// Add submission capabilities
self.capabilities.session.append(
Capability::Submission,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Submission,
Capabilities::Submission(SubmissionCapabilities {
max_delayed_send: 86400 * 30,
submission_extensions: VecMap::from_iter([
("FUTURERELEASE".to_string(), Vec::new()),
("SIZE".to_string(), Vec::new()),
("DSN".to_string(), Vec::new()),
("DELIVERYBY".to_string(), Vec::new()),
("MT-PRIORITY".to_string(), vec!["MIXER".to_string()]),
("REQUIRETLS".to_string(), vec![]),
]),
}),
);
// Add vacation response capabilities
self.capabilities.session.append(
Capability::VacationResponse,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::VacationResponse,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Sieve capabilities
let sieve = bp.setting_infallible::<SieveUserInterpreter>().await;
let disabled_capabilities = sieve
.disable_capabilities
.into_iter()
.map(|v| v.as_str())
.collect::<AHashSet<&str>>();
let mut extensions = sieve::compiler::grammar::Capability::all()
.iter()
.map(|c| c.to_string())
.filter(|c| !disabled_capabilities.contains(c.as_str()))
.collect::<Vec<String>>();
extensions.sort_unstable();
self.capabilities.session.append(
Capability::Sieve,
Capabilities::SieveSession(SieveSessionCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Sieve,
Capabilities::SieveAccount(SieveAccountCapabilities {
max_script_name: sieve.max_script_name_length as u64,
max_script_size: sieve.max_script_size,
max_scripts: sieve.max_scripts.unwrap_or(u32::MAX as u64),
max_redirects: sieve.max_redirects,
extensions,
notification_methods: if !sieve.allowed_notify_uris.is_empty() {
sieve.allowed_notify_uris.into_inner().into()
} else {
None
},
ext_lists: None,
}),
);
// Add Blob capabilities
self.capabilities.session.append(
Capability::Blob,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Blob,
Capabilities::Blob(BlobCapabilities {
max_size_blob_set: (self.request_max_size as u64 * 3 / 4) - 512,
max_data_sources: self.request_max_calls as u64,
supported_type_names: vec![
DataType::Email,
DataType::Thread,
DataType::SieveScript,
],
supported_digest_algorithms: vec!["sha", "sha-256", "sha-512"],
}),
);
// Add Quota capabilities
self.capabilities.session.append(
Capability::Quota,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::Quota,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Email Delivery Push capabilities
self.capabilities.session.append(
Capability::EmailPush,
Capabilities::Empty(EmptyCapabilities::default()),
);
self.capabilities.account.insert(
Capability::EmailPush,
Capabilities::Empty(EmptyCapabilities::default()),
);
// Add Web Push VAPID capabilities
if let Some(application_server_key) = self
.vapid
.as_ref()
.map(|vapid| vapid.public_key().to_string())
{
self.capabilities.session.append(
Capability::WebPushVapid,
Capabilities::WebPush(WebPushCapabilities {
application_server_key,
}),
);
}
}
}
+298
View File
@@ -0,0 +1,298 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::{AHashMap, AHashSet};
use nlp::language::Language;
use registry::{
schema::{
enums::{
CompressionAlgo, SearchCalendarField, SearchContactField, SearchEmailField,
StorageQuota,
},
prelude::ObjectType,
structs::{
AddressBook, Authentication, Calendar, DataRetention, Domain, Email, FileStorage, Jmap,
Search, SieveUserInterpreter, SystemSettings,
},
},
types::EnumImpl,
};
use std::time::Duration;
use store::{
registry::bootstrap::Bootstrap,
search::{CalendarSearchField, ContactSearchField, EmailSearchField, SearchField},
write::SearchIndex,
};
use types::special_use::SpecialUse;
use utils::cron::SimpleCron;
use crate::storage::ObjectQuota;
#[derive(Clone)]
pub struct EmailConfig {
pub default_language: Language,
pub default_domain_id: u32,
pub default_domain_name: String,
pub mailbox_max_depth: usize,
pub mailbox_name_max_len: usize,
pub mail_attachments_max_size: usize,
pub mail_max_size: usize,
pub mail_autoexpunge_after: Option<u64>,
pub email_submission_autoexpunge_after: Option<u64>,
pub changes_max_history: Option<usize>,
pub share_notification_max_history: Option<Duration>,
pub sieve_max_script_name: usize,
pub default_folders: Vec<DefaultFolder>,
pub shared_folder: String,
pub encrypt: bool,
pub encrypt_append: bool,
pub index_batch_size: usize,
pub index_fields: AHashMap<SearchIndex, AHashSet<SearchField>>,
pub max_objects: ObjectQuota,
pub compression: CompressionAlgo,
pub account_purge_frequency: SimpleCron,
pub data_purge_frequency: SimpleCron,
pub blob_purge_frequency: SimpleCron,
}
#[derive(Clone, Debug)]
pub struct DefaultFolder {
pub name: String,
pub aliases: Vec<String>,
pub special_use: SpecialUse,
pub subscribe: bool,
pub create: bool,
}
impl EmailConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let email = bp.setting_infallible::<Email>().await;
let dr = bp.setting_infallible::<DataRetention>().await;
let sieve = bp.setting_infallible::<SieveUserInterpreter>().await;
let search = bp.setting_infallible::<Search>().await;
let jmap = bp.setting_infallible::<Jmap>().await;
let file = bp.setting_infallible::<FileStorage>().await;
let calendar = bp.setting_infallible::<Calendar>().await;
let address_book = bp.setting_infallible::<AddressBook>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
let auth = bp.setting_infallible::<Authentication>().await;
// Obtain default domain name
let default_domain_name = if system.default_domain_id.is_valid()
&& let Some(default_domain) =
bp.get_infallible::<Domain>(system.default_domain_id).await
{
default_domain.name
} else {
if system.default_domain_id.is_valid() {
bp.build_error(
ObjectType::SystemSettings.singleton(),
format!(
"Default domain with ID {} not found",
system.default_domain_id
),
);
}
"localhost.local".to_string()
};
// Parse default object quotas
let mut max_objects = ObjectQuota::default();
for (item, max) in [
(StorageQuota::MaxEmails, email.max_messages),
(StorageQuota::MaxMailboxes, email.max_mailboxes),
(StorageQuota::MaxSieveScripts, sieve.max_scripts),
(StorageQuota::MaxEmailIdentities, email.max_identities),
(StorageQuota::MaxEmailSubmissions, email.max_submissions),
(StorageQuota::MaxMaskedAddresses, email.max_masked_addresses),
(StorageQuota::MaxAppPasswords, auth.max_app_passwords),
(StorageQuota::MaxApiKeys, auth.max_api_keys),
(StorageQuota::MaxPublicKeys, email.max_public_keys),
(StorageQuota::MaxPushSubscriptions, jmap.max_subscriptions),
(StorageQuota::MaxCalendars, calendar.max_calendars),
(StorageQuota::MaxCalendarEvents, calendar.max_events),
(
StorageQuota::MaxParticipantIdentities,
calendar.max_participant_identities,
),
(
StorageQuota::MaxCalendarEventNotifications,
calendar.max_event_notifications,
),
(
StorageQuota::MaxAddressBooks,
address_book.max_address_books,
),
(StorageQuota::MaxContactCards, address_book.max_contacts),
(StorageQuota::MaxFiles, file.max_files),
(StorageQuota::MaxFolders, file.max_folders),
] {
if let Some(max) = max {
max_objects.set(item, max as u32);
}
}
// Parse default folders
let mut default_folders = Vec::new();
let mut shared_folder = "Shared Folders".to_string();
for (special_use, folder) in email.default_folders {
let special_use = match special_use {
registry::schema::enums::SpecialUse::Inbox => SpecialUse::Inbox,
registry::schema::enums::SpecialUse::Trash => SpecialUse::Trash,
registry::schema::enums::SpecialUse::Junk => SpecialUse::Junk,
registry::schema::enums::SpecialUse::Drafts => SpecialUse::Drafts,
registry::schema::enums::SpecialUse::Archive => SpecialUse::Archive,
registry::schema::enums::SpecialUse::Sent => SpecialUse::Sent,
registry::schema::enums::SpecialUse::Important => SpecialUse::Important,
registry::schema::enums::SpecialUse::Memos => SpecialUse::Memos,
registry::schema::enums::SpecialUse::Scheduled => SpecialUse::Scheduled,
registry::schema::enums::SpecialUse::Snoozed => SpecialUse::Snoozed,
registry::schema::enums::SpecialUse::Shared => {
shared_folder = folder.name;
continue;
}
};
default_folders.push(DefaultFolder {
name: folder.name,
aliases: folder.aliases.into_inner(),
special_use,
subscribe: folder.subscribe,
create: folder.create
|| matches!(
special_use,
SpecialUse::Inbox | SpecialUse::Trash | SpecialUse::Junk
),
});
}
for (special_use, name) in [
(SpecialUse::Inbox, "Inbox"),
(SpecialUse::Trash, "Deleted Items"),
(SpecialUse::Junk, "Junk Mail"),
(SpecialUse::Drafts, "Drafts"),
(SpecialUse::Sent, "Sent Items"),
] {
if !default_folders.iter().any(|f| f.special_use == special_use) {
default_folders.push(DefaultFolder {
name: name.to_string(),
aliases: Vec::new(),
special_use,
subscribe: true,
create: true,
});
}
}
// Search Index settings
let mut index_fields = AHashMap::new();
if search.index_email {
index_fields.insert(
SearchIndex::Email,
search
.index_email_fields
.into_iter()
.map(|field| {
SearchField::Email(match field {
SearchEmailField::From => EmailSearchField::From,
SearchEmailField::To => EmailSearchField::To,
SearchEmailField::Cc => EmailSearchField::Cc,
SearchEmailField::Bcc => EmailSearchField::Bcc,
SearchEmailField::Subject => EmailSearchField::Subject,
SearchEmailField::Body => EmailSearchField::Body,
SearchEmailField::Attachment => EmailSearchField::Attachment,
SearchEmailField::ReceivedAt => EmailSearchField::ReceivedAt,
SearchEmailField::SentAt => EmailSearchField::SentAt,
SearchEmailField::Size => EmailSearchField::Size,
SearchEmailField::HasAttachment => EmailSearchField::HasAttachment,
SearchEmailField::Headers => EmailSearchField::Headers,
})
})
.collect(),
);
}
if search.index_contacts {
index_fields.insert(
SearchIndex::Contacts,
search
.index_contact_fields
.into_iter()
.map(|field| {
SearchField::Contact(match field {
SearchContactField::Member => ContactSearchField::Member,
SearchContactField::Kind => ContactSearchField::Kind,
SearchContactField::Name => ContactSearchField::Name,
SearchContactField::Nickname => ContactSearchField::Nickname,
SearchContactField::Organization => ContactSearchField::Organization,
SearchContactField::Email => ContactSearchField::Email,
SearchContactField::Phone => ContactSearchField::Phone,
SearchContactField::OnlineService => ContactSearchField::OnlineService,
SearchContactField::Address => ContactSearchField::Address,
SearchContactField::Note => ContactSearchField::Note,
SearchContactField::Uid => ContactSearchField::Uid,
})
})
.collect(),
);
}
if search.index_calendar {
index_fields.insert(
SearchIndex::Calendar,
search
.index_calendar_fields
.into_iter()
.map(|field| {
SearchField::Calendar(match field {
SearchCalendarField::Title => CalendarSearchField::Title,
SearchCalendarField::Description => CalendarSearchField::Description,
SearchCalendarField::Location => CalendarSearchField::Location,
SearchCalendarField::Owner => CalendarSearchField::Owner,
SearchCalendarField::Attendee => CalendarSearchField::Attendee,
SearchCalendarField::Start => CalendarSearchField::Start,
SearchCalendarField::Uid => CalendarSearchField::Uid,
})
})
.collect(),
);
}
EmailConfig {
default_language: Language::from_iso_639(search.default_language.as_str())
.unwrap_or(Language::English),
mailbox_max_depth: email.max_mailbox_depth as usize,
mailbox_name_max_len: email.max_mailbox_name_length as usize,
mail_attachments_max_size: email.max_attachment_size as usize,
mail_max_size: email.max_message_size as usize,
mail_autoexpunge_after: dr.expunge_trash_after.map(|d| d.into_inner().as_secs()),
email_submission_autoexpunge_after: dr
.expunge_submissions_after
.map(|d| d.into_inner().as_secs()),
changes_max_history: dr.max_changes_history.map(|v| v as usize),
share_notification_max_history: dr.expunge_share_notify_after.map(|v| v.into_inner()),
sieve_max_script_name: sieve.max_script_name_length as usize,
encrypt: email.encrypt_at_rest,
encrypt_append: email.encrypt_on_append,
index_batch_size: search.index_batch_size as usize,
index_fields,
max_objects,
default_folders,
shared_folder,
account_purge_frequency: dr.expunge_schedule.into(),
data_purge_frequency: dr.data_cleanup_schedule.into(),
blob_purge_frequency: dr.blob_cleanup_schedule.into(),
compression: email.compression_algorithm,
default_domain_id: system.default_domain_id.id() as u32,
default_domain_name,
}
}
}
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use registry::schema::structs::{Imap, Rate};
use std::time::Duration;
use store::registry::bootstrap::Bootstrap;
#[derive(Default, Clone)]
pub struct ImapConfig {
pub max_request_size: usize,
pub max_auth_failures: u32,
pub allow_plain_auth: bool,
pub timeout_auth: Duration,
pub timeout_unauth: Duration,
pub timeout_idle: Duration,
pub rate_requests: Option<Rate>,
pub rate_concurrent: Option<u64>,
pub max_messages_per_command: u32,
pub max_messages_per_save: u32,
pub min_uid_batch_size: u32,
pub max_uid_batches: u32,
}
impl ImapConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let imap = bp.setting_infallible::<Imap>().await;
ImapConfig {
max_request_size: imap.max_request_size as usize,
max_auth_failures: imap.max_auth_failures as u32,
timeout_auth: imap.timeout_authenticated.into_inner(),
timeout_unauth: imap.timeout_anonymous.into_inner(),
timeout_idle: imap.timeout_idle.into_inner(),
rate_requests: imap.max_request_rate,
rate_concurrent: imap.max_concurrent,
allow_plain_auth: imap.allow_plain_text_auth,
max_messages_per_command: imap.max_messages_per_command.min(u32::MAX as u64) as u32,
max_messages_per_save: imap.max_messages_per_save.min(u32::MAX as u64) as u32,
min_uid_batch_size: imap.min_uid_batch_size.min(u32::MAX as u64) as u32,
max_uid_batches: imap.max_uid_batches.min(u32::MAX as u64) as u32,
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::network::webpush::{Vapid, VapidKey};
use jmap_proto::request::capability::BaseCapabilities;
use registry::schema::{prelude::ObjectType, structs::Jmap};
use std::time::Duration;
use store::registry::bootstrap::Bootstrap;
#[derive(Default, Clone)]
pub struct JmapConfig {
pub query_max_results: usize,
pub snippet_max_results: usize,
pub changes_max_results: usize,
pub request_max_size: usize,
pub request_max_calls: usize,
pub request_max_concurrent: Option<u64>,
pub get_max_objects: usize,
pub set_max_objects: usize,
pub upload_max_size: usize,
pub upload_max_concurrent: Option<u64>,
pub upload_tmp_quota_size: usize,
pub upload_tmp_quota_amount: usize,
pub upload_tmp_ttl: u64,
pub mail_parse_max_items: usize,
pub contact_parse_max_items: usize,
pub calendar_parse_max_items: usize,
pub event_source_throttle: Duration,
pub push_attempt_interval: Duration,
pub push_attempts_max: u32,
pub push_retry_interval: Duration,
pub push_timeout: Duration,
pub push_verify_timeout: Duration,
pub push_throttle: Duration,
pub push_total_shards: u32,
pub push_max_size: usize,
pub web_socket_throttle: Duration,
pub web_socket_timeout: Duration,
pub web_socket_heartbeat: Duration,
pub vapid: Option<Vapid>,
pub capabilities: BaseCapabilities,
}
impl JmapConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let jmap = bp.setting_infallible::<Jmap>().await;
let web_push_key = jmap
.web_push_key
.secret()
.await
.map_err(|err| {
bp.build_error(
ObjectType::Jmap.singleton(),
format!("Unable to retrieve Web Push key: {err}"),
);
})
.unwrap_or_default()
.map(|k| k.into_owned());
let web_push_contact = jmap
.web_push_contact
.as_deref()
.and_then(crate::network::webpush::normalize_contact)
.or_else(|| {
let hostname = bp.registry.local_hostname();
(!hostname.is_empty()).then(|| format!("mailto:postmaster@{hostname}"))
});
let mut jmap = JmapConfig {
query_max_results: jmap.query_max_results as usize,
changes_max_results: jmap.changes_max_results as usize,
snippet_max_results: jmap.snippet_max_results as usize,
request_max_size: jmap.max_request_size as usize,
request_max_calls: jmap.max_method_calls as usize,
request_max_concurrent: jmap.max_concurrent_requests,
get_max_objects: jmap.get_max_results as usize,
set_max_objects: jmap.set_max_objects as usize,
upload_max_size: jmap.max_upload_size as usize,
upload_max_concurrent: jmap.max_concurrent_uploads,
upload_tmp_quota_size: jmap.upload_quota as usize,
upload_tmp_quota_amount: jmap.max_upload_count as usize,
upload_tmp_ttl: jmap.upload_ttl.into_inner().as_secs().max(1),
mail_parse_max_items: jmap.parse_limit_email as usize,
contact_parse_max_items: jmap.parse_limit_contact as usize,
calendar_parse_max_items: jmap.parse_limit_event as usize,
event_source_throttle: jmap.event_source_throttle.into_inner(),
web_socket_throttle: jmap.websocket_throttle.into_inner(),
web_socket_timeout: jmap.websocket_timeout.into_inner(),
web_socket_heartbeat: jmap.websocket_heartbeat.into_inner(),
push_attempt_interval: jmap.push_attempt_wait.into_inner(),
push_attempts_max: jmap.push_max_attempts as u32,
push_retry_interval: jmap.push_retry_wait.into_inner(),
push_timeout: jmap.push_request_timeout.into_inner(),
push_verify_timeout: jmap.push_verify_timeout.into_inner(),
push_throttle: jmap.push_throttle.into_inner(),
push_total_shards: jmap.push_shards_total as u32,
push_max_size: jmap.max_push_size as usize,
vapid: None,
capabilities: BaseCapabilities::default(),
};
// Enable Web Push VAPID only when a signing key is configured
jmap.vapid = web_push_key
.as_deref()
.map(str::trim)
.filter(|pem| !pem.is_empty())
.and_then(|pem| match VapidKey::from_pkcs8_pem(pem) {
Ok(key) => Some(key),
Err(err) => {
bp.build_error(
ObjectType::Jmap.singleton(),
format!("Invalid Web Push VAPID key: {err}"),
);
None
}
})
.map(|key| Vapid::new(key, web_push_contact));
// Add capabilities
jmap.add_capabilities(bp).await;
jmap
}
}
+12
View File
@@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod capabilities;
pub mod email;
pub mod imap;
pub mod jmap;
pub mod scripts;
pub mod spamfilter;
@@ -0,0 +1,279 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
VERSION_PUBLIC,
expr::if_block::{BootstrapExprExt, IfBlock},
scripts::{
functions::{register_functions_trusted, register_functions_untrusted},
plugins::RegisterSievePlugins,
},
};
use ahash::AHashMap;
use registry::{
schema::{
prelude::ObjectType,
structs::{
SieveSystemInterpreter, SieveSystemScript, SieveUserInterpreter, SieveUserScript,
SystemSettings,
},
},
types::EnumImpl,
};
use sieve::{Compiler, Runtime, Sieve, compiler::grammar::Capability};
use std::{collections::hash_map::Entry, sync::Arc};
use store::registry::bootstrap::Bootstrap;
pub struct Scripting {
pub untrusted_compiler: Compiler,
pub untrusted_runtime: Runtime,
pub trusted_runtime: Runtime,
pub trusted_compiler: Compiler,
pub max_received_headers: usize,
pub from_addr: IfBlock,
pub from_name: IfBlock,
pub return_path: IfBlock,
pub sign: IfBlock,
pub untrusted_sign: IfBlock,
pub trusted_scripts: AHashMap<String, Arc<Sieve>>,
pub untrusted_scripts: AHashMap<String, Arc<Sieve>>,
pub http_client: reqwest::Client,
}
impl Scripting {
pub async fn parse(bp: &mut Bootstrap) -> Self {
// Parse untrusted compiler
let untrusted = bp.setting_infallible::<SieveUserInterpreter>().await;
let untrusted_sign = bp.compile_expr(
ObjectType::SieveUserInterpreter.singleton(),
&untrusted.ctx_dkim_sign_domain(),
);
let mut fnc_map_untrusted = register_functions_untrusted().register_plugins_untrusted();
let untrusted_compiler = Compiler::new()
.with_max_script_size(untrusted.max_script_size as usize)
.with_max_string_size(untrusted.max_string_length as usize)
.with_max_variable_name_size(untrusted.max_var_name_length as usize)
.with_max_nested_blocks(untrusted.max_nested_blocks as usize)
.with_max_nested_tests(untrusted.max_nested_tests as usize)
.with_max_nested_foreverypart(untrusted.max_nested_for_every as usize)
.with_max_match_variables(untrusted.max_match_vars as usize)
.with_max_local_variables(untrusted.max_local_vars as usize)
.with_max_header_size(untrusted.max_header_size as usize)
.with_max_includes(untrusted.max_includes as usize)
.register_functions(&mut fnc_map_untrusted);
// Parse untrusted runtime
let mut untrusted_runtime = Runtime::new()
.with_functions(&mut fnc_map_untrusted)
.with_max_nested_includes(untrusted.max_nested_includes as usize)
.with_cpu_limit(untrusted.max_cpu_cycles as usize)
.with_max_variable_size(untrusted.max_var_size as usize)
.with_max_redirects(untrusted.max_redirects as usize)
.with_max_received_headers(usize::MAX) // This is set to usize::MAX here, but the actual limit is enforced during ingestion.
.with_max_header_size(untrusted.max_header_size as usize)
.with_max_out_messages(untrusted.max_out_messages as usize)
.with_default_vacation_expiry(untrusted.default_expiry_vacation.into_inner().as_secs())
.with_default_duplicate_expiry(
untrusted.default_expiry_duplicate.into_inner().as_secs(),
)
.with_capability(Capability::Expressions)
.without_capabilities(
untrusted
.disable_capabilities
.iter()
.map(|cap| cap.as_str()),
)
.with_valid_notification_uris(untrusted.allowed_notify_uris)
.with_protected_headers(untrusted.protected_headers)
.with_vacation_default_subject(untrusted.default_subject)
.with_vacation_subject_prefix(untrusted.default_subject_prefix)
.with_env_variable("name", "Stalwart Server")
.with_env_variable("version", VERSION_PUBLIC)
.with_env_variable("location", "MS")
.with_env_variable("phase", "during");
// Parse trusted compiler and runtime
let mut fnc_map_trusted = register_functions_trusted().register_plugins_trusted();
// Allocate compiler and runtime
let trusted = bp.setting_infallible::<SieveSystemInterpreter>().await;
let system = bp.setting_infallible::<SystemSettings>().await;
let local_hostname = if !system.default_hostname.is_empty() {
system.default_hostname.clone()
} else {
bp.registry.local_hostname().to_string()
};
let trusted_compiler = Compiler::new()
.with_max_string_size(52428800)
.with_max_variable_name_size(100)
.with_max_nested_blocks(50)
.with_max_nested_tests(50)
.with_max_nested_foreverypart(10)
.with_max_local_variables(8192)
.with_max_header_size(10240)
.with_max_includes(10)
.with_no_capability_check(trusted.no_capability_check)
.register_functions(&mut fnc_map_trusted);
let mut trusted_runtime = Runtime::new()
.without_capabilities([
Capability::FileInto,
Capability::Vacation,
Capability::VacationSeconds,
Capability::Fcc,
Capability::Mailbox,
Capability::MailboxId,
Capability::MboxMetadata,
Capability::ServerMetadata,
Capability::ImapSieve,
Capability::Duplicate,
])
.with_capability(Capability::Expressions)
.with_capability(Capability::While)
.with_max_variable_size(trusted.max_var_size as usize)
.with_max_header_size(10240)
.with_valid_notification_uri("mailto")
.with_functions(&mut fnc_map_trusted)
.with_max_redirects(trusted.max_redirects as usize)
.with_max_out_messages(trusted.max_out_messages as usize)
.with_cpu_limit(trusted.max_cpu_cycles as usize)
.with_max_nested_includes(trusted.max_nested_includes as usize)
.with_max_received_headers(trusted.max_received_headers as usize)
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs());
trusted_runtime.set_local_hostname(local_hostname.clone());
untrusted_runtime.set_local_hostname(local_hostname);
// Parse trusted scripts
let mut trusted_scripts: AHashMap<String, Arc<Sieve>> = AHashMap::new();
for script in bp.list_infallible::<SieveSystemScript>().await {
if !script.object.is_active {
continue;
}
match trusted_compiler.compile(script.object.contents.as_bytes()) {
Ok(compiled) => match trusted_scripts.entry(script.object.name.to_lowercase()) {
Entry::Vacant(entry) => {
entry.insert(compiled.into());
}
Entry::Occupied(_) => {
bp.build_error(
script.id,
format!(
"Another active system Sieve script is already named {:?}, script names are case insensitive",
script.object.name
),
);
}
},
Err(err) => {
bp.build_error(
script.id,
format!("Failed to compile system Sieve script: {err}"),
);
}
}
}
// Parse untrusted scripts
let mut untrusted_scripts: AHashMap<String, Arc<Sieve>> = AHashMap::new();
for script in bp.list_infallible::<SieveUserScript>().await {
if !script.object.is_active {
continue;
}
match untrusted_compiler.compile(script.object.contents.as_bytes()) {
Ok(compiled) => match untrusted_scripts.entry(script.object.name.to_lowercase()) {
Entry::Vacant(entry) => {
entry.insert(compiled.into());
}
Entry::Occupied(_) => {
bp.build_error(
script.id,
format!(
"Another active user global Sieve script is already named {:?}, script names are case insensitive",
script.object.name
),
);
}
},
Err(err) => {
bp.build_error(
script.id,
format!("Failed to compile user global Sieve script: {err}"),
);
}
}
}
Scripting {
untrusted_compiler,
untrusted_runtime,
trusted_runtime,
trusted_compiler,
untrusted_scripts,
trusted_scripts,
http_client: utils::http::http_client_builder(cfg!(feature = "test_mode"))
.pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default(),
max_received_headers: untrusted.max_received_headers as usize,
from_addr: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_from_address(),
),
from_name: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_from_name(),
),
return_path: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_default_return_path(),
),
sign: bp.compile_expr(
ObjectType::SieveSystemInterpreter.singleton(),
&trusted.ctx_dkim_sign_domain(),
),
untrusted_sign,
}
}
pub fn trusted_script(&self, name: &str) -> Option<&Arc<Sieve>> {
script_by_name(&self.trusted_scripts, name)
}
pub fn untrusted_script(&self, name: &str) -> Option<&Arc<Sieve>> {
script_by_name(&self.untrusted_scripts, name)
}
}
fn script_by_name<'x>(
scripts: &'x AHashMap<String, Arc<Sieve>>,
name: &str,
) -> Option<&'x Arc<Sieve>> {
scripts
.get(name)
.or_else(|| scripts.get(name.to_lowercase().as_str()))
}
impl Clone for Scripting {
fn clone(&self) -> Self {
Self {
untrusted_compiler: self.untrusted_compiler.clone(),
untrusted_runtime: self.untrusted_runtime.clone(),
trusted_runtime: self.trusted_runtime.clone(),
from_addr: self.from_addr.clone(),
from_name: self.from_name.clone(),
return_path: self.return_path.clone(),
max_received_headers: self.max_received_headers,
sign: self.sign.clone(),
untrusted_sign: self.untrusted_sign.clone(),
trusted_scripts: self.trusted_scripts.clone(),
untrusted_scripts: self.untrusted_scripts.clone(),
trusted_compiler: self.trusted_compiler.clone(),
http_client: self.http_client.clone(),
}
}
}
@@ -0,0 +1,742 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::expr::{
Variable,
functions::ResolveVariable,
if_block::{BootstrapExprExt, IfBlock},
};
use ahash::AHashSet;
use mail_auth::common::resolver::ToReverseName;
use nlp::classifier::model::{CcfhClassifier, FhClassifier};
use registry::schema::{
enums::{ExpressionVariable, ModelSize},
prelude::ObjectType,
structs::{
self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule,
SpamSettings, SpamTag,
},
};
use sieve::SpamStatus;
use std::{
net::{IpAddr, SocketAddr},
time::Duration,
};
use store::registry::{RegistryObject, bootstrap::Bootstrap};
use tokio::net::lookup_host;
use utils::{cache::CacheItemWeight, glob::GlobMap};
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)]
pub enum SpamClassifier {
FhClassifier {
classifier: FhClassifier,
last_trained_at: u64,
},
CcfhClassifier {
classifier: CcfhClassifier,
last_trained_at: u64,
},
#[default]
Disabled,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterConfig {
pub enabled: bool,
pub card_is_ham: bool,
pub trusted_reply: bool,
pub grey_list_expiry: Option<u64>,
pub dnsbl: DnsBlConfig,
pub rules: SpamFilterRules,
pub lists: SpamFilterLists,
pub pyzor: Option<PyzorConfig>,
pub classifier: Option<ClassifierConfig>,
pub scores: SpamFilterScoreConfig,
pub spam_rules_url: Option<String>,
pub url_client: reqwest::Client,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterScoreConfig {
pub reject_threshold: f32,
pub discard_threshold: f32,
pub spam_threshold: f32,
}
impl SpamFilterScoreConfig {
pub fn spam_percentage(&self, score: f32) -> u8 {
let spam_threshold = self.spam_threshold;
if spam_threshold <= 0.0 {
return if score >= spam_threshold { 100 } else { 0 };
}
let max_threshold = [self.reject_threshold, self.discard_threshold]
.into_iter()
.filter(|threshold| *threshold > spam_threshold)
.min_by(f32::total_cmp)
.unwrap_or(spam_threshold * 2.0);
if score <= 0.0 {
0
} else if score < spam_threshold {
((50.0 * score / spam_threshold) as u8).min(49)
} else {
((50.0 + 50.0 * (score - spam_threshold) / (max_threshold - spam_threshold)) as u8)
.min(100)
}
}
pub fn is_spam(&self, score: f32) -> bool {
score >= self.spam_threshold
}
}
pub fn spam_status(percentage: Option<u8>) -> SpamStatus {
match percentage {
Some(0) => SpamStatus::Ham,
Some(100) => SpamStatus::Spam,
Some(percentage) => SpamStatus::MaybeSpam(percentage as f64 / 100.0),
None => SpamStatus::Unknown,
}
}
#[derive(Debug, Clone, Default)]
pub struct DnsBlConfig {
pub max_ip_checks: usize,
pub max_domain_checks: usize,
pub max_email_checks: usize,
pub max_url_checks: usize,
pub servers: Vec<DnsBlServer>,
}
#[derive(Debug, Clone, Default)]
pub struct SpamFilterLists {
pub file_extensions: GlobMap<FileExtension>,
pub scores: GlobMap<SpamFilterAction<f32>>,
}
#[derive(Debug, Clone)]
pub enum SpamFilterAction<T> {
Allow(T),
Discard,
Reject,
Disabled,
}
#[derive(Debug, Clone, Default)]
pub struct ClassifierConfig {
pub w_params: FtrlParameters,
pub i_params: Option<FtrlParameters>,
pub reservoir_capacity: usize,
pub min_ham_samples: u64,
pub min_spam_samples: u64,
pub auto_learn_reply_ham: bool,
pub auto_learn_card_is_ham: bool,
pub auto_learn_spam_trap: bool,
pub auto_learn_spam_rbl_count: u32,
pub hold_samples_for: u64,
pub train_frequency: Option<u64>,
pub log_scale: bool,
pub l2_normalize: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FtrlParameters {
pub feature_hash_size: usize,
pub alpha: f64,
pub beta: f64,
pub l1_ratio: f64,
pub l2_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct PyzorConfig {
pub address: SocketAddr,
pub timeout: Duration,
pub min_count: u64,
pub min_wl_count: u64,
pub ratio: f64,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SpamFilterRules {
pub url: Vec<IfBlock>,
pub domain: Vec<IfBlock>,
pub email: Vec<IfBlock>,
pub ip: Vec<IfBlock>,
pub header: Vec<IfBlock>,
pub body: Vec<IfBlock>,
pub any: Vec<IfBlock>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileExtension {
pub known_types: AHashSet<String>,
pub is_bad: bool,
pub is_archive: bool,
pub is_nz: bool,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Element {
Url,
Domain,
Email,
Ip,
Header,
Body,
#[default]
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Location {
EnvelopeFrom,
EnvelopeTo,
HeaderDkimPass,
HeaderReceived,
HeaderFrom,
HeaderReplyTo,
HeaderSubject,
HeaderTo,
HeaderCc,
HeaderBcc,
HeaderMid,
HeaderDnt,
Ehlo,
BodyText,
BodyHtml,
Attachment,
Tcp,
}
#[derive(Debug, Clone)]
pub struct DnsBlServer {
pub id: String,
pub zone: IfBlock,
pub scope: Element,
pub tags: IfBlock,
}
impl SpamFilterConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let spam = bp.setting_infallible::<SpamSettings>().await;
SpamFilterConfig {
enabled: spam.enable,
card_is_ham: spam.trust_contacts,
trusted_reply: spam.trust_replies,
dnsbl: DnsBlConfig::parse(bp).await,
rules: SpamFilterRules::parse(bp).await,
lists: SpamFilterLists::parse(bp).await,
pyzor: PyzorConfig::parse(bp).await,
classifier: ClassifierConfig::parse(bp).await,
scores: SpamFilterScoreConfig {
reject_threshold: spam.score_reject.into_inner() as f32,
discard_threshold: spam.score_discard.into_inner() as f32,
spam_threshold: spam.score_spam.into_inner() as f32,
},
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
spam_rules_url: spam.spam_filter_rules_url,
url_client: utils::http::http_client_builder(true)
.pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none())
.user_agent("Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/118.0")
.build()
.unwrap_or_default(),
}
}
}
impl SpamFilterRules {
pub async fn parse(bp: &mut Bootstrap) -> SpamFilterRules {
let mut rules = vec![];
for rule in bp.list_infallible::<SpamRule>().await {
if let Some(rule) = SpamFilterRule::parse(bp, rule) {
rules.push(rule);
}
}
rules.sort_by_key(|a| a.priority);
let mut result = SpamFilterRules::default();
for rule in rules {
match rule.scope {
Element::Url => result.url.push(rule.rule),
Element::Domain => result.domain.push(rule.rule),
Element::Email => result.email.push(rule.rule),
Element::Ip => result.ip.push(rule.rule),
Element::Header => result.header.push(rule.rule),
Element::Body => result.body.push(rule.rule),
Element::Any => result.any.push(rule.rule),
}
}
result
}
}
struct SpamFilterRule {
rule: IfBlock,
priority: i32,
scope: Element,
}
impl SpamFilterRule {
pub fn parse(bp: &mut Bootstrap, obj: RegistryObject<SpamRule>) -> Option<Self> {
match obj.object {
SpamRule::Any(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Any,
priority: rule.priority as i32,
}
.into(),
SpamRule::Url(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Url,
priority: rule.priority as i32,
}
.into(),
SpamRule::Domain(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Domain,
priority: rule.priority as i32,
}
.into(),
SpamRule::Email(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Email,
priority: rule.priority as i32,
}
.into(),
SpamRule::Ip(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Ip,
priority: rule.priority as i32,
}
.into(),
SpamRule::Header(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Header,
priority: rule.priority as i32,
}
.into(),
SpamRule::Body(rule) if rule.enable => SpamFilterRule {
rule: bp.compile_expr(obj.id, &rule.ctx_condition()),
scope: Element::Body,
priority: rule.priority as i32,
}
.into(),
_ => None,
}
}
}
impl DnsBlConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut servers = vec![];
for server in bp.list_infallible::<SpamDnsblServer>().await {
if let Some(server) = DnsBlServer::parse(bp, server) {
servers.push(server);
}
}
let dnsbl = bp.setting_infallible::<SpamDnsblSettings>().await;
DnsBlConfig {
max_ip_checks: dnsbl.ip_limit as usize,
max_domain_checks: dnsbl.domain_limit as usize,
max_email_checks: dnsbl.email_limit as usize,
max_url_checks: dnsbl.url_limit as usize,
servers,
}
}
}
impl DnsBlServer {
pub fn parse(bp: &mut Bootstrap, obj: RegistryObject<SpamDnsblServer>) -> Option<Self> {
match obj.object {
SpamDnsblServer::Any(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Any,
id: server.name,
}
.into(),
SpamDnsblServer::Url(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Url,
id: server.name,
}
.into(),
SpamDnsblServer::Domain(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Domain,
id: server.name,
}
.into(),
SpamDnsblServer::Email(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Email,
id: server.name,
}
.into(),
SpamDnsblServer::Ip(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Ip,
id: server.name,
}
.into(),
SpamDnsblServer::Header(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Header,
id: server.name,
}
.into(),
SpamDnsblServer::Body(server) if server.enable => DnsBlServer {
zone: bp.compile_expr(obj.id, &server.ctx_zone()),
tags: bp.compile_expr(obj.id, &server.ctx_tag()),
scope: Element::Body,
id: server.name,
}
.into(),
_ => None,
}
}
}
impl SpamFilterLists {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut lists = SpamFilterLists {
file_extensions: GlobMap::default(),
scores: GlobMap::default(),
};
for tag in bp.list_infallible::<SpamTag>().await {
match tag.object {
SpamTag::Score(tag) => lists.scores.insert_pattern(
&tag.tag,
SpamFilterAction::Allow(tag.score.into_inner() as f32),
),
SpamTag::Discard(tag) => lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Discard),
SpamTag::Reject(tag) => lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Reject),
}
}
for ext in bp.list_infallible::<SpamFileExtension>().await {
let ext = ext.object;
lists.file_extensions.insert_pattern(
&ext.extension,
FileExtension {
known_types: ext.content_types.into_iter().collect(),
is_bad: ext.is_bad,
is_archive: ext.is_archive,
is_nz: ext.is_nz,
},
);
}
lists
}
}
impl PyzorConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let pyzor = bp.setting_infallible::<SpamPyzor>().await;
if !pyzor.enable {
return None;
}
let port = pyzor.port;
let host = pyzor.host;
let address = match lookup_host(format!("{host}:{port}"))
.await
.map(|mut a| a.next())
{
Ok(Some(address)) => address,
Ok(None) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
"Invalid address: No addresses found.",
);
return None;
}
Err(err) => {
bp.build_error(
ObjectType::SpamPyzor.singleton(),
format!("Invalid address: {}", err),
);
return None;
}
};
PyzorConfig {
address,
timeout: pyzor.timeout.into_inner(),
min_count: pyzor.block_count,
min_wl_count: pyzor.allow_count,
ratio: pyzor.ratio.into_inner(),
}
.into()
}
}
impl ClassifierConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let classifier = bp.setting_infallible::<structs::SpamClassifier>().await;
let (log_scale, l2_normalize, w_params, i_params) = match classifier.model {
structs::SpamClassifierModel::FtrlFh(model) => (
model.feature_log_scale,
model.feature_l2_normalize,
FtrlParameters::parse(&model.parameters),
None,
),
structs::SpamClassifierModel::FtrlCcfh(model) => (
model.feature_log_scale,
model.feature_l2_normalize,
FtrlParameters::parse(&model.parameters),
Some(FtrlParameters::parse(&model.indicator_parameters)),
),
structs::SpamClassifierModel::Disabled => return None,
};
ClassifierConfig {
w_params,
i_params,
reservoir_capacity: classifier.reservoir_capacity as usize,
auto_learn_card_is_ham: classifier.learn_ham_from_card,
auto_learn_reply_ham: classifier.learn_ham_from_reply,
auto_learn_spam_trap: classifier.learn_spam_from_traps,
auto_learn_spam_rbl_count: classifier.learn_spam_from_rbl_hits as u32,
hold_samples_for: classifier.hold_samples_for.into_inner().as_secs(),
min_ham_samples: classifier.min_ham_samples,
min_spam_samples: classifier.min_spam_samples,
train_frequency: classifier.train_frequency.map(|d| d.into_inner().as_secs()),
log_scale,
l2_normalize,
}
.into()
}
}
impl FtrlParameters {
pub fn parse(params: &structs::FtrlParameters) -> Self {
let hash_size = match params.num_features {
ModelSize::V16 => 16,
ModelSize::V17 => 17,
ModelSize::V18 => 18,
ModelSize::V19 => 19,
ModelSize::V20 => 20,
ModelSize::V21 => 21,
ModelSize::V22 => 22,
ModelSize::V23 => 23,
ModelSize::V24 => 24,
ModelSize::V25 => 25,
ModelSize::V26 => 26,
ModelSize::V27 => 27,
ModelSize::V28 => 28,
};
FtrlParameters {
feature_hash_size: 1 << hash_size,
alpha: params.alpha.into_inner(),
beta: params.beta.into_inner(),
l1_ratio: params.l1_ratio.into_inner(),
l2_ratio: params.l2_ratio.into_inner(),
}
}
}
impl SpamClassifier {
pub fn is_active(&self) -> bool {
!matches!(self, SpamClassifier::Disabled)
}
}
impl Location {
pub fn as_str(&self) -> &'static str {
match self {
Location::EnvelopeFrom => "env_from",
Location::EnvelopeTo => "env_to",
Location::HeaderDkimPass => "dkim_pass",
Location::HeaderReceived => "received",
Location::HeaderFrom => "from",
Location::HeaderReplyTo => "reply_to",
Location::HeaderSubject => "subject",
Location::HeaderTo => "to",
Location::HeaderCc => "cc",
Location::HeaderBcc => "bcc",
Location::HeaderMid => "message_id",
Location::HeaderDnt => "dnt",
Location::Ehlo => "ehlo",
Location::BodyText => "body_text",
Location::BodyHtml => "body_html",
Location::Attachment => "attachment",
Location::Tcp => "tcp",
}
}
}
impl Element {
pub fn as_str(&self) -> &'static str {
match self {
Element::Url => "url",
Element::Domain => "domain",
Element::Email => "email",
Element::Ip => "ip",
Element::Header => "header",
Element::Body => "body",
Element::Any => "any",
}
}
}
pub struct IpResolver {
ip: IpAddr,
ip_string: String,
reverse: String,
octets: Variable<'static>,
}
impl ResolveVariable for IpResolver {
fn resolve_variable(&self, variable: ExpressionVariable) -> Variable<'_> {
match variable {
ExpressionVariable::Ip | ExpressionVariable::Value => self.ip_string.as_str().into(),
ExpressionVariable::IpReverse => self.reverse.as_str().into(),
ExpressionVariable::Octets => self.octets.clone(),
ExpressionVariable::IsV4 => Variable::Integer(self.ip.is_ipv4() as _),
ExpressionVariable::IsV6 => Variable::Integer(self.ip.is_ipv6() as _),
_ => Variable::Integer(0),
}
}
fn resolve_global(&self, _: &str) -> Variable<'_> {
Variable::Integer(0)
}
}
impl IpResolver {
pub fn new(ip: IpAddr) -> Self {
Self {
ip_string: ip.to_string(),
reverse: ip.to_reverse_name(),
octets: Variable::Array(match ip {
IpAddr::V4(ipv4_addr) => ipv4_addr
.octets()
.iter()
.map(|o| Variable::Integer(*o as _))
.collect(),
IpAddr::V6(ipv6_addr) => ipv6_addr
.octets()
.iter()
.map(|o| Variable::Integer(*o as _))
.collect(),
}),
ip,
}
}
}
impl CacheItemWeight for IpResolver {
fn weight(&self) -> u64 {
(std::mem::size_of::<IpResolver>() + self.ip_string.len() + self.reverse.len()) as u64
}
}
impl<T> SpamFilterAction<T> {
pub fn as_score(&self) -> Option<&T> {
match self {
SpamFilterAction::Allow(value) => Some(value),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(spam: f32, discard: f32, reject: f32) -> SpamFilterScoreConfig {
SpamFilterScoreConfig {
reject_threshold: reject,
discard_threshold: discard,
spam_threshold: spam,
}
}
#[test]
fn spam_percentage_defaults() {
let config = config(5.0, 0.0, 0.0);
for (score, expected) in [
(-10.0, 0),
(0.0, 0),
(0.5, 5),
(2.5, 25),
(4.9, 49),
(4.999, 49),
(5.0, 50),
(7.5, 75),
(9.9, 99),
(10.0, 100),
(50.0, 100),
] {
assert_eq!(config.spam_percentage(score), expected, "score {score}");
}
}
#[test]
fn spam_percentage_matches_is_spam() {
for config in [
config(5.0, 0.0, 0.0),
config(5.0, 20.0, 15.0),
config(1.0, 0.0, 3.0),
config(12.5, 25.0, 0.0),
config(0.0, 0.0, 0.0),
] {
for score in (-2000..=4000).map(|score| score as f32 / 100.0) {
assert_eq!(
config.spam_percentage(score) >= 50,
config.is_spam(score),
"score {score} with {config:?}"
);
}
}
}
#[test]
fn spam_percentage_ceiling_is_lowest_enabled_threshold() {
let reject_lowest = config(5.0, 20.0, 15.0);
assert_eq!(reject_lowest.spam_percentage(10.0), 75);
assert_eq!(reject_lowest.spam_percentage(15.0), 100);
let discard_only = config(5.0, 15.0, 0.0);
assert_eq!(discard_only.spam_percentage(10.0), 75);
let below_spam_threshold = config(5.0, 3.0, 0.0);
assert_eq!(below_spam_threshold.spam_percentage(7.5), 75);
}
#[test]
fn spam_status_from_percentage() {
assert!(matches!(spam_status(None), SpamStatus::Unknown));
assert!(matches!(spam_status(Some(0)), SpamStatus::Ham));
assert!(matches!(spam_status(Some(100)), SpamStatus::Spam));
assert!(matches!(
spam_status(Some(50)),
SpamStatus::MaybeSpam(fraction) if fraction == 0.5
));
}
}
+342
View File
@@ -0,0 +1,342 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::io::Cursor;
use self::{mailstore::jmap::JmapConfig, smtp::SmtpConfig, storage::Storage};
use crate::{
Core, Network,
auth::oauth::config::OAuthConfig,
config::mailstore::{
email::EmailConfig, imap::ImapConfig, scripts::Scripting, spamfilter::SpamFilterConfig,
},
};
use arc_swap::ArcSwap;
use groupware::GroupwareConfig;
use hyper::HeaderMap;
use p256::elliptic_curve::sec1::ToEncodedPoint;
use pkcs8::EncodePrivateKey;
use rsa::{
RsaPrivateKey,
pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey},
pkcs8::DecodePrivateKey as _,
traits::PublicKeyParts,
};
use store::registry::bootstrap::Bootstrap;
use telemetry::Metrics;
pub mod groupware;
pub mod inner;
pub mod mailstore;
pub mod network;
pub mod server;
pub mod smtp;
pub mod storage;
pub mod telemetry;
impl Core {
pub async fn parse(bp: &mut Bootstrap, mut storage: Storage) -> Self {
Self {
sieve: Scripting::parse(bp).await,
network: Network::parse(bp).await,
smtp: Box::pin(SmtpConfig::parse(bp)).await,
jmap: JmapConfig::parse(bp).await,
imap: ImapConfig::parse(bp).await,
oauth: OAuthConfig::parse(bp).await,
metrics: Metrics::parse(bp).await,
spam: SpamFilterConfig::parse(bp).await,
email: EmailConfig::parse(bp).await,
groupware: GroupwareConfig::parse(bp).await,
storage,
}
}
pub fn into_shared(self) -> ArcSwap<Self> {
ArcSwap::from_pointee(self)
}
}
const RSA_MIN_MODULUS_BITS: usize = 2048;
const RSA_MAX_MODULUS_BITS: usize = 8192;
fn no_key_found(pem: &str, expected: &str) -> String {
if pem.contains("ENCRYPTED PRIVATE KEY") || pem.contains("Proc-Type: 4,ENCRYPTED") {
format!(
"No usable {expected} private key found in PEM: the key is password-protected, \
which is not supported. Decrypt it first with 'openssl pkcs8 -topk8 -nocrypt'."
)
} else {
format!("No usable {expected} private key found in PEM")
}
}
pub struct RsaSigningKey {
pub pkcs1_der: Vec<u8>,
pub modulus: Vec<u8>,
pub exponent: Vec<u8>,
}
pub fn build_rsa_keypair(pem: &str) -> Result<RsaSigningKey, String> {
for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) {
let key = match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? {
rustls_pemfile::Item::Pkcs1Key(key) => {
RsaPrivateKey::from_pkcs1_der(key.secret_pkcs1_der())
.map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}"))?
}
rustls_pemfile::Item::Pkcs8Key(key) => {
RsaPrivateKey::from_pkcs8_der(key.secret_pkcs8_der())
.map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}"))?
}
_ => continue,
};
let bits = key.n().bits();
if !(RSA_MIN_MODULUS_BITS..=RSA_MAX_MODULUS_BITS).contains(&bits) {
return Err(format!(
"RSA key modulus is {bits} bits, expected between {RSA_MIN_MODULUS_BITS} and {RSA_MAX_MODULUS_BITS}"
));
}
let pkcs1_der = key
.to_pkcs1_der()
.map_err(|err| format!("Failed to encode RSA key as PKCS1: {err}"))?;
return Ok(RsaSigningKey {
pkcs1_der: pkcs1_der.as_bytes().to_vec(),
modulus: key.n().to_bytes_be(),
exponent: key.e().to_bytes_be(),
});
}
Err(no_key_found(pem, "RSA"))
}
#[derive(Clone, Copy)]
pub enum EcKeyCurve {
P256,
P384,
}
pub struct EcdsaSigningKey {
pub pkcs8_der: Vec<u8>,
pub x: Vec<u8>,
pub y: Vec<u8>,
}
pub fn build_ecdsa_pem(curve: EcKeyCurve, pem: &str) -> Result<EcdsaSigningKey, String> {
for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) {
let pkcs8 = match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? {
rustls_pemfile::Item::Pkcs8Key(key) => key.secret_pkcs8_der().to_vec(),
rustls_pemfile::Item::Sec1Key(key) => curve
.sec1_to_pkcs8(key.secret_sec1_der())?
.as_bytes()
.to_vec(),
_ => continue,
};
let (x, y) = curve.public_coordinates(&pkcs8)?;
return Ok(EcdsaSigningKey {
pkcs8_der: pkcs8,
x,
y,
});
}
Err(no_key_found(pem, "ECDSA"))
}
impl EcKeyCurve {
fn sec1_to_pkcs8(self, der: &[u8]) -> Result<pkcs8::SecretDocument, String> {
match self {
EcKeyCurve::P256 => p256::SecretKey::from_sec1_der(der)
.map_err(|err| format!("Failed to parse SEC1 ECDSA key: {err}"))?
.to_pkcs8_der()
.map_err(|err| format!("Failed to convert SEC1 ECDSA key to PKCS8: {err}")),
EcKeyCurve::P384 => p384::SecretKey::from_sec1_der(der)
.map_err(|err| format!("Failed to parse SEC1 ECDSA key: {err}"))?
.to_pkcs8_der()
.map_err(|err| format!("Failed to convert SEC1 ECDSA key to PKCS8: {err}")),
}
}
fn public_coordinates(self, pkcs8: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
use pkcs8::DecodePrivateKey;
match self {
EcKeyCurve::P256 => {
let point = p256::SecretKey::from_pkcs8_der(pkcs8)
.map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}"))?
.public_key()
.to_encoded_point(false);
Ok((
point.x().map(|x| x.to_vec()).unwrap_or_default(),
point.y().map(|y| y.to_vec()).unwrap_or_default(),
))
}
EcKeyCurve::P384 => {
let point = p384::SecretKey::from_pkcs8_der(pkcs8)
.map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}"))?
.public_key()
.to_encoded_point(false);
Ok((
point.x().map(|x| x.to_vec()).unwrap_or_default(),
point.y().map(|y| y.to_vec()).unwrap_or_default(),
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::{EcKeyCurve, build_ecdsa_pem, build_rsa_keypair};
const P256_SEC1: &str = "-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJ9a6n/cu7XaQez5ZX8z8jDFkkfsMB1P9Vbqzbaes2zOoAoGCCqGSM49
AwEHoUQDQgAEPCbID7bo+8Nk1vIsTFhVKwRWvb9GWTzzwS75Dd8iZuFl23Twn6Sp
V2ZO1FC0WyXxcVOMZN2sJFlCjtaQS+p5Zg==
-----END EC PRIVATE KEY-----";
const P256_PKCS8: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgn1rqf9y7tdpB7Pll
fzPyMMWSR+wwHU/1VurNtp6zbM6hRANCAAQ8JsgPtuj7w2TW8ixMWFUrBFa9v0ZZ
PPPBLvkN3yJm4WXbdPCfpKlXZk7UULRbJfFxU4xk3awkWUKO1pBL6nlm
-----END PRIVATE KEY-----";
const P384_SEC1: &str = "-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDAeecJf8ju/70Nf5nbI4DeRo/+Z3VWXUvB+GwuUczew7fyMbyc6B3EE
BskOIqvqu6egBwYFK4EEACKhZANiAAQQjDW03Xn2h9ZmmCMRx+uRaLLfg4o2XITE
pwACH9EY4IjTe9LNNp5CTjERd+RlpWxkYopmDS5Trzycz9sDxxSzzXmq90vomJqt
fTnNHPFHuR2SAiwuzUf26rcPwa7DCWk=
-----END EC PRIVATE KEY-----";
const P384_PKCS8: &str = "-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAeecJf8ju/70Nf5nbI
4DeRo/+Z3VWXUvB+GwuUczew7fyMbyc6B3EEBskOIqvqu6ehZANiAAQQjDW03Xn2
h9ZmmCMRx+uRaLLfg4o2XITEpwACH9EY4IjTe9LNNp5CTjERd+RlpWxkYopmDS5T
rzycz9sDxxSzzXmq90vomJqtfTnNHPFHuR2SAiwuzUf26rcPwa7DCWk=
-----END PRIVATE KEY-----";
#[test]
fn ecdsa_pem_accepts_sec1_and_pkcs8() {
let sec1 =
build_ecdsa_pem(EcKeyCurve::P256, P256_SEC1).expect("P-256 SEC1 key should parse");
let pkcs8 =
build_ecdsa_pem(EcKeyCurve::P256, P256_PKCS8).expect("P-256 PKCS8 key should parse");
assert_eq!((&sec1.x, &sec1.y), (&pkcs8.x, &pkcs8.y));
assert_eq!(sec1.x.len(), 32);
let sec1 =
build_ecdsa_pem(EcKeyCurve::P384, P384_SEC1).expect("P-384 SEC1 key should parse");
let pkcs8 =
build_ecdsa_pem(EcKeyCurve::P384, P384_PKCS8).expect("P-384 PKCS8 key should parse");
assert_eq!((&sec1.x, &sec1.y), (&pkcs8.x, &pkcs8.y));
assert_eq!(sec1.x.len(), 48);
}
const RSA_PKCS1: &str = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAt5Kp7o945bxbnvviI9Kzbjcwi6B5cStu4dBbNhe/ld0Js4tQ\n8Uq9qYaFBlocYzWkEd3e2IG0+uIVB37ewUe0xjq25u6G4ZWeK+SZgzXB4jHinXvh\nuhHW+KzbmO+aYO115451Cu6ymJ8DLVaR6zxT9CJsiS4lMsYZ5JHcLY3az1A5z0df\nF+chjR+sLxdc0ggKqnX6fT/sVXHIlVk6riyeFV929k/v1f0pmRQ2nNu0NMSOK7Mk\nqsvHiAb1e/41LIwlbmbzd5ASHitYYXKP+2YR29SRr2D+52S1M29h4/XbUcP6Zo2U\np5mKgQ0kFZ8pHFhbruamzRp87+yhu98IbZ9ksQIDAQABAoIBAAu2+BGxhbNReR5U\n8Co9krZEntw2NjHG5glSkNOLoe4IIEudJyHy1VYpb7lHTFr3bBw4xrUV1+0PuuxS\nyBfZAdwJmKz1iVWBhQnDiZliN5h9+vp2UqIba9bMPypMFhO766OGh4kWUP7k3ODK\njr7Oh4QDo14AvB54nmPj/ANLM2y50/Upy5s7FK0tm0ntzxSscwQFSZAJ9B0ne6Qe\nu1/PXgiXW4JKNOgrCTrRB2BcOi/Ke6OA/kg54sD+Z9PZivO/qHTx9xXzqivmbg9a\nGmoivaWH/pKwAywFogJnWH/iTe+r//fKdlEDeK+s/iCr0ht//c0w+GxPvPF//wz0\n+1u9+n0CgYEA5M7jzpT8rCYWdORvvRP1BC4+A5jb83zXW4FS+rZRSmq775zDPAif\npm653vAlNHIphEvqSdVw64+36nJFtjuBI17BHCQi0j3iNVjrLC7lbfqIobnNDdmR\n9VeqZ6qwPYt2oi4iBY2dAnPdYVTDMomHSC4vW/SER0l9A9bxt3Co1a0CgYEAzWOQ\n490s6K186CyUMFrNrUmIWEJNd7b6JGI+oCioZLtPZzxO4ebc+bHEPbpbSqx7lJRJ\nt5u6zw/RwUc+6YXXImekvMfZpZMH9v1wjp3djnxGQO4ucmvmu6H25qcYup8tRtlo\n2AVLd1jg3yka1yr7O26M3bhVfm5LOUQfoLuCA5UCgYEA3Iw7882SfFE+RjBHMIcD\nHqOALTFzmhDU+SQAGyAP3V5ihwWg/sYFNYT3btgl1JbSQ+51B/RQIw9mJPs/DPfw\nc2qLU5fVZLg3ylpKXU1a4xaiCtmwuM/mLAnzfHd/5+L9WDiFnLqzBEEwu/fbK2R7\nXOz/w3A+7QP+F+xhFAPpCgUCgYEAsnZOIkA/UlnUi6SYir+LsYOQLihGSbw687xN\n8DoDv6sl3mz/mbhQz8GP45b21hazNrH2r8xn8J0tRATU/HIoMaPe942rZvwv0oP6\n9mDjb3g6TxbmUtPA485iy53rldTTsZkdSX6oSSZ4FlAQG2AkdkqjqdAOsVHCmRrB\nZJco7FUCgYBwk7tQt3YS5b0wi8fH3BIfAH31vJ2VGlAin860H8FXjAj8EZ7Ff9Iq\n5dQIyPbp89TOSxIVxPGniI2ruLy4DZQM7xa42oyxyRir4UeHN2P5D2yEAHaVSwSd\nO6yiiOBj62OATapI8BqeFJZGRFltDsj6XbwC/Z9S2tRKCuE/zp+FLg==\n-----END RSA PRIVATE KEY-----";
const RSA_PKCS8: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3kqnuj3jlvFue\n++Ij0rNuNzCLoHlxK27h0Fs2F7+V3Qmzi1DxSr2phoUGWhxjNaQR3d7YgbT64hUH\nft7BR7TGOrbm7obhlZ4r5JmDNcHiMeKde+G6Edb4rNuY75pg7XXnjnUK7rKYnwMt\nVpHrPFP0ImyJLiUyxhnkkdwtjdrPUDnPR18X5yGNH6wvF1zSCAqqdfp9P+xVcciV\nWTquLJ4VX3b2T+/V/SmZFDac27Q0xI4rsySqy8eIBvV7/jUsjCVuZvN3kBIeK1hh\nco/7ZhHb1JGvYP7nZLUzb2Hj9dtRw/pmjZSnmYqBDSQVnykcWFuu5qbNGnzv7KG7\n3whtn2SxAgMBAAECggEAC7b4EbGFs1F5HlTwKj2StkSe3DY2McbmCVKQ04uh7ggg\nS50nIfLVVilvuUdMWvdsHDjGtRXX7Q+67FLIF9kB3AmYrPWJVYGFCcOJmWI3mH36\n+nZSohtr1sw/KkwWE7vro4aHiRZQ/uTc4MqOvs6HhAOjXgC8HnieY+P8A0szbLnT\n9SnLmzsUrS2bSe3PFKxzBAVJkAn0HSd7pB67X89eCJdbgko06CsJOtEHYFw6L8p7\no4D+SDniwP5n09mK87+odPH3FfOqK+ZuD1oaaiK9pYf+krADLAWiAmdYf+JN76v/\n98p2UQN4r6z+IKvSG3/9zTD4bE+88X//DPT7W736fQKBgQDkzuPOlPysJhZ05G+9\nE/UELj4DmNvzfNdbgVL6tlFKarvvnMM8CJ+mbrne8CU0cimES+pJ1XDrj7fqckW2\nO4EjXsEcJCLSPeI1WOssLuVt+oihuc0N2ZH1V6pnqrA9i3aiLiIFjZ0Cc91hVMMy\niYdILi9b9IRHSX0D1vG3cKjVrQKBgQDNY5Dj3SzorXzoLJQwWs2tSYhYQk13tvok\nYj6gKKhku09nPE7h5tz5scQ9ultKrHuUlEm3m7rPD9HBRz7phdciZ6S8x9mlkwf2\n/XCOnd2OfEZA7i5ya+a7ofbmpxi6ny1G2WjYBUt3WODfKRrXKvs7bozduFV+bks5\nRB+gu4IDlQKBgQDcjDvzzZJ8UT5GMEcwhwMeo4AtMXOaENT5JAAbIA/dXmKHBaD+\nxgU1hPdu2CXUltJD7nUH9FAjD2Yk+z8M9/BzaotTl9VkuDfKWkpdTVrjFqIK2bC4\nz+YsCfN8d3/n4v1YOIWcurMEQTC799srZHtc7P/DcD7tA/4X7GEUA+kKBQKBgQCy\ndk4iQD9SWdSLpJiKv4uxg5AuKEZJvDrzvE3wOgO/qyXebP+ZuFDPwY/jlvbWFrM2\nsfavzGfwnS1EBNT8cigxo973jatm/C/Sg/r2YONveDpPFuZS08DjzmLLneuV1NOx\nmR1JfqhJJngWUBAbYCR2SqOp0A6xUcKZGsFklyjsVQKBgHCTu1C3dhLlvTCLx8fc\nEh8AffW8nZUaUCKfzrQfwVeMCPwRnsV/0irl1AjI9unz1M5LEhXE8aeIjau4vLgN\nlAzvFrjajLHJGKvhR4c3Y/kPbIQAdpVLBJ07rKKI4GPrY4BNqkjwGp4UlkZEWW0O\nyPpdvAL9n1La1EoK4T/On4Uu\n-----END PRIVATE KEY-----";
#[test]
fn rsa_pem_accepts_pkcs1_and_pkcs8() {
let a = build_rsa_keypair(RSA_PKCS1).expect("PKCS1 RSA key should parse");
let b = build_rsa_keypair(RSA_PKCS8).expect("PKCS8 RSA key should parse");
assert_eq!(a.modulus, b.modulus);
assert_eq!(a.exponent, b.exponent);
assert_eq!(a.pkcs1_der, b.pkcs1_der);
assert_eq!(a.modulus.len(), 256);
}
#[test]
fn signing_keys_are_accepted_by_jsonwebtoken() {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
#[derive(serde::Serialize)]
struct Claims {
sub: &'static str,
}
let claims = Claims { sub: "test" };
for (curve, pem, alg) in [
(EcKeyCurve::P256, P256_SEC1, Algorithm::ES256),
(EcKeyCurve::P256, P256_PKCS8, Algorithm::ES256),
(EcKeyCurve::P384, P384_SEC1, Algorithm::ES384),
(EcKeyCurve::P384, P384_PKCS8, Algorithm::ES384),
] {
let key = build_ecdsa_pem(curve, pem).expect("key should parse");
jsonwebtoken::encode(
&Header::new(alg),
&claims,
&EncodingKey::from_ec_der(&key.pkcs8_der),
)
.unwrap_or_else(|err| panic!("{alg:?} signing failed: {err}"));
}
for pem in [RSA_PKCS1, RSA_PKCS8] {
let key = build_rsa_keypair(pem).expect("key should parse");
for alg in [Algorithm::RS256, Algorithm::PS512] {
jsonwebtoken::encode(
&Header::new(alg),
&claims,
&EncodingKey::from_rsa_der(&key.pkcs1_der),
)
.unwrap_or_else(|err| panic!("{alg:?} signing failed: {err}"));
}
}
}
#[test]
fn ecdsa_pem_rejects_keyless_pem() {
let err = match build_ecdsa_pem(
EcKeyCurve::P256,
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----",
) {
Ok(_) => panic!("expected a certificate-only PEM to be rejected"),
Err(err) => err,
};
assert!(err.contains("No usable ECDSA private key"), "{err}");
}
const P256_ENCRYPTED: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCoW4qsep9YbFLRW2u4\nk8ljAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQZYoEYHqh+y9uqT70\n6521jwSBkA9dcdq6hT/7Fzqcu0wX3QVr+8g1Kxc6tCV9dLShi8VU2ax8jG3zZt3h\nBp1CLyX8UfT98SujtoH36PEXPDDTralcP6vWViqGx5AagT4DRFjcI8yucTUXkLoD\n9ZIRBVPviTeznEHt3OvCCMuO76rsyu/gxNC7D46TBtq8JX1OFcaXPctpN8l5GKqH\nh4gA6av3og==\n-----END ENCRYPTED PRIVATE KEY-----";
#[test]
fn ecdsa_pem_reports_password_protected_key() {
let err = match build_ecdsa_pem(EcKeyCurve::P256, P256_ENCRYPTED) {
Ok(_) => panic!("expected an encrypted key to be rejected"),
Err(err) => err,
};
assert!(err.contains("password-protected"), "{err}");
}
#[test]
fn rsa_pem_rejects_undersized_modulus() {
const RSA_1024: &str = "-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCz6gcAg0f+2/HFrudtMfRSylyzI8W/lNmPQZhUpz+R6D/7/+4/
HFKsUcZIi+nzdOnrzW/kw19nVOKk2ylAUNV9d2TR75HqrPBYsu0LCDRidb9XOyhY
bQJII1KuFlaWNjfxG28Tlg//FdVPPkn/oTQwnhvMcWCK3Hatho6cx9uzWwIDAQAB
AoGBAIPvjwr1OwrOyFIrnVMaWw2LkMdd6FpCEflYJRmPPLMHGkT2vgRSBN6RaVMy
J3J9vj1J/lBIZeIlAb/baDjeDnAj5GBzCB319oxnBuZSmpyYntW1DEsdhbK0Yeu+
7v05oXBXfzdZvGBWYrwlj5ipoHQo0R+WN4NVXqJFwiagaGBBAkEA6qwv5Pww54za
fHNUD1M6MKRBk5m0Y/GJ58sWmnmFJI6I3sHBIfcy5lylm5KecduzSKoVtAUUbNWf
KOKoZcKHMwJBAMRD2WDEd5+8q5ZxzYG0x5sEdz1lhJkt+YSbudNgfE1kPDDrCE0V
8+hgNdp6Mj1hfihwB0hTCcnaPsXLl9AyAzkCQDVU+HWD0uFso2LRGvN4qKrRSY3v
yo1EIWEqSHLG1zldo0FsqyW69jhgKcrXYWbi1TXYYaJN3Tx2t/skt7yYnv0CQDtD
NYdHq8tbAADcei5ZNRB058BtP/206SwGjbTq5H3F73rh7U7BezXGn1xKG5N3Nc3m
DfzjvgfqU5wMHtopz9kCQBw3AAiRCY8Y0UgejUtu8tXIK76qebaNcMCabBnFrAqV
A4Oj1c5BcOHVtww9W6NeiiRMJpUNN71gmyjsnOyT3cY=
-----END RSA PRIVATE KEY-----";
let err = match build_rsa_keypair(RSA_1024) {
Ok(_) => panic!("expected a 1024-bit RSA key to be rejected"),
Err(err) => err,
};
assert!(err.contains("1024 bits"), "{err}");
}
}
+555
View File
@@ -0,0 +1,555 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::*;
use crate::{
expr::if_block::{BootstrapExprExt, IfBlock},
network::{
autoconfig::pacc::{
Authentication, Configuration, HttpServer, Info, Logo, OAuthPublic, Protocols,
Provider, TextServer,
},
security::Security,
},
};
use mail_builder::mime::make_boundary;
use registry::schema::{
enums::{AcmeChallengeType, ClusterTaskType, ProviderInfo, ServiceProtocol},
prelude::{ObjectType, Property},
structs::{
self, AcmeProvider, Asn, ClusterTaskGroup, HttpForm, MailExchanger, Rate, Service,
SystemSettings, TaskManager,
},
};
use std::{str::FromStr, time::Duration};
use utils::map::vec_map::VecMap;
#[derive(Clone)]
pub struct Network {
pub node_id: u64,
pub roles: ClusterRoles,
pub server_name: String,
pub security: Security,
pub http: Http,
pub contact_form: Option<ContactForm>,
pub asn_geo_lookup: AsnGeoLookupConfig,
pub task_manager: TaskManager,
pub has_acme_tls_challenge: bool,
pub has_acme_http_challenge: bool,
pub info: NetworkInfo,
}
#[derive(Clone)]
pub struct NetworkInfo {
pub pacc: Pacc,
pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
#[derive(Clone)]
pub struct Pacc {
pub prefix: String,
pub suffix: String,
}
#[derive(Clone)]
pub struct Http {
pub rate_authenticated: Option<Rate>,
pub rate_anonymous: Option<Rate>,
pub url_https: String,
pub allowed_endpoint: IfBlock,
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
pub use_forwarded: bool,
pub redirect_root: Option<String>,
}
#[derive(Clone)]
pub struct ContactForm {
pub rcpt_to: Vec<String>,
pub max_size: usize,
pub rate: Option<Rate>,
pub validate_domain: bool,
pub from_email: FieldOrDefault,
pub from_subject: FieldOrDefault,
pub from_name: FieldOrDefault,
pub field_honey_pot: Option<String>,
}
#[derive(Clone)]
pub struct ClusterRoles {
pub store_maintenance: bool,
pub account_maintenance: bool,
pub push_notifications: bool,
pub search_indexing: bool,
pub spam_training: bool,
pub metrics_calculate: bool,
pub metrics_push: bool,
pub outbound_mta: bool,
pub task_scheduler: bool,
pub task_manager: bool,
}
#[derive(Clone, Default)]
pub enum AsnGeoLookupConfig {
Resource {
expires: Duration,
timeout: Duration,
max_size: usize,
headers: HeaderMap,
asn_resources: Vec<String>,
geo_resources: Vec<String>,
},
Dns {
zone_ipv4: String,
zone_ipv6: String,
separator: String,
index_asn: usize,
index_asn_name: Option<usize>,
index_country: Option<usize>,
},
#[default]
Disabled,
}
#[derive(Clone)]
pub struct FieldOrDefault {
pub field: Option<String>,
pub default: String,
}
impl ContactForm {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let form = bp.setting_infallible::<HttpForm>().await;
if !form.enable {
return None;
} else if form.deliver_to.is_empty() {
bp.build_error(
ObjectType::HttpForm.singleton(),
"Contact form is enabled but no recipient addresses are configured",
);
return None;
}
Some(ContactForm {
rcpt_to: form.deliver_to.into_inner(),
max_size: form.max_size as usize,
validate_domain: form.validate_domain,
from_email: FieldOrDefault {
field: form.field_email,
default: form.default_from_address,
},
from_subject: FieldOrDefault {
field: form.field_subject,
default: form.default_subject,
},
from_name: FieldOrDefault {
field: form.field_name,
default: form.default_name,
},
field_honey_pot: form.field_honey_pot,
rate: form.rate_limit,
})
}
}
impl Network {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let system = bp.setting_infallible::<SystemSettings>().await;
let mut has_acme_tls_challenge = false;
let mut has_acme_http_challenge = false;
let mut has_acme_challenges = false;
for provider in bp.list_infallible::<AcmeProvider>().await {
match provider.object.challenge_type {
AcmeChallengeType::Http01 => has_acme_http_challenge = true,
AcmeChallengeType::TlsAlpn01 => has_acme_tls_challenge = true,
_ => {}
}
has_acme_challenges = true;
}
if !has_acme_challenges {
// Assume this is an initial deployment and optimistically set both to true
// to avoid requiring a reload after ACME providers are added
has_acme_http_challenge = true;
has_acme_tls_challenge = true;
}
const SPLIT_HERE: &str = "$$__SPLIT_HERE__$$";
let mut pacc = Configuration {
protocols: Protocols::default(),
authentication: Some(Authentication {
oauth_public: Some(OAuthPublic {
issuer: SPLIT_HERE.to_string(),
}),
password: true,
}),
info: Info {
provider: Provider {
name: "Stalwart".into(),
..Default::default()
},
..Default::default()
},
};
let default_hostname = if !system.default_hostname.is_empty() {
system.default_hostname.as_str()
} else {
bp.registry.local_hostname()
};
let mut http_host = default_hostname.to_string();
for (service, details) in &system.services {
let hostname = details.hostname.as_deref().unwrap_or(default_hostname);
match service {
ServiceProtocol::Jmap => {
if hostname != http_host {
http_host = hostname.to_string();
}
pacc.protocols.jmap = HttpServer {
url: format!("https://{hostname}/jmap/session",),
}
.into();
}
ServiceProtocol::Caldav => {
pacc.protocols.caldav = HttpServer {
url: format!("https://{hostname}/dav/cal/",),
}
.into();
}
ServiceProtocol::Carddav => {
pacc.protocols.carddav = HttpServer {
url: format!("https://{hostname}/dav/card/",),
}
.into();
}
ServiceProtocol::Webdav => {
pacc.protocols.webdav = HttpServer {
url: format!("https://{hostname}/dav/file/",),
}
.into();
}
ServiceProtocol::Imap => {
pacc.protocols.imap = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Pop3 => {
pacc.protocols.pop3 = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Smtp => {
pacc.protocols.smtp = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Managesieve => {
pacc.protocols.managesieve = TextServer {
host: hostname.to_string(),
}
.into();
}
}
}
for (tag, text) in system.provider_info {
match tag {
ProviderInfo::ProviderName => pacc.info.provider.name = text,
ProviderInfo::ProviderShortName => pacc.info.provider.short_name = Some(text),
ProviderInfo::UserDocumentation => {
pacc.info.help.get_or_insert_default().documentation = Some(text)
}
ProviderInfo::DeveloperDocumentation => {
pacc.info.help.get_or_insert_default().developer = Some(text)
}
ProviderInfo::ContactUri => {
pacc.info
.help
.get_or_insert_default()
.contact
.get_or_insert_default()
.push(text);
}
ProviderInfo::LogoUrl => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
url: text,
..Default::default()
});
} else {
logo[0].url = text;
}
}
ProviderInfo::LogoWidth => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
width: text.parse().ok(),
..Default::default()
});
} else {
logo[0].width = text.parse().ok();
}
}
ProviderInfo::LogoHeight => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
height: text.parse().ok(),
..Default::default()
});
} else {
logo[0].height = text.parse().ok();
}
}
}
}
let (prefix, suffix) = serde_json::to_string(&pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.unwrap();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(),
security: Security::parse(bp).await,
contact_form: ContactForm::parse(bp).await,
asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(),
roles: ClusterRoles::default(),
http: Http::parse(bp, &http_host).await,
task_manager: bp.setting_infallible::<TaskManager>().await,
has_acme_tls_challenge,
has_acme_http_challenge,
info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc: Pacc { prefix, suffix },
},
};
if let Some(role) = &bp.role {
match &role.tasks {
ClusterTaskGroup::EnableAll => {}
ClusterTaskGroup::DisableAll => {
for network_role in network.roles.all_mut() {
*network_role = false;
}
}
ClusterTaskGroup::EnableSome(group) => {
for network_role in network.roles.all_mut() {
*network_role = false;
}
for task_type in group.task_types.iter() {
network.roles.set_role(*task_type, true);
}
}
ClusterTaskGroup::DisableSome(group) => {
for task_type in group.task_types.iter() {
network.roles.set_role(*task_type, false);
}
}
}
}
network
}
pub fn message_id(&self) -> String {
format!("{}@{}", make_boundary("."), self.server_name)
}
}
impl Http {
#[cfg_attr(
any(feature = "dev_mode", feature = "test_mode"),
allow(unused_variables)
)]
pub async fn parse(bp: &mut Bootstrap, server_name: &str) -> Self {
let http = bp.setting_infallible::<structs::Http>().await;
// Parse HTTP headers
let mut http_headers = http
.response_headers
.iter()
.map(|(k, v)| {
Ok((
hyper::header::HeaderName::from_str(k.trim()).map_err(|err| {
format!("Invalid header found in property \"http.headers\": {}", err)
})?,
hyper::header::HeaderValue::from_str(v.trim()).map_err(|err| {
format!("Invalid header found in property \"http.headers\": {}", err)
})?,
))
})
.collect::<Result<Vec<_>, String>>()
.map_err(|e| {
bp.build_error(
ObjectType::Http.singleton(),
format!("Failed to parse HTTP headers: {}", e),
)
})
.unwrap_or_default();
// Add permissive CORS headers
#[cfg(feature = "dev_mode")]
let use_permissive_cors = true;
#[cfg(not(feature = "dev_mode"))]
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
if use_permissive_cors {
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
hyper::header::HeaderValue::from_static("*"),
));
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_HEADERS,
hyper::header::HeaderValue::from_static(
"Authorization, Content-Type, Accept, X-Requested-With",
),
));
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_METHODS,
hyper::header::HeaderValue::from_static(
"POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS",
),
));
}
// Add HTTP Strict Transport Security
if http.enable_hsts {
http_headers.push((
hyper::header::STRICT_TRANSPORT_SECURITY,
hyper::header::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
));
}
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
let server_name = "127.0.0.1";
Http {
url_https: if !bp.registry.is_recovery_mode() {
if let Some(url) = bp.registry.public_url() {
url.to_string()
} else {
format!("https://{server_name}")
}
} else {
String::new()
},
allowed_endpoint: if bp.registry.is_recovery_mode() {
IfBlock::empty(ObjectType::Http.singleton(), Property::AllowedEndpoints)
} else {
bp.compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints())
},
rate_authenticated: if bp.registry.is_recovery_mode() {
None
} else {
http.rate_limit_authenticated
},
rate_anonymous: if bp.registry.is_recovery_mode() {
None
} else {
http.rate_limit_anonymous
},
response_headers: http_headers,
use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root,
}
}
}
impl AsnGeoLookupConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
match bp.setting_infallible::<Asn>().await {
Asn::Resource(asn) => Some(AsnGeoLookupConfig::Resource {
expires: asn.expires.into_inner(),
timeout: asn.timeout.into_inner(),
max_size: asn.max_size as usize,
headers: asn
.http_auth
.build_headers(asn.http_headers, None)
.await
.map_err(|err| {
bp.build_error(
ObjectType::Asn.singleton(),
format!("Unable to build HTTP headers: {}", err),
)
})
.ok()?,
asn_resources: asn.asn_urls.into_inner(),
geo_resources: asn.geo_urls.into_inner(),
}),
Asn::Dns(asn) => Some(AsnGeoLookupConfig::Dns {
zone_ipv4: asn.zone_ip_v4,
zone_ipv6: asn.zone_ip_v6,
separator: asn.separator,
index_asn: asn.index_asn as usize,
index_asn_name: asn.index_asn_name.map(|v| v as usize),
index_country: asn.index_country.map(|v| v as usize),
}),
Asn::Disabled => None,
}
}
}
impl ClusterRoles {
fn all_mut(&mut self) -> impl Iterator<Item = &mut bool> {
[
&mut self.store_maintenance,
&mut self.account_maintenance,
&mut self.push_notifications,
&mut self.search_indexing,
&mut self.spam_training,
&mut self.outbound_mta,
&mut self.task_manager,
&mut self.task_scheduler,
&mut self.metrics_calculate,
&mut self.metrics_push,
]
.into_iter()
}
fn set_role(&mut self, role: ClusterTaskType, enabled: bool) {
match role {
ClusterTaskType::StoreMaintenance => self.store_maintenance = enabled,
ClusterTaskType::AccountMaintenance => self.account_maintenance = enabled,
ClusterTaskType::PushNotifications => self.push_notifications = enabled,
ClusterTaskType::SearchIndexing => self.search_indexing = enabled,
ClusterTaskType::SpamClassifierTraining => self.spam_training = enabled,
ClusterTaskType::MetricsCalculate => self.metrics_calculate = enabled,
ClusterTaskType::MetricsPush => self.metrics_push = enabled,
ClusterTaskType::OutboundMta => self.outbound_mta = enabled,
ClusterTaskType::TaskQueueProcessing => self.task_manager = enabled,
ClusterTaskType::TaskScheduler => self.task_scheduler = enabled,
}
}
}
impl Default for ClusterRoles {
fn default() -> Self {
ClusterRoles {
store_maintenance: true,
account_maintenance: true,
push_notifications: true,
search_indexing: true,
spam_training: true,
metrics_calculate: true,
metrics_push: true,
outbound_mta: true,
task_manager: true,
task_scheduler: true,
}
}
}
+349
View File
@@ -0,0 +1,349 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{
DEFAULT_TLS_TIMEOUT, Listener, Listeners, ServerProtocol, TcpListener,
tls::{TLS12_VERSION, TLS13_VERSION},
};
use crate::{
Inner,
network::{TcpAcceptor, tls::CertificateResolver},
};
use registry::{
schema::{
enums::{NetworkListenerProtocol, TlsCipherSuite, TlsVersion},
prelude::{ObjectType, SocketAddr},
structs::{ClusterListenerGroup, NetworkListener, SystemSettings},
},
types::{id::ObjectId, map::Map},
};
use rustls::{
ALL_VERSIONS, ServerConfig, SupportedCipherSuite,
crypto::aws_lc_rs::{ALL_CIPHER_SUITES, cipher_suite::*, default_provider},
};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr as StdSocketAddr},
str::FromStr,
sync::Arc,
};
use store::registry::{RegistryObject, bootstrap::Bootstrap};
use tokio::net::TcpSocket;
use tokio_rustls::TlsAcceptor;
use types::id::Id;
use utils::snowflake::SnowflakeIdGenerator;
impl Listeners {
pub async fn parse(bp: &mut Bootstrap) -> Self {
// Parse ACME managers
let mut servers = Listeners {
span_id_gen: Arc::new(SnowflakeIdGenerator::new()),
..Default::default()
};
// Parse servers
if !bp.registry.is_recovery_mode() {
let system = bp.setting_infallible::<SystemSettings>().await;
for listener in bp.list_infallible::<NetworkListener>().await {
if bp.role.as_ref().is_none_or(|r| match &r.listeners {
ClusterListenerGroup::EnableAll => true,
ClusterListenerGroup::DisableAll => false,
ClusterListenerGroup::EnableSome(group) => {
group.listener_ids.iter().any(|id| *id == listener.id.id())
}
ClusterListenerGroup::DisableSome(group) => {
!group.listener_ids.iter().any(|id| *id == listener.id.id())
}
}) {
servers.parse_server(bp, listener, &system);
}
}
} else {
servers.parse_server(
bp,
RegistryObject {
id: ObjectId::new(ObjectType::NetworkListener, Id::singleton()),
object: NetworkListener {
bind: Map::new(vec![
SocketAddr::from_str(&format!(
"[::]:{}",
std::env::var("STALWART_RECOVERY_MODE_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(8080)
))
.unwrap(),
]),
name: "http-recovery".to_string(),
protocol: NetworkListenerProtocol::Http,
tls_implicit: false,
..Default::default()
},
revision: 0,
},
&SystemSettings::default(),
);
}
servers
}
pub fn parse_server(
&mut self,
bp: &mut Bootstrap,
listener: RegistryObject<NetworkListener>,
system: &SystemSettings,
) {
let id = listener.id;
let revision = listener.revision;
let listener = listener.object;
// Parse protocol
let protocol = match listener.protocol {
NetworkListenerProtocol::Smtp => ServerProtocol::Smtp,
NetworkListenerProtocol::Lmtp => ServerProtocol::Lmtp,
NetworkListenerProtocol::Http => ServerProtocol::Http,
NetworkListenerProtocol::Imap => ServerProtocol::Imap,
NetworkListenerProtocol::Pop3 => ServerProtocol::Pop3,
NetworkListenerProtocol::ManageSieve => ServerProtocol::ManageSieve,
};
// Build listeners
let mut listeners = Vec::new();
for addr in listener.bind.iter() {
// Parse bind address and build socket
let mut addr = addr.0;
let socket = match if addr.is_ipv4() {
TcpSocket::new_v4()
} else {
TcpSocket::new_v6()
} {
Ok(socket) => socket,
Err(err)
if is_ipv6_unsupported(&err)
&& addr.is_ipv6()
&& addr.ip().is_unspecified() =>
{
let v4_addr =
StdSocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), addr.port());
bp.build_warning(
id,
format!(
"IPv6 unavailable on this host ({err}); \
falling back from {addr} to {v4_addr}"
),
);
addr = v4_addr;
match TcpSocket::new_v4() {
Ok(socket) => socket,
Err(err) => {
bp.build_error(
id,
format!("Failed to create IPv4 fallback socket: {err}"),
);
return;
}
}
}
Err(err) => {
bp.build_error(id, format!("Failed to create socket: {err}"));
return;
}
};
#[cfg(windows)]
if addr.is_ipv6()
&& addr.ip().is_unspecified()
&& let Err(err) = socket2::SockRef::from(&socket).set_only_v6(false)
{
bp.build_warning(
id,
format!(
"Failed to disable IPV6_V6ONLY on {addr} ({err}); \
IPv4 clients will not be able to connect to this listener"
),
);
}
if let Err(err) = socket.set_reuseaddr(listener.socket_reuse_address) {
bp.build_error(id, format!("Failed to set SO_REUSEADDR: {err}"));
return;
}
#[cfg(not(target_env = "msvc"))]
if let Err(err) = socket.set_reuseport(listener.socket_reuse_port) {
bp.build_error(id, format!("Failed to set SO_REUSEPORT: {err}"));
return;
}
if let Some(send_size) = listener.socket_send_buffer_size
&& let Err(err) = socket.set_send_buffer_size(send_size as u32)
{
bp.build_error(id, format!("Failed to set SO_SNDBUF: {err}"));
return;
}
if let Some(recv_size) = listener.socket_receive_buffer_size
&& let Err(err) = socket.set_recv_buffer_size(recv_size as u32)
{
bp.build_error(id, format!("Failed to set SO_RCVBUF: {err}"));
return;
}
if let Some(tos) = listener.socket_tos_v4
&& let Err(err) = socket.set_tos_v4(tos as u32)
{
bp.build_error(id, format!("Failed to set IP_TOS: {err}"));
return;
}
listeners.push(TcpListener {
socket,
addr,
ttl: listener.socket_ttl.map(|v| v as u32),
backlog: listener.socket_backlog.map(|v| v as u32),
nodelay: listener.socket_no_delay,
});
}
let span_id_gen = self.span_id_gen.clone();
self.servers.push(Listener {
max_connections: listener.max_connections.unwrap_or(system.max_connections),
tls_timeout: listener
.tls_timeout
.map_or(DEFAULT_TLS_TIMEOUT, |timeout| timeout.into_inner()),
id: listener.name.clone(),
registry_id: id,
protocol,
listeners,
proxy_networks: if !listener.override_proxy_trusted_networks.is_empty() {
listener.override_proxy_trusted_networks.as_slice().to_vec()
} else {
system.proxy_trusted_networks.as_slice().to_vec()
},
span_id_gen,
});
self.parsed_listeners.push(RegistryObject {
id,
object: listener,
revision,
});
}
pub async fn parse_tcp_acceptors(&mut self, bp: &mut Bootstrap, inner: Arc<Inner>) {
let resolver = Arc::new(CertificateResolver::new(inner.clone()));
for listener in std::mem::take(&mut self.parsed_listeners) {
let id = listener.id;
let listener = listener.object;
// Build TLS config
let acceptor = if listener.use_tls {
// Parse protocol versions
let mut tls_v2 = true;
let mut tls_v3 = true;
for disabled in listener.tls_disable_protocols {
match disabled {
TlsVersion::Tls12 => {
tls_v2 = false;
}
TlsVersion::Tls13 => {
tls_v3 = false;
}
}
}
// Parse cipher suites
let mut disabled_ciphers: Vec<SupportedCipherSuite> = Vec::new();
for disabled in listener.tls_disable_cipher_suites {
disabled_ciphers.push(match disabled {
TlsCipherSuite::Tls13Aes256GcmSha384 => TLS13_AES_256_GCM_SHA384,
TlsCipherSuite::Tls13Aes128GcmSha256 => TLS13_AES_128_GCM_SHA256,
TlsCipherSuite::Tls13Chacha20Poly1305Sha256 => {
TLS13_CHACHA20_POLY1305_SHA256
}
TlsCipherSuite::TlsEcdheEcdsaWithAes256GcmSha384 => {
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
}
TlsCipherSuite::TlsEcdheEcdsaWithAes128GcmSha256 => {
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
}
TlsCipherSuite::TlsEcdheEcdsaWithChacha20Poly1305Sha256 => {
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
}
TlsCipherSuite::TlsEcdheRsaWithAes256GcmSha384 => {
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
}
TlsCipherSuite::TlsEcdheRsaWithAes128GcmSha256 => {
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
}
TlsCipherSuite::TlsEcdheRsaWithChacha20Poly1305Sha256 => {
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
}
});
}
// Build cert provider
let mut provider = default_provider();
if !disabled_ciphers.is_empty() {
provider.cipher_suites = ALL_CIPHER_SUITES
.iter()
.filter(|suite| !disabled_ciphers.contains(suite))
.copied()
.collect();
}
// Build server config
let mut server_config = match ServerConfig::builder_with_provider(provider.into())
.with_protocol_versions(if tls_v3 == tls_v2 {
ALL_VERSIONS
} else if tls_v3 {
TLS13_VERSION
} else {
TLS12_VERSION
}) {
Ok(server_config) => server_config
.with_no_client_auth()
.with_cert_resolver(resolver.clone()),
Err(err) => {
bp.build_error(id, format!("Failed to build TLS server config: {err}"));
return;
}
};
server_config.ignore_client_order = listener.tls_ignore_client_order;
// Build acceptor
let default_config = Arc::new(server_config);
TcpAcceptor::Tls {
acceptor: TlsAcceptor::from(default_config.clone()),
config: default_config,
implicit: listener.tls_implicit,
}
} else {
TcpAcceptor::Plain
};
self.tcp_acceptors.insert(listener.name, acceptor);
}
}
}
fn is_ipv6_unsupported(err: &std::io::Error) -> bool {
let code = err.raw_os_error();
#[cfg(unix)]
{
matches!(code, Some(libc::EAFNOSUPPORT) | Some(libc::EPROTONOSUPPORT))
}
#[cfg(windows)]
{
matches!(code, Some(10047) | Some(10043))
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
+83
View File
@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::network::TcpAcceptor;
use ahash::AHashMap;
use registry::{
schema::structs::NetworkListener,
types::{id::ObjectId, ipmask::IpAddrOrMask},
};
use serde::{Deserialize, Serialize};
use std::{fmt::Display, net::SocketAddr, sync::Arc, time::Duration};
use store::registry::RegistryObject;
use tokio::net::TcpSocket;
use utils::snowflake::SnowflakeIdGenerator;
pub mod listener;
pub mod tls;
#[derive(Default)]
pub struct Listeners {
pub servers: Vec<Listener>,
pub tcp_acceptors: AHashMap<String, TcpAcceptor>,
pub span_id_gen: Arc<SnowflakeIdGenerator>,
parsed_listeners: Vec<RegistryObject<NetworkListener>>,
}
#[derive(Debug, Default)]
pub struct Listener {
pub registry_id: ObjectId,
pub id: String,
pub protocol: ServerProtocol,
pub listeners: Vec<TcpListener>,
pub proxy_networks: Vec<IpAddrOrMask>,
pub max_connections: u64,
pub tls_timeout: Duration,
pub span_id_gen: Arc<SnowflakeIdGenerator>,
}
pub const DEFAULT_TLS_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug)]
pub struct TcpListener {
pub socket: TcpSocket,
pub addr: SocketAddr,
pub backlog: Option<u32>,
// TCP options
pub ttl: Option<u32>,
pub nodelay: bool,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Default, Serialize, Deserialize)]
pub enum ServerProtocol {
#[default]
Smtp,
Lmtp,
Imap,
Pop3,
Http,
ManageSieve,
}
impl ServerProtocol {
pub fn as_str(&self) -> &'static str {
match self {
ServerProtocol::Smtp => "smtp",
ServerProtocol::Lmtp => "lmtp",
ServerProtocol::Imap => "imap",
ServerProtocol::Http => "http",
ServerProtocol::Pop3 => "pop3",
ServerProtocol::ManageSieve => "managesieve",
}
}
}
impl Display for ServerProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
+278
View File
@@ -0,0 +1,278 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::network::acme::ParsedCert;
use ahash::{AHashMap, AHashSet};
use rcgen::generate_simple_self_signed;
use registry::{
schema::{
prelude::Object,
structs::{Certificate, PublicText, SecretText, SystemSettings},
},
types::{datetime::UTCDateTime, map::Map},
};
use rustls::{
SupportedProtocolVersion,
crypto::aws_lc_rs::sign::any_supported_type,
sign::CertifiedKey,
version::{TLS12, TLS13},
};
use rustls_pemfile::{Item, certs, read_all};
use rustls_pki_types::PrivateKeyDer;
use std::{io::Cursor, sync::Arc};
use store::{
registry::{bootstrap::Bootstrap, write::RegistryWrite},
write::now,
};
pub static TLS13_VERSION: &[&SupportedProtocolVersion] = &[&TLS13];
pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12];
pub(crate) async fn parse_certificates(
bp: &mut Bootstrap,
certificates: &mut AHashMap<Box<str>, Arc<CertifiedKey>>,
subject_names: &mut AHashSet<Box<str>>,
) {
let system = bp.setting_infallible::<SystemSettings>().await;
// Parse certificates
let now = now() as i64;
let mut certs_expired = Vec::new();
let mut certs_expirations = AHashMap::new();
for cert_obj in bp.list_infallible::<Certificate>().await {
let obj_id = cert_obj.id;
let revision = cert_obj.revision;
let mut cert = cert_obj.object;
let is_file_backed = matches!(cert.certificate, PublicText::File(_))
|| matches!(cert.private_key, SecretText::File(_));
let mut public = None;
let mut refreshed_meta = None;
if is_file_backed {
let pem = match cert.certificate.value().await {
Ok(value) => value.into_owned().into_bytes(),
Err(err) => {
bp.build_error(obj_id, format!("Failed to obtain certificate value: {err}"));
continue;
}
};
match ParsedCert::parse(&pem) {
Ok(parsed) => {
let not_valid_after =
UTCDateTime::from_timestamp(parsed.valid_not_after.timestamp());
let not_valid_before =
UTCDateTime::from_timestamp(parsed.valid_not_before.timestamp());
let sans = Map::new(parsed.sans);
if cert.not_valid_after != not_valid_after
|| cert.not_valid_before != not_valid_before
|| cert.issuer != parsed.issuer
|| cert.subject_alternative_names != sans
{
refreshed_meta =
Some((not_valid_after, not_valid_before, parsed.issuer, sans));
}
public = Some(pem);
}
Err(err) => {
bp.build_error(obj_id, format!("Invalid certificate: {err}"));
continue;
}
}
}
let (not_valid_after, not_valid_before) = match refreshed_meta.as_ref() {
Some((after, before, _, _)) => (after.timestamp(), before.timestamp()),
None => (
cert.not_valid_after.timestamp(),
cert.not_valid_before.timestamp(),
),
};
if not_valid_after <= now {
certs_expired.push((
obj_id,
cert.subject_alternative_names.clone().into_inner(),
Object {
inner: cert.into(),
revision,
},
));
continue;
} else if not_valid_before > now {
continue; // Skip certificates that are not yet valid
}
let secret = match cert.private_key.secret().await {
Ok(secret) => secret.into_owned().into_bytes(),
Err(err) => {
bp.build_error(
obj_id,
format!("Failed to obtain private key secret: {err}"),
);
continue;
}
};
let public = match public {
Some(public) => public,
None => match cert.certificate.value().await {
Ok(value) => value.into_owned().into_bytes(),
Err(err) => {
bp.build_error(obj_id, format!("Failed to obtain certificate value: {err}"));
continue;
}
},
};
if let Some((not_valid_after, not_valid_before, issuer, sans)) = refreshed_meta {
let old = Object {
inner: cert.clone().into(),
revision,
};
cert.not_valid_after = not_valid_after;
cert.not_valid_before = not_valid_before;
cert.issuer = issuer;
cert.subject_alternative_names = sans;
let new = Object {
inner: cert.clone().into(),
revision,
};
if let Err(err) = bp
.registry
.write(RegistryWrite::update(obj_id.id(), &new, &old))
.await
{
trc::error!(
err.details("Failed to refresh TLS certificate metadata in registry.")
.caused_by(trc::location!())
);
}
}
// Add default certificate
if system
.default_certificate_id
.as_ref()
.is_some_and(|id| *id == obj_id.id())
{
cert.subject_alternative_names
.push_unchecked("*".to_string());
}
// Ensure that the most up-to-date certificate is used
cert.subject_alternative_names.inner_mut().retain(|name| {
if certs_expirations
.get(name)
.is_none_or(|expires| *expires < not_valid_after)
{
certs_expirations.insert(name.clone(), not_valid_after);
true
} else {
false
}
});
match build_certified_key(public, secret) {
Ok(key) => {
// Add certificates
let key = Arc::new(key);
for name in cert.subject_alternative_names.into_inner() {
subject_names.insert(name.as_str().into());
certificates.insert(
name.strip_prefix("*.")
.map(Into::into)
.unwrap_or_else(|| name.into_boxed_str()),
key.clone(),
);
}
}
Err(err) => {
bp.build_error(obj_id, format!("Invalid certificate: {err}"));
}
}
}
// Remove expired certificates
if !certs_expired.is_empty() {
for (id, sans, object) in certs_expired {
if let Err(err) = bp
.registry
.write(RegistryWrite::delete_object(id, &object))
.await
{
trc::error!(
err.details("Failed to delete expired TLS certificate from registry.")
.caused_by(trc::location!())
);
} else {
trc::event!(
Tls(trc::TlsEvent::ExpiredCertificateRemoved),
Details = sans
);
}
}
}
}
pub(crate) fn build_certified_key(
cert: Vec<u8>,
pk_bytes: Vec<u8>,
) -> Result<CertifiedKey, String> {
let mut pk = None;
for item in read_all(&mut Cursor::new(pk_bytes)) {
match item.map_err(|err| format!("Failed to read private key PEM: {err}"))? {
Item::Pkcs8Key(key) => {
pk = Some(PrivateKeyDer::Pkcs8(key));
break;
}
Item::Pkcs1Key(key) => {
pk = Some(PrivateKeyDer::Pkcs1(key));
break;
}
Item::Sec1Key(key) => {
pk = Some(PrivateKeyDer::Sec1(key));
break;
}
_ => continue, // Skip certificates, DH params, etc.
}
}
let pk = pk.ok_or_else(|| "No private keys found.".to_string())?;
let cert = certs(&mut Cursor::new(cert))
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("Failed to read certificates: {err}"))?;
if !cert.is_empty() {
Ok(CertifiedKey {
cert,
key: any_supported_type(&pk)
.map_err(|err| format!("Failed to sign certificate: {err}",))?,
ocsp: None,
})
} else {
Err("No certificates found.".to_string())
}
}
pub(crate) fn build_self_signed_cert(
domains: impl Into<Vec<String>>,
) -> Result<CertifiedKey, String> {
let domains = domains
.into()
.into_iter()
.map(|domain| {
if domain.is_ascii() {
domain
} else {
idna::domain_to_ascii(&domain).unwrap_or(domain)
}
})
.collect::<Vec<_>>();
let rcgen::CertifiedKey { cert, signing_key } = generate_simple_self_signed(domains)
.map_err(|err| format!("Failed to generate self-signed certificate: {err}",))?;
build_certified_key(
cert.pem().into_bytes(),
signing_key.serialize_pem().into_bytes(),
)
}
+380
View File
@@ -0,0 +1,380 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::expr::{
self,
if_block::{BootstrapExprExt, IfBlock},
};
use mail_auth::{
common::crypto::{Ed25519Key, HashAlgorithm, RsaKey, Sha256, SigningKey},
dkim::{Canonicalization, Done},
dkim2::{Dkim2Signer, Done as Dkim2Done, Flag},
};
use mail_parser::decoders::base64::base64_decode;
use registry::{
schema::{
enums::{self, Dkim2Flag, ExpressionConstant},
prelude::ObjectType,
structs::{Dkim1Signature, DkimSignature, SenderAuth},
},
types::{ObjectImpl, map::Map},
};
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, pem::PemObject};
use store::registry::bootstrap::Bootstrap;
use utils::cache::CacheItemWeight;
#[derive(Clone)]
pub struct MailAuthConfig {
pub dkim: DkimAuthConfig,
pub arc: ArcAuthConfig,
pub spf: SpfAuthConfig,
pub dmarc: DmarcAuthConfig,
pub iprev: IpRevAuthConfig,
}
#[derive(Clone)]
pub struct DkimAuthConfig {
pub verify: IfBlock,
pub sign: IfBlock,
pub strict: bool,
}
#[derive(Clone)]
pub struct ArcAuthConfig {
pub verify: IfBlock,
//pub seal: IfBlock,
}
#[derive(Clone)]
pub struct SpfAuthConfig {
pub verify_ehlo: IfBlock,
pub verify_mail_from: IfBlock,
}
#[derive(Clone)]
pub struct DmarcAuthConfig {
pub verify: IfBlock,
}
#[derive(Clone)]
pub struct IpRevAuthConfig {
pub verify: IfBlock,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum VerifyStrategy {
#[default]
Relaxed,
Strict,
Disable,
}
pub enum Dkim1Signer {
RsaSha256(mail_auth::dkim::DkimSigner<RsaKey<Sha256>, Done>),
Ed25519Sha256(mail_auth::dkim::DkimSigner<Ed25519Key, Done>),
}
#[derive(Default)]
pub struct DkimSigners {
pub dkim1: Vec<Dkim1Signer>,
pub dkim2: Option<Dkim2Signer<Dkim2Done>>,
}
impl MailAuthConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let auth = bp.setting_infallible::<SenderAuth>().await;
MailAuthConfig {
dkim: DkimAuthConfig {
verify: bp
.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_dkim_verify()),
sign: bp.compile_expr(
ObjectType::SenderAuth.singleton(),
&auth.ctx_dkim_sign_domain(),
),
strict: auth.dkim_strict,
},
arc: ArcAuthConfig {
verify: bp.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_arc_verify()),
//seal: bp.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_arc_seal_domain()),
},
spf: SpfAuthConfig {
verify_ehlo: bp.compile_expr(
ObjectType::SenderAuth.singleton(),
&auth.ctx_spf_ehlo_verify(),
),
verify_mail_from: bp.compile_expr(
ObjectType::SenderAuth.singleton(),
&auth.ctx_spf_from_verify(),
),
},
dmarc: DmarcAuthConfig {
verify: bp
.compile_expr(ObjectType::SenderAuth.singleton(), &auth.ctx_dmarc_verify()),
},
iprev: IpRevAuthConfig {
verify: bp.compile_expr(
ObjectType::SenderAuth.singleton(),
&auth.ctx_reverse_ip_verify(),
),
},
}
}
}
impl DkimSigners {
pub async fn insert(&mut self, domain: String, signature: DkimSignature) -> trc::Result<()> {
let mut errors = vec![];
if !signature.validate(&mut errors) {
return Err(trc::DkimEvent::BuildError
.reason("DKIM signature validation failed")
.details(
errors
.into_iter()
.map(|v| trc::Value::from(v.to_string()))
.collect::<Vec<_>>(),
));
}
match signature {
DkimSignature::Dkim1Ed25519Sha256(signature) => {
let private_key = signature
.private_key
.secret()
.await
.map_err(|err| trc::DkimEvent::BuildError.reason(err))?;
let private_key = simple_pem_parse(&private_key).ok_or_else(|| {
trc::DkimEvent::BuildError
.reason("Failed to parse ED25519 private key PEM")
.details("Invalid PEM format")
})?;
let key =
Ed25519Key::from_pkcs8_maybe_unchecked_der(&private_key).map_err(|err| {
trc::DkimEvent::BuildError
.reason(err)
.details("Failed to build ED25519 key")
})?;
self.dkim1
.push(Dkim1Signer::Ed25519Sha256(build_dkim1_signer(
domain, signature, key,
)));
}
DkimSignature::Dkim1RsaSha256(signature) => {
let private_key = signature
.private_key
.secret()
.await
.map_err(|err| trc::DkimEvent::BuildError.reason(err))?;
let key = rsa_key_parse(private_key.as_bytes())?;
self.dkim1.push(Dkim1Signer::RsaSha256(build_dkim1_signer(
domain, signature, key,
)));
}
DkimSignature::Dkim2Ed25519Sha256(signature) => {
let private_key = signature
.private_key
.secret()
.await
.map_err(|err| trc::DkimEvent::BuildError.reason(err))?;
let private_key = simple_pem_parse(&private_key).ok_or_else(|| {
trc::DkimEvent::BuildError
.reason("Failed to parse ED25519 private key PEM")
.details("Invalid PEM format")
})?;
let key =
Ed25519Key::from_pkcs8_maybe_unchecked_der(&private_key).map_err(|err| {
trc::DkimEvent::BuildError
.reason(err)
.details("Failed to build ED25519 key")
})?;
self.dkim2 = Some(match self.dkim2.take() {
None => Dkim2Signer::from_key(key)
.domain(domain)
.selector(signature.selector)
.flags(map_dkim2_flags(signature.flags)),
Some(signer) => signer
.additional_key(key, signature.selector)
.flags(map_dkim2_flags(signature.flags)),
});
}
DkimSignature::Dkim2RsaSha256(signature) => {
let private_key = signature
.private_key
.secret()
.await
.map_err(|err| trc::DkimEvent::BuildError.reason(err))?;
let key = rsa_key_parse(private_key.as_bytes())?;
self.dkim2 = Some(match self.dkim2.take() {
None => Dkim2Signer::from_key(key)
.domain(domain)
.selector(signature.selector)
.flags(map_dkim2_flags(signature.flags)),
Some(signer) => signer
.additional_key(key, signature.selector)
.flags(map_dkim2_flags(signature.flags)),
});
}
}
Ok(())
}
}
fn map_dkim2_flags(flags: Map<enums::Dkim2Flag>) -> impl Iterator<Item = Flag> {
flags.into_inner().into_iter().map(|flag| match flag {
Dkim2Flag::Donotmodify => Flag::DoNotModify,
Dkim2Flag::Donotexplode => Flag::DoNotExplode,
Dkim2Flag::Feedback => Flag::Feedback,
})
}
pub fn rsa_key_parse(private_key: &[u8]) -> trc::Result<RsaKey<Sha256>> {
PrivatePkcs1KeyDer::from_pem_slice(private_key)
.map(PrivateKeyDer::Pkcs1)
.or_else(|_| PrivatePkcs8KeyDer::from_pem_slice(private_key).map(PrivateKeyDer::Pkcs8))
.map_err(|err| {
trc::DkimEvent::BuildError
.reason(err)
.details("Failed to build RSA key")
})
.and_then(|key| {
RsaKey::<Sha256>::from_key_der(key).map_err(|err| {
trc::DkimEvent::BuildError
.reason(err)
.details("Failed to build RSA key")
})
})
}
pub fn simple_pem_parse(contents: &str) -> Option<Vec<u8>> {
let mut contents = contents.as_bytes().iter().copied();
let mut base64 = vec![];
'outer: while let Some(ch) = contents.next() {
if !ch.is_ascii_whitespace() {
if ch == b'-' {
for ch in contents.by_ref() {
if ch == b'\n' {
break;
}
}
} else {
base64.push(ch);
}
for ch in contents.by_ref() {
if ch == b'-' {
break 'outer;
} else if !ch.is_ascii_whitespace() {
base64.push(ch);
}
}
}
}
base64_decode(&base64)
}
fn build_dkim1_signer<T: SigningKey>(
domain: String,
signature: Dkim1Signature,
key: T,
) -> mail_auth::dkim::DkimSigner<T, Done> {
let mut signer = mail_auth::dkim::DkimSigner::from_key(key)
.domain(domain)
.selector(signature.selector)
.headers(signature.headers)
.reporting(signature.report);
match signature.canonicalization {
enums::DkimCanonicalization::RelaxedRelaxed => {
signer = signer
.body_canonicalization(Canonicalization::Relaxed)
.header_canonicalization(Canonicalization::Relaxed);
}
enums::DkimCanonicalization::SimpleSimple => {
signer = signer
.body_canonicalization(Canonicalization::Simple)
.header_canonicalization(Canonicalization::Simple);
}
enums::DkimCanonicalization::RelaxedSimple => {
signer = signer
.body_canonicalization(Canonicalization::Simple)
.header_canonicalization(Canonicalization::Relaxed);
}
enums::DkimCanonicalization::SimpleRelaxed => {
signer = signer
.body_canonicalization(Canonicalization::Relaxed)
.header_canonicalization(Canonicalization::Simple);
}
}
if let Some(expire) = signature.expire {
signer = signer.expiration(expire.into_inner().as_secs());
}
if let Some(auid) = signature.auid {
signer = signer.agent_user_identifier(auid);
}
if let Some(atps) = signature.third_party {
signer = signer.atps(atps);
}
if let Some(atpsh) = signature.third_party_hash {
signer = signer.atpsh(match atpsh {
enums::DkimHash::Sha256 => HashAlgorithm::Sha256,
enums::DkimHash::Sha1 => HashAlgorithm::Sha1,
});
}
signer
}
impl<'x> TryFrom<expr::Variable<'x>> for VerifyStrategy {
type Error = ();
fn try_from(value: expr::Variable<'x>) -> Result<Self, Self::Error> {
match value {
expr::Variable::Constant(c) => match c {
ExpressionConstant::Relaxed => Ok(VerifyStrategy::Relaxed),
ExpressionConstant::Strict => Ok(VerifyStrategy::Strict),
ExpressionConstant::Disable => Ok(VerifyStrategy::Disable),
_ => Err(()),
},
_ => Err(()),
}
}
}
impl VerifyStrategy {
#[inline(always)]
pub fn verify(&self) -> bool {
matches!(self, VerifyStrategy::Strict | VerifyStrategy::Relaxed)
}
#[inline(always)]
pub fn is_strict(&self) -> bool {
matches!(self, VerifyStrategy::Strict)
}
}
impl CacheItemWeight for Dkim1Signer {
fn weight(&self) -> u64 {
std::mem::size_of::<Self>() as u64
}
}
impl CacheItemWeight for DkimSigners {
fn weight(&self) -> u64 {
(std::mem::size_of::<Self>()
+ self.dkim1.len() * std::mem::size_of::<Dkim1Signer>()
+ std::mem::size_of::<Dkim2Signer<Dkim2Done>>()) as u64
}
}
+92
View File
@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod auth;
pub mod queue;
pub mod report;
pub mod resolver;
pub mod session;
use self::{
auth::MailAuthConfig, queue::QueueConfig, report::ReportConfig, resolver::Resolvers,
session::SessionConfig,
};
use crate::{config::smtp::queue::RequireOptional, expr::if_block::IfBlock};
use registry::{
schema::{properties::ObjectType, structs::Rate},
types::id::ObjectId,
};
use store::registry::bootstrap::Bootstrap;
#[derive(Clone)]
pub struct SmtpConfig {
pub session: SessionConfig,
pub queue: QueueConfig,
pub resolvers: Resolvers,
pub mail_auth: MailAuthConfig,
pub report: ReportConfig,
pub mta_sts_client: reqwest::Client,
pub tls_report_client: reqwest::Client,
}
#[derive(Debug, Clone)]
//#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct QueueRateLimiter {
pub id: ObjectId,
pub expr: IfBlock,
pub keys: u16,
pub rate: Rate,
}
pub const THROTTLE_RCPT: u16 = 1 << 0;
pub const THROTTLE_RCPT_DOMAIN: u16 = 1 << 1;
pub const THROTTLE_SENDER: u16 = 1 << 2;
pub const THROTTLE_SENDER_DOMAIN: u16 = 1 << 3;
pub const THROTTLE_AUTH_AS: u16 = 1 << 4;
pub const THROTTLE_LISTENER: u16 = 1 << 5;
pub const THROTTLE_MX: u16 = 1 << 6;
pub const THROTTLE_REMOTE_IP: u16 = 1 << 7;
pub const THROTTLE_LOCAL_IP: u16 = 1 << 8;
pub const THROTTLE_HELO_DOMAIN: u16 = 1 << 9;
impl SmtpConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let config = Self {
session: SessionConfig::parse(bp).await,
queue: QueueConfig::parse(bp).await,
resolvers: Resolvers::parse(bp).await,
mail_auth: MailAuthConfig::parse(bp).await,
report: ReportConfig::parse(bp).await,
mta_sts_client: utils::http::http_client_builder(false)
.pool_max_idle_per_host(0)
.user_agent(crate::USER_AGENT)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default(),
tls_report_client: utils::http::unpooled_http_client(false),
};
if !config.resolvers.dnssec_available
&& (config.queue.tls_strategy.is_empty()
|| config
.queue
.tls_strategy
.values()
.any(|t| !matches!(t.dane, RequireOptional::Disable)))
{
bp.build_warning(
ObjectType::DnsResolver.singleton(),
concat!(
"The configured DNS resolver cannot validate DNSSEC. ",
"DANE has been disabled to avoid deferring mail. ",
"Ensure the resolver is DNSSEC-capable and reachable over TCP."
),
);
}
config
}
}
+801
View File
@@ -0,0 +1,801 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::*;
use crate::{
config::server::ServerProtocol,
expr::{
if_block::{BootstrapExprExt, IfBlock},
*,
},
};
use ahash::AHashMap;
use directory::Credentials;
use mail_auth::IpLookupStrategy;
use registry::schema::{
enums::{self, ExpressionConstant, ExpressionVariable, MtaRequiredOrOptional},
prelude::ObjectType,
structs::{
DsnReportSettings, MtaConnectionStrategy, MtaDeliveryExpiration, MtaDeliverySchedule,
MtaDeliveryScheduleIntervalsOrDefault, MtaInboundThrottle, MtaOutboundStrategy,
MtaOutboundThrottle, MtaQueueQuota, MtaRoute, MtaTlsStrategy, MtaVirtualQueue,
},
};
use std::{
fmt::Display,
hash::{Hash, Hasher},
net::IpAddr,
time::Duration,
};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
rkyv::Serialize,
rkyv::Deserialize,
rkyv::Archive,
serde::Deserialize,
)]
#[rkyv(derive(Debug, Clone, Copy, PartialEq), compare(PartialEq))]
#[repr(transparent)]
pub struct QueueName([u8; 8]);
pub const DEFAULT_QUEUE_NAME: QueueName = QueueName([b'd', b'e', b'f', b'a', b'u', b'l', b't', 0]);
#[derive(Clone)]
pub struct QueueConfig {
// Strategy resolver
pub route: IfBlock,
pub queue: IfBlock,
pub connection: IfBlock,
pub tls: IfBlock,
// DSN
pub dsn: Dsn,
// Rate limits
pub inbound_limiters: QueueRateLimiters,
pub outbound_limiters: QueueRateLimiters,
pub quota: QueueQuotas,
// Strategies
pub queue_strategy: AHashMap<String, QueueStrategy>,
pub connection_strategy: AHashMap<String, ConnectionStrategy>,
pub routing_strategy: AHashMap<String, RoutingStrategy>,
pub tls_strategy: AHashMap<String, TlsStrategy>,
pub virtual_queues: AHashMap<QueueName, VirtualQueue>,
}
#[derive(Clone, Hash, PartialEq, Eq, Debug)]
pub enum RoutingStrategy {
Local,
Mx(MxConfig),
Relay(RelayConfig),
}
#[derive(Clone, Debug)]
pub struct MxConfig {
pub max_mx: usize,
pub max_multi_homed: usize,
pub ip_lookup_strategy: IpLookupStrategy,
}
#[derive(Clone)]
pub struct Dsn {
pub name: IfBlock,
pub address: IfBlock,
pub sign: IfBlock,
}
#[derive(Clone, Debug)]
pub struct VirtualQueue {
pub threads: usize,
}
#[derive(Clone, Debug)]
pub struct QueueStrategy {
pub retry: Vec<u64>,
pub notify: Vec<u64>,
pub expiry: QueueExpiry,
pub virtual_queue: QueueName,
}
#[derive(
rkyv::Serialize,
rkyv::Deserialize,
rkyv::Archive,
Debug,
Clone,
Copy,
PartialEq,
Eq,
serde::Deserialize,
)]
pub enum QueueExpiry {
Ttl(u64),
Attempts(u32),
}
#[derive(Clone, Debug)]
pub struct TlsStrategy {
pub dane: RequireOptional,
pub mta_sts: RequireOptional,
pub tls: RequireOptional,
pub allow_invalid_certs: bool,
pub timeout_tls: Duration,
pub timeout_mta_sts: Duration,
}
#[derive(Clone, Debug)]
pub struct ConnectionStrategy {
pub source_ipv4: Vec<IpAndHost>,
pub source_ipv6: Vec<IpAndHost>,
pub ehlo_hostname: Option<String>,
pub timeout_connect: Duration,
pub timeout_greeting: Duration,
pub timeout_ehlo: Duration,
pub timeout_mail: Duration,
pub timeout_rcpt: Duration,
pub timeout_data: Duration,
}
#[derive(Clone, Debug)]
pub struct IpAndHost {
pub ip: IpAddr,
pub host: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct QueueRateLimiters {
pub sender: Vec<QueueRateLimiter>,
pub rcpt: Vec<QueueRateLimiter>,
pub remote: Vec<QueueRateLimiter>,
}
#[derive(Clone, Default)]
pub struct QueueQuotas {
pub sender: Vec<QueueQuota>,
pub rcpt: Vec<QueueQuota>,
pub rcpt_domain: Vec<QueueQuota>,
}
#[derive(Clone)]
pub struct QueueQuota {
pub id: ObjectId,
pub expr: IfBlock,
pub keys: u16,
pub size: Option<u64>,
pub messages: Option<u64>,
}
#[derive(Clone, Hash, PartialEq, Eq)]
pub struct RelayConfig {
pub address: HostOrIp<Box<str>, IpStr>,
pub port: u16,
pub protocol: ServerProtocol,
pub auth: Option<Credentials>,
pub tls_implicit: bool,
pub tls_allow_invalid_certs: bool,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub enum HostOrIp<N, I> {
Host(N),
Ip(I),
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct IpStr {
pub ip: IpAddr,
pub ip_str: Box<str>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum RequireOptional {
#[default]
Optional,
Require,
Disable,
}
impl QueueConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let st = bp.setting_infallible::<MtaOutboundStrategy>().await;
let dsn = bp.setting_infallible::<DsnReportSettings>().await;
let mut queue = QueueConfig {
route: bp.compile_expr(ObjectType::MtaOutboundStrategy.singleton(), &st.ctx_route()),
queue: bp.compile_expr(
ObjectType::MtaOutboundStrategy.singleton(),
&st.ctx_schedule(),
),
connection: bp.compile_expr(
ObjectType::MtaOutboundStrategy.singleton(),
&st.ctx_connection(),
),
tls: bp.compile_expr(ObjectType::MtaOutboundStrategy.singleton(), &st.ctx_tls()),
dsn: Dsn {
name: bp.compile_expr(
ObjectType::DsnReportSettings.singleton(),
&dsn.ctx_from_name(),
),
address: bp.compile_expr(
ObjectType::DsnReportSettings.singleton(),
&dsn.ctx_from_address(),
),
sign: bp.compile_expr(
ObjectType::DsnReportSettings.singleton(),
&dsn.ctx_dkim_sign_domain(),
),
},
inbound_limiters: QueueRateLimiters::parse_inbound(bp).await,
outbound_limiters: QueueRateLimiters::parse_outbound(bp).await,
quota: QueueQuotas::parse(bp).await,
queue_strategy: Default::default(),
connection_strategy: Default::default(),
routing_strategy: Default::default(),
tls_strategy: Default::default(),
virtual_queues: Default::default(),
};
// Parse virtual queues
let mut queue_id_to_name = AHashMap::new();
for obj in bp.list_infallible::<MtaVirtualQueue>().await {
if let Some(queue_name) = QueueName::new(&obj.object.name) {
queue_id_to_name.insert(obj.id.id(), queue_name);
queue.virtual_queues.insert(
queue_name,
VirtualQueue {
threads: obj.object.threads_per_node as usize,
},
);
}
}
// Parse queue strategies
for obj in bp.list_infallible::<MtaDeliverySchedule>().await {
let virtual_queue = if let Some(name) = queue_id_to_name.get(&obj.object.queue_id) {
*name
} else {
bp.build_error(
obj.id,
format!("Virtual queue ID '{}' does not exist.", obj.object.queue_id),
);
continue;
};
queue.queue_strategy.insert(
obj.object.name,
QueueStrategy {
retry: match obj.object.retry {
MtaDeliveryScheduleIntervalsOrDefault::Default => vec![
2 * 60,
5 * 60,
10 * 60,
15 * 60,
30 * 60,
60 * 60,
2 * 60 * 60,
24 * 60 * 60,
3 * 24 * 60 * 60,
],
MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals
.intervals
.into_iter()
.map(|d| d.duration.as_secs())
.collect(),
},
notify: match obj.object.notify {
MtaDeliveryScheduleIntervalsOrDefault::Default => {
vec![24 * 60 * 60, 3 * 24 * 60 * 60]
}
MtaDeliveryScheduleIntervalsOrDefault::Custom(intervals) => intervals
.intervals
.into_iter()
.map(|d| d.duration.as_secs())
.collect(),
},
expiry: match obj.object.expiry {
MtaDeliveryExpiration::Ttl(exp) => {
QueueExpiry::Ttl(exp.expire.into_inner().as_secs())
}
MtaDeliveryExpiration::Attempts(exp) => {
QueueExpiry::Attempts(exp.max_attempts as u32)
}
},
virtual_queue,
},
);
}
// Parse connection strategies
for obj in bp.list_infallible::<MtaConnectionStrategy>().await {
let mut source_ipv4 = Vec::new();
let mut source_ipv6 = Vec::new();
for ip_host in obj.object.source_ips {
let ip_host = IpAndHost {
ip: ip_host.source_ip.into_inner(),
host: ip_host.ehlo_hostname,
};
if ip_host.ip.is_ipv4() {
source_ipv4.push(ip_host);
} else {
source_ipv6.push(ip_host);
}
}
queue.connection_strategy.insert(
obj.object.name,
ConnectionStrategy {
source_ipv4,
source_ipv6,
ehlo_hostname: obj.object.ehlo_hostname,
timeout_connect: obj.object.connect_timeout.into_inner(),
timeout_greeting: obj.object.greeting_timeout.into_inner(),
timeout_ehlo: obj.object.ehlo_timeout.into_inner(),
timeout_mail: obj.object.mail_from_timeout.into_inner(),
timeout_rcpt: obj.object.rcpt_to_timeout.into_inner(),
timeout_data: obj.object.data_timeout.into_inner(),
},
);
}
// Parse routing strategies
for obj in bp.list_infallible::<MtaRoute>().await {
match obj.object {
MtaRoute::Mx(route) => {
queue.routing_strategy.insert(
route.name,
RoutingStrategy::Mx(MxConfig {
max_mx: route.max_mx_hosts as usize,
max_multi_homed: route.max_multihomed as usize,
ip_lookup_strategy: match route.ip_lookup_strategy {
enums::MtaIpStrategy::V4ThenV6 => IpLookupStrategy::Ipv4thenIpv6,
enums::MtaIpStrategy::V6ThenV4 => IpLookupStrategy::Ipv6thenIpv4,
enums::MtaIpStrategy::V4Only => IpLookupStrategy::Ipv4Only,
enums::MtaIpStrategy::V6Only => IpLookupStrategy::Ipv6Only,
},
}),
);
}
MtaRoute::Relay(route) => {
let secret = route
.auth_secret
.secret()
.await
.map_err(|err| {
bp.build_error(obj.id, err);
})
.unwrap_or_default();
queue.routing_strategy.insert(
route.name,
RoutingStrategy::Relay(RelayConfig {
address: if let Ok(ip) = route.address.parse() {
HostOrIp::Ip(IpStr {
ip,
ip_str: route.address.into(),
})
} else {
HostOrIp::Host(route.address.into())
},
port: route.port as u16,
protocol: match route.protocol {
enums::MtaProtocol::Smtp => ServerProtocol::Smtp,
enums::MtaProtocol::Lmtp => ServerProtocol::Lmtp,
},
auth: route.auth_username.zip(secret).map(|(user, secret)| {
Credentials::Basic {
username: user,
secret: secret.into_owned(),
mfa_token: None,
}
}),
tls_implicit: route.implicit_tls,
tls_allow_invalid_certs: route.allow_invalid_certs,
}),
);
}
MtaRoute::Local(route) => {
queue
.routing_strategy
.insert(route.name, RoutingStrategy::Local);
}
}
}
// Parse TLS strategies
for obj in bp.list_infallible::<MtaTlsStrategy>().await {
queue.tls_strategy.insert(
obj.object.name,
TlsStrategy {
dane: match obj.object.dane {
MtaRequiredOrOptional::Optional => RequireOptional::Optional,
MtaRequiredOrOptional::Require => RequireOptional::Require,
MtaRequiredOrOptional::Disable => RequireOptional::Disable,
},
mta_sts: match obj.object.mta_sts {
MtaRequiredOrOptional::Optional => RequireOptional::Optional,
MtaRequiredOrOptional::Require => RequireOptional::Require,
MtaRequiredOrOptional::Disable => RequireOptional::Disable,
},
tls: match obj.object.start_tls {
MtaRequiredOrOptional::Optional => RequireOptional::Optional,
MtaRequiredOrOptional::Require => RequireOptional::Require,
MtaRequiredOrOptional::Disable => RequireOptional::Disable,
},
allow_invalid_certs: obj.object.allow_invalid_certs,
timeout_tls: obj.object.tls_timeout.into_inner(),
timeout_mta_sts: obj.object.mta_sts_timeout.into_inner(),
},
);
}
queue
}
}
impl QueueRateLimiters {
async fn parse_inbound(bp: &mut Bootstrap) -> QueueRateLimiters {
let mut throttle = QueueRateLimiters::default();
for obj in bp.list_infallible::<MtaInboundThrottle>().await {
if !obj.object.enable {
continue;
}
let limiter = QueueRateLimiter {
expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()),
id: obj.id,
keys: obj
.object
.key
.iter()
.map(|key| match key {
enums::MtaInboundThrottleKey::Rcpt => THROTTLE_RCPT,
enums::MtaInboundThrottleKey::RcptDomain => THROTTLE_RCPT_DOMAIN,
enums::MtaInboundThrottleKey::Sender => THROTTLE_SENDER,
enums::MtaInboundThrottleKey::SenderDomain => THROTTLE_SENDER_DOMAIN,
enums::MtaInboundThrottleKey::AuthenticatedAs => THROTTLE_AUTH_AS,
enums::MtaInboundThrottleKey::Listener => THROTTLE_LISTENER,
enums::MtaInboundThrottleKey::RemoteIp => THROTTLE_REMOTE_IP,
enums::MtaInboundThrottleKey::LocalIp => THROTTLE_LOCAL_IP,
enums::MtaInboundThrottleKey::HeloDomain => THROTTLE_HELO_DOMAIN,
})
.fold(0, |acc, key| acc | key),
rate: obj.object.rate,
};
if (limiter.keys & (THROTTLE_RCPT | THROTTLE_RCPT_DOMAIN)) != 0
|| limiter.expr.all_items().any(|c| {
matches!(
c,
ExpressionItem::Variable(
ExpressionVariable::Rcpt | ExpressionVariable::RcptDomain
)
)
})
{
throttle.rcpt.push(limiter);
} else if (limiter.keys
& (THROTTLE_SENDER
| THROTTLE_SENDER_DOMAIN
| THROTTLE_HELO_DOMAIN
| THROTTLE_AUTH_AS))
!= 0
|| limiter.expr.all_items().any(|c| {
matches!(
c,
ExpressionItem::Variable(
ExpressionVariable::Sender
| ExpressionVariable::SenderDomain
| ExpressionVariable::HeloDomain
| ExpressionVariable::AuthenticatedAs
)
)
})
{
throttle.sender.push(limiter);
} else {
throttle.remote.push(limiter);
}
}
throttle
}
async fn parse_outbound(bp: &mut Bootstrap) -> QueueRateLimiters {
// Parse throttle
let mut throttle = QueueRateLimiters::default();
for obj in bp.list_infallible::<MtaOutboundThrottle>().await {
if !obj.object.enable {
continue;
}
let limiter = QueueRateLimiter {
expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()),
id: obj.id,
keys: obj
.object
.key
.iter()
.map(|key| match key {
enums::MtaOutboundThrottleKey::RcptDomain => THROTTLE_RCPT_DOMAIN,
enums::MtaOutboundThrottleKey::Sender => THROTTLE_SENDER,
enums::MtaOutboundThrottleKey::SenderDomain => THROTTLE_SENDER_DOMAIN,
enums::MtaOutboundThrottleKey::Mx => THROTTLE_MX,
enums::MtaOutboundThrottleKey::RemoteIp => THROTTLE_REMOTE_IP,
enums::MtaOutboundThrottleKey::LocalIp => THROTTLE_LOCAL_IP,
})
.fold(0, |acc, key| acc | key),
rate: obj.object.rate,
};
if (limiter.keys & (THROTTLE_MX | THROTTLE_REMOTE_IP | THROTTLE_LOCAL_IP)) != 0
|| limiter.expr.all_items().any(|c| {
matches!(
c,
ExpressionItem::Variable(
ExpressionVariable::Mx
| ExpressionVariable::RemoteIp
| ExpressionVariable::LocalIp
)
)
})
{
throttle.remote.push(limiter);
} else if (limiter.keys & (THROTTLE_RCPT_DOMAIN)) != 0
|| limiter
.expr
.all_items()
.any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::RcptDomain)))
{
throttle.rcpt.push(limiter);
} else {
throttle.sender.push(limiter);
}
}
throttle
}
}
impl QueueQuotas {
async fn parse(bp: &mut Bootstrap) -> QueueQuotas {
let mut capacities = QueueQuotas {
sender: Vec::new(),
rcpt: Vec::new(),
rcpt_domain: Vec::new(),
};
for obj in bp.list_infallible::<MtaQueueQuota>().await {
if !obj.object.enable {
continue;
}
let quota = QueueQuota {
expr: bp.compile_expr(obj.id, &obj.object.ctx_match_()),
id: obj.id,
keys: obj
.object
.key
.iter()
.map(|key| match key {
enums::MtaQueueQuotaKey::Rcpt => THROTTLE_RCPT,
enums::MtaQueueQuotaKey::RcptDomain => THROTTLE_RCPT_DOMAIN,
enums::MtaQueueQuotaKey::Sender => THROTTLE_SENDER,
enums::MtaQueueQuotaKey::SenderDomain => THROTTLE_SENDER_DOMAIN,
})
.fold(0, |acc, key| acc | key),
size: obj.object.size,
messages: obj.object.messages,
};
if (quota.keys & THROTTLE_RCPT) != 0
|| quota
.expr
.all_items()
.any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::Rcpt)))
{
capacities.rcpt.push(quota);
} else if (quota.keys & THROTTLE_RCPT_DOMAIN) != 0
|| quota
.expr
.all_items()
.any(|c| matches!(c, ExpressionItem::Variable(ExpressionVariable::RcptDomain)))
{
capacities.rcpt_domain.push(quota);
} else {
capacities.sender.push(quota);
}
}
capacities
}
}
impl<'x> TryFrom<Variable<'x>> for RequireOptional {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(ExpressionConstant::Optional) => Ok(RequireOptional::Optional),
Variable::Constant(ExpressionConstant::Require) => Ok(RequireOptional::Require),
Variable::Constant(ExpressionConstant::Disable) => Ok(RequireOptional::Disable),
_ => Err(()),
}
}
}
impl<'x> TryFrom<Variable<'x>> for IpLookupStrategy {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(value) => match value {
ExpressionConstant::Ipv4Only => Ok(IpLookupStrategy::Ipv4Only),
ExpressionConstant::Ipv6Only => Ok(IpLookupStrategy::Ipv6Only),
ExpressionConstant::Ipv6ThenIpv4 => Ok(IpLookupStrategy::Ipv6thenIpv4),
ExpressionConstant::Ipv4ThenIpv6 => Ok(IpLookupStrategy::Ipv4thenIpv6),
_ => Err(()),
},
Variable::String(value) => {
match value.as_str() {
"ipv4_only" => Ok(IpLookupStrategy::Ipv4Only),
"ipv6_only" => Ok(IpLookupStrategy::Ipv6Only),
//"ipv4_and_ipv6" => IpLookupStrategy::Ipv4AndIpv6,
"ipv6_then_ipv4" => Ok(IpLookupStrategy::Ipv6thenIpv4),
"ipv4_then_ipv6" => Ok(IpLookupStrategy::Ipv4thenIpv6),
_ => Err(()),
}
}
_ => Err(()),
}
}
}
impl std::fmt::Debug for RelayConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RelayConfig")
.field("address", &self.address)
.field("port", &self.port)
.field("protocol", &self.protocol)
.field("tls_implicit", &self.tls_implicit)
.field("tls_allow_invalid_certs", &self.tls_allow_invalid_certs)
.finish()
}
}
impl TlsStrategy {
#[inline(always)]
pub fn try_dane(&self) -> bool {
matches!(
self.dane,
RequireOptional::Require | RequireOptional::Optional
)
}
#[inline(always)]
pub fn try_start_tls(&self) -> bool {
matches!(
self.tls,
RequireOptional::Require | RequireOptional::Optional
)
}
#[inline(always)]
pub fn is_dane_required(&self) -> bool {
matches!(self.dane, RequireOptional::Require)
}
#[inline(always)]
pub fn try_mta_sts(&self) -> bool {
matches!(
self.mta_sts,
RequireOptional::Require | RequireOptional::Optional
)
}
#[inline(always)]
pub fn is_mta_sts_required(&self) -> bool {
matches!(self.mta_sts, RequireOptional::Require)
}
#[inline(always)]
pub fn is_tls_required(&self) -> bool {
matches!(self.tls, RequireOptional::Require)
|| self.is_dane_required()
|| self.is_mta_sts_required()
}
}
impl Hash for MxConfig {
fn hash<H: Hasher>(&self, state: &mut H) {
self.max_mx.hash(state);
self.max_multi_homed.hash(state);
}
}
impl PartialEq for MxConfig {
fn eq(&self, other: &Self) -> bool {
self.max_mx == other.max_mx && self.max_multi_homed == other.max_multi_homed
}
}
impl Eq for MxConfig {}
impl QueueName {
pub fn new(name: impl AsRef<[u8]>) -> Option<Self> {
let name_bytes = name.as_ref();
if (1..=8).contains(&name_bytes.len()) {
let mut bytes = [0; 8];
bytes[..name_bytes.len()].copy_from_slice(name_bytes);
QueueName(bytes).into()
} else {
None
}
}
pub fn from_bytes(name: &[u8]) -> Option<Self> {
name.try_into().ok().map(|bytes: [u8; 8]| QueueName(bytes))
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.0)
.unwrap_or_default()
.trim_end_matches('\0')
}
pub fn into_inner(self) -> [u8; 8] {
self.0
}
pub fn as_slice(&self) -> &[u8] {
&self.0
}
}
impl ArchivedQueueName {
pub fn as_str(&self) -> &str {
std::str::from_utf8(self.0.as_ref())
.unwrap_or_default()
.trim_end_matches('\0')
}
pub fn as_slice(&self) -> &[u8] {
self.0.as_ref()
}
}
impl Default for QueueName {
fn default() -> Self {
DEFAULT_QUEUE_NAME
}
}
impl Display for QueueName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_str().fmt(f)
}
}
impl Display for ArchivedQueueName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_str().fmt(f)
}
}
impl AsRef<[u8]> for QueueName {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
+273
View File
@@ -0,0 +1,273 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::*;
use crate::expr::{
Variable,
if_block::{BootstrapExprExt, IfBlock},
};
use registry::schema::{
enums::ExpressionConstant,
prelude::ObjectType,
structs::{
DataRetention, DkimReportSettings, DmarcReportSettings, ReportSettings, SpfReportSettings,
TlsReportSettings,
},
};
use std::{str::FromStr, time::Duration};
#[derive(Clone)]
pub struct ReportConfig {
pub submitter: IfBlock,
pub analysis: ReportAnalysis,
pub dkim: Report,
pub spf: Report,
pub dmarc: Report,
pub dmarc_aggregate: AggregateReport,
pub tls: AggregateReport,
}
#[derive(Clone)]
pub struct ReportAnalysis {
pub addresses: Vec<AddressMatch>,
pub forward: bool,
pub store: Option<Duration>,
pub max_size: usize,
}
#[derive(Clone)]
pub enum AddressMatch {
StartsWith(String),
EndsWith(String),
Equals(String),
}
#[derive(Clone)]
pub struct AggregateReport {
pub name: IfBlock,
pub address: IfBlock,
pub org_name: IfBlock,
pub contact_info: IfBlock,
pub send: IfBlock,
pub sign: IfBlock,
pub max_size: IfBlock,
}
#[derive(Clone)]
pub struct Report {
pub name: IfBlock,
pub address: IfBlock,
pub subject: IfBlock,
pub sign: IfBlock,
pub send: IfBlock,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AggregateFrequency {
Hourly,
Daily,
Weekly,
#[default]
Never,
}
impl ReportConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let report = bp.setting_infallible::<ReportSettings>().await;
let dkim = bp.setting_infallible::<DkimReportSettings>().await;
let spf = bp.setting_infallible::<SpfReportSettings>().await;
let dmarc = bp.setting_infallible::<DmarcReportSettings>().await;
let tls = bp.setting_infallible::<TlsReportSettings>().await;
let dr = bp.setting_infallible::<DataRetention>().await;
ReportConfig {
submitter: bp.compile_expr(
ObjectType::ReportSettings.singleton(),
&report.ctx_outbound_report_submitter(),
),
analysis: ReportAnalysis {
addresses: report
.inbound_report_addresses
.iter()
.filter_map(|addr| AddressMatch::from_str(addr).ok())
.collect(),
forward: report.inbound_report_forwarding,
store: dr.hold_mta_reports_for.map(|d| d.into_inner()),
max_size: std::cmp::max(report.inbound_report_max_size, 1024) as usize,
},
dkim: Report {
name: bp.compile_expr(
ObjectType::DkimReportSettings.singleton(),
&dkim.ctx_from_name(),
),
address: bp.compile_expr(
ObjectType::DkimReportSettings.singleton(),
&dkim.ctx_from_address(),
),
subject: bp.compile_expr(
ObjectType::DkimReportSettings.singleton(),
&dkim.ctx_subject(),
),
sign: bp.compile_expr(
ObjectType::DkimReportSettings.singleton(),
&dkim.ctx_dkim_sign_domain(),
),
send: bp.compile_expr(
ObjectType::DkimReportSettings.singleton(),
&dkim.ctx_send_frequency(),
),
},
spf: Report {
name: bp.compile_expr(
ObjectType::SpfReportSettings.singleton(),
&spf.ctx_from_name(),
),
address: bp.compile_expr(
ObjectType::SpfReportSettings.singleton(),
&spf.ctx_from_address(),
),
subject: bp.compile_expr(
ObjectType::SpfReportSettings.singleton(),
&spf.ctx_subject(),
),
sign: bp.compile_expr(
ObjectType::SpfReportSettings.singleton(),
&spf.ctx_dkim_sign_domain(),
),
send: bp.compile_expr(
ObjectType::SpfReportSettings.singleton(),
&spf.ctx_send_frequency(),
),
},
dmarc: Report {
name: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_failure_from_name(),
),
address: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_failure_from_address(),
),
subject: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_failure_subject(),
),
sign: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_failure_dkim_sign_domain(),
),
send: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_failure_send_frequency(),
),
},
dmarc_aggregate: AggregateReport {
name: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_from_name(),
),
address: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_from_address(),
),
org_name: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_org_name(),
),
contact_info: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_contact_info(),
),
send: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_send_frequency(),
),
sign: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_dkim_sign_domain(),
),
max_size: bp.compile_expr(
ObjectType::DmarcReportSettings.singleton(),
&dmarc.ctx_aggregate_max_report_size(),
),
},
tls: AggregateReport {
name: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_from_name(),
),
address: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_from_address(),
),
org_name: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_org_name(),
),
contact_info: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_contact_info(),
),
send: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_send_frequency(),
),
sign: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_dkim_sign_domain(),
),
max_size: bp.compile_expr(
ObjectType::TlsReportSettings.singleton(),
&tls.ctx_max_report_size(),
),
},
}
}
}
impl<'x> TryFrom<Variable<'x>> for AggregateFrequency {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(ExpressionConstant::Disable) => Ok(AggregateFrequency::Never),
Variable::Constant(ExpressionConstant::Hourly) => Ok(AggregateFrequency::Hourly),
Variable::Constant(ExpressionConstant::Daily) => Ok(AggregateFrequency::Daily),
Variable::Constant(ExpressionConstant::Weekly) => Ok(AggregateFrequency::Weekly),
_ => Err(()),
}
}
}
impl ReportAnalysis {
pub fn is_report_address(&self, address: &str) -> bool {
self.addresses.iter().any(|addr_match| match addr_match {
AddressMatch::StartsWith(prefix) => address.starts_with(prefix),
AddressMatch::EndsWith(suffix) => address.ends_with(suffix),
AddressMatch::Equals(value) => address == value,
})
}
}
impl FromStr for AddressMatch {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if let Some(value) = value.strip_prefix('*').map(|v| v.trim()) {
if !value.is_empty() {
return Ok(AddressMatch::EndsWith(value.to_lowercase()));
}
} else if let Some(value) = value.strip_suffix('*').map(|v| v.trim()) {
if !value.is_empty() {
return Ok(AddressMatch::StartsWith(value.to_lowercase()));
}
} else if value.contains('@') {
return Ok(AddressMatch::Equals(value.trim().to_lowercase()));
}
Err(format!("Invalid address match value {:?}.", value,))
}
}
+406
View File
@@ -0,0 +1,406 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::AHashMap;
use mail_auth::{
MessageAuthenticator,
hickory_resolver::{
TokioResolver,
config::{
CLOUDFLARE, ConnectionConfig, GOOGLE, NameServerConfig, ProtocolConfig, QUAD9,
ResolverConfig, ResolverOpts,
},
net::runtime::TokioRuntimeProvider,
system_conf::read_system_conf,
},
};
use registry::schema::{
enums::{DnsResolverProtocol, PolicyEnforcement},
prelude::ObjectType,
structs::{DnsResolver, MtaSts, SystemSettings},
};
use serde::{Deserialize, Serialize};
use std::{
fmt::Display,
hash::{DefaultHasher, Hash, Hasher},
net::IpAddr,
str::FromStr,
sync::Arc,
};
use store::registry::bootstrap::Bootstrap;
use utils::cache::CacheItemWeight;
pub struct Resolvers {
pub dns: MessageAuthenticator,
pub dnssec: DnssecResolver,
pub dnssec_available: bool,
}
#[derive(Clone)]
pub struct DnssecResolver {
pub resolver: TokioResolver,
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub enum TlsaMatching {
Full,
Sha256,
Sha512,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct TlsaEntry {
pub is_end_entity: bool,
pub is_spki: bool,
pub matching: TlsaMatching,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tlsa {
pub entries: Vec<TlsaEntry>,
pub has_end_entities: bool,
pub has_intermediates: bool,
}
#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Mode {
Enforce,
Testing,
#[default]
None,
}
#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MxPattern {
Equals(String),
StartsWith(String),
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub mode: Mode,
pub mx: Box<[MxPattern]>,
pub max_age: u64,
}
impl CacheItemWeight for Tlsa {
fn weight(&self) -> u64 {
self.entries
.iter()
.map(|entry| (entry.data.len() + std::mem::size_of::<TlsaEntry>()) as u64)
.sum::<u64>()
+ std::mem::size_of::<Tlsa>() as u64
}
}
impl CacheItemWeight for Policy {
fn weight(&self) -> u64 {
(std::mem::size_of::<Policy>()
+ self
.mx
.iter()
.map(|mx| match mx {
MxPattern::Equals(t) => t.len(),
MxPattern::StartsWith(t) => t.len(),
})
.sum::<usize>()) as u64
}
}
impl Resolvers {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut resolver_config: ResolverConfig;
let mut opts = ResolverOpts::default();
match bp.setting_infallible::<DnsResolver>().await {
DnsResolver::System(resolver) => match read_system_conf() {
Ok((config, options)) => {
resolver_config = config;
opts = options;
opts.num_concurrent_reqs = resolver.concurrency as usize;
opts.timeout = resolver.timeout.into_inner();
opts.preserve_intermediates = resolver.preserve_intermediates;
opts.try_tcp_on_error = resolver.tcp_on_error;
opts.attempts = resolver.attempts as usize;
opts.edns0 = resolver.enable_edns;
}
Err(err) => {
bp.build_error(
ObjectType::DnsResolver.singleton(),
format!("Failed to read system DNS config: {err}"),
);
resolver_config = ResolverConfig::udp_and_tcp(&CLOUDFLARE);
}
},
DnsResolver::Custom(resolver) => {
resolver_config = ResolverConfig::default();
let mut nameservers: AHashMap<IpAddr, Vec<ConnectionConfig>> = AHashMap::new();
for server in resolver.servers {
let ip = server.address.into_inner();
let port = server.port as u16;
let protocol = match server.protocol {
DnsResolverProtocol::Udp => ProtocolConfig::Udp,
DnsResolverProtocol::Tcp => ProtocolConfig::Tcp,
DnsResolverProtocol::Tls => ProtocolConfig::Tls {
server_name: Arc::from(server.address.to_string()),
},
};
let mut connection = ConnectionConfig::new(protocol);
connection.port = port;
nameservers.entry(ip).or_default().push(connection);
}
for (ip, connections) in nameservers {
resolver_config.add_name_server(NameServerConfig::new(ip, true, connections));
}
opts.num_concurrent_reqs = resolver.concurrency as usize;
opts.timeout = resolver.timeout.into_inner();
opts.preserve_intermediates = resolver.preserve_intermediates;
opts.try_tcp_on_error = resolver.tcp_on_error;
opts.attempts = resolver.attempts as usize;
opts.edns0 = resolver.enable_edns;
}
DnsResolver::Cloudflare(resolver) => {
resolver_config = if resolver.use_tls {
ResolverConfig::tls(&CLOUDFLARE)
} else {
ResolverConfig::udp_and_tcp(&CLOUDFLARE)
};
opts.num_concurrent_reqs = resolver.concurrency as usize;
opts.timeout = resolver.timeout.into_inner();
opts.preserve_intermediates = resolver.preserve_intermediates;
opts.try_tcp_on_error = resolver.tcp_on_error;
opts.attempts = resolver.attempts as usize;
opts.edns0 = resolver.enable_edns;
}
DnsResolver::Quad9(resolver) => {
resolver_config = if resolver.use_tls {
ResolverConfig::tls(&QUAD9)
} else {
ResolverConfig::udp_and_tcp(&QUAD9)
};
opts.num_concurrent_reqs = resolver.concurrency as usize;
opts.timeout = resolver.timeout.into_inner();
opts.preserve_intermediates = resolver.preserve_intermediates;
opts.try_tcp_on_error = resolver.tcp_on_error;
opts.attempts = resolver.attempts as usize;
opts.edns0 = resolver.enable_edns;
}
DnsResolver::Google(resolver) => {
resolver_config = ResolverConfig::udp_and_tcp(&GOOGLE);
opts.num_concurrent_reqs = resolver.concurrency as usize;
opts.timeout = resolver.timeout.into_inner();
opts.preserve_intermediates = resolver.preserve_intermediates;
opts.try_tcp_on_error = resolver.tcp_on_error;
opts.attempts = resolver.attempts as usize;
opts.edns0 = resolver.enable_edns;
}
}
// We already have a cache, so disable the built-in cache
opts.cache_size = 0;
// Prepare DNSSEC resolver options
let config_dnssec = resolver_config.clone();
let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true;
let dnssec = DnssecResolver {
resolver: TokioResolver::builder_with_config(
config_dnssec,
TokioRuntimeProvider::default(),
)
.with_options(opts_dnssec)
.build()
.expect("Failed to build DNSSEC resolver"),
};
Resolvers {
#[cfg(not(feature = "test_mode"))]
dnssec_available: ensure_dnssec(&resolver_config, &dnssec.resolver).await,
#[cfg(feature = "test_mode")]
dnssec_available: true,
dns: MessageAuthenticator::new(resolver_config, opts).unwrap(),
dnssec,
}
}
}
#[cfg(not(feature = "test_mode"))]
async fn ensure_dnssec(config: &ResolverConfig, resolver: &TokioResolver) -> bool {
config.name_servers().iter().any(|name_server| {
name_server
.connections
.iter()
.any(|connection| !matches!(connection.protocol, ProtocolConfig::Udp))
}) && resolver
.lookup(
hickory_proto::rr::Name::root(),
hickory_proto::rr::RecordType::DNSKEY,
)
.await
.is_ok_and(|lookup| {
lookup
.answers()
.iter()
.any(|record| record.proof.is_secure())
})
}
impl Policy {
pub async fn try_parse(bp: &mut Bootstrap) -> Option<Self> {
let mta = bp.setting_infallible::<MtaSts>().await;
if matches!(mta.mode, PolicyEnforcement::Disable) {
return None;
}
let mut mx_hosts = mta.mx_hosts.into_inner();
if mx_hosts.is_empty() {
let settings = bp.setting_infallible::<SystemSettings>().await;
let default_host = settings.default_hostname.as_str();
mx_hosts = settings
.mail_exchangers
.iter()
.map(|mx| mx.hostname.as_deref().unwrap_or(default_host).to_string())
.collect();
}
if !mx_hosts.is_empty() {
mx_hosts.sort_unstable();
mx_hosts.dedup();
let mut policy = Policy {
id: Default::default(),
mode: match mta.mode {
PolicyEnforcement::Enforce => Mode::Enforce,
PolicyEnforcement::Testing => Mode::Testing,
PolicyEnforcement::Disable => Mode::None,
},
mx: mx_hosts
.into_iter()
.map(|mx| {
if let Some(mx) = mx.strip_prefix("*.") {
MxPattern::StartsWith(mx.to_string())
} else {
MxPattern::Equals(mx)
}
})
.collect(),
max_age: mta.max_age.into_inner().as_secs(),
};
policy.id = policy.hash().to_string();
Some(policy)
} else {
None
}
}
fn hash(&self) -> u64 {
let mut s = DefaultHasher::new();
self.mode.hash(&mut s);
self.max_age.hash(&mut s);
self.mx.hash(&mut s);
s.finish()
}
}
impl FromStr for Mode {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"enforce" => Ok(Self::Enforce),
"testing" | "test" => Ok(Self::Testing),
"none" => Ok(Self::None),
_ => Err(format!("Invalid mode value {value:?}")),
}
}
}
impl Default for Resolvers {
fn default() -> Self {
let (config, opts) = match read_system_conf() {
Ok(conf) => conf,
Err(_) => (
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
ResolverOpts::default(),
),
};
let config_dnssec = config.clone();
let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true;
Self {
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
dnssec: DnssecResolver {
resolver: TokioResolver::builder_with_config(
config_dnssec,
TokioRuntimeProvider::default(),
)
.with_options(opts_dnssec)
.build()
.expect("Failed to build DNSSEC resolver"),
},
dnssec_available: true,
}
}
}
impl Display for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("version: STSv1\r\n")?;
f.write_str("mode: ")?;
match self.mode {
Mode::Enforce => f.write_str("enforce")?,
Mode::Testing => f.write_str("testing")?,
Mode::None => f.write_str("none")?,
}
f.write_str("\r\nmax_age: ")?;
self.max_age.fmt(f)?;
f.write_str("\r\n")?;
for mx in &self.mx {
f.write_str("mx: ")?;
mx.fmt(f)?;
f.write_str("\r\n")?;
}
Ok(())
}
}
impl Display for MxPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MxPattern::Equals(mx) => f.write_str(mx),
MxPattern::StartsWith(mx) => {
f.write_str("*.")?;
f.write_str(mx)
}
}
}
}
impl Clone for Resolvers {
fn clone(&self) -> Self {
Self {
dns: self.dns.clone(),
dnssec: self.dnssec.clone(),
dnssec_available: self.dnssec_available,
}
}
}
+567
View File
@@ -0,0 +1,567 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use self::resolver::Policy;
use super::*;
use crate::expr::{
Variable,
if_block::{BootstrapExprExt, IfBlock},
};
use ahash::AHashSet;
use hyper::HeaderMap;
use registry::schema::{
enums::{self, ExpressionConstant, MtaStage},
prelude::ObjectType,
structs::{
MtaExtensions, MtaHook, MtaInboundSession, MtaMilter, MtaStageAuth, MtaStageConnect,
MtaStageData, MtaStageEhlo, MtaStageMail, MtaStageRcpt,
},
};
use smtp_proto::*;
use std::{
net::{SocketAddr, ToSocketAddrs},
str::FromStr,
time::Duration,
};
#[derive(Clone)]
pub struct SessionConfig {
pub timeout: IfBlock,
pub duration: IfBlock,
pub transfer_limit: IfBlock,
pub connect: Connect,
pub ehlo: Ehlo,
pub auth: Auth,
pub mail: Mail,
pub rcpt: Rcpt,
pub data: Data,
pub extensions: Extensions,
pub mta_sts_policy: Option<Policy>,
pub milters: Vec<Milter>,
pub hooks: Vec<MTAHook>,
}
#[derive(Clone)]
pub struct Connect {
pub hostname: IfBlock,
pub script: IfBlock,
pub greeting: IfBlock,
}
#[derive(Clone)]
pub struct Ehlo {
pub script: IfBlock,
pub require: IfBlock,
pub reject_non_fqdn: IfBlock,
}
#[derive(Clone)]
pub struct Extensions {
pub pipelining: IfBlock,
pub chunking: IfBlock,
pub requiretls: IfBlock,
pub dsn: IfBlock,
pub vrfy: IfBlock,
pub expn: IfBlock,
pub no_soliciting: IfBlock,
pub future_release: IfBlock,
pub deliver_by: IfBlock,
pub mt_priority: IfBlock,
}
#[derive(Clone)]
pub struct Auth {
pub mechanisms: IfBlock,
pub require: IfBlock,
pub must_match_sender: IfBlock,
pub errors_max: IfBlock,
pub errors_wait: IfBlock,
}
#[derive(Clone)]
pub struct Mail {
pub script: IfBlock,
pub rewrite: IfBlock,
pub is_allowed: IfBlock,
}
#[derive(Clone)]
pub struct Rcpt {
pub script: IfBlock,
pub relay: IfBlock,
pub rewrite: IfBlock,
pub errors_max: IfBlock,
pub errors_wait: IfBlock,
pub max_recipients: IfBlock,
}
#[derive(Debug, Default, Clone)]
pub enum AddressMapping {
Enable,
Custom(IfBlock),
#[default]
Disable,
}
#[derive(Clone)]
pub struct Data {
pub script: IfBlock,
pub spam_filter: IfBlock,
pub max_messages: IfBlock,
pub max_message_size: IfBlock,
pub max_received_headers: IfBlock,
pub add_received: IfBlock,
pub add_received_spf: IfBlock,
pub add_return_path: IfBlock,
pub add_auth_results: IfBlock,
pub add_message_id: IfBlock,
pub add_date: IfBlock,
pub add_delivered_to: bool,
}
#[derive(Clone)]
pub struct Milter {
pub enable: IfBlock,
pub id: ObjectId,
pub addrs: Vec<SocketAddr>,
pub hostname: String,
pub port: u16,
pub timeout_connect: Duration,
pub timeout_command: Duration,
pub timeout_data: Duration,
pub tls: bool,
pub tls_allow_invalid_certs: bool,
pub tempfail_on_error: bool,
pub max_frame_len: usize,
pub protocol_version: MilterVersion,
pub flags_actions: Option<u32>,
pub flags_protocol: Option<u32>,
pub run_on_stage: AHashSet<Stage>,
}
#[derive(Clone, Copy)]
pub enum MilterVersion {
V2,
V6,
}
#[derive(Clone)]
pub struct MTAHook {
pub enable: IfBlock,
pub id: ObjectId,
pub url: String,
pub timeout: Duration,
pub headers: HeaderMap,
pub tls_allow_invalid_certs: bool,
pub tempfail_on_error: bool,
pub run_on_stage: AHashSet<Stage>,
pub max_response_size: usize,
pub client: reqwest::Client,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum Stage {
Connect,
Ehlo,
Auth,
Mail,
Rcpt,
Data,
}
impl SessionConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let session = bp.setting_infallible::<MtaInboundSession>().await;
let connect = bp.setting_infallible::<MtaStageConnect>().await;
let auth = bp.setting_infallible::<MtaStageAuth>().await;
let ehlo = bp.setting_infallible::<MtaStageEhlo>().await;
let mail = bp.setting_infallible::<MtaStageMail>().await;
let rcpt = bp.setting_infallible::<MtaStageRcpt>().await;
let data = bp.setting_infallible::<MtaStageData>().await;
let ext = bp.setting_infallible::<MtaExtensions>().await;
let mut hooks = Vec::new();
for hook in bp.list_infallible::<MtaHook>().await {
let id = hook.id;
let hook = hook.object;
let enable = bp.compile_expr(id, &hook.ctx_enable());
let headers = match hook
.http_auth
.build_headers(hook.http_headers, "application/json".into())
.await
{
Ok(headers) => headers,
Err(err) => {
bp.build_error(id, format!("Unable to build HTTP headers: {}", err));
continue;
}
};
hooks.push(MTAHook {
enable,
id,
url: hook.url,
timeout: hook.timeout.into_inner(),
headers,
tls_allow_invalid_certs: hook.allow_invalid_certs,
tempfail_on_error: hook.temp_fail_on_error,
run_on_stage: hook.stages.into_iter().map(Stage::from).collect(),
max_response_size: hook.max_response_size as usize,
client: utils::http::http_client_builder(hook.allow_invalid_certs)
.build()
.unwrap_or_default(),
});
}
SessionConfig {
timeout: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_timeout(),
),
duration: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_max_duration(),
),
transfer_limit: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_transfer_limit(),
),
connect: Connect {
hostname: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_hostname(),
),
script: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_script(),
),
greeting: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_smtp_greeting(),
),
},
ehlo: Ehlo {
script: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_script()),
require: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_require()),
reject_non_fqdn: bp.compile_expr(
ObjectType::MtaStageEhlo.singleton(),
&ehlo.ctx_reject_non_fqdn(),
),
},
auth: Auth {
mechanisms: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_sasl_mechanisms(),
),
require: bp.compile_expr(ObjectType::MtaStageAuth.singleton(), &auth.ctx_require()),
must_match_sender: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_must_match_sender(),
),
errors_max: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_max_failures(),
),
errors_wait: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_wait_on_fail(),
),
},
mail: Mail {
script: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_script()),
rewrite: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_rewrite()),
is_allowed: bp.compile_expr(
ObjectType::MtaStageMail.singleton(),
&mail.ctx_is_sender_allowed(),
),
},
rcpt: Rcpt {
script: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_script()),
relay: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_allow_relaying(),
),
rewrite: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_rewrite()),
errors_max: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_max_failures(),
),
errors_wait: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_wait_on_fail(),
),
max_recipients: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_max_recipients(),
),
},
data: Data {
script: bp.compile_expr(ObjectType::MtaStageData.singleton(), &data.ctx_script()),
spam_filter: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_enable_spam_filter(),
),
max_messages: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_messages(),
),
max_message_size: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_message_size(),
),
max_received_headers: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_received_headers(),
),
add_received: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_received_header(),
),
add_received_spf: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_received_spf_header(),
),
add_return_path: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_return_path_header(),
),
add_auth_results: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_auth_results_header(),
),
add_message_id: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_message_id_header(),
),
add_date: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_date_header(),
),
add_delivered_to: data.add_delivered_to_header,
},
extensions: Extensions {
pipelining: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_pipelining()),
chunking: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_chunking()),
requiretls: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_require_tls(),
),
dsn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_dsn()),
vrfy: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_vrfy()),
expn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_expn()),
no_soliciting: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_no_soliciting(),
),
future_release: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_future_release(),
),
deliver_by: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_deliver_by()),
mt_priority: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_mt_priority(),
),
},
mta_sts_policy: Policy::try_parse(bp).await,
milters: bp
.list_infallible::<MtaMilter>()
.await
.into_iter()
.filter_map(|milter| {
let id = milter.id;
let milter = milter.object;
Some(Milter {
enable: bp.compile_expr(id, &milter.ctx_enable()),
id,
addrs: format!("{}:{}", milter.hostname, milter.port)
.to_socket_addrs()
.map_err(|err| {
bp.build_error(
id,
format!(
"Unable to resolve milter hostname {}: {}",
milter.hostname, err
),
)
})
.ok()?
.collect(),
hostname: milter.hostname,
port: milter.port as u16,
timeout_connect: milter.timeout_connect.into_inner(),
timeout_command: milter.timeout_command.into_inner(),
timeout_data: milter.timeout_data.into_inner(),
tls: milter.use_tls,
tls_allow_invalid_certs: milter.allow_invalid_certs,
tempfail_on_error: milter.temp_fail_on_error,
max_frame_len: milter.max_response_size as usize,
protocol_version: match milter.protocol_version {
enums::MilterVersion::V2 => MilterVersion::V2,
enums::MilterVersion::V6 => MilterVersion::V6,
},
flags_actions: milter.flags_action.map(|v| v as u32),
flags_protocol: milter.flags_protocol.map(|v| v as u32),
run_on_stage: milter.stages.into_iter().map(Stage::from).collect(),
})
})
.collect(),
hooks,
}
}
}
#[derive(Default)]
pub struct Mechanism(u64);
impl FromStr for Mechanism {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Mechanism(match value.to_ascii_uppercase().as_str() {
"LOGIN" => AUTH_LOGIN,
"PLAIN" => AUTH_PLAIN,
"XOAUTH2" => AUTH_XOAUTH2,
"OAUTHBEARER" => AUTH_OAUTHBEARER,
/*"SCRAM-SHA-256-PLUS" => AUTH_SCRAM_SHA_256_PLUS,
"SCRAM-SHA-256" => AUTH_SCRAM_SHA_256,
"SCRAM-SHA-1-PLUS" => AUTH_SCRAM_SHA_1_PLUS,
"SCRAM-SHA-1" => AUTH_SCRAM_SHA_1,
"XOAUTH" => AUTH_XOAUTH,
"9798-M-DSA-SHA1" => AUTH_9798_M_DSA_SHA1,
"9798-M-ECDSA-SHA1" => AUTH_9798_M_ECDSA_SHA1,
"9798-M-RSA-SHA1-ENC" => AUTH_9798_M_RSA_SHA1_ENC,
"9798-U-DSA-SHA1" => AUTH_9798_U_DSA_SHA1,
"9798-U-ECDSA-SHA1" => AUTH_9798_U_ECDSA_SHA1,
"9798-U-RSA-SHA1-ENC" => AUTH_9798_U_RSA_SHA1_ENC,
"EAP-AES128" => AUTH_EAP_AES128,
"EAP-AES128-PLUS" => AUTH_EAP_AES128_PLUS,
"ECDH-X25519-CHALLENGE" => AUTH_ECDH_X25519_CHALLENGE,
"ECDSA-NIST256P-CHALLENGE" => AUTH_ECDSA_NIST256P_CHALLENGE,
"EXTERNAL" => AUTH_EXTERNAL,
"GS2-KRB5" => AUTH_GS2_KRB5,
"GS2-KRB5-PLUS" => AUTH_GS2_KRB5_PLUS,
"GSS-SPNEGO" => AUTH_GSS_SPNEGO,
"GSSAPI" => AUTH_GSSAPI,
"KERBEROS_V4" => AUTH_KERBEROS_V4,
"KERBEROS_V5" => AUTH_KERBEROS_V5,
"NMAS-SAMBA-AUTH" => AUTH_NMAS_SAMBA_AUTH,
"NMAS_AUTHEN" => AUTH_NMAS_AUTHEN,
"NMAS_LOGIN" => AUTH_NMAS_LOGIN,
"NTLM" => AUTH_NTLM,
"OAUTH10A" => AUTH_OAUTH10A,
"OPENID20" => AUTH_OPENID20,
"OTP" => AUTH_OTP,
"SAML20" => AUTH_SAML20,
"SECURID" => AUTH_SECURID,
"SKEY" => AUTH_SKEY,
"SPNEGO" => AUTH_SPNEGO,
"SPNEGO-PLUS" => AUTH_SPNEGO_PLUS,
"SXOVER-PLUS" => AUTH_SXOVER_PLUS,
"CRAM-MD5" => AUTH_CRAM_MD5,
"DIGEST-MD5" => AUTH_DIGEST_MD5,
"ANONYMOUS" => AUTH_ANONYMOUS,*/
_ => return Err(format!("Unsupported mechanism {:?}.", value)),
}))
}
}
impl<'x> TryFrom<Variable<'x>> for Mechanism {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(value) => Mechanism::try_from(value),
Variable::Array(items) => {
let mut mechanism = 0;
for item in items {
match item {
Variable::Constant(value) => mechanism |= Mechanism::try_from(value)?.0,
_ => return Err(()),
}
}
Ok(Mechanism(mechanism))
}
_ => Err(()),
}
}
}
impl TryFrom<ExpressionConstant> for Mechanism {
type Error = ();
fn try_from(value: ExpressionConstant) -> Result<Self, Self::Error> {
match value {
ExpressionConstant::Login => Ok(Mechanism(AUTH_LOGIN)),
ExpressionConstant::Plain => Ok(Mechanism(AUTH_PLAIN)),
ExpressionConstant::Xoauth2 => Ok(Mechanism(AUTH_XOAUTH2)),
ExpressionConstant::Oauthbearer => Ok(Mechanism(AUTH_OAUTHBEARER)),
_ => Err(()),
}
}
}
impl From<Mechanism> for u64 {
fn from(value: Mechanism) -> Self {
value.0
}
}
impl From<u64> for Mechanism {
fn from(value: u64) -> Self {
Mechanism(value)
}
}
impl<'x> TryFrom<Variable<'x>> for MtPriority {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(value) => match value {
ExpressionConstant::Mixer => Ok(MtPriority::Mixer),
ExpressionConstant::Stanag4406 => Ok(MtPriority::Stanag4406),
ExpressionConstant::Nsep => Ok(MtPriority::Nsep),
_ => Err(()),
},
Variable::String(value) => {
let value = value.as_str();
if value.eq_ignore_ascii_case("MIXER") {
Ok(MtPriority::Mixer)
} else if value.eq_ignore_ascii_case("STANAG4406") {
Ok(MtPriority::Stanag4406)
} else if value.eq_ignore_ascii_case("NSEP") {
Ok(MtPriority::Nsep)
} else {
Err(())
}
}
_ => Err(()),
}
}
}
impl From<MtaStage> for Stage {
fn from(value: MtaStage) -> Self {
match value {
MtaStage::Connect => Stage::Connect,
MtaStage::Ehlo => Stage::Ehlo,
MtaStage::Auth => Stage::Auth,
MtaStage::Mail => Stage::Mail,
MtaStage::Rcpt => Stage::Rcpt,
MtaStage::Data => Stage::Data,
}
}
}
+57
View File
@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use coordinator::Coordinator;
use directory::{Directories, Directory};
use registry::schema::prelude::ObjectType;
use std::{collections::HashMap, sync::Arc};
use store::{
BlobStore, InMemoryStore, RegistryStore, SearchStore, Store, registry::bootstrap::Bootstrap,
};
pub type IdMap<V> = HashMap<u32, Arc<V>, nohash_hasher::BuildNoHashHasher<u32>>;
#[derive(Clone)]
pub struct Storage {
pub registry: RegistryStore,
pub data: Store,
pub blob: BlobStore,
pub search: SearchStore,
pub memory: InMemoryStore,
pub metrics: Store,
pub tracing: Store,
pub coordinator: Coordinator,
pub directory: Option<Arc<Directory>>,
pub directories: IdMap<Directory>,
}
impl Storage {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let memory = InMemoryStore::build(bp).await.unwrap_or_default();
let directory = Directories::build(bp).await;
let search = SearchStore::build(bp).await.unwrap_or_default();
if let Err(err) = search.create_indexes().await {
bp.build_warning(
ObjectType::SearchStore.singleton(),
format!("Failed to create search indexes: {err}"),
);
}
Storage {
registry: bp.registry.clone(),
data: bp.data_store.clone(),
blob: BlobStore::build(bp).await.unwrap_or_default(),
search,
coordinator: Coordinator::build(bp, &memory).await.unwrap_or_default(),
memory,
tracing: Store::build_tracing(bp).await.unwrap_or_default(),
metrics: Store::build_metrics(bp).await.unwrap_or_default(),
directory: directory.default_directory,
directories: directory.directories,
}
}
}
+716
View File
@@ -0,0 +1,716 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::config::storage::Storage;
use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose::STANDARD};
use hyper::HeaderMap;
use opentelemetry::{InstrumentationScope, KeyValue};
use opentelemetry_otlp::{
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
};
use opentelemetry_sdk::{Resource, metrics::Temporality};
use opentelemetry_semantic_conventions::resource::SERVICE_VERSION;
use registry::schema::{
enums::{EventPolicy, LogRotateFrequency},
prelude::ObjectType,
structs::{self, EventTracingLevel, MetricsPrometheus, Tracer, WebHook},
};
use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration};
use store::registry::bootstrap::Bootstrap;
use trc::{EventType, Level, MetricType, TelemetryEvent, ipc::subscriber::Interests};
#[derive(Debug)]
pub struct TelemetrySubscriber {
pub id: String,
pub interests: Interests,
pub typ: TelemetrySubscriberType,
pub lossy: bool,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum TelemetrySubscriberType {
ConsoleTracer(ConsoleTracer),
LogTracer(LogTracer),
OtelTracer(OtelTracer),
Webhook(WebhookTracer),
#[cfg(unix)]
JournalTracer(crate::telemetry::tracers::journald::Subscriber),
}
#[derive(Debug)]
pub struct OtelTracer {
pub span_exporter: SpanExporter,
pub span_exporter_enable: bool,
pub log_exporter: LogExporter,
pub log_exporter_enable: bool,
pub throttle: Duration,
}
pub struct OtelMetrics {
pub resource: Resource,
pub instrumentation: InstrumentationScope,
pub exporter: MetricExporter,
pub interval: Duration,
}
#[derive(Debug)]
pub struct ConsoleTracer {
pub ansi: bool,
pub multiline: bool,
pub buffered: bool,
}
#[derive(Debug)]
pub struct LogTracer {
pub path: String,
pub prefix: String,
pub rotate: RotationStrategy,
pub ansi: bool,
pub multiline: bool,
}
#[derive(Debug)]
pub struct WebhookTracer {
pub url: String,
pub key: String,
pub timeout: Duration,
pub throttle: Duration,
pub discard_after: Duration,
pub tls_allow_invalid_certs: bool,
pub headers: HeaderMap,
pub client: reqwest::Client,
}
#[derive(Debug)]
pub enum RotationStrategy {
Daily,
Hourly,
Minutely,
Never,
}
#[derive(Debug)]
pub struct Telemetry {
pub tracers: Tracers,
pub metrics: Interests,
}
#[derive(Debug)]
pub struct Tracers {
pub interests: Interests,
pub levels: AHashMap<EventType, Level>,
pub subscribers: Vec<TelemetrySubscriber>,
}
#[derive(Debug, Clone, Default)]
pub struct Metrics {
pub prometheus: Option<PrometheusMetrics>,
pub otel: Option<Arc<OtelMetrics>>,
pub log_path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PrometheusMetrics {
pub auth: Option<String>,
}
impl Telemetry {
pub async fn parse(bp: &mut Bootstrap, storage: &Storage) -> Self {
let mut telemetry = Telemetry {
tracers: Tracers::parse(bp, storage).await,
metrics: Interests::default(),
};
// Parse metrics
let metrics = bp.setting_infallible::<structs::Metrics>().await;
apply_metrics(metrics.metrics, metrics.metrics_policy, |metric_type| {
let event_id = metric_type.event_id();
if event_id != usize::MAX {
telemetry.metrics.set(event_id);
}
});
telemetry
}
}
impl Tracers {
pub async fn parse(bp: &mut Bootstrap, storage: &Storage) -> Self {
let mut custom_levels = AHashMap::new();
let mut tracers: Vec<TelemetrySubscriber> = Vec::new();
let mut global_interests = Interests::default();
if !bp.registry.is_recovery_mode() {
// Parse custom logging levels
for level in bp.list_infallible::<EventTracingLevel>().await {
custom_levels.insert(level.object.event, level.object.level.into());
}
// Parse tracers
for tracer in bp.list_infallible::<Tracer>().await {
let id = tracer.id;
let tracer = tracer.object;
let level;
let lossy;
let events;
let events_policy;
let enable;
let typ = match tracer {
Tracer::Log(tracer) if tracer.enable => {
level = Level::from(tracer.level);
lossy = tracer.lossy;
events = tracer.events;
events_policy = tracer.events_policy;
enable = tracer.enable;
TelemetrySubscriberType::LogTracer(LogTracer {
path: tracer.path,
prefix: tracer.prefix,
rotate: match tracer.rotate {
LogRotateFrequency::Daily => RotationStrategy::Daily,
LogRotateFrequency::Hourly => RotationStrategy::Hourly,
LogRotateFrequency::Minutely => RotationStrategy::Minutely,
LogRotateFrequency::Never => RotationStrategy::Never,
},
ansi: tracer.ansi,
multiline: tracer.multiline,
})
}
Tracer::Stdout(tracer) if tracer.enable => {
level = Level::from(tracer.level);
lossy = tracer.lossy;
events = tracer.events;
events_policy = tracer.events_policy;
enable = tracer.enable;
if !tracers
.iter()
.any(|t| matches!(t.typ, TelemetrySubscriberType::ConsoleTracer(_)))
{
TelemetrySubscriberType::ConsoleTracer(ConsoleTracer {
ansi: tracer.ansi,
multiline: tracer.multiline,
buffered: tracer.buffered,
})
} else {
bp.build_error(id, "Only one console tracer is allowed");
continue;
}
}
Tracer::Journal(tracer) if tracer.enable => {
#[cfg(unix)]
{
level = Level::from(tracer.level);
lossy = tracer.lossy;
events = tracer.events;
events_policy = tracer.events_policy;
enable = tracer.enable;
if !tracers
.iter()
.any(|t| matches!(t.typ, TelemetrySubscriberType::JournalTracer(_)))
{
match crate::telemetry::tracers::journald::Subscriber::new() {
Ok(subscriber) => {
TelemetrySubscriberType::JournalTracer(subscriber)
}
Err(e) => {
bp.build_error(
id,
format!("Failed to create journald subscriber: {e}"),
);
continue;
}
}
} else {
bp.build_error(id, "Only one journal tracer is allowed");
continue;
}
}
#[cfg(not(unix))]
{
bp.build_error(id, "Journald is only available on Unix systems.");
continue;
}
}
Tracer::OtelHttp(tracer) if tracer.enable => {
level = Level::from(tracer.level);
lossy = tracer.lossy;
events = tracer.events;
events_policy = tracer.events_policy;
enable = tracer.enable;
let headers = match tracer
.http_auth
.build_headers(tracer.http_headers, None)
.await
{
Ok(headers) => headers
.into_iter()
.filter_map(|(k, v)| {
k.and_then(|k| {
Some((k.to_string(), v.to_str().ok()?.to_string()))
})
})
.collect::<HashMap<String, String>>(),
Err(err) => {
bp.build_error(
id,
format!("Failed to build OpenTelemetry HTTP headers: {err}"),
);
continue;
}
};
let mut span_exporter = SpanExporter::builder()
.with_http()
.with_endpoint(tracer.endpoint.clone())
.with_timeout(tracer.timeout.into_inner());
let mut log_exporter = LogExporter::builder()
.with_http()
.with_endpoint(tracer.endpoint)
.with_timeout(tracer.timeout.into_inner());
if !headers.is_empty() {
span_exporter = span_exporter.with_headers(headers.clone());
log_exporter = log_exporter.with_headers(headers);
}
match (span_exporter.build(), log_exporter.build()) {
(Ok(span_exporter), Ok(log_exporter)) => {
TelemetrySubscriberType::OtelTracer(OtelTracer {
span_exporter,
log_exporter,
throttle: tracer.throttle.into_inner(),
span_exporter_enable: tracer.enable_span_exporter,
log_exporter_enable: tracer.enable_log_exporter,
})
}
(Err(err), _) => {
bp.build_error(
id,
format!("Failed to build OpenTelemetry span exporter: {err}"),
);
continue;
}
(_, Err(err)) => {
bp.build_error(
id,
format!("Failed to build OpenTelemetry log exporter: {err}"),
);
continue;
}
}
}
Tracer::OtelGrpc(tracer) if tracer.enable => {
level = Level::from(tracer.level);
lossy = tracer.lossy;
events = tracer.events;
events_policy = tracer.events_policy;
enable = tracer.enable;
let mut span_exporter = SpanExporter::builder()
.with_tonic()
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
.with_timeout(tracer.timeout.into_inner());
let mut log_exporter = LogExporter::builder()
.with_tonic()
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
.with_timeout(tracer.timeout.into_inner());
if let Some(endpoint) = tracer.endpoint {
span_exporter = span_exporter.with_endpoint(endpoint.clone());
log_exporter = log_exporter.with_endpoint(endpoint);
}
match (span_exporter.build(), log_exporter.build()) {
(Ok(span_exporter), Ok(log_exporter)) => {
TelemetrySubscriberType::OtelTracer(OtelTracer {
span_exporter,
log_exporter,
throttle: tracer.throttle.into_inner(),
span_exporter_enable: tracer.enable_span_exporter,
log_exporter_enable: tracer.enable_log_exporter,
})
}
(Err(err), _) => {
bp.build_error(
id,
format!("Failed to build OpenTelemetry span exporter: {err}"),
);
continue;
}
(_, Err(err)) => {
bp.build_error(
id,
format!("Failed to build OpenTelemetry log exporter: {err}"),
);
continue;
}
}
}
_ => continue,
};
if !enable {
continue;
}
// Create tracer
let mut tracer = TelemetrySubscriber {
id: format!("t_{}", id.id()),
interests: Default::default(),
lossy,
typ,
};
// Parse disabled events
let exclude_event = match &tracer.typ {
TelemetrySubscriberType::ConsoleTracer(_) => None,
TelemetrySubscriberType::LogTracer(_) => {
EventType::Telemetry(TelemetryEvent::LogError).into()
}
TelemetrySubscriberType::OtelTracer(_) => {
EventType::Telemetry(TelemetryEvent::OtelExporterError).into()
}
TelemetrySubscriberType::Webhook(_) => {
EventType::Telemetry(TelemetryEvent::WebhookError).into()
}
#[cfg(unix)]
TelemetrySubscriberType::JournalTracer(_) => {
EventType::Telemetry(TelemetryEvent::JournalError).into()
}
};
// Parse disabled events
apply_events(events, events_policy, |event_type| {
if exclude_event != Some(event_type) {
let event_level = custom_levels
.get(&event_type)
.copied()
.unwrap_or(event_type.level());
if level.is_contained(event_level) {
tracer.interests.set(event_type);
global_interests.set(event_type);
}
}
});
if !tracer.interests.is_empty() {
tracers.push(tracer);
} else {
bp.build_warning(id, "No events enabled for tracer");
}
}
// Parse webhooks
for hook in bp.list_infallible::<WebHook>().await {
let id = hook.id;
let hook = hook.object;
if !hook.enable {
continue;
}
let headers = match hook
.http_auth
.build_headers(hook.http_headers, "application/json".into())
.await
{
Ok(headers) => headers,
Err(err) => {
bp.build_error(id, format!("Unable to build HTTP headers: {}", err));
continue;
}
};
// Build tracer
let mut tracer = TelemetrySubscriber {
id: format!("w_{}", id.id()),
interests: Default::default(),
lossy: hook.lossy,
typ: TelemetrySubscriberType::Webhook(WebhookTracer {
url: hook.url,
timeout: hook.timeout.into_inner(),
tls_allow_invalid_certs: hook.allow_invalid_certs,
client: utils::http::http_client_builder(hook.allow_invalid_certs)
.build()
.unwrap_or_default(),
headers,
key: hook
.signature_key
.secret()
.await
.map_err(|err| {
bp.build_error(
id,
format!("Unable to retrieve signature key: {}", err),
);
})
.unwrap_or_default()
.unwrap_or_default()
.into_owned(),
throttle: hook.throttle.into_inner(),
discard_after: hook.discard_after.into_inner(),
}),
};
// Parse webhook events
apply_events(hook.events, hook.events_policy, |event_type| {
if event_type != EventType::Telemetry(TelemetryEvent::WebhookError) {
tracer.interests.set(event_type);
global_interests.set(event_type);
}
});
if !tracer.interests.is_empty() {
tracers.push(tracer);
} else {
bp.build_error(id, "No events enabled for webhook");
}
}
#[cfg(feature = "dev_mode")]
if let Ok(level) = std::env::var("LOG") {
let level = Level::from_str(&level).expect("Invalid LOG level");
for event_type in EventType::variants() {
let event_level = custom_levels
.get(event_type)
.copied()
.unwrap_or(event_type.level());
if level.is_contained(event_level) {
global_interests.set(event_type.to_id() as usize);
}
}
tracers.push(TelemetrySubscriber {
id: "default".to_string(),
interests: global_interests.clone(),
typ: TelemetrySubscriberType::ConsoleTracer(ConsoleTracer {
ansi: true,
multiline: false,
buffered: true,
}),
lossy: false,
});
}
} else {
// Add default tracer if none were found
let level = std::env::var("STALWART_RECOVERY_MODE_LOG_LEVEL")
.ok()
.and_then(|level| Level::from_str(&level).ok())
.unwrap_or(Level::Info);
for event_type in EventType::variants() {
let event_level = custom_levels
.get(event_type)
.copied()
.unwrap_or(event_type.level());
if level.is_contained(event_level) {
global_interests.set(event_type.to_id() as usize);
}
}
tracers.push(TelemetrySubscriber {
id: "recover-log".to_string(),
interests: global_interests.clone(),
typ: TelemetrySubscriberType::ConsoleTracer(ConsoleTracer {
ansi: true,
multiline: false,
buffered: true,
}),
lossy: false,
});
}
Tracers {
subscribers: tracers,
interests: global_interests,
levels: custom_levels,
}
}
}
impl Metrics {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let metrics = bp.setting_infallible::<structs::Metrics>().await;
let resource = Resource::builder()
.with_service_name("stalwart")
.with_attribute(KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION")))
.build();
let instrumentation = InstrumentationScope::builder("stalwart")
.with_version(env!("CARGO_PKG_VERSION"))
.build();
Metrics {
prometheus: match metrics.prometheus {
MetricsPrometheus::Enabled(prom) => {
let secret = prom
.auth_secret
.secret()
.await
.map_err(|err| {
bp.build_error(
ObjectType::Metrics.singleton(),
format!("Unable to retrieve Prometheus auth secret: {err}"),
);
})
.unwrap_or_default();
Some(PrometheusMetrics {
auth: prom.auth_username.and_then(|user| {
secret.map(|secret| STANDARD.encode(format!("{user}:{secret}")))
}),
})
}
MetricsPrometheus::Disabled => None,
},
otel: match metrics.open_telemetry {
structs::MetricsOtel::Http(otel) => {
let headers = match otel.http_auth.build_headers(otel.http_headers, None).await
{
Ok(headers) => headers
.into_iter()
.filter_map(|(k, v)| {
k.and_then(|k| Some((k.to_string(), v.to_str().ok()?.to_string())))
})
.collect::<HashMap<String, String>>(),
Err(err) => {
bp.build_error(
ObjectType::Metrics.singleton(),
format!("Failed to build OpenTelemetry HTTP headers: {err}"),
);
Default::default()
}
};
let mut exporter = MetricExporter::builder()
.with_temporality(Temporality::Delta)
.with_http()
.with_endpoint(otel.endpoint)
.with_timeout(otel.timeout.into_inner());
if !headers.is_empty() {
exporter = exporter.with_headers(headers);
}
match exporter.build() {
Ok(exporter) => Some(Arc::new(OtelMetrics {
exporter,
interval: otel.interval.into_inner(),
resource,
instrumentation,
})),
Err(err) => {
bp.build_error(
ObjectType::Metrics.singleton(),
format!("Failed to build OpenTelemetry metrics exporter: {err}"),
);
None
}
}
}
structs::MetricsOtel::Grpc(otel) => {
let mut exporter = MetricExporter::builder()
.with_temporality(Temporality::Delta)
.with_tonic()
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
.with_timeout(otel.timeout.into_inner());
if let Some(endpoint) = otel.endpoint {
exporter = exporter.with_endpoint(endpoint);
}
match exporter.build() {
Ok(exporter) => Some(Arc::new(OtelMetrics {
exporter,
interval: otel.interval.into_inner(),
resource,
instrumentation,
})),
Err(err) => {
bp.build_error(
ObjectType::Metrics.singleton(),
format!("Failed to build OpenTelemetry metrics exporter: {err}"),
);
None
}
}
}
structs::MetricsOtel::Disabled => None,
},
log_path: bp
.list_infallible::<Tracer>()
.await
.into_iter()
.find_map(|tracer| {
if let Tracer::Log(log_tracer) = tracer.object
&& log_tracer.enable
{
Some(log_tracer.path)
} else {
None
}
}),
}
}
}
fn apply_events(
event_types: impl IntoIterator<Item = EventType>,
policy: EventPolicy,
mut apply_fn: impl FnMut(EventType),
) {
let mut exclude_events = AHashSet::new();
for event_type in event_types {
if policy == EventPolicy::Include {
apply_fn(event_type);
} else {
exclude_events.insert(event_type);
}
}
if policy != EventPolicy::Include {
for event_type in EventType::variants() {
if !exclude_events.contains(event_type) {
apply_fn(*event_type);
}
}
}
}
fn apply_metrics(
event_types: impl IntoIterator<Item = MetricType>,
policy: EventPolicy,
mut apply_fn: impl FnMut(MetricType),
) {
let mut exclude_events = AHashSet::new();
for event_type in event_types {
if policy == EventPolicy::Include {
apply_fn(event_type);
} else {
exclude_events.insert(event_type);
}
}
if policy != EventPolicy::Include {
for event_type in MetricType::variants() {
if !exclude_events.contains(event_type) {
apply_fn(*event_type);
}
}
}
}
impl std::fmt::Debug for OtelMetrics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OtelMetrics")
.field("interval", &self.interval)
.finish()
}
}