From c54ec2397ad064d3073a4a1debeef61bdb691dcd Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:05:52 +0200 Subject: [PATCH] DNS, DKIM and ACME improvements - part 3 --- CHANGELOG.md | 3 +- Cargo.lock | 16 +- crates/common/Cargo.toml | 1 + crates/common/src/config/network.rs | 14 +- .../src/network/autoconfig/autodiscover.rs} | 179 ++++-------------- .../src/network/autoconfig/autodiscover_v2.rs | 57 ++++++ .../network/autoconfig/legacy_autoconfig.rs | 104 ++++++++++ crates/common/src/network/autoconfig/mod.rs | 3 + crates/common/src/network/dkim.rs | 23 ++- crates/common/src/network/dns/records.rs | 9 + crates/dav-proto/Cargo.toml | 2 +- crates/http-proto/src/context.rs | 26 +-- crates/http/Cargo.toml | 2 +- crates/http/src/auth/oauth/auth.rs | 9 +- crates/http/src/auth/oauth/openid.rs | 4 +- crates/http/src/auth/oauth/token.rs | 4 +- crates/http/src/lib.rs | 1 - crates/http/src/request.rs | 46 ++++- crates/services/src/task_manager/dns.rs | 66 ++++++- crates/smtp/src/inbound/data.rs | 17 +- tests/Cargo.toml | 2 +- tests/src/smtp/inbound/sign.rs | 6 +- 22 files changed, 377 insertions(+), 217 deletions(-) rename crates/{http/src/autoconfig/mod.rs => common/src/network/autoconfig/autodiscover.rs} (50%) create mode 100644 crates/common/src/network/autoconfig/autodiscover_v2.rs create mode 100644 crates/common/src/network/autoconfig/legacy_autoconfig.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e9be424b..a243e849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,12 +61,13 @@ This version includes **multiple breaking changes**. If you are upgrading from v ## Fixed - Directory: - - Cannot remove built-in "admin" role from user once it was assigend (#1467) + - Cannot remove built-in "admin" role from user once it was assigned (#1467) - Delete associated records (#963) - Updated Role permissions not applied (#2038) - Recreated account cannot log in until server is restarted (#1469) - Subaddressing does not work for groups (#475) - New LDAP aliases are rejected (#1318). + - Validate account and group names (#2209) - MTA: - Relay to IP addresses (#838) - Duplicate delivery inverted check diff --git a/Cargo.lock b/Cargo.lock index 2aabce1a..04dace7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "proxy-header", "psl", "pwhash", + "quick-xml 0.39.0", "quick_cache", "rasn", "rasn-cms", @@ -1631,7 +1632,7 @@ dependencies = [ "hashify", "hyper 1.8.1", "mail-parser", - "quick-xml 0.38.4", + "quick-xml 0.39.0", "rkyv", "serde", "serde_json", @@ -3011,7 +3012,7 @@ dependencies = [ "mail-send", "mime", "pkcs8", - "quick-xml 0.38.4", + "quick-xml 0.39.0", "registry", "rkyv", "rsa", @@ -5670,15 +5671,6 @@ dependencies = [ "serde", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.39.0" @@ -7792,7 +7784,7 @@ dependencies = [ "nlp", "num_cpus", "pop3", - "quick-xml 0.38.4", + "quick-xml 0.39.0", "rayon", "registry", "reqwest 0.12.28", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 9e7cb168..b794d6ec 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -85,6 +85,7 @@ rasn-pkix = "0.10" sequoia-openpgp = { version = "2.0", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] } zxcvbn = "3.1.0" pkcs8 = { version = "0.10.2", features = ["alloc", "std"] } +quick-xml = "0.39" [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index e9aa4204..59dfc484 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -52,7 +52,8 @@ pub struct NetworkInfo { pub struct Http { pub rate_authenticated: Option, pub rate_anonymous: Option, - 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::().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::().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, diff --git a/crates/http/src/autoconfig/mod.rs b/crates/common/src/network/autoconfig/autodiscover.rs similarity index 50% rename from crates/http/src/autoconfig/mod.rs rename to crates/common/src/network/autoconfig/autodiscover.rs index cb804967..8727fc82 100644 --- a/crates/http/src/autoconfig/mod.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -4,121 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, manager::application::Resource}; -use http_proto::*; +use crate::{Server, manager::application::Resource}; use quick_xml::Reader; use quick_xml::events::Event; -use registry::schema::enums::NetworkListenerProtocol; -use registry::schema::structs::NetworkListener; +use registry::schema::enums::ServiceProtocol; use std::fmt::Write; -use std::future::Future; -use utils::url_params::UrlParams; -pub trait Autoconfig: Sync + Send { - fn handle_autoconfig_request( - &self, - req: &HttpRequest, - ) -> impl Future> + Send; - fn handle_autodiscover_request( +impl Server { + pub async fn handle_autodiscover_request( &self, body: Option>, - ) -> impl Future> + Send; -} - -impl Autoconfig for Server { - async fn handle_autoconfig_request(&self, req: &HttpRequest) -> trc::Result { - // Obtain parameters - let params = UrlParams::new(req.uri().query()); - 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 listeners = self.registry().list::().await?; - let server_name = &self.core.network.server_name; - - // Build XML response - let mut config = String::with_capacity(1024); - config.push_str("\n"); - config.push_str("\n"); - let _ = writeln!(&mut config, "\t"); - let _ = writeln!(&mut config, "\t\t{domain}"); - let _ = writeln!(&mut config, "\t\t{emailaddress}"); - let _ = writeln!( - &mut config, - "\t\t{domain}" - ); - for listener in listeners { - let listener = listener.object; - let Some(port) = listener.bind.as_slice().first().map(|l| l.0.port()) else { - continue; - }; - let (protocol, tag) = match listener.protocol { - NetworkListenerProtocol::Smtp if port != 25 => ("smtp", "outgoingServer"), - NetworkListenerProtocol::Imap => ("imap", "incomingServer"), - NetworkListenerProtocol::Pop3 => ("pop3", "incomingServer"), - _ => continue, - }; - let _ = writeln!(&mut config, "\t\t<{tag} type=\"{protocol}\">"); - let _ = writeln!(&mut config, "\t\t\t{server_name}"); - let _ = writeln!(&mut config, "\t\t\t{port}"); - let _ = writeln!( - &mut config, - "\t\t\t{}", - if listener.tls_implicit { - "SSL" - } else { - "STARTTLS" - } - ); - let _ = writeln!(&mut config, "\t\t\t{emailaddress}"); - let _ = writeln!( - &mut config, - "\t\t\tpassword-cleartext" - ); - let _ = writeln!(&mut config, "\t\t"); - } - - config.push_str("\t\n"); - - for (tag, protocol, url) in [ - ("addressBook", "carddav", "card"), - ("calendar", "caldav", "cal"), - ("fileShare", "webdav", "file"), - ] { - let _ = writeln!(&mut config, "\t<{tag} type=\"{protocol}\">"); - let _ = writeln!(&mut config, "\t\t{emailaddress}"); - let _ = writeln!( - &mut config, - "\t\thttp-basic" - ); - let _ = writeln!( - &mut config, - "\t\thttps://{server_name}/dav/{url}" - ); - let _ = writeln!(&mut config, "\t"); - } - - let _ = writeln!( - &mut config, - "\t" - ); - config.push_str("\n"); - - Ok( - Resource::new("application/xml; charset=utf-8", config.into_bytes()) - .into_http_response(), - ) - } - - async fn handle_autodiscover_request( - &self, - body: Option>, - ) -> trc::Result { + ) -> trc::Result>> { // Obtain parameters let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default()) .map_err(|err| { @@ -127,8 +23,7 @@ impl Autoconfig for Server { .details("Failed to parse autodiscover request") .ctx(trc::Key::Reason, err) })?; - let listeners = self.registry().list::().await?; - let server_name = &self.core.network.server_name; + let default_host = &self.core.network.server_name; // Build XML response let mut config = String::with_capacity(1024); @@ -159,47 +54,47 @@ impl Autoconfig for Server { let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t\t\temail"); let _ = writeln!(&mut config, "\t\t\tsettings"); - for listener in listeners { - let listener = listener.object; - let Some(port) = listener.bind.as_slice().first().map(|l| l.0.port()) else { - continue; - }; - - let protocol = match listener.protocol { - NetworkListenerProtocol::Imap => "IMAP", - NetworkListenerProtocol::Pop3 => "POP3", - NetworkListenerProtocol::Smtp if port != 25 => "SMTP", + 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, }; - let _ = writeln!(&mut config, "\t\t\t"); - let _ = writeln!(&mut config, "\t\t\t\t{protocol}",); - let _ = writeln!(&mut config, "\t\t\t\t{server_name}"); - let _ = writeln!(&mut config, "\t\t\t\t{port}"); - let _ = writeln!(&mut config, "\t\t\t\t{emailaddress}"); - let _ = writeln!(&mut config, "\t\t\t\ton"); - let _ = writeln!(&mut config, "\t\t\t\t0"); - let _ = writeln!(&mut config, "\t\t\t\t0"); - let _ = writeln!( - &mut config, - "\t\t\t\t{}", - if listener.tls_implicit { "on" } else { "off" } - ); - if listener.tls_implicit { - let _ = writeln!(&mut config, "\t\t\t\tTLS"); + 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"); + let _ = writeln!(&mut config, "\t\t\t\t{protocol}",); + let _ = writeln!(&mut config, "\t\t\t\t{server_name}"); + let _ = writeln!(&mut config, "\t\t\t\t{port}"); + let _ = writeln!(&mut config, "\t\t\t\t{emailaddress}"); + let _ = writeln!(&mut config, "\t\t\t\ton"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!(&mut config, "\t\t\t\t0"); + let _ = writeln!( + &mut config, + "\t\t\t\t{}", + if is_tls == 1 { "on" } else { "off" } + ); + if is_tls == 1 { + let _ = writeln!(&mut config, "\t\t\t\tTLS"); + } + let _ = writeln!(&mut config, "\t\t\t\toff"); + let _ = writeln!(&mut config, "\t\t\t"); + } } - let _ = writeln!(&mut config, "\t\t\t\toff"); - let _ = writeln!(&mut config, "\t\t\t"); } let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t"); let _ = writeln!(&mut config, ""); - Ok( - Resource::new("application/xml; charset=utf-8", config.into_bytes()) - .into_http_response(), - ) + Ok(Resource::new( + "application/xml; charset=utf-8", + config.into_bytes(), + )) } } diff --git a/crates/common/src/network/autoconfig/autodiscover_v2.rs b/crates/common/src/network/autoconfig/autodiscover_v2.rs new file mode 100644 index 00000000..afb57994 --- /dev/null +++ b/crates/common/src/network/autoconfig/autodiscover_v2.rs @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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>, 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)) + } + } +} diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs new file mode 100644 index 00000000..e7e2d7e0 --- /dev/null +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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>> { + // 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("\n"); + config.push_str("\n"); + let _ = writeln!(&mut config, "\t"); + let _ = writeln!(&mut config, "\t\t{domain}"); + let _ = writeln!(&mut config, "\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\t{domain}" + ); + 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{server_name}"); + let _ = writeln!(&mut config, "\t\t\t{port}"); + let _ = writeln!( + &mut config, + "\t\t\t{}", + if is_tls == 1 { "SSL" } else { "STARTTLS" } + ); + let _ = writeln!(&mut config, "\t\t\t{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\t\tpassword-cleartext" + ); + let _ = writeln!(&mut config, "\t\t"); + } + } + } + + config.push_str("\t\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{emailaddress}"); + let _ = writeln!( + &mut config, + "\t\thttp-basic" + ); + let _ = writeln!( + &mut config, + "\t\thttps://{server_name}/dav/{url}" + ); + let _ = writeln!(&mut config, "\t"); + } + + let _ = writeln!( + &mut config, + "\t" + ); + config.push_str("\n"); + + Ok(Resource::new( + "application/xml; charset=utf-8", + config.into_bytes(), + )) + } +} diff --git a/crates/common/src/network/autoconfig/mod.rs b/crates/common/src/network/autoconfig/mod.rs index 22541416..88d4fe2e 100644 --- a/crates/common/src/network/autoconfig/mod.rs +++ b/crates/common/src/network/autoconfig/mod.rs @@ -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; diff --git a/crates/common/src/network/dkim.rs b/crates/common/src/network/dkim.rs index f0e2f2c4..8372cf1d 100644 --- a/crates/common/src/network/dkim.rs +++ b/crates/common/src/network/dkim.rs @@ -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-}` — 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-}`: 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::(); + result.extend(rand_str.as_bytes()); + } v => { if let Some(fmt) = v.strip_prefix("date-") { if fmt.is_empty() { diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index 8ea02e78..21c6dda5 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -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 => { diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index dc3aa7ff..07911a67 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" trc = { path = "../trc" } types = { path = "../types" } hashify = "0.2.6" -quick-xml = { version = "0.38" } +quick-xml = { version = "0.39" } calcard = { version = "0.3", features = ["rkyv"] } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } hyper = "1.6.0" diff --git a/crates/http-proto/src/context.rs b/crates/http-proto/src/context.rs index 97bc30d8..cb7d9a34 100644 --- a/crates/http-proto/src/context.rs +++ b/crates/http-proto/src/context.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{HttpContext, HttpRequest, HttpSessionData}; use common::{ Server, expr::{functions::ResolveVariable, *}, @@ -12,29 +13,20 @@ use compact_str::{ToCompactString, format_compact}; use hyper::StatusCode; use registry::schema::enums::ExpressionVariable; -use crate::{HttpContext, HttpRequest, HttpSessionData}; - impl<'x> HttpContext<'x> { pub fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self { Self { session, req } } - pub async fn resolve_response_url(&self, server: &Server) -> String { - server - .eval_if( - &server.core.network.http.response_url, - self, - self.session.session_id, + pub fn resolve_response_url(&self, server: &Server) -> String { + if self.session.is_tls { + server.core.network.http.url_https.clone() + } else { + format!( + "{}:{}", + server.core.network.http.url_http, self.session.local_port ) - .await - .unwrap_or_else(|| { - format!( - "http{}://{}:{}", - if self.session.is_tls { "s" } else { "" }, - self.session.local_ip, - self.session.local_port - ) - }) + } } pub async fn has_endpoint_access(&self, server: &Server) -> StatusCode { diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index cf6b53a7..90ff00bc 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -30,7 +30,7 @@ hyper = { version = "1.0.1", features = ["server", "http1", "http2"] } hyper-util = { version = "0.1.1", features = ["tokio"] } http-body-util = "0.1.0" async-stream = "0.3.5" -quick-xml = "0.38" +quick-xml = "0.39" serde = { version = "1.0", features = ["derive"]} serde_json = "1.0" x509-parser = "0.18" diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index b8231c0d..a2bf3405 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -379,9 +379,7 @@ impl OAuthApiHandler for Server { .await?; // Build response - let base_url = HttpContext::new(&session, req) - .resolve_response_url(self) - .await; + let base_url = HttpContext::new(&session, req).resolve_response_url(self); Ok(JsonResponse::new(DeviceAuthResponse { verification_uri: format!("{base_url}/authorize"), verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"), @@ -399,10 +397,7 @@ impl OAuthApiHandler for Server { req: HttpRequest, session: HttpSessionData, ) -> trc::Result { - let base_url = HttpContext::new(&session, &req) - .resolve_response_url(self) - .await - .to_string(); + let base_url = HttpContext::new(&session, &req).resolve_response_url(self); Ok(JsonResponse::new(OAuthMetadata { authorization_endpoint: format!("{base_url}/authorize/code",), diff --git a/crates/http/src/auth/oauth/openid.rs b/crates/http/src/auth/oauth/openid.rs index 3ce822e8..eb3aff76 100644 --- a/crates/http/src/auth/oauth/openid.rs +++ b/crates/http/src/auth/oauth/openid.rs @@ -60,9 +60,7 @@ impl OpenIdHandler for Server { req: HttpRequest, session: HttpSessionData, ) -> trc::Result { - let base_url = HttpContext::new(&session, &req) - .resolve_response_url(self) - .await; + let base_url = HttpContext::new(&session, &req).resolve_response_url(self); Ok(JsonResponse::new(OpenIdMetadata { authorization_endpoint: format!("{base_url}/authorize/code",), diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 4270de57..f14720b1 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -62,9 +62,7 @@ impl TokenHandler for Server { let mut response = TokenResponse::error(ErrorType::InvalidGrant); - let issuer = HttpContext::new(&session, req) - .resolve_response_url(self) - .await; + let issuer = HttpContext::new(&session, req).resolve_response_url(self); if grant_type.eq_ignore_ascii_case("authorization_code") { response = if let (Some(code), Some(client_id), Some(redirect_uri)) = ( diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index 518d1f55..f7a0dccf 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -5,7 +5,6 @@ */ pub mod auth; -pub mod autoconfig; pub mod form; pub mod management; pub mod request; diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 979bbdd7..281ba64c 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -13,7 +13,6 @@ use crate::{ registration::ClientRegistrationHandler, token::TokenHandler, }, }, - autoconfig::Autoconfig, form::FormHandler, management::{ManagementApi, ToManageHttpResponse}, }; @@ -200,14 +199,14 @@ impl ParseHttp for Server { self.authenticate_headers(&req, &session).await?; self.handle_session_resource( - ctx.resolve_response_url(self).await, + ctx.resolve_response_url(self), &access_token, ) .await .map(|s| s.into_http_response()) } else { Ok(Session::new( - ctx.resolve_response_url(self).await, + ctx.resolve_response_url(self), &self.core.jmap.capabilities, ) .into_http_response()) @@ -323,9 +322,10 @@ impl ParseHttp for Server { self.is_http_anonymous_request_allowed(session.remote_ip) .await?; - let todo = "fix autoconfig generation"; - - return self.handle_autoconfig_request(&req).await; + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response()); } ("autoconfig", &Method::GET) => { if path.next().unwrap_or_default() == "mail" @@ -335,7 +335,10 @@ impl ParseHttp for Server { self.is_http_anonymous_request_allowed(session.remote_ip) .await?; - return self.handle_autoconfig_request(&req).await; + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response()); } } (_, &Method::OPTIONS) => { @@ -455,7 +458,10 @@ impl ParseHttp for Server { self.is_http_anonymous_request_allowed(session.remote_ip) .await?; - return self.handle_autoconfig_request(&req).await; + return self + .handle_autoconfig_request(req.uri().query()) + .await + .map(|resource| resource.into_http_response()); } } "calendar" => { @@ -487,7 +493,7 @@ impl ParseHttp for Server { }); } } - "autodiscover" | "Autodiscover" => { + "autodiscover" | "Autodiscover" | "AutoDiscover" => { if req.method() == Method::POST && path .next() @@ -502,7 +508,27 @@ impl ParseHttp for Server { .handle_autodiscover_request( fetch_body(&mut req, 8192, session.session_id).await, ) - .await; + .await + .map(|resource| resource.into_http_response()); + } else if req.method() == Method::POST + && path + .next() + .unwrap_or_default() + .eq_ignore_ascii_case("autodiscover.json") + { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self + .handle_autodiscover_v2_request(req.uri().query()) + .await + .map(|result| match result { + Ok(resource) => resource.into_http_response(), + Err(err) => HttpResponse::new(StatusCode::BAD_REQUEST) + .with_content_type("application/json; charset=utf-8") + .with_text_body(err), + }); } } "robots.txt" => { diff --git a/crates/services/src/task_manager/dns.rs b/crates/services/src/task_manager/dns.rs index 0bd4639b..82cd26d5 100644 --- a/crates/services/src/task_manager/dns.rs +++ b/crates/services/src/task_manager/dns.rs @@ -6,7 +6,9 @@ use crate::task_manager::TaskResult; use common::Server; -use registry::schema::structs::TaskDnsManagement; +use registry::schema::structs::{DnsManagement, Domain, TaskDnsManagement}; +use std::fmt::Write; +use store::ahash::AHashSet; pub(crate) trait DnsManagementTask: Sync + Send { fn dns_management(&self, task: &TaskDnsManagement) -> impl Future + Send; @@ -28,6 +30,64 @@ impl DnsManagementTask for Server { } } -async fn dns_management(server: &Server, imip: &TaskDnsManagement) -> trc::Result { - todo!() +async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Result { + if task.update_records.is_empty() { + return Ok(TaskResult::permanent( + "No DNS records to update".to_string(), + )); + } + let Some(domain) = server.registry().object::(task.domain_id).await? else { + return Ok(TaskResult::permanent("Domain not found".to_string())); + }; + let DnsManagement::Automatic(props) = &domain.dns_management else { + return Ok(TaskResult::permanent( + "Domain is not set to automatic DNS management".to_string(), + )); + }; + let dns_updater = match server.build_dns_updater(props.dns_server_id).await? { + Ok(updater) => updater, + Err(err) => { + return Ok(TaskResult::permanent(format!( + "Failed to build DNS updater: {}", + err + ))); + } + }; + let origin = props.origin.as_deref().unwrap_or(&domain.name); + let records = server + .build_dns_records(task.domain_id, &domain, task.update_records.as_slice()) + .await?; + + // Delete any previous records + let delete_records = records + .iter() + .map(|record| (&record.name, record.record.as_type())) + .collect::>(); + for (name, record_type) in delete_records { + let _ = dns_updater.delete(origin, name, record_type).await; + } + + // Add new records + let mut errors = String::new(); + for record in records { + if let Err(err) = dns_updater + .create(origin, &record.name, record.record, false) + .await + { + if !errors.is_empty() { + errors.push_str("; "); + } + let _ = write!( + &mut errors, + "Failed to create DNS record for {}: {}", + record.name, err + ); + } + } + + if !errors.is_empty() { + Ok(TaskResult::Success(vec![])) + } else { + Ok(TaskResult::permanent(errors)) + } } diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 134f1e4d..e9cc6c4b 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -30,7 +30,7 @@ use common::{ }; use mail_auth::{ AuthenticatedMessage, AuthenticationResults, DkimResult, DmarcResult, ReceivedSpf, - common::{headers::HeaderWriter, verify::VerifySignature}, + common::{crypto::Algorithm, headers::HeaderWriter, verify::VerifySignature}, dmarc::{self, verify::DmarcParameters}, }; use mail_builder::headers::{date::Date, message_id::generate_message_id_header}; @@ -67,7 +67,7 @@ impl Session { }; // Authenticate message - let auth_message = AuthenticatedMessage::from_parsed( + let mut auth_message = AuthenticatedMessage::from_parsed( &parsed_message, self.server.core.smtp.mail_auth.dkim.strict, ); @@ -107,6 +107,19 @@ impl Session { .await .unwrap_or(VerifyStrategy::Relaxed); let dkim_output = if dkim.verify() || dmarc.verify() { + // Remove insecure DKIM signatures before verification + for header in &mut auth_message.dkim_headers { + if let Ok(signature) = &mut header.header + && (signature.algorithm() == Algorithm::RsaSha1 + || (signature.algorithm() == Algorithm::RsaSha256 + && signature.b.len() < 128)) + { + header.header = Err(mail_auth::Error::CryptoError( + "Insecure DKIM signature".into(), + )); + } + } + let time = Instant::now(); let dkim_output = self .server diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 37e6159a..43358a91 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -79,7 +79,7 @@ biscuit = "0.7.0" form_urlencoded = "1.1.0" rkyv = { version = "0.8.10", features = ["little_endian"] } compact_str = "0.9.0" -quick-xml = "0.38" +quick-xml = "0.39" jmap-tools = { version = "0.1" } [target.'cfg(not(target_env = "msvc"))'.dependencies] diff --git a/tests/src/smtp/inbound/sign.rs b/tests/src/smtp/inbound/sign.rs index 0cfd7d09..8b52bc3d 100644 --- a/tests/src/smtp/inbound/sign.rs +++ b/tests/src/smtp/inbound/sign.rs @@ -16,7 +16,7 @@ use mail_auth::{ spf::Spf, }; use registry::schema::{ - enums::DkimCanonicalization, + enums::{DkimCanonicalization, DkimRotationStage}, structs::{ Dkim1Signature, DkimSignature, Domain, Expression, SecretText, SecretTextValue, SenderAuth, }, @@ -178,7 +178,7 @@ impl Account { pub async fn create_dkim_signatures(&self, domain_id: Id) -> Vec { let rsa_id = self .registry_create_object(DkimSignature::Dkim1RsaSha256(Dkim1Signature { - enabled: true, + stage: DkimRotationStage::Active, selector: "rsa".to_string(), canonicalization: DkimCanonicalization::SimpleRelaxed, domain_id, @@ -191,7 +191,7 @@ impl Account { let ed_id = self .registry_create_object(DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature { - enabled: true, + stage: DkimRotationStage::Active, selector: "ed".to_string(), canonicalization: DkimCanonicalization::RelaxedSimple, domain_id,