From 00bd215cc10a846fb050a246d2c388f1618bc3f9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 14:47:58 -0700 Subject: [PATCH] Relay: relays in the domains' MX and SPF records (RL-25) A relay that is enabled, joined and has a public name is in the records of the domains it serves: their MX set is the relays, each at its new mxPreference (10 by default), and SPF names each sending host by its address records. The relays marked inboundOnly are left out of SPF, and this server's own hosts are in it unless a relay has sendThrough on. Hosts under the domain get their own HELO policy. Past SPF's ten lookups, the domain's SPF record is left as it is, with an event. When what the relays put in the records changes, the link service queues the MX and SPF records again for every domain with Automatic DNS that publishes them. A hash of it is kept in the store, so a server with no relays publishes nothing new. A publish never cuts an MX set of two or more hosts down to one: that record set is left as it is and the task says why. --- crates/common/src/network/dns/records.rs | 64 +++++ crates/features/src/relay/dns.rs | 283 +++++++++++++++++++ crates/features/src/relay/link.rs | 1 + crates/features/src/relay/mod.rs | 11 + crates/jmap-proto/src/object/inbuxa_relay.rs | 4 + crates/jmap/src/inbuxa/relay.rs | 2 + crates/services/src/inbuxa_relay_link.rs | 6 + crates/services/src/task_manager/dns.rs | 143 +++++++++- docs/spec/features/relay.md | 32 ++- tests/src/system/relay.rs | 92 ++++++ 10 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 crates/features/src/relay/dns.rs diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index 8a77d401..33a06723 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -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 diff --git a/crates/features/src/relay/dns.rs b/crates/features/src/relay/dns.rs new file mode 100644 index 00000000..9bddb788 --- /dev/null +++ b/crates/features/src/relay/dns.rs @@ -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, + /// 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 { + let domain = domain.trim().trim_end_matches('.'); + let mut mx: Vec<(String, u16)> = Vec::new(); + let mut senders: Vec = 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 { + 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> { + let hash = inputs_hash(relays); + let recorded = data + .get_value::>(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 = (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); + } + } +} diff --git a/crates/features/src/relay/link.rs b/crates/features/src/relay/link.rs index 9a62e26c..601e9621 100644 --- a/crates/features/src/relay/link.rs +++ b/crates/features/src/relay/link.rs @@ -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(), diff --git a/crates/features/src/relay/mod.rs b/crates/features/src/relay/mod.rs index f28138e3..c0971564 100644 --- a/crates/features/src/relay/mod.rs +++ b/crates/features/src/relay/mod.rs @@ -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() diff --git a/crates/jmap-proto/src/object/inbuxa_relay.rs b/crates/jmap-proto/src/object/inbuxa_relay.rs index 52367c62..fa0ffb65 100644 --- a/crates/jmap-proto/src/object/inbuxa_relay.rs +++ b/crates/jmap-proto/src/object/inbuxa_relay.rs @@ -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, diff --git a/crates/jmap/src/inbuxa/relay.rs b/crates/jmap/src/inbuxa/relay.rs index eede336a..b28c97f0 100644 --- a/crates/jmap/src/inbuxa/relay.rs +++ b/crates/jmap/src/inbuxa/relay.rs @@ -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)), diff --git a/crates/services/src/inbuxa_relay_link.rs b/crates/services/src/inbuxa_relay_link.rs index a67154a4..224a71d5 100644 --- a/crates/services/src/inbuxa_relay_link.rs +++ b/crates/services/src/inbuxa_relay_link.rs @@ -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 = all .into_iter() .filter(|relay| relay.enabled) diff --git a/crates/services/src/task_manager/dns.rs b/crates/services/src/task_manager/dns.rs index ab650477..e403dd5f 100644 --- a/crates/services/src/task_manager/dns.rs +++ b/crates/services/src/task_manager/dns.rs @@ -2,17 +2,85 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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 { + let domain_ids = server + .registry() + .query::>(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_id) + .await + .caused_by(trc::location!())? + else { + continue; + }; + let DnsManagement::Automatic(props) = &domain.dns_management else { + continue; + }; + let records: Vec = 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 + 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 { + let hosts = |records: &[DnsRecord]| { + let mut hosts: Vec = 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 { + 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); + } +} diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index ea626d58..7f9e6bb6 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -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: ... -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: diff --git a/tests/src/system/relay.rs b/tests/src/system/relay.rs index 2a4e61dd..aa9fda1e 100644 --- a/tests/src/system/relay.rs +++ b/tests/src/system/relay.rs @@ -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 { + let server = &test.server; + for id in server + .registry() + .query::>(store::registry::RegistryQuery::new(ObjectType::Domain)) + .await + .unwrap() + { + let Some(domain) = server.registry().object::(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, "jane@example.com", &message("Live one", "")).await; assert!(reply.starts_with("250"), "{reply}"); -- 2.54.0