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}");