DNS, DKIM and ACME improvements - part 3
This commit is contained in:
203
crates/common/src/network/autoconfig/autodiscover.rs
Normal file
203
crates/common/src/network/autoconfig/autodiscover.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
57
crates/common/src/network/autoconfig/autodiscover_v2.rs
Normal file
57
crates/common/src/network/autoconfig/autodiscover_v2.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
104
crates/common/src/network/autoconfig/legacy_autoconfig.rs
Normal file
104
crates/common/src/network/autoconfig/legacy_autoconfig.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user