DNS, DKIM and ACME improvements - part 3

This commit is contained in:
Maurus Decimus
2026-04-02 11:05:52 +02:00
parent d8fa19d105
commit c54ec2397a
22 changed files with 377 additions and 217 deletions

View File

@@ -52,7 +52,8 @@ pub struct NetworkInfo {
pub struct Http {
pub rate_authenticated: Option<Rate>,
pub rate_anonymous: Option<Rate>,
pub response_url: IfBlock,
pub url_https: String,
pub url_http: String,
pub allowed_endpoint: IfBlock,
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
pub use_forwarded: bool,
@@ -186,6 +187,7 @@ impl Network {
},
};
let mut http_host = system.default_hostname.clone();
for (service, details) in &system.services {
let hostname = details
.hostname
@@ -194,6 +196,9 @@ impl Network {
match service {
ServiceProtocol::Jmap => {
if hostname != http_host {
http_host = hostname.to_string();
}
pacc.authentication.as_mut().unwrap().oauth_public = OAuthPublic {
issuer: format!("https://{hostname}/",),
}
@@ -309,7 +314,7 @@ impl Network {
contact_form: ContactForm::parse(bp).await,
asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(),
roles: ClusterRoles::default(),
http: Http::parse(bp).await,
http: Http::parse(bp, &http_host).await,
task_manager: bp.setting_infallible::<TaskManager>().await,
has_acme_tls_challenge,
has_acme_http_challenge,
@@ -349,7 +354,7 @@ impl Network {
}
impl Http {
pub async fn parse(bp: &mut Bootstrap) -> Self {
pub async fn parse(bp: &mut Bootstrap, server_name: &str) -> Self {
let http = bp.setting_infallible::<structs::Http>().await;
// Parse HTTP headers
@@ -404,7 +409,8 @@ impl Http {
}
Http {
response_url: bp.compile_expr(ObjectType::Http.singleton(), &http.ctx_base_url()),
url_https: format!("https://{}", server_name),
url_http: format!("http://{}", server_name),
allowed_endpoint: bp
.compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints()),
rate_authenticated: http.rate_limit_authenticated,

View File

@@ -0,0 +1,203 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Server, manager::application::Resource};
use quick_xml::Reader;
use quick_xml::events::Event;
use registry::schema::enums::ServiceProtocol;
use std::fmt::Write;
impl Server {
pub async fn handle_autodiscover_request(
&self,
body: Option<Vec<u8>>,
) -> trc::Result<Resource<Vec<u8>>> {
// Obtain parameters
let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default())
.map_err(|err| {
trc::ResourceEvent::BadParameters
.into_err()
.details("Failed to parse autodiscover request")
.ctx(trc::Key::Reason, err)
})?;
let default_host = &self.core.network.server_name;
// Build XML response
let mut config = String::with_capacity(1024);
let _ = writeln!(&mut config, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(
&mut config,
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">"
);
let _ = writeln!(
&mut config,
"\t<Response xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a\">"
);
let _ = writeln!(&mut config, "\t\t<User>");
let _ = writeln!(
&mut config,
"\t\t\t<DisplayName>{emailaddress}</DisplayName>"
);
let _ = writeln!(
&mut config,
"\t\t\t<AutoDiscoverSMTPAddress>{emailaddress}</AutoDiscoverSMTPAddress>"
);
// DeploymentId is a required field of User but we are not a MS Exchange server so use a random value
let _ = writeln!(
&mut config,
"\t\t\t<DeploymentId>644560b8-a1ce-429c-8ace-23395843f701</DeploymentId>"
);
let _ = writeln!(&mut config, "\t\t</User>");
let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
for (protocol, service) in &self.core.network.info.services {
let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
ServiceProtocol::Smtp => ("SMTP", [587, 465]),
_ => continue,
};
for (is_tls, port) in ports.into_iter().enumerate() {
if is_tls == 1 || service.cleartext {
let server_name = service.hostname.as_deref().unwrap_or(default_host);
let _ = writeln!(&mut config, "\t\t\t<Protocol>");
let _ = writeln!(&mut config, "\t\t\t\t<Type>{protocol}</Type>",);
let _ = writeln!(&mut config, "\t\t\t\t<Server>{server_name}</Server>");
let _ = writeln!(&mut config, "\t\t\t\t<Port>{port}</Port>");
let _ = writeln!(&mut config, "\t\t\t\t<LoginName>{emailaddress}</LoginName>");
let _ = writeln!(&mut config, "\t\t\t\t<AuthRequired>on</AuthRequired>");
let _ = writeln!(&mut config, "\t\t\t\t<DirectoryPort>0</DirectoryPort>");
let _ = writeln!(&mut config, "\t\t\t\t<ReferralPort>0</ReferralPort>");
let _ = writeln!(
&mut config,
"\t\t\t\t<SSL>{}</SSL>",
if is_tls == 1 { "on" } else { "off" }
);
if is_tls == 1 {
let _ = writeln!(&mut config, "\t\t\t\t<Encryption>TLS</Encryption>");
}
let _ = writeln!(&mut config, "\t\t\t\t<SPA>off</SPA>");
let _ = writeln!(&mut config, "\t\t\t</Protocol>");
}
}
}
let _ = writeln!(&mut config, "\t\t</Account>");
let _ = writeln!(&mut config, "\t</Response>");
let _ = writeln!(&mut config, "</Autodiscover>");
Ok(Resource::new(
"application/xml; charset=utf-8",
config.into_bytes(),
))
}
}
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
if bytes.is_empty() {
return Err("Empty request body".to_string());
}
let mut reader = Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
let mut buf = Vec::with_capacity(128);
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let found_tag_name = e.name();
if tag_name
.as_bytes()
.eq_ignore_ascii_case(found_tag_name.as_ref())
{
continue 'outer;
} else if tag_name == "EMailAddress" {
// Skip unsupported tags under Request, such as AcceptableResponseSchema
let mut tag_count = 0;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::End(_)) => {
if tag_count == 0 {
break;
} else {
tag_count -= 1;
}
}
Ok(Event::Start(_)) => {
tag_count += 1;
}
Ok(Event::Eof) => {
return Err(format!(
"Expected value, found unexpected EOF at position {}.",
reader.buffer_position()
));
}
_ => (),
}
}
} else {
return Err(format!(
"Expected tag {}, found unexpected tag {} at position {}.",
tag_name,
String::from_utf8_lossy(found_tag_name.as_ref()),
reader.buffer_position()
));
}
}
Ok(Event::Decl(_) | Event::Text(_)) => (),
Err(e) => {
return Err(format!(
"Error at position {}: {:?}",
reader.buffer_position(),
e
));
}
Ok(event) => {
return Err(format!(
"Expected tag {}, found unexpected event {event:?} at position {}.",
tag_name,
reader.buffer_position()
));
}
}
}
}
if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf)
&& let Ok(text) = text.xml_content()
&& text.contains('@')
{
return Ok(text.trim().to_lowercase());
}
Err(format!(
"Expected email address, found unexpected value at position {}.",
reader.buffer_position()
))
}
#[cfg(test)]
mod tests {
#[test]
fn parse_autodiscover() {
let r = r#"<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>email@example.com</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
</Request>
</Autodiscover>"#;
assert_eq!(
super::parse_autodiscover_request(r.as_bytes()).unwrap(),
"email@example.com"
);
}
}

View File

@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Server, manager::application::Resource};
use utils::url_params::UrlParams;
impl Server {
pub async fn handle_autodiscover_v2_request(
&self,
query: Option<&str>,
) -> trc::Result<Result<Resource<Vec<u8>>, String>> {
// Parse query parameters
let params = UrlParams::new(query);
let emailaddress = params.get("Email").unwrap_or_default().to_lowercase();
let protocol = params.get("Protocol").unwrap_or_default();
// Validate email address
let Some((_, domain)) = emailaddress.rsplit_once('@') else {
return Err(trc::ResourceEvent::BadParameters
.into_err()
.details("Missing domain in email address"));
};
if domain.is_empty() {
return Err(trc::ResourceEvent::BadParameters
.into_err()
.details("Missing domain in email address"));
}
if protocol.eq_ignore_ascii_case("autodiscoverv1") {
let server_name = &self.core.network.server_name;
let body = format!(
"{{\"Protocol\":\"AutodiscoverV1\",\
\"Url\":\"https://{server_name}/autodiscover/autodiscover.xml\"}}"
);
Ok(Ok(Resource::new(
"application/json; charset=utf-8",
body.into_bytes(),
)))
} else {
let safe_protocol: String = protocol
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
let err = format!(
"{{\"ErrorCode\":\"InvalidProtocol\",\
\"ErrorMessage\":\"The given protocol value \
'{safe_protocol}' is invalid. \
Supported values are 'AutodiscoverV1'\"}}"
);
Ok(Err(err))
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol;
use std::fmt::Write;
use utils::url_params::UrlParams;
impl Server {
pub async fn handle_autoconfig_request(
&self,
uri: Option<&str>,
) -> trc::Result<Resource<Vec<u8>>> {
// Obtain parameters
let params = UrlParams::new(uri);
let emailaddress = params
.get("emailaddress")
.unwrap_or_default()
.to_lowercase();
let Some((_, domain)) = emailaddress.rsplit_once('@') else {
return Err(trc::ResourceEvent::BadParameters
.into_err()
.details("Missing domain in email address"));
};
let default_host = &self.core.network.server_name;
// Build XML response
let mut config = String::with_capacity(1024);
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
config.push_str("<clientConfig version=\"1.1\">\n");
let _ = writeln!(&mut config, "\t<emailProvider id=\"{domain}\">");
let _ = writeln!(&mut config, "\t\t<domain>{domain}</domain>");
let _ = writeln!(&mut config, "\t\t<displayName>{emailaddress}</displayName>");
let _ = writeln!(
&mut config,
"\t\t<displayShortName>{domain}</displayShortName>"
);
for (protocol, service) in &self.core.network.info.services {
let (protocol, tag, ports) = match protocol {
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
ServiceProtocol::Pop3 => ("pop3", "incomingServer", [110, 995]),
_ => continue,
};
for (is_tls, port) in ports.into_iter().enumerate() {
if is_tls == 1 || service.cleartext {
let server_name = service.hostname.as_deref().unwrap_or(default_host);
let _ = writeln!(&mut config, "\t\t<{tag} type=\"{protocol}\">");
let _ = writeln!(&mut config, "\t\t\t<hostname>{server_name}</hostname>");
let _ = writeln!(&mut config, "\t\t\t<port>{port}</port>");
let _ = writeln!(
&mut config,
"\t\t\t<socketType>{}</socketType>",
if is_tls == 1 { "SSL" } else { "STARTTLS" }
);
let _ = writeln!(&mut config, "\t\t\t<username>{emailaddress}</username>");
let _ = writeln!(
&mut config,
"\t\t\t<authentication>password-cleartext</authentication>"
);
let _ = writeln!(&mut config, "\t\t</{tag}>");
}
}
}
config.push_str("\t</emailProvider>\n");
for (protocol, service) in &self.core.network.info.services {
let (tag, protocol, url) = match protocol {
ServiceProtocol::Carddav => ("addressBook", "carddav", "card"),
ServiceProtocol::Caldav => ("calendar", "caldav", "cal"),
ServiceProtocol::Webdav => ("fileShare", "webdav", "file"),
_ => continue,
};
let server_name = service.hostname.as_deref().unwrap_or(default_host);
let _ = writeln!(&mut config, "\t<{tag} type=\"{protocol}\">");
let _ = writeln!(&mut config, "\t\t<username>{emailaddress}</username>");
let _ = writeln!(
&mut config,
"\t\t<authentication>http-basic</authentication>"
);
let _ = writeln!(
&mut config,
"\t\t<serverURL>https://{server_name}/dav/{url}</serverURL>"
);
let _ = writeln!(&mut config, "\t</{tag}>");
}
let _ = writeln!(
&mut config,
"\t<clientConfigUpdate url=\"https://autoconfig.{domain}/mail/config-v1.1.xml\"></clientConfigUpdate>"
);
config.push_str("</clientConfig>\n");
Ok(Resource::new(
"application/xml; charset=utf-8",
config.into_bytes(),
))
}
}

View File

@@ -4,4 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod autodiscover;
pub mod autodiscover_v2;
pub mod legacy_autoconfig;
pub mod pacc;

View File

@@ -14,6 +14,8 @@ use pkcs8::Document;
use registry::schema::enums::DkimSignatureType;
use registry::schema::structs::DkimSignature;
use rsa::pkcs1::DecodeRsaPublicKey;
use store::rand::distr::Alphanumeric;
use store::rand::{self, Rng};
pub async fn generate_dkim_private_key(
key_type: DkimSignatureType,
@@ -130,11 +132,12 @@ pub fn generate_dkim_dns_record_name(key: &DkimSignature, domain: &str) -> Strin
/// Generate a DKIM selector from a template string.
///
/// Supported variables:
/// - `{algorithm}` signing algorithm in lowercase (`rsa`, `ed25519`)
/// - `{hash}` hash algorithm (`sha256`)
/// - `{version}` DKIM version number (`1`)
/// - `{date-<fmt>}` current UTC date formatted with chrono strftime (e.g. `{date-%Y%m%d}`)
/// - `{epoch}` current UTC unix timestamp
/// - `{algorithm}`: signing algorithm in lowercase (`rsa`, `ed25519`)
/// - `{hash}`: hash algorithm (`sha256`)
/// - `{version}`: DKIM version number (`1`)
/// - `{date-<fmt>}`: current UTC date formatted with chrono strftime (e.g. `{date-%Y%m%d}`)
/// - `{epoch}`: current UTC unix timestamp
/// - `{random}`: random 8-character alphanumeric string
///
pub fn generate_dkim_selector(
template: &str,
@@ -147,7 +150,7 @@ pub fn generate_dkim_selector(
while !chars.is_empty() {
// Find next '{' or consume literal text
let Some(open) = memchr(b'{', chars) else {
// No more variables append remaining literal
// No more variables: append remaining literal
// SAFETY: template is valid UTF-8, and we only slice on ASCII boundaries
result.extend(
chars
@@ -182,6 +185,14 @@ pub fn generate_dkim_selector(
"epoch" => {
result.extend_from_slice(now.timestamp().to_string().as_bytes());
}
"random" => {
let rand_str: String = rand::rng()
.sample_iter(Alphanumeric)
.take(8)
.map(|ch| char::from(ch.to_ascii_lowercase()))
.collect::<String>();
result.extend(rand_str.as_bytes());
}
v => {
if let Some(fmt) = v.strip_prefix("date-") {
if fmt.is_empty() {

View File

@@ -243,6 +243,15 @@ impl Server {
}),
});
}
// ACME DNS-PERSIST-01 validation record
records.push(NamedDnsRecord {
name: format!("_validation-persist.{domain_name}."),
record: DnsRecord::TXT(format!(
"{provider_name}; accounturi={}",
provider.account_uri
)),
});
}
}
DnsRecordType::Tlsa => {