Relay: relays in the domains' MX and SPF records (RL-25) #268
No files matched your search
@@ -13,6 +13,7 @@ use dns_update::{
|
||||
CAARecord, DnsRecord, KeyValue, MXRecord, NamedDnsRecord, SRVRecord, TLSARecord, TlsaCertUsage,
|
||||
TlsaMatching, TlsaSelector, bind::BindSerializer,
|
||||
};
|
||||
use inbuxa_features::relay::dns as relay_dns;
|
||||
use registry::schema::{
|
||||
enums::{DnsRecordType, ServiceProtocol},
|
||||
prelude::{ObjectType, Property},
|
||||
@@ -39,6 +40,19 @@ impl Server {
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_off_for(domain_name).await?;
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
// inbuxa: relays that take the domain's mail are its MX set, and
|
||||
// the ones that send are in its SPF (relay spec, RL-25)
|
||||
let relay_dns = if record_types
|
||||
.iter()
|
||||
.any(|t| matches!(t, DnsRecordType::Mx | DnsRecordType::Spf))
|
||||
{
|
||||
relay_dns::for_domain(
|
||||
&inbuxa_features::relay::all(self.store()).await?,
|
||||
domain_name,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for record_type in record_types {
|
||||
match record_type {
|
||||
@@ -62,6 +76,18 @@ impl Server {
|
||||
}
|
||||
}
|
||||
DnsRecordType::Mx => {
|
||||
if let Some(relays) = &relay_dns {
|
||||
for (host, preference) in &relays.mx {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::MX(MXRecord {
|
||||
exchange: format!("{host}."),
|
||||
priority: *preference,
|
||||
}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for mx in &network.info.mxs {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
@@ -76,6 +102,44 @@ impl Server {
|
||||
}
|
||||
}
|
||||
DnsRecordType::Spf => {
|
||||
if let Some(relays) = &relay_dns {
|
||||
let mut hosts = relays.senders.clone();
|
||||
if relays.server_sends {
|
||||
let own = network
|
||||
.info
|
||||
.mxs
|
||||
.iter()
|
||||
.map(|mx| mx.hostname.as_deref().unwrap_or(default_host))
|
||||
.chain(network.info.mxs.is_empty().then_some(default_host));
|
||||
for host in own {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
for host in &hosts {
|
||||
if host.ends_with(&domain_name_suffix) {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("{host}."),
|
||||
record: DnsRecord::TXT("v=spf1 a -all".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
match relay_dns::spf_policy(&hosts) {
|
||||
Some(policy) => records.push(NamedDnsRecord {
|
||||
name: format!("{domain_name}."),
|
||||
record: DnsRecord::TXT(policy),
|
||||
}),
|
||||
None => trc::event!(
|
||||
Dns(trc::DnsEvent::BuildError),
|
||||
Domain = domain_name.to_string(),
|
||||
Total = hosts.len(),
|
||||
Details = "More hosts send mail than one SPF record can name; the domain's SPF record was left as it is",
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mxs = network
|
||||
.info
|
||||
.mxs
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! A domain's MX and SPF records when relays take its mail (relay spec,
|
||||
//! RL-25). The relays named are those enabled and joined, with a public
|
||||
//! name, that serve the domain: their settings' `domains` name it, or name
|
||||
//! none. With none of them, a domain's records are made as without relays.
|
||||
//!
|
||||
//! What the records are made of is kept as a hash under `d`: when it
|
||||
//! changes, a node queues the domains' DNS tasks again ([`dns_change`]).
|
||||
|
||||
use super::{FEATURE, Json, Relay};
|
||||
use serde::Serialize as SerdeSerialize;
|
||||
use store::{
|
||||
SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const KIND_DNS: u8 = b'd';
|
||||
|
||||
/// SPF allows ten mechanisms that look up DNS (RFC 7208, 4.6.4).
|
||||
pub const SPF_LOOKUPS_MAX: usize = 10;
|
||||
|
||||
/// The relays' part of one domain's records.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize)]
|
||||
pub struct RelayDns {
|
||||
/// The MX set: each relay's public name and preference, lowest first.
|
||||
pub mx: Vec<(String, u16)>,
|
||||
/// The relays that send mail, not `inboundOnly` (RL-20).
|
||||
pub senders: Vec<String>,
|
||||
/// Whether this server still sends mail itself: no enabled relay has
|
||||
/// `sendThrough` on (RL-24).
|
||||
pub server_sends: bool,
|
||||
}
|
||||
|
||||
/// A relay that can be in a domain's records: enabled, joined, and with a
|
||||
/// public name.
|
||||
fn counted(relay: &Relay) -> bool {
|
||||
relay.enabled && relay.join.relay_pin.is_some() && !relay.settings.hostname.trim().is_empty()
|
||||
}
|
||||
|
||||
fn takes_mail_for(relay: &Relay, domain: &str) -> bool {
|
||||
counted(relay)
|
||||
&& (relay.settings.domains.is_empty()
|
||||
|| relay
|
||||
.settings
|
||||
.domains
|
||||
.iter()
|
||||
.any(|d| d.trim().eq_ignore_ascii_case(domain)))
|
||||
}
|
||||
|
||||
fn host(relay: &Relay) -> String {
|
||||
relay
|
||||
.settings
|
||||
.hostname
|
||||
.trim()
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// The relays' records for `domain`, or `None` when no relay takes its mail.
|
||||
pub fn for_domain(relays: &[Relay], domain: &str) -> Option<RelayDns> {
|
||||
let domain = domain.trim().trim_end_matches('.');
|
||||
let mut mx: Vec<(String, u16)> = Vec::new();
|
||||
let mut senders: Vec<String> = Vec::new();
|
||||
for relay in relays.iter().filter(|r| takes_mail_for(r, domain)) {
|
||||
let host = host(relay);
|
||||
match mx.iter_mut().find(|(h, _)| *h == host) {
|
||||
// One name on two relays: the lower preference
|
||||
Some((_, preference)) => *preference = (*preference).min(relay.mx_preference),
|
||||
None => mx.push((host.clone(), relay.mx_preference)),
|
||||
}
|
||||
if !relay.inbound_only && !senders.contains(&host) {
|
||||
senders.push(host);
|
||||
}
|
||||
}
|
||||
if mx.is_empty() {
|
||||
return None;
|
||||
}
|
||||
mx.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
|
||||
senders.sort();
|
||||
Some(RelayDns {
|
||||
mx,
|
||||
senders,
|
||||
server_sends: !relays.iter().any(|r| r.enabled && r.send_through),
|
||||
})
|
||||
}
|
||||
|
||||
/// The domain's SPF policy, given the hosts that send for it: each by its
|
||||
/// address records, as `a:` (a relay's link address isn't the one it sends
|
||||
/// from). `None` past [`SPF_LOOKUPS_MAX`], where every check would fail.
|
||||
pub fn spf_policy(hosts: &[String]) -> Option<String> {
|
||||
if hosts.len() > SPF_LOOKUPS_MAX {
|
||||
return None;
|
||||
}
|
||||
let mut policy = String::from("v=spf1");
|
||||
for host in hosts {
|
||||
policy.push_str(" a:");
|
||||
policy.push_str(host);
|
||||
}
|
||||
policy.push_str(" -all");
|
||||
Some(policy)
|
||||
}
|
||||
|
||||
/// Everything the relays put in any domain's MX and SPF records.
|
||||
#[derive(SerdeSerialize)]
|
||||
struct Inputs<'a> {
|
||||
relays: Vec<(u32, &'a str, u16, bool, &'a [String])>,
|
||||
server_sends: bool,
|
||||
}
|
||||
|
||||
fn inputs_hash(relays: &[Relay]) -> u64 {
|
||||
let mut listed: Vec<_> = relays
|
||||
.iter()
|
||||
.filter(|r| counted(r))
|
||||
.map(|r| {
|
||||
(
|
||||
r.id,
|
||||
r.settings.hostname.trim(),
|
||||
r.mx_preference,
|
||||
r.inbound_only,
|
||||
r.settings.domains.as_slice(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
listed.sort();
|
||||
if listed.is_empty() {
|
||||
// No relay in any record: the records are the server's own, and
|
||||
// `sendThrough` alone changes none of them
|
||||
return 0;
|
||||
}
|
||||
let inputs = Inputs {
|
||||
relays: listed,
|
||||
server_sends: !relays.iter().any(|r| r.enabled && r.send_through),
|
||||
};
|
||||
xxhash_rust::xxh3::xxh3_64(&serde_json::to_vec(&inputs).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn dns_class() -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: vec![FEATURE, KIND_DNS],
|
||||
})
|
||||
}
|
||||
|
||||
/// What the relays now put in the domains' DNS records, hashed, when it
|
||||
/// isn't what was last recorded on any node. With nothing recorded yet, no
|
||||
/// relays in any record is no change, so a server with none publishes
|
||||
/// nothing new when it first runs this.
|
||||
pub async fn dns_change(data: &Store, relays: &[Relay]) -> trc::Result<Option<u64>> {
|
||||
let hash = inputs_hash(relays);
|
||||
let recorded = data
|
||||
.get_value::<Json<u64>>(ValueKey::from(dns_class()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map_or(0, |Json(hash)| hash);
|
||||
Ok((recorded != hash).then_some(hash))
|
||||
}
|
||||
|
||||
/// Records a [`dns_change`] once the domains' DNS tasks are queued.
|
||||
pub async fn record_dns(data: &Store, hash: u64) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(dns_class(), Json(&hash).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn relay(id: u32, host: &str, preference: u16) -> Relay {
|
||||
let mut relay: Relay = serde_json::from_value(serde_json::json!({
|
||||
"name": format!("r{id}"),
|
||||
"address": "10.8.0.2",
|
||||
}))
|
||||
.unwrap();
|
||||
relay.id = id;
|
||||
relay.settings.hostname = host.into();
|
||||
relay.mx_preference = preference;
|
||||
relay.join.relay_pin = Some([1; 32]);
|
||||
relay
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relays_are_the_mx_set() {
|
||||
let mut inbound = relay(3, "mx3.example.com", 30);
|
||||
inbound.inbound_only = true;
|
||||
let mut waiting = relay(4, "mx4.example.com", 5);
|
||||
waiting.join.relay_pin = None;
|
||||
let mut off = relay(5, "mx5.example.com", 5);
|
||||
off.enabled = false;
|
||||
let mut other = relay(6, "mx6.example.com", 5);
|
||||
other.settings.domains = vec!["other.test".into()];
|
||||
let relays = [
|
||||
relay(1, "MX2.example.com.", 20),
|
||||
relay(2, "mx1.example.com", 10),
|
||||
inbound,
|
||||
waiting,
|
||||
off,
|
||||
other,
|
||||
];
|
||||
let dns = for_domain(&relays, "example.com").unwrap();
|
||||
assert_eq!(
|
||||
dns.mx,
|
||||
[
|
||||
("mx1.example.com".to_string(), 10),
|
||||
("mx2.example.com".to_string(), 20),
|
||||
("mx3.example.com".to_string(), 30),
|
||||
]
|
||||
);
|
||||
// Not the one that only takes mail in
|
||||
assert_eq!(dns.senders, ["mx1.example.com", "mx2.example.com"]);
|
||||
assert!(dns.server_sends);
|
||||
|
||||
// A relay for some domains only is theirs
|
||||
let dns = for_domain(&relays, "Other.Test").unwrap();
|
||||
assert_eq!(dns.mx.len(), 4);
|
||||
assert!(dns.mx.contains(&("mx6.example.com".to_string(), 5)));
|
||||
|
||||
// No relay that takes mail: the server's own records
|
||||
assert_eq!(for_domain(&relays[3..5], "example.com"), None);
|
||||
assert_eq!(for_domain(&[], "example.com"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_through_leaves_the_server_out() {
|
||||
let mut through = relay(1, "mx1.example.com", 10);
|
||||
through.send_through = true;
|
||||
let dns = for_domain(&[through], "example.com").unwrap();
|
||||
assert!(!dns.server_sends);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spf_names_each_host() {
|
||||
let hosts = vec!["mx1.example.com".to_string(), "mail.example.com".into()];
|
||||
assert_eq!(
|
||||
spf_policy(&hosts).unwrap(),
|
||||
"v=spf1 a:mx1.example.com a:mail.example.com -all"
|
||||
);
|
||||
assert_eq!(spf_policy(&[]).unwrap(), "v=spf1 -all");
|
||||
let many: Vec<String> = (0..11).map(|i| format!("mx{i}.example.com")).collect();
|
||||
assert_eq!(spf_policy(&many), None);
|
||||
assert!(spf_policy(&many[..10]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_changes_the_records() {
|
||||
let base = vec![relay(1, "mx1.example.com", 10)];
|
||||
let hash = inputs_hash(&base);
|
||||
assert_ne!(hash, 0);
|
||||
|
||||
// None in any record hashes as nothing
|
||||
let mut waiting = base.clone();
|
||||
waiting[0].join.relay_pin = None;
|
||||
assert_eq!(inputs_hash(&waiting), 0);
|
||||
assert_eq!(inputs_hash(&[]), 0);
|
||||
|
||||
let mut renamed = base.clone();
|
||||
renamed[0].name = "edge".into();
|
||||
renamed[0].settings.max_recipients += 1;
|
||||
assert_eq!(inputs_hash(&renamed), hash, "not in any record");
|
||||
|
||||
for change in [
|
||||
|r: &mut Relay| r.mx_preference = 20,
|
||||
|r: &mut Relay| r.inbound_only = true,
|
||||
|r: &mut Relay| r.send_through = true,
|
||||
|r: &mut Relay| r.settings.hostname = "mx9.example.com".into(),
|
||||
|r: &mut Relay| r.settings.domains = vec!["example.com".into()],
|
||||
|r: &mut Relay| r.enabled = false,
|
||||
] {
|
||||
let mut changed = base.clone();
|
||||
change(&mut changed[0]);
|
||||
assert_ne!(inputs_hash(&changed), hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +432,7 @@ mod tests {
|
||||
enabled: true,
|
||||
send_through: false,
|
||||
inbound_only: false,
|
||||
mx_preference: 10,
|
||||
settings: RelaySettings {
|
||||
hostname: "mx1.example.com".into(),
|
||||
domains: domains.iter().map(|d| d.to_string()).collect(),
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
//! - `i` + relay id (u32) + `Inbound` id: a message taken from the relay.
|
||||
//! - `o` + relay id (u32) + `Outbound` id: a message handed to the relay
|
||||
//! ([`outbound::Handed`]), as JSON.
|
||||
//! - `d`: what the relays last put in the domains' DNS records, hashed
|
||||
//! ([`dns::dns_change`]).
|
||||
//!
|
||||
//! Numbers are big-endian. There are few relays, so they're read whole.
|
||||
|
||||
pub mod dns;
|
||||
pub mod link;
|
||||
pub mod messages;
|
||||
pub mod outbound;
|
||||
@@ -150,6 +153,9 @@ pub struct Relay {
|
||||
/// Takes mail in but sends none (RL-20).
|
||||
#[serde(default)]
|
||||
pub inbound_only: bool,
|
||||
/// Its MX preference, where DNS is published for a domain (RL-25).
|
||||
#[serde(default = "default_mx_preference")]
|
||||
pub mx_preference: u16,
|
||||
/// What the downstream pushes to the relay, but for the certificate and
|
||||
/// the keys, which it adds itself. Empty `domains` means every domain
|
||||
/// this server has.
|
||||
@@ -189,6 +195,10 @@ fn default_link_port() -> u16 {
|
||||
443
|
||||
}
|
||||
|
||||
fn default_mx_preference() -> u16 {
|
||||
10
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -595,6 +605,7 @@ mod tests {
|
||||
enabled: true,
|
||||
send_through: false,
|
||||
inbound_only: false,
|
||||
mx_preference: 10,
|
||||
settings: RelaySettings {
|
||||
hostname: "mx1.example.com".into(),
|
||||
..Default::default()
|
||||
|
||||
@@ -31,6 +31,8 @@ pub enum RelayProperty {
|
||||
Enabled,
|
||||
SendThrough,
|
||||
InboundOnly,
|
||||
/// Its MX preference, where DNS is published (RL-25).
|
||||
MxPreference,
|
||||
/// What is pushed to the relay.
|
||||
Settings,
|
||||
SmtpCertificate,
|
||||
@@ -77,6 +79,7 @@ impl Property for RelayProperty {
|
||||
RelayProperty::Enabled => "enabled",
|
||||
RelayProperty::SendThrough => "sendThrough",
|
||||
RelayProperty::InboundOnly => "inboundOnly",
|
||||
RelayProperty::MxPreference => "mxPreference",
|
||||
RelayProperty::Settings => "settings",
|
||||
RelayProperty::SmtpCertificate => "smtpCertificate",
|
||||
RelayProperty::SmtpPrivateKey => "smtpPrivateKey",
|
||||
@@ -106,6 +109,7 @@ impl RelayProperty {
|
||||
b"enabled" => RelayProperty::Enabled,
|
||||
b"sendThrough" => RelayProperty::SendThrough,
|
||||
b"inboundOnly" => RelayProperty::InboundOnly,
|
||||
b"mxPreference" => RelayProperty::MxPreference,
|
||||
b"settings" => RelayProperty::Settings,
|
||||
b"smtpCertificate" => RelayProperty::SmtpCertificate,
|
||||
b"smtpPrivateKey" => RelayProperty::SmtpPrivateKey,
|
||||
|
||||
@@ -45,6 +45,7 @@ const ALL: &[P] = &[
|
||||
P::Enabled,
|
||||
P::SendThrough,
|
||||
P::InboundOnly,
|
||||
P::MxPreference,
|
||||
P::Settings,
|
||||
P::SmtpCertificate,
|
||||
P::SmtpPrivateKey,
|
||||
@@ -152,6 +153,7 @@ fn to_value(relay: &StoredRelay, links: &[NodeLink], properties: &[P], now: u64)
|
||||
P::Enabled => Value::Bool(relay.enabled),
|
||||
P::SendThrough => Value::Bool(relay.send_through),
|
||||
P::InboundOnly => Value::Bool(relay.inbound_only),
|
||||
P::MxPreference => Value::Number(u64::from(relay.mx_preference).into()),
|
||||
P::Settings => json_to_value(serde_json::to_value(&relay.settings).unwrap_or_default()),
|
||||
P::SmtpCertificate => text(relay.smtp_certificate.as_deref()),
|
||||
P::SmtpPrivateKey => text(relay.smtp_private_key.as_ref().map(|_| MASKED_PASSWORD)),
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//! linked node with the lowest id takes them. A node with no link to a
|
||||
//! joined relay for an hour says so with `relay.link-down` (RL-T23).
|
||||
|
||||
use crate::task_manager::dns::queue_relay_dns;
|
||||
use common::{
|
||||
BuildServer, Inner, Server,
|
||||
config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol},
|
||||
@@ -187,6 +188,11 @@ async fn reconcile(
|
||||
};
|
||||
// Which relays outbound mail goes through (RL-20, RL-24)
|
||||
server.inner.data.relay_senders.set_relays(&all);
|
||||
// The domains' MX and SPF records, when the relays in them changed (RL-25)
|
||||
if let Some(hash) = relays::dns::dns_change(data, &all).await? {
|
||||
queue_relay_dns(server).await?;
|
||||
relays::dns::record_dns(data, hash).await?;
|
||||
}
|
||||
let wanted: HashMap<u32, (String, u16)> = all
|
||||
.into_iter()
|
||||
.filter(|relay| relay.enabled)
|
||||
|
||||
@@ -2,17 +2,85 @@
|
||||
* 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 crate::task_manager::TaskResult;
|
||||
use common::Server;
|
||||
use dns_update::{CAARecord, DnsRecord, DnsRecordType, Error as DnsUpdateError, KeyValue};
|
||||
use registry::schema::structs::{
|
||||
DnsManagement, Domain, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus,
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::DnsRecordType as RecordType,
|
||||
prelude::ObjectType,
|
||||
structs::{
|
||||
DnsManagement, Domain, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, map::Map},
|
||||
};
|
||||
use std::fmt::Write;
|
||||
use store::ahash::AHashMap;
|
||||
use trc::DnsEvent;
|
||||
use store::{ahash::AHashMap, registry::RegistryQuery, write::BatchBuilder};
|
||||
use trc::{AddContext, DnsEvent, TaskManagerEvent};
|
||||
use types::id::Id;
|
||||
|
||||
/// Queues the MX and SPF records again for every domain whose DNS is
|
||||
/// published and names them, when what the relays put in them changed
|
||||
/// (relay spec, RL-25). Returns how many domains.
|
||||
pub(crate) async fn queue_relay_dns(server: &Server) -> trc::Result<usize> {
|
||||
let domain_ids = server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let mut batch = BatchBuilder::new();
|
||||
let mut total = 0;
|
||||
for domain_id in domain_ids {
|
||||
let Some(domain) = server
|
||||
.registry()
|
||||
.object::<Domain>(domain_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let DnsManagement::Automatic(props) = &domain.dns_management else {
|
||||
continue;
|
||||
};
|
||||
let records: Vec<RecordType> = props
|
||||
.publish_records
|
||||
.iter()
|
||||
.filter(|r| matches!(r, RecordType::Mx | RecordType::Spf))
|
||||
.copied()
|
||||
.collect();
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let task = Task::DnsManagement(TaskDnsManagement {
|
||||
domain_id,
|
||||
update_records: Map::new(records),
|
||||
on_success_renew_certificate: false,
|
||||
status: TaskStatus::now(),
|
||||
});
|
||||
trc::event!(
|
||||
TaskManager(TaskManagerEvent::TaskQueued),
|
||||
Type = task.object_type().as_str(),
|
||||
Domain = domain.name.clone(),
|
||||
Details = "The relays in its MX or SPF records changed",
|
||||
);
|
||||
batch.schedule_task(task);
|
||||
total += 1;
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
server
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
server.notify_task_queue();
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
pub(crate) trait DnsManagementTask: Sync + Send {
|
||||
fn dns_management(&self, task: &TaskDnsManagement) -> impl Future<Output = TaskResult> + Send;
|
||||
@@ -110,6 +178,22 @@ async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Resul
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: a publish never cuts a domain's MX set down to one host
|
||||
// (relay spec, RL-25)
|
||||
if record_type == DnsRecordType::MX
|
||||
&& let Ok(existing) = dns_updater.list_rrset(origin, &name, record_type).await
|
||||
&& let Some(hosts) = shrinks_mx_set(&existing, &recs)
|
||||
{
|
||||
if !errors.is_empty() {
|
||||
errors.push_str("; ");
|
||||
}
|
||||
let _ = write!(
|
||||
&mut errors,
|
||||
"Left the MX records for {name} as they are: publishing would replace their {hosts} hosts with one"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = dns_updater
|
||||
.set_rrset(origin, &name, record_type, recs)
|
||||
.await
|
||||
@@ -143,6 +227,25 @@ async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Resul
|
||||
}
|
||||
}
|
||||
|
||||
/// How many hosts a published MX set has, when `new` would replace them
|
||||
/// with one.
|
||||
fn shrinks_mx_set(existing: &[DnsRecord], new: &[DnsRecord]) -> Option<usize> {
|
||||
let hosts = |records: &[DnsRecord]| {
|
||||
let mut hosts: Vec<String> = records
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DnsRecord::MX(mx) => Some(mx.exchange.trim_end_matches('.').to_ascii_lowercase()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
hosts.sort();
|
||||
hosts.dedup();
|
||||
hosts.len()
|
||||
};
|
||||
let (before, after) = (hosts(existing), hosts(new));
|
||||
(before > 1 && after == 1).then_some(before)
|
||||
}
|
||||
|
||||
fn same_rrset_family(a: &DnsRecord, b: &DnsRecord) -> bool {
|
||||
match (a, b) {
|
||||
(DnsRecord::TXT(_), DnsRecord::TXT(_)) => same_txt_family(a, b),
|
||||
@@ -203,3 +306,35 @@ fn is_owned_txt_name(name: &str) -> bool {
|
||||
|| name.contains("_validation-persist.")
|
||||
|| name.contains("._domainkey.")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dns_update::MXRecord;
|
||||
|
||||
fn mx(hosts: &[&str]) -> Vec<DnsRecord> {
|
||||
hosts
|
||||
.iter()
|
||||
.map(|host| {
|
||||
DnsRecord::MX(MXRecord {
|
||||
exchange: host.to_string(),
|
||||
priority: 10,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_mx_set_isnt_cut_to_one() {
|
||||
let three = mx(&["mail.example.com.", "mx2.example.com.", "mx3.example.com."]);
|
||||
assert_eq!(shrinks_mx_set(&three, &mx(&["mail.example.com."])), Some(3));
|
||||
// The same host twice is one host
|
||||
assert_eq!(
|
||||
shrinks_mx_set(&mx(&["a.test.", "A.test"]), &mx(&["b.test."])),
|
||||
None
|
||||
);
|
||||
assert_eq!(shrinks_mx_set(&three, &mx(&["a.test.", "b.test."])), None);
|
||||
assert_eq!(shrinks_mx_set(&mx(&["a.test."]), &mx(&["b.test."])), None);
|
||||
assert_eq!(shrinks_mx_set(&[], &mx(&["a.test."])), None);
|
||||
}
|
||||
}
|
||||
@@ -507,7 +507,7 @@ When no link is up, or after RL-13:
|
||||
|
||||
| Object | What it holds |
|
||||
|---|---|
|
||||
| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once |
|
||||
| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), `mxPreference` (its MX preference, 10 by default, RL-25), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once |
|
||||
| `inbuxa:RelayMessage` | Read-only view of a relay's `Inbound` and `Outbound` queues, fetched over the link when asked. Never message content. `/get`, `/query` (filter `relayId`, `direction`; newest first) and `/set` destroy only: discards held mail, or cancels an outbound message, whose recipients fail here with no DSN as a delivery cancelled in the queue does. Each id is the relay's id and a hash of the relay's own message id, so nothing is stored |
|
||||
|
||||
Permissions: `sysRelayGet`, `sysRelayUpdate`, `sysRelayQueueGet`,
|
||||
@@ -869,8 +869,34 @@ In progress. Done so far:
|
||||
DSN.
|
||||
- The served schema lists the Relays page (`CustomComponent/Relays`) in
|
||||
Settings, beside Routes, so inbuxa-admin can show it.
|
||||
- Still to come in step 5: inbuxa-admin's Relays page, certificates
|
||||
(RL-27), DNS (RL-25) and upgrades (RL-29).
|
||||
- inbuxa-admin's Relays page: the list with what needs attention, adding
|
||||
a relay with its join token, each relay's settings with their help, its
|
||||
queues, a new join, and removing it. Still to come there: the guided
|
||||
add, the security checks, and the link to the relay's events.
|
||||
- DNS (RL-25). A relay is in a domain's records when it is enabled,
|
||||
joined, and has a public name, and its `domains` name the domain or
|
||||
name none. With any such relay, the domain's MX set is those relays,
|
||||
each at its `mxPreference`, and the server's own MX hosts are left out.
|
||||
The SPF record names each sending host by its address records
|
||||
(`v=spf1 a:<host> ... -all`): the relays not marked `inboundOnly`, and
|
||||
this server's own MX hosts unless an enabled relay has `sendThrough`
|
||||
on. Each host under the domain gets `v=spf1 a -all` for its HELO name.
|
||||
More than ten hosts would break SPF's lookup limit: the domain's SPF
|
||||
record is then left as it is, with a `dns.build-error` event. With no
|
||||
such relay, a domain's records are made as before.
|
||||
- What the relays put in any domain's records is kept as a hash. When it
|
||||
changes (a relay joins or is removed, is turned on or off, gets another
|
||||
public name, domains or preference, or `inboundOnly` or `sendThrough`
|
||||
changes), the link service queues the MX and SPF records again for
|
||||
every domain whose DNS is Automatic and publishes them. The same
|
||||
records are in the zone file a domain with Manual DNS exports.
|
||||
- A publish never cuts a published MX set of two or more hosts down to
|
||||
one. That MX record set is left as it is, and the task fails with a
|
||||
reason that says so. This holds with or without relays.
|
||||
- Tested: the joined test relay is example.com's only MX host, at
|
||||
preference 10 and then 20 after a change. SPF names it and this
|
||||
server, and the relay's host gets its own `a` policy.
|
||||
- Still to come in step 5: certificates (RL-27) and upgrades (RL-29).
|
||||
|
||||
Order:
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use inbuxa_relay_proto::{time, token::JoinToken};
|
||||
use mail_auth::{common::parse::TxtRecordParser, mta_sts::TlsRpt};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::DnsRecordType,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
CertificateManagement, DkimManagement, DnsManagement, Domain, Expression,
|
||||
@@ -401,6 +402,59 @@ impl Drop for RelayProcess {
|
||||
}
|
||||
|
||||
/// Polls `check` against the relay object until it's true, or panics.
|
||||
/// Waits for the link service to see the relays' DNS change (RL-25).
|
||||
async fn relay_dns_recorded(test: &TestServer) {
|
||||
let data = test.server.store();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let relays = inbuxa_features::relay::all(data).await.unwrap();
|
||||
if inbuxa_features::relay::dns::dns_change(data, &relays)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the relays' DNS change wasn't seen"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// example.com's MX and SPF records, as `name TYPE value`.
|
||||
async fn example_com_records(test: &TestServer) -> Vec<String> {
|
||||
let server = &test.server;
|
||||
for id in server
|
||||
.registry()
|
||||
.query::<Vec<types::id::Id>>(store::registry::RegistryQuery::new(ObjectType::Domain))
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
let Some(domain) = server.registry().object::<Domain>(id).await.unwrap() else {
|
||||
continue;
|
||||
};
|
||||
if domain.name != "example.com" {
|
||||
continue;
|
||||
}
|
||||
return server
|
||||
.build_dns_records(id, &domain, &[DnsRecordType::Mx, DnsRecordType::Spf])
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|r| match r.record {
|
||||
dns_update::DnsRecord::MX(mx) => {
|
||||
format!("{} MX {} {}", r.name, mx.priority, mx.exchange)
|
||||
}
|
||||
dns_update::DnsRecord::TXT(txt) => format!("{} TXT {txt}", r.name),
|
||||
other => format!("{} {other:?}", r.name),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
panic!("no example.com domain");
|
||||
}
|
||||
|
||||
async fn wait_for(
|
||||
account: &Account,
|
||||
id: &str,
|
||||
@@ -778,6 +832,44 @@ pub async fn link(test: &mut TestServer) {
|
||||
"not served: {replies:?}"
|
||||
);
|
||||
|
||||
// Joined, it is example.com's MX set, and in its SPF with this server,
|
||||
// which still sends its own mail (RL-25)
|
||||
relay_dns_recorded(test).await;
|
||||
let records = example_com_records(test).await;
|
||||
assert!(
|
||||
records.contains(&"example.com. MX 10 mx1.example.com.".to_string()),
|
||||
"{records:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
records.iter().filter(|r| r.contains(" MX ")).count(),
|
||||
1,
|
||||
"only the relay: {records:?}"
|
||||
);
|
||||
let own = &test.server.core.network.server_name;
|
||||
assert!(
|
||||
records.contains(&format!(
|
||||
"example.com. TXT v=spf1 a:mx1.example.com a:{own} -all"
|
||||
)),
|
||||
"{records:?}"
|
||||
);
|
||||
assert!(
|
||||
records.contains(&"mx1.example.com. TXT v=spf1 a -all".to_string()),
|
||||
"{records:?}"
|
||||
);
|
||||
// Its preference is the relay's, and changing it changes the records
|
||||
call(
|
||||
admin,
|
||||
"inbuxa:Relay/set",
|
||||
json!({"update": {&id: {"mxPreference": 20}}}),
|
||||
)
|
||||
.await;
|
||||
relay_dns_recorded(test).await;
|
||||
let records = example_com_records(test).await;
|
||||
assert!(
|
||||
records.contains(&"example.com. MX 20 mx1.example.com.".to_string()),
|
||||
"{records:?}"
|
||||
);
|
||||
|
||||
// Live (RL-12): delivered here before the sender hears 250
|
||||
let reply = send_mail(smtp_port, "[email protected]", &message("Live one", "")).await;
|
||||
assert!(reply.starts_with("250"), "{reply}");
|
||||
|
||||
Reference in new issue
Block a user