mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-10-11 21:15: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::{
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use mail_auth::{
|
use mail_auth::{
|
||||||
|
@ -13,7 +16,12 @@ use mail_auth::{
|
||||||
Resolver,
|
Resolver,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
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 struct Resolvers {
|
||||||
pub dns: Resolver,
|
pub dns: Resolver,
|
||||||
|
@ -47,20 +55,21 @@ pub struct Tlsa {
|
||||||
pub has_intermediates: bool,
|
pub has_intermediates: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
#[derive(Debug, PartialEq, Eq, Hash, Default, Clone, Copy)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Enforce,
|
Enforce,
|
||||||
Testing,
|
Testing,
|
||||||
|
#[default]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)]
|
||||||
pub enum MxPattern {
|
pub enum MxPattern {
|
||||||
Equals(String),
|
Equals(String),
|
||||||
StartsWith(String),
|
StartsWith(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
|
||||||
pub struct Policy {
|
pub struct Policy {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub mode: Mode,
|
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 {
|
impl Default for Resolvers {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let (config, opts) = match read_system_conf() {
|
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 {
|
impl Clone for Resolvers {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
|
@ -11,7 +11,7 @@ use crate::{
|
||||||
expr::{if_block::IfBlock, tokenizer::TokenMap, *},
|
expr::{if_block::IfBlock, tokenizer::TokenMap, *},
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::throttle::parse_throttle;
|
use self::{resolver::Policy, throttle::parse_throttle};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ pub struct SessionConfig {
|
||||||
pub rcpt: Rcpt,
|
pub rcpt: Rcpt,
|
||||||
pub data: Data,
|
pub data: Data,
|
||||||
pub extensions: Extensions,
|
pub extensions: Extensions,
|
||||||
|
pub mta_sts_policy: Option<Policy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
#[derive(Default, Debug, Clone)]
|
||||||
|
@ -188,6 +189,7 @@ impl SessionConfig {
|
||||||
.filter_map(|id| parse_pipe(config, &id, &has_rcpt_vars))
|
.filter_map(|id| parse_pipe(config, &id, &has_rcpt_vars))
|
||||||
.collect();
|
.collect();
|
||||||
session.throttle = SessionThrottle::parse(config);
|
session.throttle = SessionThrottle::parse(config);
|
||||||
|
session.mta_sts_policy = Policy::try_parse(config);
|
||||||
|
|
||||||
for (value, key, token_map) in [
|
for (value, key, token_map) in [
|
||||||
(&mut session.duration, "session.duration", &has_conn_vars),
|
(&mut session.duration, "session.duration", &has_conn_vars),
|
||||||
|
@ -713,6 +715,7 @@ impl Default for SessionConfig {
|
||||||
"false",
|
"false",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
mta_sts_policy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ use std::{
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use ahash::AHashMap;
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use store::{
|
use store::{
|
||||||
write::{BatchBuilder, ValueClass},
|
write::{BatchBuilder, ValueClass},
|
||||||
|
@ -124,6 +125,32 @@ impl ConfigManager {
|
||||||
Ok(results)
|
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(
|
async fn db_list(
|
||||||
&self,
|
&self,
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
|
@ -385,6 +412,46 @@ impl ConfigManager {
|
||||||
Err("External configuration file does not contain a version key".to_string())
|
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 {
|
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) => {
|
(_, &Method::OPTIONS) => {
|
||||||
return ().into_http_response();
|
return ().into_http_response();
|
||||||
}
|
}
|
||||||
|
|
|
@ -224,6 +224,72 @@ impl JMAP {
|
||||||
content: "v=spf1 mx ra=postmaster -all".to_string(),
|
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
|
// Add DMARC records
|
||||||
records.push(DnsRecord {
|
records.push(DnsRecord {
|
||||||
typ: "TXT".to_string(),
|
typ: "TXT".to_string(),
|
||||||
|
@ -236,12 +302,8 @@ impl JMAP {
|
||||||
if !name.ends_with(domain_name) {
|
if !name.ends_with(domain_name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cert = if let Some(cert) = key.cert.first().map(|cert| cert.as_ref()) {
|
|
||||||
cert
|
for (cert_num, cert) in key.cert.iter().enumerate() {
|
||||||
} else {
|
|
||||||
tracing::debug!("No certificate found for domain: {}", domain_name);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let parsed_cert = match parse_x509_certificate(cert) {
|
let parsed_cert = match parse_x509_certificate(cert) {
|
||||||
Ok((_, parsed_cert)) => parsed_cert,
|
Ok((_, parsed_cert)) => parsed_cert,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
@ -255,6 +317,7 @@ impl JMAP {
|
||||||
} else {
|
} else {
|
||||||
format!("_25._tcp.mail.{name}.")
|
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 (s, cert) in [cert, parsed_cert.subject_pki.raw].into_iter().enumerate() {
|
||||||
for (m, hash) in [
|
for (m, hash) in [
|
||||||
|
@ -267,11 +330,12 @@ impl JMAP {
|
||||||
records.push(DnsRecord {
|
records.push(DnsRecord {
|
||||||
typ: "TLSA".to_string(),
|
typ: "TLSA".to_string(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
content: format!("3 {} {} {}", s, m + 1, hash),
|
content: format!("{} {} {} {}", cu, s, m + 1, hash),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue