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,412 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use ahash::AHashSet;
|
||||
use common::{Server, psl};
|
||||
use mail_auth::{
|
||||
flate2::read::GzDecoder,
|
||||
report::{Feedback, Report, tlsrpt::TlsReport},
|
||||
zip,
|
||||
};
|
||||
use mail_parser::{Message, MessagePart, MimeHeaders, PartType};
|
||||
use registry::{
|
||||
schema::structs::{ArfExternalReport, DmarcExternalReport, TlsExternalReport},
|
||||
types::datetime::UTCDateTime,
|
||||
};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{Cursor, Read},
|
||||
};
|
||||
use store::write::{BatchBuilder, now};
|
||||
use trc::IncomingReportEvent;
|
||||
use types::id::Id;
|
||||
|
||||
use crate::reporting::{inbound::LogReport, index::ExternalReportIndex};
|
||||
|
||||
enum Compression {
|
||||
None,
|
||||
Gzip,
|
||||
Zip,
|
||||
}
|
||||
|
||||
enum Format<D, T, A> {
|
||||
Dmarc(D),
|
||||
Tls(T),
|
||||
Arf(A),
|
||||
}
|
||||
|
||||
pub(crate) struct ReportData<'x> {
|
||||
compression: Compression,
|
||||
format: Format<(), (), ()>,
|
||||
data: &'x [u8],
|
||||
}
|
||||
|
||||
impl<'x> ReportData<'x> {
|
||||
fn from_part(part: &'x MessagePart<'x>) -> Option<Self> {
|
||||
match &part.body {
|
||||
PartType::Text(report) => {
|
||||
if part
|
||||
.content_type()
|
||||
.and_then(|ct| ct.subtype())
|
||||
.is_some_and(|t| t.eq_ignore_ascii_case("xml"))
|
||||
|| part
|
||||
.attachment_name()
|
||||
.and_then(|n| n.rsplit_once('.'))
|
||||
.is_some_and(|(_, e)| e.eq_ignore_ascii_case("xml"))
|
||||
{
|
||||
Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Dmarc(()),
|
||||
data: report.as_bytes(),
|
||||
})
|
||||
} else if part.is_content_type("message", "feedback-report") {
|
||||
Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Arf(()),
|
||||
data: report.as_bytes(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
PartType::Binary(report) | PartType::InlineBinary(report) => {
|
||||
if part.is_content_type("message", "feedback-report") {
|
||||
return Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Arf(()),
|
||||
data: report.as_ref(),
|
||||
});
|
||||
}
|
||||
|
||||
let subtype = part
|
||||
.content_type()
|
||||
.and_then(|ct| ct.subtype())
|
||||
.unwrap_or("");
|
||||
let attachment_name = part.attachment_name();
|
||||
let ext = attachment_name
|
||||
.and_then(|f| f.rsplit_once('.'))
|
||||
.map_or("", |(_, e)| e);
|
||||
let tls_parts = subtype.rsplit_once('+');
|
||||
let compression = match (tls_parts.map(|(_, c)| c).unwrap_or(subtype), ext) {
|
||||
("gzip", _) => Compression::Gzip,
|
||||
("zip", _) => Compression::Zip,
|
||||
(_, "gz") => Compression::Gzip,
|
||||
(_, "zip") => Compression::Zip,
|
||||
_ => Compression::None,
|
||||
};
|
||||
let format = match (tls_parts.map(|(c, _)| c).unwrap_or(subtype), ext) {
|
||||
("xml", _) => Format::Dmarc(()),
|
||||
("tlsrpt", _) | (_, "json") => Format::Tls(()),
|
||||
_ => {
|
||||
if attachment_name.is_some_and(|n| n.contains(".xml") || n.contains('!')) {
|
||||
Format::Dmarc(())
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(ReportData {
|
||||
compression,
|
||||
format,
|
||||
data: report.as_ref(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(message: &'x Message<'x>) -> Vec<Self> {
|
||||
message.parts.iter().filter_map(Self::from_part).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_present(message: &Message<'_>) -> bool {
|
||||
message
|
||||
.parts
|
||||
.iter()
|
||||
.any(|part| ReportData::from_part(part).is_some())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AnalyzeReport: Sync + Send {
|
||||
fn analyze_report(&self, message: Message<'static>, session_id: u64);
|
||||
}
|
||||
|
||||
impl AnalyzeReport for Server {
|
||||
fn analyze_report(&self, message: Message<'static>, session_id: u64) {
|
||||
let core = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let from: String = message
|
||||
.from()
|
||||
.and_then(|a| a.last())
|
||||
.and_then(|a| a.address())
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
let to: Vec<String> = message.to().map_or_else(Vec::new, |a| {
|
||||
a.iter()
|
||||
.filter_map(|a| a.address())
|
||||
.map(|a| a.into())
|
||||
.collect()
|
||||
});
|
||||
let subject: String = message.subject().unwrap_or_default().into();
|
||||
let reports = ReportData::extract(&message);
|
||||
let max_size = core.core.smtp.report.analysis.max_size;
|
||||
|
||||
for report in reports {
|
||||
let data = match report.compression {
|
||||
Compression::None => Cow::Borrowed(report.data),
|
||||
Compression::Gzip => {
|
||||
match read_capped(GzDecoder::new(report.data), 0, max_size) {
|
||||
Ok(buf) => Cow::Owned(buf),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Compression::Zip => {
|
||||
let data = report.data.to_vec();
|
||||
let result = tokio::task::spawn_blocking(
|
||||
move || -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(data))
|
||||
.map_err(std::io::Error::other)?;
|
||||
if archive.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut file =
|
||||
archive.by_index(0).map_err(std::io::Error::other)?;
|
||||
let size_hint = file.size();
|
||||
read_capped(&mut file, size_hint, max_size)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(buf)) => Cow::Owned(buf),
|
||||
Ok(Err(err)) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let report = match report.format {
|
||||
Format::Dmarc(_) => match Report::parse_xml(&data) {
|
||||
Ok(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Dmarc(report)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DmarcParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err,
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Format::Tls(_) => match TlsReport::parse_json(&data) {
|
||||
Ok(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Tls(report)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::TlsRpcParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = format!("{err:?}"),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Format::Arf(_) => match Feedback::parse_arf(&data) {
|
||||
Some(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Arf(report.into_owned())
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::ArfParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Store report
|
||||
if let Some(expires_in) = &core.core.smtp.report.analysis.store {
|
||||
let expires = now() + expires_in.as_secs();
|
||||
let item_id = core.inner.data.queue_id_gen.generate();
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
match report {
|
||||
Format::Dmarc(report) => {
|
||||
let mut report = DmarcExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
Format::Tls(report) => {
|
||||
let mut report = TlsExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
Format::Arf(report) => {
|
||||
let mut report = ArfExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = core.core.storage.data.write(batch.build_all()).await
|
||||
&& !err.is_assertion_failure()
|
||||
{
|
||||
trc::error!(
|
||||
err.span_id(session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to write report")
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Option<Id> {
|
||||
let mut tenant_ids = Vec::with_capacity(domains.len());
|
||||
for domain in domains {
|
||||
if let Some(tenant_id) = server
|
||||
.domain(domain)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to lookup domain")
|
||||
);
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.and_then(|domain| domain.id_tenant)
|
||||
.map(Id::from)
|
||||
&& !tenant_ids.contains(&tenant_id)
|
||||
{
|
||||
tenant_ids.push(tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
if tenant_ids.len() == 1 {
|
||||
tenant_ids.into_iter().next()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capped(
|
||||
reader: impl Read,
|
||||
size_hint: u64,
|
||||
max_size: usize,
|
||||
) -> Result<Vec<u8>, std::io::Error> {
|
||||
let max_size = max_size as u64;
|
||||
if size_hint > max_size {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Report is larger than the {max_size} byte limit"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(size_hint.min(64 * 1024) as usize);
|
||||
reader
|
||||
.take(max_size.saturating_add(1))
|
||||
.read_to_end(&mut buf)?;
|
||||
|
||||
if buf.len() as u64 > max_size {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Report is larger than the {max_size} byte limit"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{core::Session, reporting::send::MtaReportSend};
|
||||
use common::network::SessionStream;
|
||||
use mail_auth::{
|
||||
AuthenticatedMessage, AuthenticationResults, DkimOutput, common::verify::VerifySignature,
|
||||
};
|
||||
use registry::schema::structs::Rate;
|
||||
use trc::OutgoingReportEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn send_dkim_report(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
rate: &Rate,
|
||||
rejected: bool,
|
||||
output: &DkimOutput<'_>,
|
||||
) {
|
||||
// Generate report
|
||||
let signature = if let Some(signature) = output.signature() {
|
||||
signature
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self
|
||||
.server
|
||||
.is_local_report_domain(signature.domain(), self.data.session_id)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle recipient
|
||||
if !self.throttle_rcpt(rcpt, rate, "dkim").await {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DkimRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(rate.count),
|
||||
trc::Value::from(rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let config = &self.server.core.smtp.report.dkim;
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut report = Vec::with_capacity(128);
|
||||
self.new_auth_failure(output.result().into(), rejected)
|
||||
.with_authentication_results(
|
||||
AuthenticationResults::new(&self.hostname)
|
||||
.with_dkim_result(output, message.from())
|
||||
.to_string(),
|
||||
)
|
||||
.with_dkim_domain(signature.domain())
|
||||
.with_dkim_selector(signature.selector())
|
||||
.with_dkim_identity(signature.identity())
|
||||
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default())
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rcpt,
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "DKIM Report".to_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DkimReport),
|
||||
SpanId = self.data.session_id,
|
||||
From = from_addr.to_string(),
|
||||
To = rcpt.to_string(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
[rcpt].into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use crate::{
|
||||
core::Session,
|
||||
queue::RecipientDomain,
|
||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::report::AggregateFrequency,
|
||||
ipc::{DmarcEvent, ToHash},
|
||||
network::SessionStream,
|
||||
};
|
||||
use compact_str::ToCompactString;
|
||||
use mail_auth::{
|
||||
ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput,
|
||||
DmarcResult, SpfResult,
|
||||
common::verify::VerifySignature,
|
||||
dkim2::Dkim2Output,
|
||||
dmarc::{self},
|
||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::FailureReportingOption,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{DmarcInternalReport, DmarcReport, DmarcReportRecord, Rate},
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, map::Map},
|
||||
};
|
||||
use std::{borrow::Cow, future::Future};
|
||||
use store::{
|
||||
SerializeInfallible, U64_LEN, ValueKey,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue, key::KeySerializer},
|
||||
};
|
||||
use trc::{AddContext, OutgoingReportEvent};
|
||||
use utils::DomainPart;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_dmarc_report(
|
||||
&self,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
auth_results: &AuthenticationResults<'_>,
|
||||
rejected: bool,
|
||||
dmarc_output: DmarcOutput,
|
||||
dkim_output: &[DkimOutput<'_>],
|
||||
dkim2_output: Option<&Dkim2Output<'_>>,
|
||||
arc_output: &Option<ArcOutput<'_>>,
|
||||
) {
|
||||
let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap();
|
||||
let config = &self.server.core.smtp.report.dmarc;
|
||||
|
||||
if self
|
||||
.server
|
||||
.is_local_report_domain(dmarc_output.domain(), self.data.session_id)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Send failure report. RFC 9991 Section 2: report generators MUST NOT
|
||||
// honor "ruf" for policy records published with "psd=y".
|
||||
if !matches!(dmarc_record.psd, dmarc::Psd::Yes)
|
||||
&& let (Some(failure_rate), Some(report_options)) = (
|
||||
self.server
|
||||
.eval_if::<Rate, _>(&config.send, self, self.data.session_id)
|
||||
.await,
|
||||
dmarc_output.failure_report(),
|
||||
)
|
||||
{
|
||||
// Verify that any external reporting addresses are authorized
|
||||
let rcpts = match self
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc_report_address(
|
||||
dmarc_output.domain(),
|
||||
dmarc_record.ruf(),
|
||||
Some(&self.server.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(rcpts) => {
|
||||
if !rcpts.is_empty() {
|
||||
let mut new_rcpts = Vec::with_capacity(rcpts.len());
|
||||
|
||||
for rcpt in rcpts {
|
||||
if self.throttle_rcpt(rcpt.uri(), &failure_rate, "dmarc").await {
|
||||
new_rcpts.push(rcpt.uri());
|
||||
}
|
||||
}
|
||||
|
||||
new_rcpts
|
||||
} else {
|
||||
if !dmarc_record.ruf().is_empty() {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
|
||||
SpanId = self.data.session_id,
|
||||
Url = dmarc_record
|
||||
.ruf()
|
||||
.iter()
|
||||
.map(|u| trc::Value::String(u.uri().to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
|
||||
SpanId = self.data.session_id,
|
||||
Url = dmarc_record
|
||||
.ruf()
|
||||
.iter()
|
||||
.map(|u| trc::Value::String(u.uri().to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// Throttle recipient
|
||||
if !rcpts.is_empty() {
|
||||
let mut report = Vec::with_capacity(128);
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut auth_failure = self
|
||||
.new_auth_failure(AuthFailureType::Dmarc, rejected)
|
||||
.with_authentication_results(auth_results.to_string())
|
||||
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default());
|
||||
|
||||
let dkim_aligned = matches!(dmarc_output.dkim_result(), DmarcResult::Pass);
|
||||
let spf_aligned = matches!(dmarc_output.spf_result(), DmarcResult::Pass);
|
||||
|
||||
// Report the first failed signature
|
||||
if let (
|
||||
dmarc::Report::Dkim
|
||||
| dmarc::Report::DkimSpf
|
||||
| dmarc::Report::All
|
||||
| dmarc::Report::Any,
|
||||
Some(signature),
|
||||
) = (
|
||||
&report_options,
|
||||
if !dkim_aligned {
|
||||
dkim_output
|
||||
.iter()
|
||||
.find_map(|o| {
|
||||
let s = o.signature()?;
|
||||
if !matches!(o.result(), DkimResult::Pass) {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| dkim_output.iter().find_map(|o| o.signature()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
) {
|
||||
auth_failure = auth_failure
|
||||
.with_dkim_domain(signature.domain())
|
||||
.with_dkim_selector(signature.selector())
|
||||
.with_dkim_identity(signature.identity());
|
||||
}
|
||||
|
||||
// Report SPF failure
|
||||
if let (
|
||||
dmarc::Report::Spf
|
||||
| dmarc::Report::DkimSpf
|
||||
| dmarc::Report::All
|
||||
| dmarc::Report::Any,
|
||||
Some(output),
|
||||
) = (
|
||||
&report_options,
|
||||
if !spf_aligned {
|
||||
self.data
|
||||
.spf_ehlo
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
if s.result() != SpfResult::Pass {
|
||||
s.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
self.data.spf_mail_from.as_ref().and_then(|s| {
|
||||
if s.result() != SpfResult::Pass {
|
||||
s.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.or(self.data.spf_mail_from.as_ref())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
) {
|
||||
auth_failure =
|
||||
auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain()));
|
||||
// TODO use DNS record
|
||||
}
|
||||
|
||||
auth_failure
|
||||
.with_identity_alignment(match (dkim_aligned, spf_aligned) {
|
||||
(false, false) => IdentityAlignment::DkimSpf,
|
||||
(false, true) => IdentityAlignment::Dkim,
|
||||
(true, false) => IdentityAlignment::Spf,
|
||||
(true, true) => IdentityAlignment::None,
|
||||
})
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
&rcpts.join(", "),
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "DMARC Report".to_compact_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcReport),
|
||||
SpanId = self.data.session_id,
|
||||
From = from_addr.to_string(),
|
||||
To = rcpts
|
||||
.iter()
|
||||
.map(|a| trc::Value::String(a.to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
rcpts.into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
Limit = vec![
|
||||
trc::Value::from(failure_rate.count),
|
||||
trc::Value::from(failure_rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send aggregate reports
|
||||
let interval = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.report.dmarc_aggregate.send,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(AggregateFrequency::Never);
|
||||
|
||||
if matches!(interval, AggregateFrequency::Never) || dmarc_record.rua().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Report the same identifier forms that were used for alignment
|
||||
let message_from = message.from();
|
||||
let header_from = message_from.domain_part();
|
||||
let header_from = header_from
|
||||
.to_ascii_domain()
|
||||
.unwrap_or(Cow::Borrowed(header_from));
|
||||
let envelope_from = self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|mf| mf.domain.as_str())
|
||||
.unwrap_or_else(|| self.data.helo_domain.as_str());
|
||||
let envelope_from = envelope_from
|
||||
.to_ascii_domain()
|
||||
.unwrap_or(Cow::Borrowed(envelope_from));
|
||||
|
||||
// Create DMARC report record
|
||||
let mut report_record = Record::new()
|
||||
.with_dmarc_output(&dmarc_output)
|
||||
.with_dkim_output(dkim_output)
|
||||
.with_source_ip(self.data.remote_ip)
|
||||
.with_header_from(header_from.as_ref())
|
||||
.with_envelope_from(envelope_from.as_ref());
|
||||
if let Some(dkim2_output) = dkim2_output {
|
||||
report_record = report_record.with_dkim2_output(dkim2_output);
|
||||
}
|
||||
if let Some(spf_ehlo) = &self.data.spf_ehlo {
|
||||
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
|
||||
}
|
||||
if let Some(spf_mail_from) = &self.data.spf_mail_from {
|
||||
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
|
||||
}
|
||||
if let Some(arc_output) = arc_output {
|
||||
report_record = report_record.with_arc_output(arc_output);
|
||||
}
|
||||
|
||||
// Submit DMARC report event
|
||||
self.server
|
||||
.schedule_report(DmarcEvent {
|
||||
domain: dmarc_output.into_domain(),
|
||||
report_record,
|
||||
dmarc_record,
|
||||
interval,
|
||||
span_id: self.data.session_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DmarcReporting: Sync + Send {
|
||||
fn send_dmarc_aggregate_report(
|
||||
&self,
|
||||
report_id: u64,
|
||||
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||
fn schedule_dmarc(&self, event: Box<DmarcEvent>) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl DmarcReporting for Server {
|
||||
async fn send_dmarc_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
|
||||
let object_id = ObjectType::DmarcInternalReport.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
let Some(report) = self
|
||||
.store()
|
||||
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Delete report
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(report.domain.len() + U64_LEN)
|
||||
.write(&report.domain)
|
||||
.write(report.policy_identifier)
|
||||
.finalize(),
|
||||
});
|
||||
self.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let span_id = self.inner.data.span_id_gen.generate();
|
||||
let event_from = report.report.date_range_begin.timestamp() as u64;
|
||||
let event_to = report.report.date_range_end.timestamp() as u64;
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcAggregateReport),
|
||||
SpanId = span_id,
|
||||
ReportId = event_from,
|
||||
Domain = report.domain.clone(),
|
||||
RangeFrom = trc::Value::Timestamp(event_from),
|
||||
RangeTo = trc::Value::Timestamp(event_to),
|
||||
);
|
||||
|
||||
// Verify external reporting addresses
|
||||
let rua = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc_report_address(
|
||||
&report.domain,
|
||||
report.rua.as_slice(),
|
||||
Some(&self.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(rcpts) => {
|
||||
if !rcpts.is_empty() {
|
||||
rcpts
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
|
||||
SpanId = span_id,
|
||||
Url = report
|
||||
.rua
|
||||
.into_iter()
|
||||
.map(|u| trc::Value::String(u.into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
|
||||
SpanId = span_id,
|
||||
Url = report
|
||||
.rua
|
||||
.into_iter()
|
||||
.map(|u| trc::Value::String(u.into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize report
|
||||
let config = &self.core.smtp.report.dmarc_aggregate;
|
||||
let from_addr = self
|
||||
.eval_if(
|
||||
&config.address,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
||||
&self
|
||||
.eval_if(
|
||||
&self.core.smtp.report.submitter,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "localhost".to_compact_string()),
|
||||
(
|
||||
self.eval_if(
|
||||
&config.name,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rua.iter().map(|a| a.as_str()),
|
||||
&mut message,
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.send_report(
|
||||
&from_addr,
|
||||
rua.iter(),
|
||||
message,
|
||||
&config.sign,
|
||||
false,
|
||||
span_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_dmarc(&self, event: Box<DmarcEvent>) {
|
||||
let object_id = ObjectType::DmarcInternalReport.to_id();
|
||||
let policy_hash = event.dmarc_record.to_hash();
|
||||
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(event.domain.len() + U64_LEN)
|
||||
.write(&event.domain)
|
||||
.write(policy_hash)
|
||||
.finalize(),
|
||||
});
|
||||
let mut rety_count = 0;
|
||||
|
||||
loop {
|
||||
// Find the report by domain name
|
||||
let mut batch = BatchBuilder::new();
|
||||
let report = match self
|
||||
.store()
|
||||
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(Some(object_id_v)) => {
|
||||
match self
|
||||
.store()
|
||||
.get_value::<DmarcInternalReport>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: object_id_v.object_id.id().id(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
{
|
||||
Ok(Some(report)) => Some((object_id_v, report)),
|
||||
Ok(None) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NotFound),
|
||||
Id = object_id_v.object_id.id().id(),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to find DMARC report for domain"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for DMARC report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for DMARC report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create report if missing
|
||||
let config = &self.core.smtp.report.dmarc_aggregate;
|
||||
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
|
||||
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
|
||||
object_id_v.version += 1;
|
||||
batch.set(pk.clone(), object_id_v.serialize());
|
||||
|
||||
(object_id_v.object_id.id().id(), report)
|
||||
} else {
|
||||
let item_id = self.inner.data.queue_id_gen.generate();
|
||||
let date_range_begin = UTCDateTime::now();
|
||||
let date_range_end = UTCDateTime::from_timestamp(
|
||||
date_range_begin.timestamp() + event.interval.as_secs() as i64,
|
||||
);
|
||||
let policy =
|
||||
PolicyPublished::from_record(event.domain.clone(), &event.dmarc_record);
|
||||
|
||||
let report = DmarcInternalReport {
|
||||
created_at: date_range_begin,
|
||||
deliver_at: date_range_end,
|
||||
domain: event.domain.clone(),
|
||||
report: DmarcReport {
|
||||
report_id: format!("{}_{policy_hash}", date_range_begin.timestamp()),
|
||||
date_range_begin,
|
||||
date_range_end,
|
||||
email: self
|
||||
.eval_if(
|
||||
&config.address,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()),
|
||||
extra_contact_info: self
|
||||
.eval_if::<String, _>(
|
||||
&config.contact_info,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await,
|
||||
org_name: self
|
||||
.eval_if::<String, _>(
|
||||
&config.org_name,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
policy_adkim: policy.adkim.into(),
|
||||
policy_aspf: policy.aspf.into(),
|
||||
policy_disposition: policy.p.into(),
|
||||
policy_domain: policy.domain,
|
||||
policy_failure_reporting_options: match event.dmarc_record.fo {
|
||||
dmarc::Report::All => vec![FailureReportingOption::All],
|
||||
dmarc::Report::Any => vec![FailureReportingOption::Any],
|
||||
dmarc::Report::Dkim => vec![FailureReportingOption::DkimFailure],
|
||||
dmarc::Report::Spf => vec![FailureReportingOption::SpfFailure],
|
||||
dmarc::Report::DkimSpf => vec![
|
||||
FailureReportingOption::DkimFailure,
|
||||
FailureReportingOption::SpfFailure,
|
||||
],
|
||||
}
|
||||
.into(),
|
||||
policy_subdomain_disposition: policy.sp.into(),
|
||||
policy_np: policy.np.into(),
|
||||
policy_discovery_method: policy.discovery_method.into(),
|
||||
policy_testing_mode: policy.testing,
|
||||
policy_version: None,
|
||||
version: 1.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
policy_identifier: policy_hash,
|
||||
rua: Map::new(
|
||||
event
|
||||
.dmarc_record
|
||||
.rua()
|
||||
.iter()
|
||||
.map(|u| u.uri.clone())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
|
||||
(item_id, report)
|
||||
};
|
||||
|
||||
// Add record
|
||||
let mut record = DmarcReportRecord::from(event.report_record.clone());
|
||||
if let Some(idx) = report
|
||||
.report
|
||||
.records
|
||||
.0
|
||||
.inner
|
||||
.iter()
|
||||
.position(|d| d.value.eq_except_count(&record))
|
||||
{
|
||||
report.report.records.0.inner[idx].value.count += 1;
|
||||
} else {
|
||||
record.count = 1;
|
||||
report.report.records.push(record);
|
||||
}
|
||||
|
||||
// Write entry
|
||||
let report_bytes = report.to_pickled_vec();
|
||||
let max_report_size = self
|
||||
.eval_if(
|
||||
&config.max_size,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(5 * 1024 * 1024);
|
||||
if max_report_size != 0 && report_bytes.len() > max_report_size {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
|
||||
SpanId = event.span_id,
|
||||
Domain = event.domain.clone(),
|
||||
Details = report_bytes.len(),
|
||||
Limit = max_report_size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
|
||||
report_bytes,
|
||||
);
|
||||
|
||||
match self.core.storage.data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
rety_count += 1;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to write DMARC report")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use ahash::AHashMap;
|
||||
use common::USER_AGENT;
|
||||
use mail_auth::report::{
|
||||
ActionDisposition, AuthFailureType, DeliveryResult, DmarcResult, Feedback, FeedbackType,
|
||||
Report, tlsrpt::TlsReport,
|
||||
};
|
||||
use std::{collections::hash_map::Entry, time::SystemTime};
|
||||
use store::write::now;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use trc::IncomingReportEvent;
|
||||
|
||||
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
|
||||
pub fn new_auth_failure(&self, ft: AuthFailureType, rejected: bool) -> Feedback<'_> {
|
||||
Feedback::new(FeedbackType::AuthFailure)
|
||||
.with_auth_failure(ft)
|
||||
.with_arrival_date(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()) as i64,
|
||||
)
|
||||
.with_source_ip(self.data.remote_ip)
|
||||
.with_reporting_mta(&self.hostname)
|
||||
.with_user_agent(USER_AGENT)
|
||||
.with_delivery_result(if rejected {
|
||||
DeliveryResult::Reject
|
||||
} else {
|
||||
DeliveryResult::Unspecified
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_report(&self) -> bool {
|
||||
let analysis = &self.server.core.smtp.report.analysis;
|
||||
|
||||
self.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.any(|addr| analysis.is_report_address(addr.report_address()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait LogReport {
|
||||
fn log(&self);
|
||||
}
|
||||
|
||||
impl LogReport for Report {
|
||||
fn log(&self) {
|
||||
let mut dmarc_pass = 0;
|
||||
let mut dmarc_quarantine = 0;
|
||||
let mut dmarc_reject = 0;
|
||||
let mut dmarc_none = 0;
|
||||
let mut dkim_pass = 0;
|
||||
let mut dkim_fail = 0;
|
||||
let mut dkim_none = 0;
|
||||
let mut spf_pass = 0;
|
||||
let mut spf_fail = 0;
|
||||
let mut spf_none = 0;
|
||||
|
||||
for record in self.records() {
|
||||
let count = std::cmp::min(record.count(), 1);
|
||||
|
||||
match record.action_disposition() {
|
||||
ActionDisposition::Pass => {
|
||||
dmarc_pass += count;
|
||||
}
|
||||
ActionDisposition::Quarantine => {
|
||||
dmarc_quarantine += count;
|
||||
}
|
||||
ActionDisposition::Reject => {
|
||||
dmarc_reject += count;
|
||||
}
|
||||
ActionDisposition::None | ActionDisposition::Unspecified => {
|
||||
dmarc_none += count;
|
||||
}
|
||||
}
|
||||
match record.dmarc_dkim_result() {
|
||||
DmarcResult::Pass => {
|
||||
dkim_pass += count;
|
||||
}
|
||||
DmarcResult::Fail => {
|
||||
dkim_fail += count;
|
||||
}
|
||||
DmarcResult::Unspecified => {
|
||||
dkim_none += count;
|
||||
}
|
||||
}
|
||||
match record.dmarc_spf_result() {
|
||||
DmarcResult::Pass => {
|
||||
spf_pass += count;
|
||||
}
|
||||
DmarcResult::Fail => {
|
||||
spf_fail += count;
|
||||
}
|
||||
DmarcResult::Unspecified => {
|
||||
spf_none += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
IncomingReport(
|
||||
if (dmarc_reject + dmarc_quarantine + dkim_fail + spf_fail) > 0 {
|
||||
IncomingReportEvent::DmarcReportWithWarnings
|
||||
} else {
|
||||
IncomingReportEvent::DmarcReport
|
||||
}
|
||||
),
|
||||
RangeFrom = trc::Value::Timestamp(self.date_range_begin()),
|
||||
RangeTo = trc::Value::Timestamp(self.date_range_end()),
|
||||
Domain = self.domain().to_string(),
|
||||
From = self.email().to_string(),
|
||||
Id = self.report_id().to_string(),
|
||||
DmarcPass = dmarc_pass,
|
||||
DmarcQuarantine = dmarc_quarantine,
|
||||
DmarcReject = dmarc_reject,
|
||||
DmarcNone = dmarc_none,
|
||||
DkimPass = dkim_pass,
|
||||
DkimFail = dkim_fail,
|
||||
DkimNone = dkim_none,
|
||||
SpfPass = spf_pass,
|
||||
SpfFail = spf_fail,
|
||||
SpfNone = spf_none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl LogReport for TlsReport {
|
||||
fn log(&self) {
|
||||
for policy in self.policies.iter().take(5) {
|
||||
let mut details = AHashMap::with_capacity(policy.failure_details.len());
|
||||
for failure in &policy.failure_details {
|
||||
let num_failures = std::cmp::min(1, failure.failed_session_count);
|
||||
match details.entry(failure.result_type) {
|
||||
Entry::Occupied(mut e) => {
|
||||
*e.get_mut() += num_failures;
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(num_failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
IncomingReport(if policy.summary.total_failure > 0 {
|
||||
IncomingReportEvent::TlsReportWithWarnings
|
||||
} else {
|
||||
IncomingReportEvent::TlsReport
|
||||
}),
|
||||
RangeFrom =
|
||||
trc::Value::Timestamp(self.date_range.start_datetime.to_timestamp() as u64),
|
||||
RangeTo = trc::Value::Timestamp(self.date_range.end_datetime.to_timestamp() as u64),
|
||||
Domain = policy.policy.policy_domain.clone(),
|
||||
From = self.contact_info.as_deref().unwrap_or_default().to_string(),
|
||||
Id = self.report_id.clone(),
|
||||
Policy = format!("{:?}", policy.policy.policy_type),
|
||||
TotalSuccesses = policy.summary.total_success,
|
||||
TotalFailures = policy.summary.total_failure,
|
||||
Details = format!("{details:?}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogReport for Feedback<'_> {
|
||||
fn log(&self) {
|
||||
trc::event!(
|
||||
IncomingReport(match self.feedback_type() {
|
||||
mail_auth::report::FeedbackType::Abuse => IncomingReportEvent::AbuseReport,
|
||||
mail_auth::report::FeedbackType::AuthFailure =>
|
||||
IncomingReportEvent::AuthFailureReport,
|
||||
mail_auth::report::FeedbackType::Fraud => IncomingReportEvent::FraudReport,
|
||||
mail_auth::report::FeedbackType::NotSpam => IncomingReportEvent::NotSpamReport,
|
||||
mail_auth::report::FeedbackType::Other => IncomingReportEvent::OtherReport,
|
||||
mail_auth::report::FeedbackType::Virus => IncomingReportEvent::VirusReport,
|
||||
}),
|
||||
RangeFrom = trc::Value::Timestamp(
|
||||
self.arrival_date()
|
||||
.map(|d| d as u64)
|
||||
.unwrap_or_else(|| { now() })
|
||||
),
|
||||
Domain = self
|
||||
.reported_domain()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
Hostname = self.reporting_mta().map(|d| trc::Value::String(d.into())),
|
||||
Url = self
|
||||
.reported_uri()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
RemoteIp = self.source_ip(),
|
||||
Total = self.incidents(),
|
||||
Result = format!("{:?}", self.delivery_result()),
|
||||
Details = self
|
||||
.authentication_results()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::DmarcActionDisposition,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
ArfExternalReport, DmarcExternalReport, DmarcInternalReport, Task, TaskDmarcReport,
|
||||
TaskStatus, TaskTlsReport, TlsExternalReport, TlsInternalReport,
|
||||
},
|
||||
},
|
||||
types::{
|
||||
EnumImpl, ObjectImpl,
|
||||
datetime::UTCDateTime,
|
||||
id::ObjectId,
|
||||
index::{IndexBuilder, IndexValue},
|
||||
},
|
||||
};
|
||||
use store::{
|
||||
SerializeInfallible, U64_LEN,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{
|
||||
BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, assert::AssertValue,
|
||||
key::KeySerializer,
|
||||
},
|
||||
xxhash_rust::xxh3::Xxh3,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
pub trait InternalReportIndex: ObjectImpl {
|
||||
fn deliver_at(&self) -> UTCDateTime;
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime);
|
||||
|
||||
fn task(&self, item_id: u64) -> Task;
|
||||
|
||||
fn primary_key(&self) -> ValueClass;
|
||||
|
||||
fn reschedule_ops(
|
||||
&mut self,
|
||||
batch: &mut BatchBuilder,
|
||||
item_id: u64,
|
||||
revision: u64,
|
||||
at: UTCDateTime,
|
||||
) {
|
||||
let current_deliver_at = self.deliver_at();
|
||||
|
||||
if current_deliver_at != at {
|
||||
let object = Self::OBJECT;
|
||||
let object_id = object.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
self.set_deliver_at(at);
|
||||
|
||||
batch
|
||||
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: current_deliver_at.timestamp() as u64,
|
||||
}))
|
||||
.set(
|
||||
ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: at.timestamp() as u64,
|
||||
}),
|
||||
object_id.serialize(),
|
||||
)
|
||||
.set(key, self.to_pickled_vec());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
|
||||
let object = Self::OBJECT;
|
||||
let object_id = object.to_id();
|
||||
let pk = self.primary_key();
|
||||
|
||||
if is_set {
|
||||
batch
|
||||
.assert_value(pk.clone(), ())
|
||||
.set(
|
||||
pk,
|
||||
ObjectIdVersioned {
|
||||
object_id: ObjectId::new(object, item_id.into()),
|
||||
version: 0,
|
||||
}
|
||||
.serialize(),
|
||||
)
|
||||
.schedule_task_with_id(item_id, self.task(item_id));
|
||||
} else {
|
||||
batch
|
||||
.clear(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id,
|
||||
item_id,
|
||||
}))
|
||||
.clear(pk)
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: item_id }))
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: self.deliver_at().timestamp() as u64,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ExternalReportIndex: ObjectImpl {
|
||||
fn text(&self) -> impl Iterator<Item = &str>;
|
||||
|
||||
fn tenant_id(&self) -> Option<Id>;
|
||||
|
||||
fn expires_at(&self) -> u64;
|
||||
|
||||
fn domains(&self) -> impl Iterator<Item = &str>;
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64);
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]>;
|
||||
|
||||
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
|
||||
let object_id = Self::OBJECT.to_id();
|
||||
let mut index_builder = IndexBuilder::default();
|
||||
for text in self.text() {
|
||||
index_builder.text(Property::Text, text);
|
||||
}
|
||||
|
||||
if let Some(tenant_id) = self.tenant_id() {
|
||||
index_builder.search(Property::MemberTenantId, tenant_id.id());
|
||||
}
|
||||
|
||||
let (success_count, fail_count) = self.success_fail_count();
|
||||
index_builder.search(Property::TotalSuccessfulSessions, success_count);
|
||||
index_builder.search(Property::TotalFailedSessions, fail_count);
|
||||
|
||||
index_builder.search(Property::ExpiresAt, self.expires_at());
|
||||
|
||||
if let Some(unique_key) = self.unique_key() {
|
||||
index_builder.unique(Property::ReportId, IndexValue::Bytes(unique_key.to_vec()));
|
||||
}
|
||||
|
||||
batch.registry_index(object_id, item_id, index_builder.keys.iter(), is_set);
|
||||
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
if is_set {
|
||||
batch.set(key, self.to_pickled_vec());
|
||||
} else {
|
||||
batch.clear(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalReportIndex for DmarcInternalReport {
|
||||
fn deliver_at(&self) -> UTCDateTime {
|
||||
self.deliver_at
|
||||
}
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime) {
|
||||
self.deliver_at = at;
|
||||
}
|
||||
|
||||
fn task(&self, item_id: u64) -> Task {
|
||||
Task::DmarcReport(TaskDmarcReport {
|
||||
report_id: item_id.into(),
|
||||
status: TaskStatus::at(self.deliver_at.timestamp()),
|
||||
})
|
||||
}
|
||||
|
||||
fn primary_key(&self) -> ValueClass {
|
||||
ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: ObjectType::DmarcInternalReport.to_id().into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(self.domain.len() + U64_LEN)
|
||||
.write(self.domain.as_str())
|
||||
.write(self.policy_identifier)
|
||||
.finalize(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalReportIndex for TlsInternalReport {
|
||||
fn deliver_at(&self) -> UTCDateTime {
|
||||
self.deliver_at
|
||||
}
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime) {
|
||||
self.deliver_at = at;
|
||||
}
|
||||
|
||||
fn task(&self, item_id: u64) -> Task {
|
||||
Task::TlsReport(TaskTlsReport {
|
||||
report_id: item_id.into(),
|
||||
status: TaskStatus::at(self.deliver_at.timestamp()),
|
||||
})
|
||||
}
|
||||
|
||||
fn primary_key(&self) -> ValueClass {
|
||||
ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: ObjectType::TlsInternalReport.to_id().into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: self.domain.as_bytes().to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for ArfExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.reported_domains
|
||||
.iter()
|
||||
.filter_map(|s| non_empty(s))
|
||||
.chain(
|
||||
[report.dkim_domain.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(non_empty),
|
||||
)
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.reported_domains
|
||||
.iter()
|
||||
.filter_map(|s| non_empty(s))
|
||||
.chain(
|
||||
[
|
||||
report.dkim_domain.as_deref(),
|
||||
report.reporting_mta.as_deref(),
|
||||
report.original_mail_from.as_deref(),
|
||||
report.original_rcpt_to.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(non_empty),
|
||||
)
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
(self.report.incidents, 0)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for DmarcExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
non_empty(&report.policy_domain)
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
non_empty(&report.email)
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
.chain(non_empty(&report.policy_domain))
|
||||
.chain(report.records.iter().flat_map(|r| {
|
||||
r.envelope_to
|
||||
.as_deref()
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
.chain(non_empty(&r.envelope_from))
|
||||
.chain(non_empty(&r.header_from))
|
||||
.chain(r.dkim_results.iter().filter_map(|d| non_empty(&d.domain)))
|
||||
.chain(r.spf_results.iter().filter_map(|s| non_empty(&s.domain)))
|
||||
}))
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
let mut success_count = 0;
|
||||
let mut fail_count = 0;
|
||||
|
||||
for record in self.report.records.iter() {
|
||||
if record.evaluated_disposition == DmarcActionDisposition::Pass {
|
||||
success_count += std::cmp::min(record.count, 1);
|
||||
} else {
|
||||
fail_count += std::cmp::min(record.count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
let report = &self.report;
|
||||
|
||||
Some(report_key(
|
||||
[
|
||||
report.org_name.as_str(),
|
||||
report.policy_domain.as_str(),
|
||||
report.report_id.as_str(),
|
||||
],
|
||||
report.date_range_begin,
|
||||
report.date_range_end,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for TlsExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.policies
|
||||
.iter()
|
||||
.flat_map(|p| non_empty(&p.policy_domain).into_iter())
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.policies
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
non_empty(&p.policy_domain)
|
||||
.into_iter()
|
||||
.chain(p.mx_hosts.iter().filter_map(|s| non_empty(s)))
|
||||
.chain(p.failure_details.iter().flat_map(|fd| {
|
||||
non_empty_opt(&fd.receiving_mx_hostname)
|
||||
.into_iter()
|
||||
.chain(non_empty_opt(&fd.receiving_mx_helo))
|
||||
}))
|
||||
})
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
let mut success_count = 0;
|
||||
let mut fail_count = 0;
|
||||
|
||||
for policy in self.report.policies.iter() {
|
||||
success_count += std::cmp::min(policy.total_successful_sessions, 1);
|
||||
fail_count += std::cmp::min(policy.total_failed_sessions, 1);
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
let report = &self.report;
|
||||
|
||||
Some(report_key(
|
||||
[
|
||||
report.organization_name.as_deref().unwrap_or_default(),
|
||||
report.report_id.as_str(),
|
||||
],
|
||||
report.date_range_start,
|
||||
report.date_range_end,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn report_key<const N: usize>(fields: [&str; N], from: UTCDateTime, to: UTCDateTime) -> [u8; 16] {
|
||||
let mut hasher = Xxh3::new();
|
||||
|
||||
for field in fields {
|
||||
hasher.update(field.as_bytes());
|
||||
hasher.update(&[0u8]);
|
||||
}
|
||||
hasher.update(&(from.timestamp() as u64).to_be_bytes());
|
||||
hasher.update(&(to.timestamp() as u64).to_be_bytes());
|
||||
|
||||
hasher.digest128().to_be_bytes()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn non_empty(s: &str) -> Option<&str> {
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn non_empty_opt(s: &Option<String>) -> Option<&str> {
|
||||
s.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::report::AggregateFrequency;
|
||||
use mail_parser::DateTime;
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub mod analysis;
|
||||
pub mod dkim;
|
||||
pub mod dmarc;
|
||||
pub mod inbound;
|
||||
pub mod index;
|
||||
pub mod scheduler;
|
||||
pub mod send;
|
||||
pub mod spf;
|
||||
pub mod tls;
|
||||
|
||||
pub trait AggregateTimestamp {
|
||||
fn to_timestamp(&self) -> u64;
|
||||
fn to_timestamp_(&self, dt: DateTime) -> u64;
|
||||
fn as_secs(&self) -> u64;
|
||||
fn due(&self) -> u64;
|
||||
}
|
||||
|
||||
impl AggregateTimestamp for AggregateFrequency {
|
||||
fn to_timestamp(&self) -> u64 {
|
||||
self.to_timestamp_(DateTime::from_timestamp(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()) as i64,
|
||||
))
|
||||
}
|
||||
|
||||
fn to_timestamp_(&self, mut dt: DateTime) -> u64 {
|
||||
(match self {
|
||||
AggregateFrequency::Hourly => {
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp()
|
||||
}
|
||||
AggregateFrequency::Daily => {
|
||||
dt.hour = 0;
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp()
|
||||
}
|
||||
AggregateFrequency::Weekly => {
|
||||
let dow = dt.day_of_week();
|
||||
dt.hour = 0;
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp() - (86400 * dow as i64)
|
||||
}
|
||||
AggregateFrequency::Never => dt.to_timestamp(),
|
||||
}) as u64
|
||||
}
|
||||
|
||||
fn as_secs(&self) -> u64 {
|
||||
match self {
|
||||
AggregateFrequency::Hourly => 3600,
|
||||
AggregateFrequency::Daily => 86400,
|
||||
AggregateFrequency::Weekly => 7 * 86400,
|
||||
AggregateFrequency::Never => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn due(&self) -> u64 {
|
||||
self.to_timestamp() + self.as_secs()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{dmarc::DmarcReporting, tls::TlsReporting};
|
||||
use common::{BuildServer, Inner, ipc::ReportingEvent};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub trait SpawnReport {
|
||||
fn spawn(self, core: Arc<Inner>);
|
||||
}
|
||||
|
||||
impl SpawnReport for mpsc::Receiver<ReportingEvent> {
|
||||
fn spawn(mut self, inner: Arc<Inner>) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = self.recv().await {
|
||||
let server = inner.build_server();
|
||||
match event {
|
||||
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
|
||||
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
|
||||
ReportingEvent::Stop => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
inbound::dkim::DkimSign,
|
||||
queue::{
|
||||
MessageSource,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
},
|
||||
};
|
||||
use common::{Server, expr::if_block::IfBlock, ipc::ReportingEvent};
|
||||
|
||||
pub trait MtaReportSend: Sync + Send {
|
||||
fn is_local_report_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = bool> + Send;
|
||||
|
||||
fn send_report(
|
||||
&self,
|
||||
from_addr: &str,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
report: Vec<u8>,
|
||||
sign_config: &IfBlock,
|
||||
deliver_now: bool,
|
||||
parent_session_id: u64,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn send_autogenerated(
|
||||
&self,
|
||||
from_addr: impl AsRef<str> + Sync + Send,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
raw_message: Vec<u8>,
|
||||
sign_config: Option<&IfBlock>,
|
||||
parent_session_id: u64,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn schedule_report(
|
||||
&self,
|
||||
report: impl Into<ReportingEvent> + Sync + Send,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl MtaReportSend for Server {
|
||||
async fn is_local_report_domain(&self, domain: &str, session_id: u64) -> bool {
|
||||
match self.domain(domain).await {
|
||||
Ok(domain) => domain.is_some(),
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.span_id(session_id)
|
||||
.details("Failed to lookup local domain")
|
||||
);
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_report(
|
||||
&self,
|
||||
from_addr: &str,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
report: Vec<u8>,
|
||||
sign_config: &IfBlock,
|
||||
deliver_now: bool,
|
||||
parent_session_id: u64,
|
||||
) {
|
||||
// Build message
|
||||
let mut message = self.new_message(from_addr, MessageSource::Report, parent_session_id);
|
||||
for rcpt_ in rcpts {
|
||||
message.add_expanded_recipient(rcpt_.as_ref(), self).await;
|
||||
}
|
||||
|
||||
// Schedule delivery at a random time between now and the next 3 hours
|
||||
if !deliver_now {
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
use common::config::smtp::queue::QueueExpiry;
|
||||
use rand::RngExt;
|
||||
|
||||
let delivery_time = rand::rng().random_range(0u64..10800u64);
|
||||
for rcpt in &mut message.message.recipients {
|
||||
rcpt.retry.due += delivery_time;
|
||||
rcpt.notify.due += delivery_time;
|
||||
if let QueueExpiry::Ttl(expires) = &mut rcpt.expires {
|
||||
*expires += delivery_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue message
|
||||
let dkim_signers = self
|
||||
.eval_signers(sign_config, &message.message, parent_session_id)
|
||||
.await;
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn send_autogenerated(
|
||||
&self,
|
||||
from_addr: impl AsRef<str> + Sync + Send,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
raw_message: Vec<u8>,
|
||||
sign_config: Option<&IfBlock>,
|
||||
parent_session_id: u64,
|
||||
) {
|
||||
// Build message
|
||||
let mut message = self.new_message(
|
||||
from_addr.as_ref(),
|
||||
MessageSource::Autogenerated,
|
||||
parent_session_id,
|
||||
);
|
||||
for rcpt in rcpts {
|
||||
message.add_expanded_recipient(rcpt, self).await;
|
||||
}
|
||||
|
||||
// Queue message
|
||||
let dkim_signers = if let Some(sign_config) = sign_config {
|
||||
self.eval_signers(sign_config, &message.message, parent_session_id)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(&raw_message, parent_session_id, self)
|
||||
.with_dkim_signers(dkim_signers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn schedule_report(&self, report: impl Into<ReportingEvent> + Sync + Send) {
|
||||
if self.inner.ipc.report_tx.send(report.into()).await.is_err() {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::ThreadError),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to send event to ReportScheduler"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{core::Session, reporting::send::MtaReportSend};
|
||||
use common::network::SessionStream;
|
||||
use mail_auth::{AuthenticationResults, SpfOutput, report::AuthFailureType};
|
||||
use registry::schema::structs::Rate;
|
||||
use trc::OutgoingReportEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn send_spf_report(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
rate: &Rate,
|
||||
rejected: bool,
|
||||
output: &SpfOutput,
|
||||
) {
|
||||
// Throttle recipient
|
||||
if !self.throttle_rcpt(rcpt, rate, "spf").await {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SpfRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(rate.count),
|
||||
trc::Value::from(rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate report
|
||||
let config = &self.server.core.smtp.report.spf;
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut report = Vec::with_capacity(128);
|
||||
self.new_auth_failure(AuthFailureType::Spf, rejected)
|
||||
.with_authentication_results(
|
||||
if let Some(mail_from) = &self.data.mail_from {
|
||||
AuthenticationResults::new(&self.hostname).with_spf_mailfrom_result(
|
||||
output,
|
||||
self.data.remote_ip,
|
||||
&mail_from.address,
|
||||
&self.data.helo_domain,
|
||||
)
|
||||
} else {
|
||||
AuthenticationResults::new(&self.hostname).with_spf_ehlo_result(
|
||||
output,
|
||||
self.data.remote_ip,
|
||||
&self.data.helo_domain,
|
||||
)
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.with_spf_dns(format!("txt : {} : v=SPF1", output.domain())) // TODO use DNS record
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mailer Daemon".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rcpt,
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "SPF Report".to_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SpfReport),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
From = from_addr.to_string(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
[rcpt].into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use crate::{
|
||||
queue::RecipientDomain,
|
||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||
};
|
||||
use common::{
|
||||
Server, USER_AGENT,
|
||||
config::smtp::{
|
||||
report::AggregateFrequency,
|
||||
resolver::{Mode, MxPattern, TlsaMatching},
|
||||
},
|
||||
ipc::{TlsEvent, ToHash},
|
||||
};
|
||||
use mail_auth::{
|
||||
flate2::{Compression, write::GzEncoder},
|
||||
mta_sts::{ReportUri, TlsRpt},
|
||||
report::tlsrpt::{FailureDetails, PolicyDetails},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::TlsPolicyType,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{TlsFailureDetails, TlsInternalReport, TlsReport, TlsReportPolicy},
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime},
|
||||
};
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use std::fmt::Write;
|
||||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
use store::{
|
||||
SerializeInfallible, ValueKey,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::{AddContext, OutgoingReportEvent};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TlsRptOptions {
|
||||
pub record: Arc<TlsRpt>,
|
||||
pub interval: AggregateFrequency,
|
||||
}
|
||||
|
||||
#[derive(Debug, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Serialize)]
|
||||
pub struct TlsFormat {
|
||||
pub rua: Vec<ReportUri>,
|
||||
pub policy: PolicyDetails,
|
||||
pub records: Vec<Option<FailureDetails>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub static TLS_HTTP_REPORT: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
|
||||
|
||||
pub trait TlsReporting: Sync + Send {
|
||||
fn send_tls_aggregate_report(
|
||||
&self,
|
||||
report_id: u64,
|
||||
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||
|
||||
fn schedule_tls(&self, event: Box<TlsEvent>) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl TlsReporting for Server {
|
||||
async fn send_tls_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
|
||||
let object_id = ObjectType::TlsInternalReport.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
let Some(report) = self
|
||||
.store()
|
||||
.get_value::<TlsInternalReport>(ValueKey::from(key.clone()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Delete report
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: report.domain.as_bytes().to_vec(),
|
||||
});
|
||||
self.core
|
||||
.storage
|
||||
.data
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let domain_name = report.domain.as_str();
|
||||
let event_from = report.report.date_range_start.timestamp() as u64;
|
||||
let event_to = report.report.date_range_end.timestamp() as u64;
|
||||
let span_id = self.inner.data.span_id_gen.generate();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::TlsAggregate),
|
||||
SpanId = span_id,
|
||||
ReportId = event_from,
|
||||
Domain = domain_name.to_string(),
|
||||
RangeFrom = trc::Value::Timestamp(event_from),
|
||||
RangeTo = trc::Value::Timestamp(event_to),
|
||||
);
|
||||
|
||||
// Generate report
|
||||
let exported_report = mail_auth::report::tlsrpt::TlsReport::from(report.report);
|
||||
let json = exported_report.to_json();
|
||||
let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default());
|
||||
let json = match std::io::Write::write_all(&mut e, json.as_bytes()).and_then(|_| e.finish())
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to compress report"
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Try delivering report over HTTP
|
||||
for uri in report.http_rua.as_slice() {
|
||||
{
|
||||
#[cfg(feature = "test_mode")]
|
||||
if uri == "https://127.0.0.1/tls" {
|
||||
TLS_HTTP_REPORT.lock().extend_from_slice(&json);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.tls_report_client
|
||||
.post(uri)
|
||||
.timeout(Duration::from_secs(2 * 60))
|
||||
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
||||
.header(CONTENT_TYPE, "application/tlsrpt+gzip")
|
||||
.body(json.to_vec())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::HttpSubmission),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
Details = "Invalid HTTP response"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Reason = err.to_string(),
|
||||
Details = "HTTP submission error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver report over SMTP
|
||||
if !report.mail_rua.is_empty() {
|
||||
let config = &self.core.smtp.report.tls;
|
||||
let from_addr = self
|
||||
.eval_if(&config.address, &RecipientDomain::new(domain_name), span_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = exported_report.write_rfc5322_from_bytes(
|
||||
domain_name,
|
||||
&self
|
||||
.eval_if(
|
||||
&self.core.smtp.report.submitter,
|
||||
&RecipientDomain::new(domain_name),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "localhost".to_string()),
|
||||
(
|
||||
self.eval_if(&config.name, &RecipientDomain::new(domain_name), span_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
report.mail_rua.iter().map(|v| v.as_str()),
|
||||
&json,
|
||||
&mut message,
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.send_report(
|
||||
&from_addr,
|
||||
report.mail_rua.iter().map(|v| v.as_str()),
|
||||
message,
|
||||
&config.sign,
|
||||
false,
|
||||
span_id,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NoRecipientsFound),
|
||||
SpanId = span_id,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_tls(&self, event: Box<TlsEvent>) {
|
||||
let object_id = ObjectType::TlsInternalReport.to_id();
|
||||
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: event.domain.as_bytes().to_vec(),
|
||||
});
|
||||
let mut rety_count = 0;
|
||||
let policy_hash = event.policy.to_hash();
|
||||
|
||||
loop {
|
||||
// Find the report by domain name
|
||||
let mut batch = BatchBuilder::new();
|
||||
let report = match self
|
||||
.store()
|
||||
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(Some(object_id_v)) => {
|
||||
match self
|
||||
.store()
|
||||
.get_value::<TlsInternalReport>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: object_id_v.object_id.id().id(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
{
|
||||
Ok(Some(report)) => Some((object_id_v, report)),
|
||||
Ok(None) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NotFound),
|
||||
Id = object_id_v.object_id.id().id(),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to find TLS report for domain"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for TLS report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for TLS report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create report if missing
|
||||
let config = &self.core.smtp.report.tls;
|
||||
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
|
||||
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
|
||||
object_id_v.version += 1;
|
||||
batch.set(pk.clone(), object_id_v.serialize());
|
||||
|
||||
(object_id_v.object_id.id().id(), report)
|
||||
} else {
|
||||
let item_id = self.inner.data.queue_id_gen.generate();
|
||||
let date_range_start = UTCDateTime::now();
|
||||
let date_range_end = UTCDateTime::from_timestamp(
|
||||
date_range_start.timestamp() + event.interval.as_secs() as i64,
|
||||
);
|
||||
|
||||
let report = TlsInternalReport {
|
||||
created_at: date_range_start,
|
||||
deliver_at: date_range_end,
|
||||
domain: event.domain.clone(),
|
||||
report: TlsReport {
|
||||
report_id: format!("{}_{policy_hash}", date_range_start.timestamp()),
|
||||
organization_name: self
|
||||
.eval_if::<String, _>(
|
||||
&config.org_name,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.clone(),
|
||||
contact_info: self
|
||||
.eval_if::<String, _>(
|
||||
&config.contact_info,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.clone(),
|
||||
date_range_end,
|
||||
date_range_start,
|
||||
policies: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
|
||||
(item_id, report)
|
||||
};
|
||||
|
||||
let policy = if let Some(policy) = report
|
||||
.policy_identifiers
|
||||
.as_slice()
|
||||
.iter()
|
||||
.position(|id| *id == policy_hash)
|
||||
.and_then(|idx| report.report.policies.0.inner.get_mut(idx))
|
||||
{
|
||||
&mut policy.value
|
||||
} else {
|
||||
// Create policy
|
||||
let mut policy = TlsReportPolicy {
|
||||
policy_type: TlsPolicyType::NoPolicyFound,
|
||||
policy_domain: report.domain.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match &event.policy {
|
||||
common::ipc::PolicyType::Tlsa(tlsa) => {
|
||||
policy.policy_type = TlsPolicyType::Tlsa;
|
||||
if let Some(tlsa) = tlsa {
|
||||
for entry in &tlsa.entries {
|
||||
policy.policy_strings.push(format!(
|
||||
"{} {} {} {}",
|
||||
if entry.is_end_entity { 3 } else { 2 },
|
||||
i32::from(entry.is_spki),
|
||||
match entry.matching {
|
||||
TlsaMatching::Full => 0,
|
||||
TlsaMatching::Sha256 => 1,
|
||||
TlsaMatching::Sha512 => 2,
|
||||
},
|
||||
entry.data.iter().fold(
|
||||
String::with_capacity(64),
|
||||
|mut s, b| {
|
||||
write!(s, "{b:02X}").ok();
|
||||
s
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
common::ipc::PolicyType::Sts(sts) => {
|
||||
policy.policy_type = TlsPolicyType::Sts;
|
||||
if let Some(sts) = sts {
|
||||
policy.policy_strings.push("version: STSv1".to_string());
|
||||
policy.policy_strings.push(format!(
|
||||
"mode: {}",
|
||||
match sts.mode {
|
||||
Mode::Enforce => "enforce",
|
||||
Mode::Testing => "testing",
|
||||
Mode::None => "none",
|
||||
}
|
||||
));
|
||||
policy
|
||||
.policy_strings
|
||||
.push(format!("max_age: {}", sts.max_age));
|
||||
for mx in &sts.mx {
|
||||
let mx = match mx {
|
||||
MxPattern::Equals(mx) => mx.to_string(),
|
||||
MxPattern::StartsWith(mx) => format!("*.{mx}"),
|
||||
};
|
||||
policy.policy_strings.push(format!("mx: {mx}"));
|
||||
policy.mx_hosts.push(mx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
for rua in &event.tls_record.rua {
|
||||
match rua {
|
||||
ReportUri::Mail(mail) => {
|
||||
report.mail_rua.push(mail.clone());
|
||||
}
|
||||
ReportUri::Http(uri) => {
|
||||
report.http_rua.push(uri.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.policy_identifiers.push(policy_hash);
|
||||
report.report.policies.push(policy);
|
||||
&mut report.report.policies.0.inner.last_mut().unwrap().value
|
||||
};
|
||||
|
||||
// Add failure details
|
||||
if let Some(mut failure) = event.failure.clone().map(TlsFailureDetails::from) {
|
||||
if let Some(idx) = policy
|
||||
.failure_details
|
||||
.0
|
||||
.inner
|
||||
.iter()
|
||||
.position(|d| d.value.eq_except_count(&failure))
|
||||
{
|
||||
policy.failure_details.0.inner[idx]
|
||||
.value
|
||||
.failed_session_count += 1;
|
||||
} else {
|
||||
failure.failed_session_count = 1;
|
||||
policy.failure_details.push(failure);
|
||||
}
|
||||
|
||||
policy.total_failed_sessions += 1;
|
||||
} else {
|
||||
policy.total_successful_sessions += 1;
|
||||
}
|
||||
|
||||
// Write entry
|
||||
let report_bytes = report.to_pickled_vec();
|
||||
let max_report_size = self
|
||||
.eval_if(
|
||||
&config.max_size,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(5 * 1024 * 1024);
|
||||
if max_report_size != 0 && report_bytes.len() > max_report_size {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
|
||||
SpanId = event.span_id,
|
||||
Domain = event.domain.clone(),
|
||||
Details = report_bytes.len(),
|
||||
Limit = max_report_size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
|
||||
report_bytes,
|
||||
);
|
||||
|
||||
match self.core.storage.data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
rety_count += 1;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to write TLS report")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user