MTA-STS Policy management

This commit is contained in:
mdecimus 2024-05-07 15:20:54 +02:00
parent 3e9d0ae5d2
commit baef85e55b
5 changed files with 311 additions and 36 deletions

View file

@ -1,6 +1,9 @@
use std::{
fmt::Display,
hash::{DefaultHasher, Hash, Hasher},
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::Arc,
time::Duration,
};
use mail_auth::{
@ -13,7 +16,12 @@ use mail_auth::{
Resolver,
};
use parking_lot::Mutex;
use utils::{config::Config, suffixlist::PublicSuffix};
use utils::{
config::{utils::ParseValue, Config},
suffixlist::PublicSuffix,
};
use crate::Core;
pub struct Resolvers {
pub dns: Resolver,
@ -47,20 +55,21 @@ pub struct Tlsa {
pub has_intermediates: bool,
}
#[derive(Debug, PartialEq, Eq, Hash)]
#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy)]
pub enum Mode {
Enforce,
Testing,
#[default]
None,
}
#[derive(Debug, PartialEq, Eq, Hash)]
#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)]
pub enum MxPattern {
Equals(String),
StartsWith(String),
}
#[derive(Debug, PartialEq, Eq, Hash)]
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct Policy {
pub id: String,
pub mode: Mode,
@ -227,6 +236,97 @@ impl Resolvers {
}
}
impl Policy {
pub fn try_parse(config: &mut Config) -> Option<Self> {
let mode = config
.property_or_default::<Option<Mode>>("session.mta-sts.mode", "testing")
.unwrap_or_default()?;
let max_age = config
.property_or_default::<Duration>("session.mta-sts.max-age", "7d")
.unwrap_or_else(|| Duration::from_secs(604800))
.as_secs();
let mut mx = Vec::new();
for (_, item) in config.values("session.mta-sts.mx") {
if let Some(item) = item.strip_prefix("*.") {
mx.push(MxPattern::StartsWith(item.to_string()));
} else {
mx.push(MxPattern::Equals(item.to_string()));
}
}
let mut policy = Self {
id: Default::default(),
mode,
mx,
max_age,
};
if !policy.mx.is_empty() {
policy.mx.sort_unstable();
policy.id = policy.hash().to_string();
}
policy.into()
}
pub fn try_build<I, T>(mut self, names: I) -> Option<Self>
where
I: IntoIterator<Item = T>,
T: AsRef<str>,
{
if self.mx.is_empty() {
for name in names {
let name = name.as_ref();
if let Some(domain) = name.strip_prefix('.') {
self.mx.push(MxPattern::StartsWith(domain.to_string()));
} else if name != "*" && !name.is_empty() {
self.mx.push(MxPattern::Equals(name.to_string()));
}
}
if !self.mx.is_empty() {
self.mx.sort_unstable();
self.id = self.hash().to_string();
Some(self)
} else {
None
}
} else {
Some(self)
}
}
fn hash(&self) -> u64 {
let mut s = DefaultHasher::new();
self.mode.hash(&mut s);
self.max_age.hash(&mut s);
self.mx.hash(&mut s);
s.finish()
}
}
impl Core {
pub fn build_mta_sts_policy(&self) -> Option<Policy> {
self.smtp
.session
.mta_sts_policy
.clone()
.and_then(|policy| policy.try_build(self.tls.certificates.load().keys()))
}
}
impl ParseValue for Mode {
fn parse_value(value: &str) -> utils::config::Result<Self> {
match value {
"enforce" => Ok(Self::Enforce),
"testing" | "test" => Ok(Self::Testing),
"none" => Ok(Self::None),
_ => Err(format!("Invalid mode value {value:?}")),
}
}
}
impl Default for Resolvers {
fn default() -> Self {
let (config, opts) = match read_system_conf() {
@ -253,6 +353,36 @@ impl Default for Resolvers {
}
}
impl Display for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("version: STSv1\r\n")?;
f.write_str("mode: ")?;
match self.mode {
Mode::Enforce => f.write_str("enforce")?,
Mode::Testing => f.write_str("testing")?,
Mode::None => unreachable!(),
}
f.write_str("\r\nmax_age: ")?;
self.max_age.fmt(f)?;
f.write_str("\r\n")?;
for mx in &self.mx {
f.write_str("mx: ")?;
let mx = match mx {
MxPattern::StartsWith(mx) => {
f.write_str("*.")?;
mx
}
MxPattern::Equals(mx) => mx,
};
f.write_str(mx)?;
f.write_str("\r\n")?;
}
Ok(())
}
}
impl Clone for Resolvers {
fn clone(&self) -> Self {
Self {

View file

@ -11,7 +11,7 @@ use crate::{
expr::{if_block::IfBlock, tokenizer::TokenMap, *},
};
use self::throttle::parse_throttle;
use self::{resolver::Policy, throttle::parse_throttle};
use super::*;
@ -29,6 +29,7 @@ pub struct SessionConfig {
pub rcpt: Rcpt,
pub data: Data,
pub extensions: Extensions,
pub mta_sts_policy: Option<Policy>,
}
#[derive(Default, Debug, Clone)]
@ -188,6 +189,7 @@ impl SessionConfig {
.filter_map(|id| parse_pipe(config, &id, &has_rcpt_vars))
.collect();
session.throttle = SessionThrottle::parse(config);
session.mta_sts_policy = Policy::try_parse(config);
for (value, key, token_map) in [
(&mut session.duration, "session.duration", &has_conn_vars),
@ -713,6 +715,7 @@ impl Default for SessionConfig {
"false",
),
},
mta_sts_policy: None,
}
}
}

View file

@ -27,6 +27,7 @@ use std::{
sync::Arc,
};
use ahash::AHashMap;
use arc_swap::ArcSwap;
use store::{
write::{BatchBuilder, ValueClass},
@ -124,6 +125,32 @@ impl ConfigManager {
Ok(results)
}
pub async fn group(
&self,
prefix: &str,
suffix: &str,
) -> store::Result<AHashMap<String, AHashMap<String, String>>> {
let mut grouped = AHashMap::new();
let mut list = self.list(prefix, true).await?;
for (key, _) in &list {
if let Some(key) = key.strip_suffix(suffix) {
grouped.insert(key.to_string(), AHashMap::new());
}
}
for (name, entries) in &mut grouped {
let prefix = format!("{name}.");
for (key, value) in &mut list {
if let Some(key) = key.strip_prefix(&prefix) {
entries.insert(key.to_string(), std::mem::take(value));
}
}
}
Ok(grouped)
}
async fn db_list(
&self,
prefix: &str,
@ -385,6 +412,46 @@ impl ConfigManager {
Err("External configuration file does not contain a version key".to_string())
}
}
pub async fn get_services(&self) -> store::Result<Vec<(String, u16, bool)>> {
let mut result = Vec::new();
for listener in self
.group("server.listener.", ".protocol")
.await
.unwrap_or_default()
.into_values()
{
let is_tls = listener
.get("tls.implicit")
.map_or(false, |tls| tls == "true");
let protocol = listener
.get("protocol")
.map(|s| s.as_str())
.unwrap_or_default();
let port = listener
.get("bind")
.or_else(|| {
listener.iter().find_map(|(key, value)| {
if key.starts_with("bind.") {
Some(value)
} else {
None
}
})
})
.and_then(|s| s.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()))
.unwrap_or_default();
if port > 0 {
result.push((protocol.to_string(), port, is_tls));
}
}
result.sort_unstable();
Ok(result)
}
}
impl Patterns {

View file

@ -254,6 +254,17 @@ impl JMAP {
};
}
}
("mta-sts.txt", &Method::GET) => {
if let Some(policy) = self.core.build_mta_sts_policy() {
return Resource {
content_type: "text/plain",
contents: policy.to_string().into_bytes(),
}
.into_http_response();
} else {
return RequestError::not_found().into_http_response();
}
}
(_, &Method::OPTIONS) => {
return ().into_http_response();
}

View file

@ -224,6 +224,72 @@ impl JMAP {
content: "v=spf1 mx ra=postmaster -all".to_string(),
});
records.push(DnsRecord {
typ: "CNAME".to_string(),
name: format!("autoconfig.{domain_name}."),
content: format!("{server_name}."),
});
let mut has_https = false;
for (protocol, port, is_tls) in self
.core
.storage
.config
.get_services()
.await
.unwrap_or_default()
{
match (protocol.as_str(), port) {
("smtp", port @ 26..=u16::MAX) => {
records.push(DnsRecord {
typ: "SRV".to_string(),
name: format!(
"_submission{}._tcp.{domain_name}.",
if is_tls { "s" } else { "" }
),
content: format!("0 1 {port} {server_name}."),
});
}
("imap" | "pop3", port @ 1..=u16::MAX) => {
records.push(DnsRecord {
typ: "SRV".to_string(),
name: format!(
"_{protocol}{}._tcp.{domain_name}.",
if is_tls { "s" } else { "" }
),
content: format!("0 1 {port} {server_name}."),
});
}
("http", port @ 1..=u16::MAX) => {
if is_tls {
has_https = true;
records.push(DnsRecord {
typ: "SRV".to_string(),
name: format!("_autodiscover._tcp.{domain_name}."),
content: format!("0 1 {port} {server_name}."),
});
}
}
_ => (),
}
}
// Add MTA-STS record
if has_https {
if let Some(policy) = self.core.build_mta_sts_policy() {
records.push(DnsRecord {
typ: "CNAME".to_string(),
name: format!("mta-sts.{domain_name}."),
content: format!("{server_name}."),
});
records.push(DnsRecord {
typ: "TXT".to_string(),
name: format!("_mta-sts.{domain_name}."),
content: format!("v=STSv1; id={}", policy.id),
});
}
}
// Add DMARC records
records.push(DnsRecord {
typ: "TXT".to_string(),
@ -236,12 +302,8 @@ impl JMAP {
if !name.ends_with(domain_name) {
continue;
}
let cert = if let Some(cert) = key.cert.first().map(|cert| cert.as_ref()) {
cert
} else {
tracing::debug!("No certificate found for domain: {}", domain_name);
continue;
};
for (cert_num, cert) in key.cert.iter().enumerate() {
let parsed_cert = match parse_x509_certificate(cert) {
Ok((_, parsed_cert)) => parsed_cert,
Err(err) => {
@ -255,6 +317,7 @@ impl JMAP {
} else {
format!("_25._tcp.mail.{name}.")
};
let cu = if cert_num == 0 { 3 } else { 2 };
for (s, cert) in [cert, parsed_cert.subject_pki.raw].into_iter().enumerate() {
for (m, hash) in [
@ -267,11 +330,12 @@ impl JMAP {
records.push(DnsRecord {
typ: "TLSA".to_string(),
name: name.clone(),
content: format!("3 {} {} {}", s, m + 1, hash),
content: format!("{} {} {} {}", cu, s, m + 1, hash),
});
}
}
}
}
Ok(records)
}