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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user