Merge pull request 'Every node records DMARC and TLS results for the aggregate reports' (#47) from fix/front-node-dmarc into main
This commit was merged in pull request #47.
This commit is contained in:
@@ -47,8 +47,10 @@ impl SpawnQueueManager for IpcReceivers {
|
||||
// inbuxa: upstream started these only when the node's role included
|
||||
// outboundMta at boot, so turning the role on later did nothing and
|
||||
// turning it off left them delivering until a restart. They now run
|
||||
// on every node and follow the role live (see Queue::start and the
|
||||
// report scheduler). This also drains the queue channel on nodes
|
||||
// on every node: the queue follows the role live (see Queue::start),
|
||||
// and the report scheduler records DMARC and TLS results on every
|
||||
// node, whatever its role (see reporting/scheduler.rs). This also
|
||||
// drains the queue channel on nodes
|
||||
// without the role, where every queued message's refresh used to sit
|
||||
// in a channel nobody read until it filled and queueing blocked.
|
||||
if !core.storage.registry.is_recovery_mode() {
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
|
||||
use crate::{
|
||||
core::Session,
|
||||
queue::RecipientDomain,
|
||||
@@ -349,29 +352,43 @@ impl DmarcReporting for Server {
|
||||
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. inbuxa: only the version read here, so a record
|
||||
// another node appends meanwhile is sent with it rather than lost
|
||||
let mut attempt = 0;
|
||||
let report = loop {
|
||||
let Some(Revisioned {
|
||||
revision,
|
||||
value: report,
|
||||
}) = self
|
||||
.store()
|
||||
.get_value::<Revisioned<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 mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||
.clear(key.clone())
|
||||
.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(),
|
||||
});
|
||||
match self.store().write(batch.build_all()).await {
|
||||
Ok(_) => break report,
|
||||
Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
|
||||
attempt += 1;
|
||||
write_retry_pause(attempt).await;
|
||||
}
|
||||
Err(err) => return Err(err.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;
|
||||
@@ -676,8 +693,11 @@ impl DmarcReporting for Server {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
// inbuxa: another node appended first; try again
|
||||
// after a short pause
|
||||
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
|
||||
rety_count += 1;
|
||||
write_retry_pause(rety_count).await;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use common::config::smtp::report::AggregateFrequency;
|
||||
@@ -15,6 +17,7 @@ pub mod inbound;
|
||||
pub mod index;
|
||||
pub mod scheduler;
|
||||
pub mod send;
|
||||
pub mod shared; // inbuxa: reports written by every node
|
||||
pub mod spf;
|
||||
pub mod tls;
|
||||
|
||||
|
||||
@@ -20,14 +20,17 @@ impl SpawnReport for mpsc::Receiver<ReportingEvent> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = self.recv().await {
|
||||
let server = inner.build_server();
|
||||
// inbuxa: reports are the outbound MTA's business, as at
|
||||
// boot, but the role is read per event so a change applies
|
||||
// without a restart. Events that arrive while the role is
|
||||
// off are dropped, as they were on a node started without it
|
||||
if !matches!(event, ReportingEvent::Stop) && !server.core.network.roles.outbound_mta
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// inbuxa: every node records what it received, whatever its
|
||||
// role. An aggregate report covers all of a domain's mail,
|
||||
// whichever node took it, and recording is a store write
|
||||
// that nodes already share: the report's primary key is
|
||||
// versioned, so concurrent appends from several nodes retry
|
||||
// rather than overwrite. Only building and sending the
|
||||
// report (the DmarcReport and TlsReport tasks) belongs to
|
||||
// the outbound MTA; the task manager keeps those to nodes
|
||||
// with that role. Upstream ran this only on outbound MTA
|
||||
// nodes, so mail received anywhere else never reached a
|
||||
// report.
|
||||
match event {
|
||||
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
|
||||
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: internal DMARC and TLS reports are shared by every node. Any node
|
||||
//! that receives mail appends to them, so several nodes can write one report
|
||||
//! at once, and the node that sends it may do so while another is appending.
|
||||
//! Appends already guard the report's versioned primary key and retry when
|
||||
//! another writer got there first; these helpers give those retries room and
|
||||
//! let the sender delete exactly the report it read.
|
||||
|
||||
use rand::RngExt;
|
||||
use std::time::Duration;
|
||||
use store::{Deserialize, xxhash_rust::xxh3::xxh3_64};
|
||||
|
||||
/// How many times a report write that lost to another writer is retried.
|
||||
/// Upstream retried three times, when only outbound MTA nodes wrote.
|
||||
pub(crate) const MAX_WRITE_RETRIES: u32 = 10;
|
||||
|
||||
/// A short random pause, longer on each attempt, before retrying a report
|
||||
/// write that lost to another node, so the writers spread out instead of
|
||||
/// colliding again.
|
||||
pub(crate) async fn write_retry_pause(attempt: u32) {
|
||||
let ms = rand::rng().random_range(5..=25u64) * u64::from(attempt.max(1));
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
}
|
||||
|
||||
/// A stored value with the hash of the bytes it was read from, for
|
||||
/// `AssertValue::Hash`: a write asserting it fails if anyone changed the
|
||||
/// value since.
|
||||
pub(crate) struct Revisioned<T> {
|
||||
pub revision: u64,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
impl<T: Deserialize> Deserialize for Revisioned<T> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
Ok(Revisioned {
|
||||
revision: xxh3_64(bytes),
|
||||
value: T::deserialize(bytes)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
|
||||
use crate::{
|
||||
queue::RecipientDomain,
|
||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||
@@ -70,28 +73,40 @@ impl TlsReporting for Server {
|
||||
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. inbuxa: only the version read here, so a result
|
||||
// another node appends meanwhile is sent with it rather than lost
|
||||
let mut attempt = 0;
|
||||
let report = loop {
|
||||
let Some(Revisioned {
|
||||
revision,
|
||||
value: report,
|
||||
}) = self
|
||||
.store()
|
||||
.get_value::<Revisioned<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 mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||
.clear(key.clone())
|
||||
.clear(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: report.domain.as_bytes().to_vec(),
|
||||
});
|
||||
match self.core.storage.data.write(batch.build_all()).await {
|
||||
Ok(_) => break report,
|
||||
Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
|
||||
attempt += 1;
|
||||
write_retry_pause(attempt).await;
|
||||
}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
};
|
||||
|
||||
let domain_name = report.domain.as_str();
|
||||
let event_from = report.report.date_range_start.timestamp() as u64;
|
||||
@@ -477,8 +492,11 @@ impl TlsReporting for Server {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
// inbuxa: another node appended first; try again
|
||||
// after a short pause
|
||||
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
|
||||
rety_count += 1;
|
||||
write_retry_pause(rety_count).await;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
|
||||
Reference in New Issue
Block a user