mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-10-09 03:55:45 +08:00
MTA-STS Policy management
This commit is contained in:
parent
3e9d0ae5d2
commit
baef85e55b
5 changed files with 311 additions and 36 deletions
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue