diff --git a/CHANGELOG.md b/CHANGELOG.md index 03520774..e4b111ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + +## [0.7.2] - 2024-04-17 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin version. + +## Added +- Support for DNS-01 and HTTP-01 ACME challenges (#226) +- Configurable external resources (#355) + +### Changed + +### Fixed +- Startup failure when Elasticsearch is down/starting up (#334) +- URL decode path elements in REST API. + ## [0.7.1] - 2024-04-12 To upgrade replace the `stalwart-mail` binary. diff --git a/Cargo.lock b/Cargo.lock index 127ce4f9..a4f55a98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6622,7 +6622,7 @@ name = "utils" version = "0.7.2" dependencies = [ "ahash 0.8.11", - "base64 0.21.7", + "base64 0.22.0", "blake3", "chrono", "dashmap", diff --git a/README.md b/README.md index 0ce72b4d..69f7cda7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Key features: - Self-service portal for password reset and encryption-at-rest key management. - **Secure and robust**: - Encryption at rest with **S/MIME** or **OpenPGP**. - - Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555). + - Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) using `TLS-ALPN-01`, `DNS-01` or `HTTP-01` challenges. - OAuth 2.0 [authorization code](https://www.rfc-editor.org/rfc/rfc8628) and [device authorization](https://www.rfc-editor.org/rfc/rfc8628) flows. - Automated blocking of hosts that cause multiple authentication errors (aka **fail2ban**). - Access Control Lists (ACLs). diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs index 0d3d4182..414fcc16 100644 --- a/crates/common/src/config/server/tls.rs +++ b/crates/common/src/config/server/tls.rs @@ -203,10 +203,10 @@ fn build_dns_updater(config: &mut Config, acme_id: &str) -> Option { match config.value_require(("acme", acme_id, "provider"))? { "rfc2136-tsig" => { let algorithm: TsigAlgorithm = config - .value_require(("acme", acme_id, "algorithm"))? + .value_require(("acme", acme_id, "tsig-algorithm"))? .parse() .map_err(|_| { - config.new_parse_error(("acme", acme_id, "algorithm"), "Invalid algorithm") + config.new_parse_error(("acme", acme_id, "tsig-algorithm"), "Invalid algorithm") }) .ok()?; let key = STANDARD diff --git a/crates/jmap/src/api/management/dkim.rs b/crates/jmap/src/api/management/dkim.rs index ab98fbca..5d00e30f 100644 --- a/crates/jmap/src/api/management/dkim.rs +++ b/crates/jmap/src/api/management/dkim.rs @@ -46,6 +46,8 @@ use crate::{ JMAP, }; +use super::decode_path_element; + #[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] pub enum Algorithm { Rsa, @@ -76,7 +78,7 @@ impl JMAP { async fn handle_get_public_key(&self, path: Vec<&str>) -> HttpResponse { let signature_id = match path.get(1) { - Some(signature_id) => *signature_id, + Some(signature_id) => decode_path_element(signature_id), None => { return RequestError::not_found().into_http_response(); } diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs index f19c8a87..7fe884f0 100644 --- a/crates/jmap/src/api/management/domain.rs +++ b/crates/jmap/src/api/management/domain.rs @@ -39,6 +39,8 @@ use crate::{ JMAP, }; +use super::decode_path_element; + #[derive(Debug, Serialize, Deserialize)] struct DnsRecord { #[serde(rename = "type")] @@ -82,7 +84,8 @@ impl JMAP { } (Some(domain), &Method::GET) => { // Obtain DNS records - match self.build_dns_records(domain).await { + let domain = decode_path_element(domain); + match self.build_dns_records(domain.as_ref()).await { Ok(records) => JsonResponse::new(json!({ "data": records, })) @@ -92,7 +95,8 @@ impl JMAP { } (Some(domain), &Method::POST) => { // Create domain - match self.core.storage.data.create_domain(domain).await { + let domain = decode_path_element(domain); + match self.core.storage.data.create_domain(domain.as_ref()).await { Ok(_) => { // Set default domain name if missing if matches!( @@ -103,7 +107,7 @@ impl JMAP { .core .storage .config - .set([("lookup.default.domain", *domain)]) + .set([("lookup.default.domain", domain.as_ref())]) .await { tracing::error!("Failed to set default domain name: {}", err); @@ -120,7 +124,8 @@ impl JMAP { } (Some(domain), &Method::DELETE) => { // Delete domain - match self.core.storage.data.delete_domain(domain).await { + let domain = decode_path_element(domain); + match self.core.storage.data.delete_domain(domain.as_ref()).await { Ok(_) => JsonResponse::new(json!({ "data": (), })) diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index 0e9c4b35..463b7507 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -128,3 +128,12 @@ impl From for ManagementApiError { } } } + +pub fn decode_path_element(item: &str) -> Cow<'_, str> { + // Bit hackish but avoids an extra dependency + form_urlencoded::parse(item.as_bytes()) + .into_iter() + .next() + .map(|(k, _)| k) + .unwrap_or_else(|| item.into()) +} diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 625b7179..e7bc606b 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -42,7 +42,7 @@ use crate::{ JMAP, }; -use super::ManagementApiError; +use super::{decode_path_element, ManagementApiError}; #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct PrincipalResponse { @@ -151,7 +151,8 @@ impl JMAP { } (Some(name), method) => { // Fetch, update or delete principal - let account_id = match self.core.storage.data.get_account_id(name).await { + let name = decode_path_element(name); + let account_id = match self.core.storage.data.get_account_id(name.as_ref()).await { Ok(Some(account_id)) => account_id, Ok(None) => { return RequestError::blank( diff --git a/crates/jmap/src/api/management/queue.rs b/crates/jmap/src/api/management/queue.rs index 7446bebc..2677940d 100644 --- a/crates/jmap/src/api/management/queue.rs +++ b/crates/jmap/src/api/management/queue.rs @@ -45,6 +45,8 @@ use crate::{ JMAP, }; +use super::decode_path_element; + #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Message { pub id: QueueId, @@ -122,7 +124,7 @@ impl JMAP { match ( path.get(1).copied().unwrap_or_default(), - path.get(2).copied(), + path.get(2).copied().map(decode_path_element), req.method(), ) { ("messages", None, &Method::GET) => { @@ -439,7 +441,7 @@ impl JMAP { } ("reports", Some(report_id), &Method::GET) => { let mut result = None; - if let Some(report_id) = parse_queued_report_id(report_id) { + if let Some(report_id) = parse_queued_report_id(report_id.as_ref()) { match report_id { QueueClass::DmarcReportHeader(event) => { let mut rua = Vec::new(); @@ -475,7 +477,7 @@ impl JMAP { } } ("reports", Some(report_id), &Method::DELETE) => { - if let Some(report_id) = parse_queued_report_id(report_id) { + if let Some(report_id) = parse_queued_report_id(report_id.as_ref()) { match report_id { QueueClass::DmarcReportHeader(event) => { self.smtp.delete_dmarc_report(event).await; diff --git a/crates/jmap/src/api/management/report.rs b/crates/jmap/src/api/management/report.rs index 14cfb5d6..f4eb5185 100644 --- a/crates/jmap/src/api/management/report.rs +++ b/crates/jmap/src/api/management/report.rs @@ -40,6 +40,8 @@ use crate::{ JMAP, }; +use super::decode_path_element; + enum ReportType { Dmarc, Tls, @@ -50,7 +52,7 @@ impl JMAP { pub async fn handle_manage_reports(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { match ( path.get(1).copied().unwrap_or_default(), - path.get(2).copied(), + path.get(2).copied().map(decode_path_element), req.method(), ) { (class @ ("dmarc" | "tls" | "arf"), None, &Method::GET) => { @@ -159,7 +161,7 @@ impl JMAP { } } (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::GET) => { - if let Some(report_id) = parse_incoming_report_id(class, report_id) { + if let Some(report_id) = parse_incoming_report_id(class, report_id.as_ref()) { match &report_id { ReportClass::Tls { .. } => match self .core @@ -215,7 +217,7 @@ impl JMAP { } } (class @ ("dmarc" | "tls" | "arf"), Some(report_id), &Method::DELETE) => { - if let Some(report_id) = parse_incoming_report_id(class, report_id) { + if let Some(report_id) = parse_incoming_report_id(class, report_id.as_ref()) { let mut batch = BatchBuilder::new(); batch.clear(ValueClass::Report(report_id)); let result = self.core.storage.data.write(batch.build()).await.is_ok(); diff --git a/crates/jmap/src/api/management/settings.rs b/crates/jmap/src/api/management/settings.rs index 5e3952f4..de8f9595 100644 --- a/crates/jmap/src/api/management/settings.rs +++ b/crates/jmap/src/api/management/settings.rs @@ -32,7 +32,7 @@ use crate::{ JMAP, }; -use super::ManagementApiError; +use super::{decode_path_element, ManagementApiError}; #[derive(Debug, serde::Serialize, serde::Deserialize)] #[serde(tag = "type")] @@ -269,7 +269,9 @@ impl JMAP { } } (Some(prefix), &Method::DELETE) if !prefix.is_empty() => { - match self.core.storage.config.clear(prefix).await { + let prefix = decode_path_element(prefix); + + match self.core.storage.config.clear(prefix.as_ref()).await { Ok(_) => JsonResponse::new(json!({ "data": (), })) diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index a086f47f..59b14be3 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -21,7 +21,7 @@ chrono = "0.4" rand = "0.8.5" webpki-roots = { version = "0.26"} ring = { version = "0.17" } -base64 = "0.21" +base64 = "0.22" serde_json = "1.0" rcgen = "0.13" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]} diff --git a/tests/resources/acme/docker-compose-pebble.yaml b/tests/resources/acme/docker-compose-pebble.yaml index 4330e19d..5806fe59 100644 --- a/tests/resources/acme/docker-compose-pebble.yaml +++ b/tests/resources/acme/docker-compose-pebble.yaml @@ -8,7 +8,7 @@ version: '3' services: pebble: image: letsencrypt/pebble:latest - command: pebble -config /test/config/pebble-config.json -strict -dnsserver 8.8.8.8:53 #-dnsserver 10.30.50.3:8053 + command: pebble -config /test/config/pebble-config.json -strict -dnsserver 10.30.50.3:8053 #-dnsserver 8.8.8.8:53 ports: - 14000:14000 # HTTPS ACME API - 15000:15000 # HTTPS Management API