Deliverability check: each node asks what the internet sees of it
Deliverability spec (inbuxa-drafts specs/deliverability.md), the server side. Every node that sends mail checks itself once a day, at its own minute in the first hour (UTC), and when an administrator asks: - its outgoing addresses (the connection strategy's, or what its EHLO name resolves to), their reverse DNS and whether it resolves back, and nine blocklists, read by each list's own codes so a refused query is never taken for a listing (DL-1 to DL-6); - for every domain: SPF for each address, each DKIM key (by signing a message that's never sent and verifying it as a receiver would), DMARC, the MTA-STS policy against the MX, TLS reporting, and the domain blocklists (DL-7 to DL-12); - whether it holds a certificate for its EHLO and MX names (DL-13). It keeps one report per node, facts only; the console grades them. - inbuxa:DeliverabilityReport: /get, and a create that asks every node to check now, broadcast as DeliverabilityCheck (DL-15). A tenant administrator gets their own domains only (DL-20). - inbuxa:DeliverabilitySettings: which built-in lists are left out, and the lists themselves (DL-6). - sysDeliverabilityGet, sysDeliverabilityUpdate, sysDeliverabilityCheck; a tenant ceiling always turns the last two off.
This commit is contained in:
1 parent
f791c78d17
commit
a24ed3b60a
34 files changed
+2576
-5
No files matched your search
@@ -34,6 +34,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "h
|
||||
base64 = "0.23"
|
||||
dns-update = { version = "0.5" }
|
||||
psl = "2"
|
||||
# inbuxa: the deliverability check
|
||||
mail-auth = { version = "0.13" }
|
||||
futures = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
|
||||
@@ -146,6 +146,10 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
|
||||
serialized.push(13u8);
|
||||
let _ = serialized.write_leb128(*account_id);
|
||||
}
|
||||
// inbuxa: DL-15
|
||||
BroadcastEvent::DeliverabilityCheck => {
|
||||
serialized.push(14u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
serialized
|
||||
@@ -284,6 +288,8 @@ where
|
||||
let account_id = self.messages.next_leb128().ok_or(())?;
|
||||
Ok(Some(BroadcastEvent::EndSessions(account_id)))
|
||||
}
|
||||
// inbuxa: DL-15
|
||||
14 => Ok(Some(BroadcastEvent::DeliverabilityCheck)),
|
||||
_ => Err(()),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
||||
.send(PushEvent::Revoke { account_id })
|
||||
.await;
|
||||
}
|
||||
// inbuxa: DL-15: this node checks
|
||||
// itself too
|
||||
BroadcastEvent::DeliverabilityCheck => {
|
||||
crate::inbuxa_deliverability::CHECK_NOW
|
||||
.notify_one();
|
||||
}
|
||||
BroadcastEvent::QueueRefresh => {
|
||||
if inner.shared_core.load().network.roles.outbound_mta {
|
||||
let _ = inner
|
||||
@@ -278,6 +284,7 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
|
||||
BroadcastEvent::EndSessions(account_id) => {
|
||||
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
|
||||
}
|
||||
BroadcastEvent::DeliverabilityCheck => "DeliverabilityCheck".into(),
|
||||
BroadcastEvent::RegistryChange(change) => match change {
|
||||
RegistryChange::Insert(id) => trc::Value::Array(vec![
|
||||
"RegistryInsert".into(),
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The deliverability check (deliverability spec, DL-1 to DL-16): every node
|
||||
//! that sends mail asks what the rest of the internet sees of it, once a day
|
||||
//! and when an administrator asks (**Check now**), and keeps one report.
|
||||
//!
|
||||
//! Each node checks itself, because only it knows which address it sends
|
||||
//! from: a cluster's nodes can each leave from their own (DL-1, DL-2). The
|
||||
//! report holds facts; the console grades them.
|
||||
|
||||
use common::{
|
||||
BuildServer, Inner, Server, config::smtp::auth::Dkim1Signer, expr::functions::EmptyResolver,
|
||||
};
|
||||
use futures::future::join_all;
|
||||
use inbuxa_features::deliverability::{
|
||||
self as model, Address, AddressSource, Certificate, DkimKey, DkimState, Dmarc, DomainReport,
|
||||
Listing, ListingState, MtaSts, Report, Settings, SpfResult,
|
||||
lists::{self, Answer, BlockList, Subject},
|
||||
};
|
||||
use mail_auth::{
|
||||
AuthenticatedMessage, DkimResult, DnsError, Error, SpfResult as Spf,
|
||||
common::headers::HeaderWriter,
|
||||
dmarc::{self, Alignment},
|
||||
mta_sts::{MtaSts as MtaStsRecord, TlsRpt},
|
||||
spf::verify::SpfParameters,
|
||||
};
|
||||
use registry::schema::{prelude::ObjectType, structs::Domain};
|
||||
use smtp::outbound::mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
future::Future,
|
||||
net::IpAddr,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
use store::{registry::RegistryQuery, write::now};
|
||||
use tokio::sync::Notify;
|
||||
use types::id::Id;
|
||||
|
||||
/// DL-16: no single lookup holds a run up for longer than this.
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// DL-16: lookups in flight at once.
|
||||
const PARALLEL: usize = 8;
|
||||
const MTA_STS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const DAY: u64 = 86_400;
|
||||
/// After a start, wait this long before a run that's overdue.
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// DL-15: wakes this node's check, from **Check now** here or on another node.
|
||||
pub static CHECK_NOW: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||
|
||||
pub fn spawn_deliverability(inner: Arc<Inner>) {
|
||||
tokio::spawn(async move {
|
||||
let mut first = true;
|
||||
loop {
|
||||
let server = inner.build_server();
|
||||
let wait = match due_in(&server).await {
|
||||
Ok(wait) => wait,
|
||||
Err(err) => {
|
||||
trc::error!(err.details("Failed to read the deliverability report"));
|
||||
Duration::from_secs(3600)
|
||||
}
|
||||
};
|
||||
let wait = if first { wait.max(SETTLE) } else { wait };
|
||||
first = false;
|
||||
let asked = tokio::select! {
|
||||
_ = tokio::time::sleep(wait) => false,
|
||||
_ = CHECK_NOW.notified() => true,
|
||||
};
|
||||
let server = inner.build_server();
|
||||
if !server.core.network.roles.outbound_mta {
|
||||
continue;
|
||||
}
|
||||
// DL-15: asked again within ten minutes, the last report stands
|
||||
if asked
|
||||
&& let Ok(Some(last)) =
|
||||
model::report(server.store(), server.core.network.node_id).await
|
||||
&& now().saturating_sub(last.checked_at) < model::MIN_INTERVAL_SECS
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = run(&server).await {
|
||||
trc::error!(err.details("Failed to run the deliverability check"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// DL-14: once a day, at a minute in the first hour of the day (UTC) that's
|
||||
/// the node's own, so nodes and servers don't all ask the lists at once.
|
||||
async fn due_in(server: &Server) -> trc::Result<Duration> {
|
||||
let node_id = server.core.network.node_id;
|
||||
let last = model::report(server.store(), node_id)
|
||||
.await?
|
||||
.map(|r| r.checked_at)
|
||||
.unwrap_or(0);
|
||||
let slot = slot_for(&server.core.network.server_name, node_id);
|
||||
let next = next_slot(last, slot);
|
||||
Ok(Duration::from_secs(next.saturating_sub(now())))
|
||||
}
|
||||
|
||||
fn slot_for(hostname: &str, node_id: u64) -> u64 {
|
||||
let hash = hostname
|
||||
.bytes()
|
||||
.fold(node_id.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, b| {
|
||||
h.rotate_left(5) ^ b as u64
|
||||
});
|
||||
hash % 3600
|
||||
}
|
||||
|
||||
/// The first daily slot after `last`; 0 (never ran) is due now.
|
||||
fn next_slot(last: u64, slot: u64) -> u64 {
|
||||
if last == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut next = last - last % DAY + slot;
|
||||
if next <= last {
|
||||
next += DAY;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// Runs the check on this node and keeps the report.
|
||||
pub async fn run(server: &Server) -> trc::Result<Report> {
|
||||
let settings = model::settings(server.store()).await?;
|
||||
let addresses = addresses(server, &settings).await;
|
||||
let mut domains = Vec::new();
|
||||
let ids = server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await?;
|
||||
for id in ids {
|
||||
if let Some(domain) = server.registry().object::<Domain>(id).await? {
|
||||
domains.push(check_domain(server, &settings, &domain, &addresses).await?);
|
||||
}
|
||||
}
|
||||
let certificates = certificates(server, &addresses).await;
|
||||
let report = Report {
|
||||
node_id: server.core.network.node_id,
|
||||
hostname: server.core.network.server_name.clone(),
|
||||
checked_at: now(),
|
||||
addresses,
|
||||
domains,
|
||||
certificates,
|
||||
};
|
||||
model::put_report(server.store(), &report).await?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// --- DL-1, DL-2: the addresses ---------------------------------------------
|
||||
|
||||
async fn addresses(server: &Server, settings: &Settings) -> Vec<Address> {
|
||||
let queue = &server.core.smtp.queue;
|
||||
// The strategy the scheduler picks for a message it knows nothing about:
|
||||
// what an expression on the node's own name, as a cluster uses, gives.
|
||||
let strategy = server
|
||||
.eval_if::<String, _>(&queue.connection, &EmptyResolver, 0)
|
||||
.await
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let connection = server.get_connection_or_default(&strategy, 0);
|
||||
let ehlo = connection
|
||||
.ehlo_hostname
|
||||
.clone()
|
||||
.unwrap_or_else(|| server.core.network.server_name.clone());
|
||||
|
||||
let mut found: Vec<(IpAddr, AddressSource, String)> = connection
|
||||
.source_ipv4
|
||||
.iter()
|
||||
.chain(connection.source_ipv6.iter())
|
||||
.map(|source| {
|
||||
(
|
||||
source.ip,
|
||||
AddressSource::Configured,
|
||||
source.host.clone().unwrap_or_else(|| ehlo.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if found.is_empty() {
|
||||
for ip in resolve_name(server, &ehlo).await {
|
||||
found.push((ip, AddressSource::Ehlo, ehlo.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(found.len());
|
||||
for (ip, source, ehlo) in found {
|
||||
let mut address = Address {
|
||||
ip: ip.to_string(),
|
||||
source,
|
||||
strategy: strategy.clone(),
|
||||
ehlo: ehlo.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
reverse_dns(server, ip, &ehlo, &mut address).await;
|
||||
address.listings = listings(server, settings, Subject::Ip(ip)).await;
|
||||
out.push(address);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The IPv4 and IPv6 addresses `name` resolves to; none when it doesn't.
|
||||
async fn resolve_name(server: &Server, name: &str) -> Vec<IpAddr> {
|
||||
let dns = &server.core.smtp.resolvers.dns;
|
||||
let cache = &server.inner.cache;
|
||||
let fqdn = fqdn(name);
|
||||
let mut ips = Vec::new();
|
||||
if let Some(Ok(v4)) = timed(dns.ipv4_lookup(fqdn.as_str(), Some(&cache.dns_ipv4))).await {
|
||||
ips.extend(v4.rrset.iter().copied().map(IpAddr::V4));
|
||||
}
|
||||
if let Some(Ok(v6)) = timed(dns.ipv6_lookup(fqdn.as_str(), Some(&cache.dns_ipv6))).await {
|
||||
ips.extend(v6.rrset.iter().copied().map(IpAddr::V6));
|
||||
}
|
||||
ips
|
||||
}
|
||||
|
||||
/// DL-5: the PTR names, whether one resolves back, and whether that one is
|
||||
/// the EHLO name.
|
||||
async fn reverse_dns(server: &Server, ip: IpAddr, ehlo: &str, address: &mut Address) {
|
||||
let dns = &server.core.smtp.resolvers.dns;
|
||||
match timed(dns.ptr_lookup(ip, Some(&server.inner.cache.dns_ptr))).await {
|
||||
Some(Ok(names)) => {
|
||||
address.ptr = names.rrset.iter().map(|n| bare(n)).collect();
|
||||
}
|
||||
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
|
||||
Some(Err(err)) => address.ptr_error = Some(err.to_string()),
|
||||
None => address.ptr_error = Some("No answer in 5 seconds".into()),
|
||||
}
|
||||
for name in address.ptr.clone() {
|
||||
if resolve_name(server, &name).await.contains(&ip) {
|
||||
address.forward_confirmed = true;
|
||||
if name.eq_ignore_ascii_case(&bare(ehlo)) {
|
||||
address.ehlo_matches = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DL-4, DL-6, DL-12: blocklists ----------------------------------------
|
||||
|
||||
async fn listings(server: &Server, settings: &Settings, subject: Subject<'_>) -> Vec<Listing> {
|
||||
let mut out = Vec::new();
|
||||
let mut asked = Vec::new();
|
||||
for list in lists::LISTS {
|
||||
let Some(name) = list.query(&subject) else {
|
||||
continue;
|
||||
};
|
||||
if settings.is_off(list.name) {
|
||||
out.push(Listing {
|
||||
list: list.name.into(),
|
||||
state: ListingState::Off,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
asked.push((list, name));
|
||||
}
|
||||
}
|
||||
for chunk in asked.chunks(PARALLEL) {
|
||||
out.extend(join_all(chunk.iter().map(|(list, name)| ask(server, list, name))).await);
|
||||
}
|
||||
// In the lists' own order, whether asked or off
|
||||
out.sort_by_key(|l| lists::LISTS.iter().position(|list| list.name == l.list));
|
||||
out
|
||||
}
|
||||
|
||||
async fn ask(server: &Server, list: &BlockList, name: &str) -> Listing {
|
||||
let dns = &server.core.smtp.resolvers.dns;
|
||||
let mut listing = Listing {
|
||||
list: list.name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
match timed(dns.ipv4_lookup(name, Some(&server.inner.cache.dns_ipv4))).await {
|
||||
Some(Ok(answer)) => {
|
||||
let Some(code) = answer.rrset.first().copied() else {
|
||||
return listing;
|
||||
};
|
||||
listing.code = Some(code.to_string());
|
||||
match list.read(code) {
|
||||
Answer::Listed(meaning) => {
|
||||
listing.state = ListingState::Listed;
|
||||
listing.meaning = Some(meaning.into());
|
||||
}
|
||||
Answer::Refused(meaning) => {
|
||||
listing.state = ListingState::Refused;
|
||||
listing.meaning = Some(meaning.into());
|
||||
}
|
||||
Answer::Unknown => {
|
||||
listing.state = ListingState::Refused;
|
||||
listing.meaning = Some("An answer this list doesn't define".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not on the list
|
||||
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
|
||||
// A list that refuses the resolver often answers REFUSED or SERVFAIL
|
||||
Some(Err(err)) => {
|
||||
listing.state = ListingState::Error;
|
||||
listing.meaning = Some(err.to_string());
|
||||
}
|
||||
None => {
|
||||
listing.state = ListingState::Error;
|
||||
listing.meaning = Some("No answer in 5 seconds".into());
|
||||
}
|
||||
}
|
||||
listing
|
||||
}
|
||||
|
||||
// --- DL-7 to DL-12: per domain --------------------------------------------
|
||||
|
||||
async fn check_domain(
|
||||
server: &Server,
|
||||
settings: &Settings,
|
||||
domain: &Domain,
|
||||
addresses: &[Address],
|
||||
) -> trc::Result<DomainReport> {
|
||||
let name = domain.name.to_lowercase();
|
||||
let mut report = DomainReport {
|
||||
domain: name.clone(),
|
||||
tenant_id: domain.member_tenant_id.map(|id| id.document_id()),
|
||||
..Default::default()
|
||||
};
|
||||
let dns = &server.core.smtp.resolvers.dns;
|
||||
let cache = &server.inner.cache;
|
||||
|
||||
// DL-7: SPF for every address the node sends from
|
||||
for address in addresses {
|
||||
let Ok(ip) = address.ip.parse::<IpAddr>() else {
|
||||
continue;
|
||||
};
|
||||
let sender = format!("postmaster@{name}");
|
||||
let output = dns
|
||||
.check_host(cache.build_auth_parameters(SpfParameters::new(
|
||||
ip,
|
||||
&name,
|
||||
&address.ehlo,
|
||||
&server.core.network.server_name,
|
||||
&sender,
|
||||
)))
|
||||
.await;
|
||||
report.spf.push(SpfResult {
|
||||
ip: address.ip.clone(),
|
||||
result: spf_name(output.result()).into(),
|
||||
});
|
||||
}
|
||||
|
||||
// DL-8: each key the domain signs with is the one published
|
||||
report.dkim = dkim_keys(server, &name).await?;
|
||||
|
||||
// DL-9: what DMARC asks of alignment; the console works it out
|
||||
if let Some(Ok(record)) =
|
||||
timed(dns.txt_lookup::<dmarc::Dmarc>(format!("_dmarc.{name}."), Some(&cache.dns_txt))).await
|
||||
{
|
||||
report.dmarc = Some(Dmarc {
|
||||
policy: match record.p {
|
||||
dmarc::Policy::None | dmarc::Policy::Unspecified => "none",
|
||||
dmarc::Policy::Quarantine => "quarantine",
|
||||
dmarc::Policy::Reject => "reject",
|
||||
}
|
||||
.into(),
|
||||
adkim: alignment(&record.adkim).into(),
|
||||
aspf: alignment(&record.aspf).into(),
|
||||
});
|
||||
}
|
||||
|
||||
// DL-10
|
||||
report.mta_sts = mta_sts(server, &name).await;
|
||||
|
||||
// DL-11
|
||||
report.tls_rpt = matches!(
|
||||
timed(dns.txt_lookup::<TlsRpt>(format!("_smtp._tls.{name}."), Some(&cache.dns_txt))).await,
|
||||
Some(Ok(_))
|
||||
);
|
||||
|
||||
// DL-12
|
||||
report.listings = listings(server, settings, Subject::Domain(&name)).await;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Signs a message that's never sent with each of the domain's DKIM keys,
|
||||
/// and verifies it as a receiver would: a key that's missing from DNS, or
|
||||
/// published but different, fails here before it fails anyone's mail.
|
||||
async fn dkim_keys(server: &Server, domain: &str) -> trc::Result<Vec<DkimKey>> {
|
||||
let Some(signers) = server.dkim_signers(domain).await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let message = format!(
|
||||
"From: deliverability-check@{domain}\r\n\
|
||||
To: deliverability-check@{domain}\r\n\
|
||||
Subject: Deliverability check\r\n\
|
||||
Date: Mon, 5 Oct 2026 00:00:00 +0000\r\n\
|
||||
Message-ID: <deliverability-check@{domain}>\r\n\
|
||||
\r\n\
|
||||
This message is signed to check the DKIM keys in DNS. It is never sent.\r\n"
|
||||
);
|
||||
let mut keys = Vec::new();
|
||||
for signer in &signers.dkim1 {
|
||||
let signature = match signer {
|
||||
Dkim1Signer::RsaSha256(signer) => signer.sign(message.as_bytes()),
|
||||
Dkim1Signer::Ed25519Sha256(signer) => signer.sign(message.as_bytes()),
|
||||
};
|
||||
let Ok(signature) = signature else {
|
||||
continue;
|
||||
};
|
||||
let selector = signature.s.clone();
|
||||
let mut signed = Vec::with_capacity(message.len() + 512);
|
||||
signature.write_header(&mut signed);
|
||||
signed.extend_from_slice(message.as_bytes());
|
||||
let state = match AuthenticatedMessage::parse(&signed) {
|
||||
Some(parsed) => {
|
||||
let outputs = server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dkim(server.inner.cache.build_auth_parameters(&parsed))
|
||||
.await;
|
||||
outputs
|
||||
.first()
|
||||
.map(|output| dkim_state(output.result()))
|
||||
.unwrap_or(DkimState::Error)
|
||||
}
|
||||
None => DkimState::Error,
|
||||
};
|
||||
keys.push(DkimKey { selector, state });
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn dkim_state(result: &DkimResult) -> DkimState {
|
||||
match result {
|
||||
DkimResult::Pass => DkimState::Matches,
|
||||
DkimResult::PermError(Error::Dns(DnsError::RecordNotFound(_)))
|
||||
| DkimResult::TempError(Error::Dns(DnsError::RecordNotFound(_))) => DkimState::Missing,
|
||||
DkimResult::TempError(_) => DkimState::Error,
|
||||
_ => DkimState::Different,
|
||||
}
|
||||
}
|
||||
|
||||
async fn mta_sts(server: &Server, domain: &str) -> MtaSts {
|
||||
let dns = &server.core.smtp.resolvers.dns;
|
||||
let cache = &server.inner.cache;
|
||||
let mut out = MtaSts::default();
|
||||
let Some(Ok(record)) =
|
||||
timed(dns.txt_lookup::<MtaStsRecord>(format!("_mta-sts.{domain}."), Some(&cache.dns_txt)))
|
||||
.await
|
||||
else {
|
||||
return out;
|
||||
};
|
||||
out.record_id = Some(record.id.clone());
|
||||
match server.lookup_mta_sts_policy(domain, MTA_STS_TIMEOUT).await {
|
||||
Ok(policy) => {
|
||||
out.fetched = true;
|
||||
out.mode = Some(
|
||||
match policy.mode {
|
||||
common::config::smtp::resolver::Mode::Enforce => "enforce",
|
||||
common::config::smtp::resolver::Mode::Testing => "testing",
|
||||
common::config::smtp::resolver::Mode::None => "none",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
out.max_age = Some(policy.max_age);
|
||||
if let Some(Ok(mxs)) = timed(dns.mx_lookup(domain, Some(&cache.dns_mx))).await {
|
||||
for mx in mxs.rrset.iter() {
|
||||
for exchange in mx.exchanges.iter() {
|
||||
let host = bare(exchange);
|
||||
if !policy.verify(&host) && !out.mx_not_covered.contains(&host) {
|
||||
out.mx_not_covered.push(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => out.error = Some(err.to_string()),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// --- DL-13: certificates ---------------------------------------------------
|
||||
|
||||
/// The EHLO names, and the server's MX names that point at this node, each
|
||||
/// with whether the node holds a certificate for it.
|
||||
async fn certificates(server: &Server, addresses: &[Address]) -> Vec<Certificate> {
|
||||
let mine: Vec<IpAddr> = addresses.iter().filter_map(|a| a.ip.parse().ok()).collect();
|
||||
let mut names: BTreeSet<String> = addresses.iter().map(|a| bare(&a.ehlo)).collect();
|
||||
let default_host = server.core.network.server_name.as_str();
|
||||
for mx in &server.core.network.info.mxs {
|
||||
let name = bare(mx.hostname.as_deref().unwrap_or(default_host));
|
||||
if !names.contains(&name)
|
||||
&& resolve_name(server, &name)
|
||||
.await
|
||||
.iter()
|
||||
.any(|ip| mine.contains(ip))
|
||||
{
|
||||
names.insert(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| Certificate {
|
||||
covered: server.resolve_certificate(&name).is_some(),
|
||||
name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
async fn timed<T>(lookup: impl Future<Output = T>) -> Option<T> {
|
||||
tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await.ok()
|
||||
}
|
||||
|
||||
fn fqdn(name: &str) -> String {
|
||||
format!("{}.", name.trim_end_matches('.'))
|
||||
}
|
||||
|
||||
fn bare(name: &str) -> String {
|
||||
name.trim_end_matches('.').to_lowercase()
|
||||
}
|
||||
|
||||
fn spf_name(result: Spf) -> &'static str {
|
||||
match result {
|
||||
Spf::Pass => "pass",
|
||||
Spf::Fail => "fail",
|
||||
Spf::SoftFail => "softFail",
|
||||
Spf::Neutral => "neutral",
|
||||
Spf::TempError => "tempError",
|
||||
Spf::PermError => "permError",
|
||||
Spf::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn alignment(alignment: &Alignment) -> &'static str {
|
||||
match alignment {
|
||||
Alignment::Relaxed => "relaxed",
|
||||
Alignment::Strict => "strict",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_daily_slot_follows_the_last_run() {
|
||||
let day = 20_000 * DAY;
|
||||
// Never ran: due now
|
||||
assert_eq!(next_slot(0, 600), 0);
|
||||
// Ran at 14:00: next is tomorrow's slot
|
||||
assert_eq!(next_slot(day + 14 * 3600, 600), day + DAY + 600);
|
||||
// Ran just before today's slot: today's slot
|
||||
assert_eq!(next_slot(day + 300, 600), day + 600);
|
||||
// Ran at the slot: tomorrow's
|
||||
assert_eq!(next_slot(day + 600, 600), day + DAY + 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_fall_in_the_first_hour_and_differ_by_node() {
|
||||
let a = slot_for("mx2.example.org", 2);
|
||||
let b = slot_for("mx3.example.org", 3);
|
||||
assert!(a < 3600 && b < 3600);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub mod broadcast;
|
||||
// inbuxa: AL-5, delegations end at their date
|
||||
pub mod inbuxa_lock_expiry;
|
||||
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
||||
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
|
||||
pub mod state_manager;
|
||||
pub mod task_manager;
|
||||
|
||||
@@ -74,6 +75,9 @@ impl SpawnServices for IpcReceivers {
|
||||
// inbuxa: personal-data catalog, D1: old log files go, per node
|
||||
inbuxa_log_retention::spawn_log_retention(inner.clone());
|
||||
|
||||
// inbuxa: deliverability spec, DL-14: each node checks itself daily
|
||||
inbuxa_deliverability::spawn_deliverability(inner.clone());
|
||||
|
||||
// Spawn task scheduler
|
||||
spawn_task_scheduler(inner);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user