Relay: relays in the domains' MX and SPF records (RL-25) #268

Merged
jcoffey-dev merged 1 commits from relay/dns into main 2026-10-30 22:01:32 +00:00
10 changed files with 631 additions and 7 deletions

No files matched your search

+64
View File
@@ -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
+283
View File
@@ -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);
}
}
}
+1
View File
@@ -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(),
+11
View File
@@ -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,
+2
View File
@@ -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)),
+6
View File
@@ -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)
+139 -4
View File
@@ -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);
}
}
+29 -3
View File
@@ -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:
+92
View File
@@ -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}");