mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-11-24 06:19:46 +00:00
Support for DNS-01 and HTTP-01 ACME challenges (closes #226)
This commit is contained in:
parent
0267f28156
commit
929d84468f
34 changed files with 764 additions and 246 deletions
89
Cargo.lock
generated
89
Cargo.lock
generated
|
@ -985,7 +985,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"arc-swap",
|
||||
|
@ -994,6 +994,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"decancer",
|
||||
"directory",
|
||||
"dns-update",
|
||||
"futures",
|
||||
"hostname 0.4.0",
|
||||
"hyper 1.2.0",
|
||||
|
@ -1496,7 +1497,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "directory"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"argon2",
|
||||
|
@ -1587,6 +1588,20 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
|
||||
|
||||
[[package]]
|
||||
name = "dns-update"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1be40017dbee3b3cb51abd068fd8319d83b95433cca53d83fef589a11ba0cd51"
|
||||
dependencies = [
|
||||
"hickory-client",
|
||||
"reqwest 0.12.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "doc-comment"
|
||||
version = "0.3.3"
|
||||
|
@ -1771,6 +1786,12 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.0"
|
||||
|
@ -2299,6 +2320,26 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hickory-client"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f3e08124cf0ddda93b1186d4af73599de401f3b52f14cd9aaa719049379462e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"hickory-proto",
|
||||
"once_cell",
|
||||
"radix_trie",
|
||||
"rand",
|
||||
"rustls 0.21.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.24.0"
|
||||
|
@ -2306,12 +2347,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "091a6fbccf4860009355e3efc52ff4acf37a63489aad7435372d44ceeb6fbbcf"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"enum-as-inner",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"h2 0.3.26",
|
||||
"http 0.2.12",
|
||||
"idna 0.4.0",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
|
@ -2657,7 +2701,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284"
|
|||
|
||||
[[package]]
|
||||
name = "imap"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"common",
|
||||
|
@ -2853,7 +2897,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "jmap"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
@ -3267,7 +3311,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mail-server"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"common",
|
||||
"directory",
|
||||
|
@ -3285,7 +3329,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "managesieve"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"bincode",
|
||||
|
@ -3538,6 +3582,15 @@ version = "1.0.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nibble_vec"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
|
@ -3553,7 +3606,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "nlp"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"bincode",
|
||||
|
@ -4394,6 +4447,16 @@ version = "0.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
|
||||
dependencies = [
|
||||
"endian-type",
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
|
@ -5349,9 +5412,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.115"
|
||||
version = "1.0.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd"
|
||||
checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
|
@ -5566,7 +5629,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
|
|||
|
||||
[[package]]
|
||||
name = "smtp"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"bincode",
|
||||
|
@ -5682,7 +5745,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "stalwart-cli"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"console",
|
||||
|
@ -5713,7 +5776,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||
|
||||
[[package]]
|
||||
name = "store"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"arc-swap",
|
||||
|
@ -6556,7 +6619,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
|||
|
||||
[[package]]
|
||||
name = "utils"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"base64 0.21.7",
|
||||
|
|
|
@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
|
|||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/stalwartlabs/cli"
|
||||
homepage = "https://github.com/stalwartlabs/cli"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
resolver = "2"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "common"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
@ -15,6 +15,7 @@ mail-parser = { version = "0.9", features = ["full_encoding", "ludicrous_mode"]
|
|||
mail-auth = { version = "0.3" }
|
||||
mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] }
|
||||
smtp-proto = { version = "0.1", features = ["serde_support"] }
|
||||
dns-update = { version = "0.1" }
|
||||
ahash = { version = "0.8.0", features = ["serde"] }
|
||||
parking_lot = "0.12.1"
|
||||
regex = "1.7.0"
|
||||
|
|
|
@ -36,11 +36,7 @@ use utils::config::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
listener::{
|
||||
acme::{directory::ACME_TLS_ALPN_NAME, AcmeResolver},
|
||||
tls::CertificateResolver,
|
||||
TcpAcceptor,
|
||||
},
|
||||
listener::{tls::CertificateResolver, TcpAcceptor},
|
||||
SharedCore,
|
||||
};
|
||||
|
||||
|
@ -219,14 +215,6 @@ impl Servers {
|
|||
|
||||
pub fn parse_tcp_acceptors(&mut self, config: &mut Config, core: SharedCore) {
|
||||
let resolver = Arc::new(CertificateResolver::new(core.clone()));
|
||||
let acme_config = {
|
||||
let mut challenge = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(AcmeResolver::new(core)));
|
||||
|
||||
challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
||||
Arc::new(challenge)
|
||||
};
|
||||
|
||||
for id_ in config
|
||||
.sub_keys("server.listener", ".protocol")
|
||||
|
@ -318,8 +306,7 @@ impl Servers {
|
|||
let default_config = Arc::new(server_config);
|
||||
TcpAcceptor::Tls {
|
||||
acceptor: TlsAcceptor::from(default_config.clone()),
|
||||
acme_config: acme_config.clone(),
|
||||
default_config,
|
||||
config: default_config,
|
||||
implicit: config
|
||||
.property_or_default(("server.listener", id, "tls.implicit"), "false")
|
||||
.unwrap_or(false),
|
||||
|
|
|
@ -30,6 +30,8 @@ use std::{
|
|||
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use arc_swap::ArcSwap;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use dns_update::{DnsUpdater, TsigAlgorithm};
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rustls::{
|
||||
crypto::ring::sign::any_supported_type,
|
||||
|
@ -47,7 +49,7 @@ use x509_parser::{
|
|||
};
|
||||
|
||||
use crate::listener::{
|
||||
acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider},
|
||||
acme::{directory::LETS_ENCRYPT_PRODUCTION_DIRECTORY, AcmeProvider, ChallengeSettings},
|
||||
tls::TlsManager,
|
||||
};
|
||||
|
||||
|
@ -64,18 +66,19 @@ impl TlsManager {
|
|||
parse_certificates(config, &mut certificates, &mut subject_names);
|
||||
|
||||
// Parse ACME providers
|
||||
for acme_id in config
|
||||
'outer: for acme_id in config
|
||||
.sub_keys("acme", ".directory")
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
let acme_id = acme_id.as_str();
|
||||
let directory = config
|
||||
.value(("acme", acme_id.as_str(), "directory"))
|
||||
.value(("acme", acme_id, "directory"))
|
||||
.unwrap_or(LETS_ENCRYPT_PRODUCTION_DIRECTORY)
|
||||
.trim()
|
||||
.to_string();
|
||||
let contact = config
|
||||
.values(("acme", acme_id.as_str(), "contact"))
|
||||
.values(("acme", acme_id, "contact"))
|
||||
.filter_map(|(_, v)| {
|
||||
let v = v.trim().to_string();
|
||||
if !v.is_empty() {
|
||||
|
@ -86,7 +89,7 @@ impl TlsManager {
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
let renew_before: Duration = config
|
||||
.property_or_default(("acme", acme_id.as_str(), "renew-before"), "30d")
|
||||
.property_or_default(("acme", acme_id, "renew-before"), "30d")
|
||||
.unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60));
|
||||
|
||||
if directory.is_empty() {
|
||||
|
@ -99,15 +102,59 @@ impl TlsManager {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Parse challenge type
|
||||
let challenge = match config
|
||||
.value(("acme", acme_id, "challenge"))
|
||||
.unwrap_or("tls-alpn-01")
|
||||
{
|
||||
"tls-alpn-01" => ChallengeSettings::TlsAlpn01,
|
||||
"http-01" => ChallengeSettings::Http01,
|
||||
"dns-01" => match build_dns_updater(config, acme_id) {
|
||||
Some(updater) => ChallengeSettings::Dns01 {
|
||||
updater,
|
||||
origin: config
|
||||
.value(("acme", acme_id, "origin"))
|
||||
.map(|s| s.to_string()),
|
||||
polling_interval: config
|
||||
.property_or_default(("acme", acme_id, "polling-interval"), "15s")
|
||||
.unwrap_or_else(|| Duration::from_secs(15)),
|
||||
propagation_timeout: config
|
||||
.property_or_default(("acme", acme_id, "propagation-timeout"), "1m")
|
||||
.unwrap_or_else(|| Duration::from_secs(60)),
|
||||
ttl: config
|
||||
.property_or_default(("acme", acme_id, "ttl"), "5m")
|
||||
.unwrap_or_else(|| Duration::from_secs(5 * 60))
|
||||
.as_secs() as u32,
|
||||
},
|
||||
None => {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
config
|
||||
.new_parse_error(("acme", acme_id, "challenge"), "Invalid challenge type");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Domains covered by this ACME manager
|
||||
let domains = config
|
||||
.values(("acme", acme_id.as_str(), "domains"))
|
||||
.map(|(_, v)| v.to_string())
|
||||
.values(("acme", acme_id, "domains"))
|
||||
.map(|(_, s)| s.trim().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if !matches!(challenge, ChallengeSettings::Dns01 { .. })
|
||||
&& domains.iter().any(|d| d.starts_with("*."))
|
||||
{
|
||||
config.new_parse_error(
|
||||
("acme", acme_id, "domains"),
|
||||
"Wildcard domains are only supported with DNS-01 challenge",
|
||||
);
|
||||
continue 'outer;
|
||||
}
|
||||
|
||||
// This ACME manager is the default when SNI is not available
|
||||
let default = config
|
||||
.property::<bool>(("acme", acme_id.as_str(), "default"))
|
||||
.property::<bool>(("acme", acme_id, "default"))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Add domains for self-signed certificate
|
||||
|
@ -119,6 +166,7 @@ impl TlsManager {
|
|||
directory,
|
||||
domains,
|
||||
contact,
|
||||
challenge,
|
||||
renew_before,
|
||||
default,
|
||||
) {
|
||||
|
@ -139,8 +187,6 @@ impl TlsManager {
|
|||
TlsManager {
|
||||
certificates: ArcSwap::from_pointee(certificates),
|
||||
acme_providers,
|
||||
acme_auth_keys: Default::default(),
|
||||
acme_in_progress: false.into(),
|
||||
self_signed_cert: build_self_signed_cert(subject_names.into_iter().collect::<Vec<_>>())
|
||||
.or_else(|err| {
|
||||
config.new_build_error("certificate.self-signed", err);
|
||||
|
@ -152,6 +198,77 @@ impl TlsManager {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_to_owned)]
|
||||
fn build_dns_updater(config: &mut Config, acme_id: &str) -> Option<DnsUpdater> {
|
||||
match config.value_require(("acme", acme_id, "provider"))? {
|
||||
"rfc2136-tsig" => {
|
||||
let algorithm: TsigAlgorithm = config
|
||||
.value_require(("acme", acme_id, "algorithm"))?
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
config.new_parse_error(("acme", acme_id, "algorithm"), "Invalid algorithm")
|
||||
})
|
||||
.ok()?;
|
||||
let key = STANDARD
|
||||
.decode(config.value_require(("acme", acme_id, "secret"))?)
|
||||
.map_err(|_| {
|
||||
config.new_parse_error(
|
||||
("acme", acme_id, "secret"),
|
||||
"Failed to base64 decode secret",
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
let host = config.value_require(("acme", acme_id, "host"))?.to_string();
|
||||
let port = config
|
||||
.property_or_default::<u16>(("acme", acme_id, "port"), "53")
|
||||
.unwrap_or(53);
|
||||
let protocol = if config.value(("acme", acme_id, "protocol")) == Some("tcp") {
|
||||
"tcp"
|
||||
} else {
|
||||
"udp"
|
||||
};
|
||||
|
||||
DnsUpdater::new_rfc2136_tsig(
|
||||
format!("{protocol}://{host}:{port}"),
|
||||
config.value_require(("acme", acme_id, "key"))?.to_string(),
|
||||
key,
|
||||
algorithm,
|
||||
)
|
||||
.map_err(|err| {
|
||||
config.new_build_error(
|
||||
("acme", acme_id, "provider"),
|
||||
format!("Failed to create RFC2136-TSIG DNS updater: {err}"),
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
"cloudflare" => {
|
||||
let timeout = config
|
||||
.property_or_default(("acme", acme_id, "timeout"), "30s")
|
||||
.unwrap_or_else(|| Duration::from_secs(30));
|
||||
|
||||
DnsUpdater::new_cloudflare(
|
||||
config
|
||||
.value_require(("acme", acme_id, "secret"))?
|
||||
.to_string(),
|
||||
config.value(("acme", acme_id, "user")),
|
||||
timeout.into(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
config.new_build_error(
|
||||
("acme", acme_id, "provider"),
|
||||
format!("Failed to create Cloudflare DNS updater: {err}"),
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
_ => {
|
||||
config.new_parse_error(("acme", acme_id, "provider"), "Unsupported provider");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_certificates(
|
||||
config: &mut Config,
|
||||
certificates: &mut AHashMap<String, Arc<CertifiedKey>>,
|
||||
|
|
|
@ -171,6 +171,8 @@ impl Resolvers {
|
|||
if let Some(attempts) = config.property("resolver.attempts") {
|
||||
opts.attempts = attempts;
|
||||
}
|
||||
// We already have a cache, so disable the built-in cache
|
||||
opts.cache_size = 0;
|
||||
|
||||
// Prepare DNSSEC resolver options
|
||||
let config_dnssec = resolver_config.clone();
|
||||
|
|
|
@ -10,13 +10,14 @@ use reqwest::{Method, Response, StatusCode};
|
|||
use ring::error::{KeyRejected, Unspecified};
|
||||
use ring::rand::SystemRandom;
|
||||
use ring::signature::{EcdsaKeyPair, EcdsaSigningAlgorithm, ECDSA_P256_SHA256_FIXED_SIGNING};
|
||||
use rustls::crypto::ring::sign::any_ecdsa_type;
|
||||
use rustls::sign::CertifiedKey;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use store::write::Bincode;
|
||||
use store::Serialize;
|
||||
|
||||
use super::jose::{key_authorization_sha256, sign, JoseError};
|
||||
use super::jose::{
|
||||
key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign, JoseError,
|
||||
};
|
||||
|
||||
pub const LETS_ENCRYPT_STAGING_DIRECTORY: &str =
|
||||
"https://acme-staging-v02.api.letsencrypt.org/directory";
|
||||
|
@ -138,33 +139,41 @@ impl Account {
|
|||
Ok(self.request(&url, "").await?.1)
|
||||
}
|
||||
|
||||
pub fn tls_alpn_01<'a>(
|
||||
pub fn http_proof(&self, challenge: &Challenge) -> Result<Vec<u8>, DirectoryError> {
|
||||
key_authorization(&self.key_pair, &challenge.token)
|
||||
.map(|key| key.into_bytes())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn dns_proof(&self, challenge: &Challenge) -> Result<String, DirectoryError> {
|
||||
key_authorization_sha256_base64(&self.key_pair, &challenge.token).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn tls_alpn_key(
|
||||
&self,
|
||||
challenges: &'a [Challenge],
|
||||
challenge: &Challenge,
|
||||
domain: String,
|
||||
) -> Result<(&'a Challenge, CertifiedKey), DirectoryError> {
|
||||
let challenge = challenges
|
||||
.iter()
|
||||
.find(|c| c.typ == ChallengeType::TlsAlpn01);
|
||||
let challenge = match challenge {
|
||||
Some(challenge) => challenge,
|
||||
None => return Err(DirectoryError::NoTlsAlpn01Challenge),
|
||||
};
|
||||
) -> Result<Vec<u8>, DirectoryError> {
|
||||
let mut params = rcgen::CertificateParams::new(vec![domain]);
|
||||
let key_auth = key_authorization_sha256(&self.key_pair, &challenge.token)?;
|
||||
params.alg = &PKCS_ECDSA_P256_SHA256;
|
||||
params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())];
|
||||
let cert = Certificate::from_params(params)?;
|
||||
let pk = any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(
|
||||
cert.serialize_private_key_der(),
|
||||
)))
|
||||
.unwrap();
|
||||
let certified_key =
|
||||
CertifiedKey::new(vec![CertificateDer::from(cert.serialize_der()?)], pk);
|
||||
Ok((challenge, certified_key))
|
||||
|
||||
Ok(Bincode::new(SerializedCert {
|
||||
certificate: cert.serialize_der()?,
|
||||
private_key: cert.serialize_private_key_der(),
|
||||
})
|
||||
.serialize())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, Deserialize)]
|
||||
pub struct SerializedCert {
|
||||
pub certificate: Vec<u8>,
|
||||
pub private_key: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Directory {
|
||||
|
@ -187,7 +196,7 @@ impl Directory {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq)]
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq, Clone, Copy)]
|
||||
pub enum ChallengeType {
|
||||
#[serde(rename = "http-01")]
|
||||
Http01,
|
||||
|
@ -223,6 +232,7 @@ pub struct Auth {
|
|||
pub status: AuthStatus,
|
||||
pub identifier: Identifier,
|
||||
pub challenges: Vec<Challenge>,
|
||||
pub wildcard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
@ -236,7 +246,7 @@ pub enum AuthStatus {
|
|||
Deactivated,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "value", rename_all = "camelCase")]
|
||||
pub enum Identifier {
|
||||
Dns(String),
|
||||
|
@ -251,7 +261,7 @@ pub struct Challenge {
|
|||
pub error: Option<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, serde::Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Problem {
|
||||
#[serde(rename = "type")]
|
||||
|
@ -271,7 +281,7 @@ pub enum DirectoryError {
|
|||
KeyRejected(KeyRejected),
|
||||
Crypto(Unspecified),
|
||||
MissingHeader(&'static str),
|
||||
NoTlsAlpn01Challenge,
|
||||
NoChallenge(ChallengeType),
|
||||
}
|
||||
|
||||
#[allow(unused_mut)]
|
||||
|
|
|
@ -31,13 +31,26 @@ pub(crate) fn sign(
|
|||
Ok(serde_json::to_string(&body)?)
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization(key: &EcdsaKeyPair, token: &str) -> Result<String, JoseError> {
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
token,
|
||||
Jwk::new(key).thumb_sha256_base64()?
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256(
|
||||
key: &EcdsaKeyPair,
|
||||
token: &str,
|
||||
) -> Result<Digest, JoseError> {
|
||||
let jwk = Jwk::new(key);
|
||||
let key_authorization = format!("{}.{}", token, jwk.thumb_sha256_base64()?);
|
||||
Ok(digest(&SHA256, key_authorization.as_bytes()))
|
||||
key_authorization(key, token).map(|s| digest(&SHA256, s.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn key_authorization_sha256_base64(
|
||||
key: &EcdsaKeyPair,
|
||||
token: &str,
|
||||
) -> Result<String, JoseError> {
|
||||
key_authorization_sha256(key, token).map(|s| URL_SAFE_NO_PAD.encode(s.as_ref()))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
|
@ -27,18 +27,16 @@ pub mod jose;
|
|||
pub mod order;
|
||||
pub mod resolver;
|
||||
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fmt::Debug, sync::Arc, time::Duration};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use dns_update::DnsUpdater;
|
||||
use rustls::sign::CertifiedKey;
|
||||
|
||||
use crate::{Core, SharedCore};
|
||||
use crate::Core;
|
||||
|
||||
use self::{
|
||||
directory::Account,
|
||||
directory::{Account, ChallengeType},
|
||||
order::{CertParseError, OrderError},
|
||||
};
|
||||
|
||||
|
@ -47,13 +45,27 @@ pub struct AcmeProvider {
|
|||
pub directory_url: String,
|
||||
pub domains: Vec<String>,
|
||||
pub contact: Vec<String>,
|
||||
pub challenge: ChallengeSettings,
|
||||
renew_before: chrono::Duration,
|
||||
account_key: ArcSwap<Vec<u8>>,
|
||||
default: bool,
|
||||
}
|
||||
|
||||
pub struct AcmeResolver {
|
||||
pub core: SharedCore,
|
||||
#[derive(Clone)]
|
||||
pub enum ChallengeSettings {
|
||||
Http01,
|
||||
TlsAlpn01,
|
||||
Dns01 {
|
||||
updater: DnsUpdater,
|
||||
origin: Option<String>,
|
||||
polling_interval: Duration,
|
||||
propagation_timeout: Duration,
|
||||
ttl: u32,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct StaticResolver {
|
||||
pub key: Option<Arc<CertifiedKey>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -73,6 +85,7 @@ impl AcmeProvider {
|
|||
directory_url: String,
|
||||
domains: Vec<String>,
|
||||
contact: Vec<String>,
|
||||
challenge: ChallengeSettings,
|
||||
renew_before: Duration,
|
||||
default: bool,
|
||||
) -> utils::config::Result<Self> {
|
||||
|
@ -92,6 +105,7 @@ impl AcmeProvider {
|
|||
renew_before: chrono::Duration::from_std(renew_before).unwrap(),
|
||||
domains,
|
||||
account_key: Default::default(),
|
||||
challenge,
|
||||
default,
|
||||
})
|
||||
}
|
||||
|
@ -116,20 +130,34 @@ impl Core {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn has_acme_order_in_progress(&self) -> bool {
|
||||
self.tls.acme_in_progress.load(Ordering::Relaxed)
|
||||
pub fn has_acme_tls_providers(&self) -> bool {
|
||||
self.tls
|
||||
.acme_providers
|
||||
.values()
|
||||
.any(|p| matches!(p.challenge, ChallengeSettings::TlsAlpn01))
|
||||
}
|
||||
|
||||
pub fn has_acme_http_providers(&self) -> bool {
|
||||
self.tls
|
||||
.acme_providers
|
||||
.values()
|
||||
.any(|p| matches!(p.challenge, ChallengeSettings::Http01))
|
||||
}
|
||||
}
|
||||
|
||||
impl AcmeResolver {
|
||||
pub fn new(core: SharedCore) -> Self {
|
||||
Self { core }
|
||||
impl ChallengeSettings {
|
||||
pub fn challenge_type(&self) -> ChallengeType {
|
||||
match self {
|
||||
ChallengeSettings::Http01 => ChallengeType::Http01,
|
||||
ChallengeSettings::TlsAlpn01 => ChallengeType::TlsAlpn01,
|
||||
ChallengeSettings::Dns01 { .. } => ChallengeType::Dns01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for AcmeResolver {
|
||||
impl Debug for StaticResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AcmeResolver").finish()
|
||||
f.debug_struct("StaticResolver").finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,6 +168,7 @@ impl Clone for AcmeProvider {
|
|||
directory_url: self.directory_url.clone(),
|
||||
domains: self.domains.clone(),
|
||||
contact: self.contact.clone(),
|
||||
challenge: self.challenge.clone(),
|
||||
renew_before: self.renew_before,
|
||||
account_key: ArcSwap::from_pointee(self.account_key.load().as_ref().clone()),
|
||||
default: self.default,
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use dns_update::DnsRecord;
|
||||
use futures::future::try_join_all;
|
||||
use rcgen::{CertificateParams, DistinguishedName, PKCS_ECDSA_P256_SHA256};
|
||||
use rustls::crypto::ring::sign::any_ecdsa_type;
|
||||
use rustls::sign::CertifiedKey;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use std::fmt::Debug;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use utils::suffixlist::DomainPart;
|
||||
use x509_parser::parse_x509_certificate;
|
||||
|
||||
use crate::listener::acme::directory::Identifier;
|
||||
use crate::listener::acme::ChallengeSettings;
|
||||
use crate::Core;
|
||||
|
||||
use super::directory::{Account, Auth, AuthStatus, Directory, DirectoryError, Order, OrderStatus};
|
||||
|
@ -27,6 +29,8 @@ pub enum OrderError {
|
|||
BadAuth(Auth),
|
||||
TooManyAttemptsAuth(String),
|
||||
ProcessingTimeout(Order),
|
||||
Store(store::Error),
|
||||
Dns(dns_update::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -80,7 +84,6 @@ impl Core {
|
|||
|
||||
pub async fn renew(&self, provider: &AcmeProvider) -> Result<Duration, AcmeError> {
|
||||
let mut backoff = 0;
|
||||
self.tls.acme_in_progress.store(true, Ordering::Relaxed);
|
||||
loop {
|
||||
match self.order(provider).await {
|
||||
Ok(pem) => return self.process_cert(provider, pem, false).await,
|
||||
|
@ -203,21 +206,165 @@ impl Core {
|
|||
let (domain, challenge_url) = match auth.status {
|
||||
AuthStatus::Pending => {
|
||||
let Identifier::Dns(domain) = auth.identifier;
|
||||
let challenge_type = provider.challenge.challenge_type();
|
||||
tracing::info!(
|
||||
context = "acme",
|
||||
event = "challenge",
|
||||
domain = domain,
|
||||
challenge = ?challenge_type,
|
||||
"Requesting challenge for domain {domain}"
|
||||
);
|
||||
let (challenge, auth_key) =
|
||||
account.tls_alpn_01(&auth.challenges, domain.clone())?;
|
||||
self.set_auth_key(provider, domain.clone(), Arc::new(auth_key));
|
||||
let challenge = auth
|
||||
.challenges
|
||||
.iter()
|
||||
.find(|c| c.typ == challenge_type)
|
||||
.ok_or(DirectoryError::NoChallenge(challenge_type))?;
|
||||
|
||||
match &provider.challenge {
|
||||
ChallengeSettings::TlsAlpn01 => {
|
||||
self.storage
|
||||
.lookup
|
||||
.key_set(
|
||||
format!("acme:{domain}").into_bytes(),
|
||||
account.tls_alpn_key(challenge, domain.clone())?,
|
||||
3600.into(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeSettings::Http01 => {
|
||||
self.storage
|
||||
.lookup
|
||||
.key_set(
|
||||
format!("acme:{}", challenge.token).into_bytes(),
|
||||
account.http_proof(challenge)?,
|
||||
3600.into(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
ChallengeSettings::Dns01 {
|
||||
updater,
|
||||
origin,
|
||||
polling_interval,
|
||||
propagation_timeout,
|
||||
ttl,
|
||||
} => {
|
||||
let dns_proof = account.dns_proof(challenge)?;
|
||||
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
||||
let name = format!("_acme-challenge.{}", domain);
|
||||
let origin = origin
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
self.smtp.resolvers.psl.domain_part(domain, DomainPart::Sld)
|
||||
})
|
||||
.unwrap_or_else(|| domain.to_string());
|
||||
|
||||
// First try deleting the record
|
||||
if let Err(err) = updater.delete(&name, &origin).await {
|
||||
// Errors are expected if the record does not exist
|
||||
tracing::trace!(
|
||||
context = "acme",
|
||||
event = "dns-delete",
|
||||
name = name,
|
||||
origin = origin,
|
||||
error = ?err,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the record
|
||||
if let Err(err) = updater
|
||||
.create(
|
||||
&name,
|
||||
DnsRecord::TXT {
|
||||
content: dns_proof.clone(),
|
||||
},
|
||||
*ttl,
|
||||
&origin,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
context = "acme",
|
||||
event = "dns-create",
|
||||
name = name,
|
||||
origin = origin,
|
||||
error = ?err,
|
||||
"Failed to create DNS record.",
|
||||
);
|
||||
return Err(OrderError::Dns(err));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
context = "acme",
|
||||
event = "dns-create",
|
||||
name = name,
|
||||
origin = origin,
|
||||
"Successfully created DNS record.",
|
||||
);
|
||||
|
||||
// Wait for changes to propagate
|
||||
let wait_until = Instant::now() + *propagation_timeout;
|
||||
let mut did_propagate = false;
|
||||
while Instant::now() < wait_until {
|
||||
match self.smtp.resolvers.dns.txt_raw_lookup(&name).await {
|
||||
Ok(result) => {
|
||||
let result = std::str::from_utf8(&result).unwrap_or_default();
|
||||
if result.contains(&dns_proof) {
|
||||
did_propagate = true;
|
||||
break;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
event = "dns-lookup",
|
||||
name = name,
|
||||
origin = origin,
|
||||
contents = ?result,
|
||||
expected_proof = ?dns_proof,
|
||||
"DNS record has not propagated yet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::trace!(
|
||||
context = "acme",
|
||||
event = "dns-lookup",
|
||||
name = name,
|
||||
origin = origin,
|
||||
error = ?err,
|
||||
"Failed to lookup DNS record.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(*polling_interval).await;
|
||||
}
|
||||
|
||||
if did_propagate {
|
||||
tracing::info!(
|
||||
context = "acme",
|
||||
event = "dns-lookup",
|
||||
name = name,
|
||||
origin = origin,
|
||||
"DNS changes have been propagated.",
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
context = "acme",
|
||||
event = "dns-lookup",
|
||||
name = name,
|
||||
origin = origin,
|
||||
"DNS changes have not been propagated within the timeout.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
account.challenge(&challenge.url).await?;
|
||||
(domain, challenge.url.clone())
|
||||
}
|
||||
AuthStatus::Valid => return Ok(()),
|
||||
_ => return Err(OrderError::BadAuth(auth)),
|
||||
};
|
||||
|
||||
for i in 0u64..5 {
|
||||
tokio::time::sleep(Duration::from_secs(1u64 << i)).await;
|
||||
let auth = account.auth(url).await?;
|
||||
|
@ -232,7 +379,16 @@ impl Core {
|
|||
);
|
||||
account.challenge(&challenge_url).await?
|
||||
}
|
||||
AuthStatus::Valid => return Ok(()),
|
||||
AuthStatus::Valid => {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
event = "auth-valid",
|
||||
domain = domain,
|
||||
"Authorization for domain {domain} is valid",
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Err(OrderError::BadAuth(auth)),
|
||||
}
|
||||
}
|
||||
|
@ -305,3 +461,9 @@ impl From<JoseError> for AcmeError {
|
|||
Self::Order(OrderError::from(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<store::Error> for OrderError {
|
||||
fn from(value: store::Error) -> Self {
|
||||
Self::Store(value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,16 +21,20 @@
|
|||
* for more details.
|
||||
*/
|
||||
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::{
|
||||
crypto::ring::sign::any_ecdsa_type,
|
||||
server::{ClientHello, ResolvesServerCert},
|
||||
sign::CertifiedKey,
|
||||
ServerConfig,
|
||||
};
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use store::write::Bincode;
|
||||
|
||||
use crate::{listener::tls::AcmeAuthKey, Core};
|
||||
use crate::{listener::acme::directory::SerializedCert, Core};
|
||||
|
||||
use super::{directory::ACME_TLS_ALPN_NAME, AcmeProvider, AcmeResolver};
|
||||
use super::{directory::ACME_TLS_ALPN_NAME, AcmeProvider, StaticResolver};
|
||||
|
||||
impl Core {
|
||||
pub(crate) fn set_cert(&self, provider: &AcmeProvider, cert: Arc<CertifiedKey>) {
|
||||
|
@ -52,57 +56,71 @@ impl Core {
|
|||
}
|
||||
|
||||
self.tls.certificates.store(certificates.into());
|
||||
|
||||
// Remove auth keys
|
||||
let mut auth_keys = self.tls.acme_auth_keys.lock();
|
||||
auth_keys.retain(|_, v| v.provider_id != provider.id);
|
||||
self.tls
|
||||
.acme_in_progress
|
||||
.store(!auth_keys.is_empty(), Ordering::Relaxed);
|
||||
}
|
||||
pub(crate) fn set_auth_key(
|
||||
&self,
|
||||
provider: &AcmeProvider,
|
||||
domain: String,
|
||||
cert: Arc<CertifiedKey>,
|
||||
) {
|
||||
self.tls
|
||||
.acme_auth_keys
|
||||
.lock()
|
||||
.insert(domain, AcmeAuthKey::new(provider.id.clone(), cert));
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for AcmeResolver {
|
||||
fn resolve(&self, client_hello: ClientHello) -> Option<Arc<CertifiedKey>> {
|
||||
let core = self.core.load();
|
||||
if core.has_acme_order_in_progress() && client_hello.is_tls_alpn_challenge() {
|
||||
match client_hello.server_name() {
|
||||
Some(domain) => {
|
||||
tracing::trace!(
|
||||
context = "acme",
|
||||
event = "auth-key",
|
||||
domain = %domain,
|
||||
"Found client supplied SNI");
|
||||
impl ResolvesServerCert for StaticResolver {
|
||||
fn resolve(&self, _: ClientHello) -> Option<Arc<CertifiedKey>> {
|
||||
self.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
core.tls
|
||||
.acme_auth_keys
|
||||
.lock()
|
||||
.get(domain)
|
||||
.map(|ak| ak.key.clone())
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
event = "error",
|
||||
reason = "missing-sni",
|
||||
"client did not supply SNI"
|
||||
);
|
||||
None
|
||||
pub(crate) fn build_acme_static_resolver(key: Option<Arc<CertifiedKey>>) -> Arc<ServerConfig> {
|
||||
let mut challenge = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(StaticResolver { key }));
|
||||
challenge.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
|
||||
Arc::new(challenge)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
pub(crate) async fn build_acme_certificate(&self, domain: &str) -> Option<Arc<CertifiedKey>> {
|
||||
match self
|
||||
.storage
|
||||
.lookup
|
||||
.key_get::<Bincode<SerializedCert>>(format!("acme:{domain}").into_bytes())
|
||||
.await
|
||||
{
|
||||
Ok(Some(cert)) => {
|
||||
match any_ecdsa_type(&PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(
|
||||
cert.inner.private_key,
|
||||
))) {
|
||||
Ok(key) => Some(Arc::new(CertifiedKey::new(
|
||||
vec![CertificateDer::from(cert.inner.certificate)],
|
||||
key,
|
||||
))),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
context = "acme",
|
||||
event = "error",
|
||||
domain = %domain,
|
||||
reason = %err,
|
||||
"Failed to parse private key",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
core.resolve_certificate(client_hello.server_name())
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
context = "acme",
|
||||
event = "error",
|
||||
domain = %domain,
|
||||
reason = %err,
|
||||
"Failed to lookup token",
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
event = "error",
|
||||
domain = %domain,
|
||||
reason = "missing-token",
|
||||
"Token not found in lookup store"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,7 +115,7 @@ impl Server {
|
|||
match stream {
|
||||
Ok((stream, remote_addr)) => {
|
||||
let core = core.as_ref().load();
|
||||
let enable_acme = is_https && core.has_acme_order_in_progress();
|
||||
let enable_acme = (is_https && core.has_acme_tls_providers()).then_some(core.clone());
|
||||
|
||||
if has_proxies && instance.proxy_networks.iter().any(|network| network.matches(&remote_addr.ip())) {
|
||||
let instance = instance.clone();
|
||||
|
|
|
@ -35,6 +35,7 @@ use utils::config::ipmask::IpAddrMask;
|
|||
use crate::{
|
||||
config::server::ServerProtocol,
|
||||
expr::{functions::ResolveVariable, *},
|
||||
Core,
|
||||
};
|
||||
|
||||
use self::limiter::{ConcurrencyLimiter, InFlight};
|
||||
|
@ -58,8 +59,7 @@ pub struct ServerInstance {
|
|||
#[derive(Default)]
|
||||
pub enum TcpAcceptor {
|
||||
Tls {
|
||||
acme_config: Arc<ServerConfig>,
|
||||
default_config: Arc<ServerConfig>,
|
||||
config: Arc<ServerConfig>,
|
||||
acceptor: TlsAcceptor,
|
||||
implicit: bool,
|
||||
},
|
||||
|
@ -99,7 +99,7 @@ pub trait SessionManager: Sync + Send + 'static + Clone {
|
|||
&self,
|
||||
mut session: SessionData<T>,
|
||||
is_tls: bool,
|
||||
enable_acme: bool,
|
||||
acme_core: Option<Arc<Core>>,
|
||||
) {
|
||||
let manager = self.clone();
|
||||
|
||||
|
@ -108,7 +108,7 @@ pub trait SessionManager: Sync + Send + 'static + Clone {
|
|||
match session
|
||||
.instance
|
||||
.acceptor
|
||||
.accept(session.stream, enable_acme)
|
||||
.accept(session.stream, acme_core)
|
||||
.await
|
||||
{
|
||||
TcpAcceptorResult::Tls(accept) => match accept.await {
|
||||
|
@ -177,14 +177,10 @@ impl Debug for TcpAcceptor {
|
|||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Tls {
|
||||
acme_config,
|
||||
default_config,
|
||||
implicit,
|
||||
..
|
||||
config, implicit, ..
|
||||
} => f
|
||||
.debug_struct("Tls")
|
||||
.field("acme_config", acme_config)
|
||||
.field("default_config", default_config)
|
||||
.field("config", config)
|
||||
.field("implicit", implicit)
|
||||
.finish(),
|
||||
Self::Plain => write!(f, "Plain"),
|
||||
|
|
|
@ -24,12 +24,11 @@
|
|||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{self, Formatter},
|
||||
sync::{atomic::AtomicBool, Arc},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use rustls::{
|
||||
server::{ClientHello, ResolvesServerCert},
|
||||
sign::CertifiedKey,
|
||||
|
@ -42,7 +41,10 @@ use tokio_rustls::{Accept, LazyConfigAcceptor};
|
|||
use crate::{Core, SharedCore};
|
||||
|
||||
use super::{
|
||||
acme::{resolver::IsTlsAlpnChallenge, AcmeProvider},
|
||||
acme::{
|
||||
resolver::{build_acme_static_resolver, IsTlsAlpnChallenge},
|
||||
AcmeProvider,
|
||||
},
|
||||
SessionStream, TcpAcceptor, TcpAcceptorResult,
|
||||
};
|
||||
|
||||
|
@ -53,17 +55,9 @@ pub static TLS12_VERSION: &[&SupportedProtocolVersion] = &[&TLS12];
|
|||
pub struct TlsManager {
|
||||
pub certificates: ArcSwap<AHashMap<String, Arc<CertifiedKey>>>,
|
||||
pub acme_providers: AHashMap<String, AcmeProvider>,
|
||||
pub(crate) acme_auth_keys: Mutex<AHashMap<String, AcmeAuthKey>>,
|
||||
pub acme_in_progress: AtomicBool,
|
||||
pub self_signed_cert: Option<Arc<CertifiedKey>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AcmeAuthKey {
|
||||
pub provider_id: String,
|
||||
pub key: Arc<CertifiedKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CertificateResolver {
|
||||
pub core: SharedCore,
|
||||
|
@ -75,12 +69,6 @@ impl CertificateResolver {
|
|||
}
|
||||
}
|
||||
|
||||
impl AcmeAuthKey {
|
||||
pub fn new(provider_id: String, key: Arc<CertifiedKey>) -> Self {
|
||||
Self { provider_id, key }
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvesServerCert for CertificateResolver {
|
||||
fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
|
||||
self.core
|
||||
|
@ -139,24 +127,54 @@ impl Core {
|
|||
}
|
||||
|
||||
impl TcpAcceptor {
|
||||
pub async fn accept<IO>(&self, stream: IO, enable_acme: bool) -> TcpAcceptorResult<IO>
|
||||
pub async fn accept<IO>(
|
||||
&self,
|
||||
stream: IO,
|
||||
enable_acme: Option<Arc<Core>>,
|
||||
) -> TcpAcceptorResult<IO>
|
||||
where
|
||||
IO: SessionStream,
|
||||
{
|
||||
match self {
|
||||
TcpAcceptor::Tls {
|
||||
acme_config,
|
||||
default_config,
|
||||
config,
|
||||
acceptor,
|
||||
implicit,
|
||||
} if *implicit => {
|
||||
if !enable_acme {
|
||||
TcpAcceptorResult::Tls(acceptor.accept(stream))
|
||||
} else {
|
||||
} if *implicit => match enable_acme {
|
||||
None => TcpAcceptorResult::Tls(acceptor.accept(stream)),
|
||||
Some(core) => {
|
||||
match LazyConfigAcceptor::new(Default::default(), stream).await {
|
||||
Ok(start_handshake) => {
|
||||
if start_handshake.client_hello().is_tls_alpn_challenge() {
|
||||
match start_handshake.into_stream(acme_config.clone()).await {
|
||||
if core.has_acme_tls_providers()
|
||||
&& start_handshake.client_hello().is_tls_alpn_challenge()
|
||||
{
|
||||
let key = match start_handshake.client_hello().server_name() {
|
||||
Some(domain) => {
|
||||
let key = core.build_acme_certificate(domain).await;
|
||||
|
||||
tracing::trace!(
|
||||
context = "acme",
|
||||
event = "auth-key",
|
||||
domain = %domain,
|
||||
found_key = key.is_some(),
|
||||
"Client supplied SNI");
|
||||
key
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
event = "error",
|
||||
reason = "missing-sni",
|
||||
"Client did not supply SNI"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
match start_handshake
|
||||
.into_stream(build_acme_static_resolver(key))
|
||||
.await
|
||||
{
|
||||
Ok(mut tls) => {
|
||||
tracing::debug!(
|
||||
context = "acme",
|
||||
|
@ -176,7 +194,7 @@ impl TcpAcceptor {
|
|||
}
|
||||
} else {
|
||||
return TcpAcceptorResult::Tls(
|
||||
start_handshake.into_stream(default_config.clone()),
|
||||
start_handshake.into_stream(config.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -192,7 +210,7 @@ impl TcpAcceptor {
|
|||
|
||||
TcpAcceptorResult::Close
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => TcpAcceptorResult::Plain(stream),
|
||||
}
|
||||
}
|
||||
|
@ -225,11 +243,6 @@ impl Clone for TlsManager {
|
|||
Self {
|
||||
certificates: ArcSwap::from_pointee(self.certificates.load().as_ref().clone()),
|
||||
acme_providers: self.acme_providers.clone(),
|
||||
acme_auth_keys: Mutex::new(self.acme_auth_keys.lock().clone()),
|
||||
acme_in_progress: self
|
||||
.acme_in_progress
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
.into(),
|
||||
self_signed_cert: self.self_signed_cert.clone(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,18 +23,12 @@
|
|||
|
||||
use nlp::tokenizers::types::{TokenType, TypesTokenizer};
|
||||
use sieve::{runtime::Variable, FunctionMap};
|
||||
use utils::suffixlist::DomainPart;
|
||||
|
||||
use crate::scripts::functions::{html::html_to_tokens, text::tokenize_words, ApplyString};
|
||||
|
||||
use super::PluginContext;
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum MatchPart {
|
||||
Sld,
|
||||
Tld,
|
||||
Host,
|
||||
}
|
||||
|
||||
pub fn register_tokenize(plugin_id: u32, fnc_map: &mut FunctionMap) {
|
||||
fnc_map.set_external_function("tokenize", plugin_id, 2);
|
||||
}
|
||||
|
@ -78,51 +72,20 @@ pub fn exec_tokenize(ctx: PluginContext<'_>) -> Variable {
|
|||
|
||||
pub fn exec_domain_part(ctx: PluginContext<'_>) -> Variable {
|
||||
let v = ctx.arguments;
|
||||
let match_part = match v[1].to_string().as_ref() {
|
||||
"sld" => MatchPart::Sld,
|
||||
"tld" => MatchPart::Tld,
|
||||
"host" => MatchPart::Host,
|
||||
let part = match v[1].to_string().as_ref() {
|
||||
"sld" => DomainPart::Sld,
|
||||
"tld" => DomainPart::Tld,
|
||||
"host" => DomainPart::Host,
|
||||
_ => return Variable::default(),
|
||||
};
|
||||
|
||||
v[0].transform(|domain| {
|
||||
let d = domain.trim().to_lowercase();
|
||||
let mut seen_dot = false;
|
||||
for (pos, ch) in d.as_bytes().iter().enumerate().rev() {
|
||||
if *ch == b'.' {
|
||||
if seen_dot {
|
||||
let maybe_domain =
|
||||
std::str::from_utf8(&d.as_bytes()[pos + 1..]).unwrap_or_default();
|
||||
if !ctx.core.smtp.resolvers.psl.contains(maybe_domain) {
|
||||
return if match_part == MatchPart::Sld {
|
||||
maybe_domain
|
||||
} else {
|
||||
std::str::from_utf8(&d.as_bytes()[..pos]).unwrap_or_default()
|
||||
}
|
||||
.to_string()
|
||||
.into();
|
||||
}
|
||||
} else if match_part == MatchPart::Tld {
|
||||
return std::str::from_utf8(&d.as_bytes()[pos + 1..])
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
.into();
|
||||
} else {
|
||||
seen_dot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seen_dot {
|
||||
if match_part == MatchPart::Sld {
|
||||
d.into()
|
||||
} else {
|
||||
Variable::default()
|
||||
}
|
||||
} else if match_part == MatchPart::Host {
|
||||
d.into()
|
||||
} else {
|
||||
Variable::default()
|
||||
}
|
||||
ctx.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.psl
|
||||
.domain_part(domain, part)
|
||||
.map(Variable::from)
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "directory"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "imap"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "jmap"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -228,6 +228,25 @@ impl JMAP {
|
|||
Err(err) => err.into_http_response(),
|
||||
};
|
||||
}
|
||||
("acme-challenge", &Method::GET) if self.core.has_acme_http_providers() => {
|
||||
if let Some(token) = path.next() {
|
||||
return match self
|
||||
.core
|
||||
.storage
|
||||
.lookup
|
||||
.key_get::<String>(format!("acme:{token}").into_bytes())
|
||||
.await
|
||||
{
|
||||
Ok(Some(proof)) => Resource {
|
||||
content_type: "text/plain",
|
||||
contents: proof.into_bytes(),
|
||||
}
|
||||
.into_http_response(),
|
||||
Ok(None) => RequestError::not_found().into_http_response(),
|
||||
Err(err) => err.into_http_response(),
|
||||
};
|
||||
}
|
||||
}
|
||||
(_, &Method::OPTIONS) => {
|
||||
return ().into_http_response();
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ homepage = "https://stalw.art"
|
|||
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
|
||||
categories = ["email"]
|
||||
license = "AGPL-3.0-only"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "nlp"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
|
|||
keywords = ["smtp", "email", "mail", "server"]
|
||||
categories = ["email"]
|
||||
license = "AGPL-3.0-only"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "store"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "utils"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -35,12 +35,60 @@ pub struct PublicSuffix {
|
|||
pub wildcards: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
pub enum DomainPart {
|
||||
Sld,
|
||||
Tld,
|
||||
Host,
|
||||
}
|
||||
|
||||
impl PublicSuffix {
|
||||
pub fn contains(&self, suffix: &str) -> bool {
|
||||
self.suffixes.contains(suffix)
|
||||
|| (!self.exceptions.contains(suffix)
|
||||
&& self.wildcards.iter().any(|w| suffix.ends_with(w)))
|
||||
}
|
||||
|
||||
pub fn domain_part(&self, domain: &str, part: DomainPart) -> Option<String> {
|
||||
let d = domain.trim().to_lowercase();
|
||||
let mut seen_dot = false;
|
||||
for (pos, ch) in d.as_bytes().iter().enumerate().rev() {
|
||||
if *ch == b'.' {
|
||||
if seen_dot {
|
||||
let maybe_domain =
|
||||
std::str::from_utf8(&d.as_bytes()[pos + 1..]).unwrap_or_default();
|
||||
if !self.contains(maybe_domain) {
|
||||
return if part == DomainPart::Sld {
|
||||
maybe_domain
|
||||
} else {
|
||||
std::str::from_utf8(&d.as_bytes()[..pos]).unwrap_or_default()
|
||||
}
|
||||
.to_string()
|
||||
.into();
|
||||
}
|
||||
} else if part == DomainPart::Tld {
|
||||
return std::str::from_utf8(&d.as_bytes()[pos + 1..])
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
.into();
|
||||
} else {
|
||||
seen_dot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if seen_dot {
|
||||
if part == DomainPart::Sld {
|
||||
d.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if part == DomainPart::Host {
|
||||
d.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for PublicSuffix {
|
||||
|
|
68
tests/resources/acme/config.toml
Normal file
68
tests/resources/acme/config.toml
Normal file
|
@ -0,0 +1,68 @@
|
|||
acme.pebble.contact = "postmaster@example.org"
|
||||
acme.pebble.directory = "https://localhost:14000/dir"
|
||||
#acme.pebble.domains = "mail.example.org"
|
||||
acme.pebble.renew-before = "30d"
|
||||
|
||||
acme.pebble.challenge = "tls-alpn-01"
|
||||
#acme.pebble.challenge = "http-01"
|
||||
#acme.pebble.challenge = "dns-01"
|
||||
|
||||
acme.pebble.domains = "*.example.org"
|
||||
acme.pebble.provider = "cloudflare"
|
||||
acme.pebble.secret = "<KEY>"
|
||||
|
||||
authentication.fallback-admin.secret = "secret"
|
||||
authentication.fallback-admin.user = "admin"
|
||||
config.local-keys.0 = "*"
|
||||
directory.internal.store = "rocksdb"
|
||||
directory.internal.type = "internal"
|
||||
lookup.default.hostname = "mail.example.org"
|
||||
lookup.default.domain = "example.org"
|
||||
oauth.key = "0Wn7rO4UdmBoE8mp3cDcD9Qlpz3na74z7fGRoSuq8fVsGPelLl3KrHomBN8h2biA"
|
||||
queue.quota.size.enable = true
|
||||
queue.quota.size.messages = 100000
|
||||
queue.quota.size.size = 10737418240
|
||||
queue.throttle.rcpt.concurrency = 5
|
||||
queue.throttle.rcpt.enable = true
|
||||
queue.throttle.rcpt.key = "rcpt_domain"
|
||||
report.analysis.addresses = "postmaster@*"
|
||||
server.http.permissive-cors = true
|
||||
server.listener.http.bind = "[::]:5002"
|
||||
server.listener.http.protocol = "http"
|
||||
server.listener.https.bind = "[::]:5001"
|
||||
server.listener.https.protocol = "http"
|
||||
server.listener.https.tls.implicit = true
|
||||
server.listener.imap.bind = "[::]:143"
|
||||
server.listener.imap.protocol = "imap"
|
||||
server.listener.imaptls.bind = "[::]:993"
|
||||
server.listener.imaptls.protocol = "imap"
|
||||
server.listener.imaptls.tls.implicit = true
|
||||
server.listener.sieve.bind = "[::]:4190"
|
||||
server.listener.sieve.protocol = "managesieve"
|
||||
server.listener.smtp.bind = "[::]:25"
|
||||
server.listener.smtp.protocol = "smtp"
|
||||
server.listener.submission.bind = "[::]:587"
|
||||
server.listener.submission.protocol = "smtp"
|
||||
server.listener.submissions.bind = "[::]:465"
|
||||
server.listener.submissions.protocol = "smtp"
|
||||
server.listener.submissions.tls.implicit = true
|
||||
session.throttle.ip.concurrency = 5
|
||||
session.throttle.ip.enable = true
|
||||
session.throttle.ip.key = "remote_ip"
|
||||
session.throttle.sender.enable = true
|
||||
session.throttle.sender.key.0 = "sender_domain"
|
||||
session.throttle.sender.key.1 = "rcpt"
|
||||
session.throttle.sender.rate = "25/1h"
|
||||
storage.blob = "rocksdb"
|
||||
storage.data = "rocksdb"
|
||||
storage.directory = "internal"
|
||||
storage.fts = "rocksdb"
|
||||
storage.lookup = "rocksdb"
|
||||
store.rocksdb.compression = "lz4"
|
||||
store.rocksdb.path = "/tmp/stalwart-temp-data"
|
||||
store.rocksdb.type = "rocksdb"
|
||||
tracer.stdout.ansi = true
|
||||
tracer.stdout.enable = true
|
||||
tracer.stdout.level = "trace"
|
||||
tracer.stdout.type = "stdout"
|
||||
version.spam-filter = 1.0
|
|
@ -1,13 +1,14 @@
|
|||
# docker-compose -f docker-compose-pebble.yaml up
|
||||
# curl --request POST --data '{"ip":"192.168.5.2"}' http://localhost:8055/set-default-ipv4
|
||||
# HTTPS port should be 5001
|
||||
# HTTP port should be 5002
|
||||
# Directory https://localhost:14000/dir
|
||||
|
||||
version: '3'
|
||||
services:
|
||||
pebble:
|
||||
image: letsencrypt/pebble:latest
|
||||
command: pebble -config /test/config/pebble-config.json -strict -dnsserver 10.30.50.3:8053
|
||||
command: pebble -config /test/config/pebble-config.json -strict -dnsserver 8.8.8.8:53 #-dnsserver 10.30.50.3:8053
|
||||
ports:
|
||||
- 14000:14000 # HTTPS ACME API
|
||||
- 15000:15000 # HTTPS Management API
|
9
tests/resources/acme/test_acme.sh
Normal file
9
tests/resources/acme/test_acme.sh
Normal file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
|
||||
rm -Rf /tmp/stalwart-temp-data
|
||||
mkdir -p /tmp/stalwart-temp-data
|
||||
cp ./tests/resources/acme/config.toml /tmp/stalwart-temp-data/config.toml
|
||||
|
||||
curl --request POST --data '{"ip":"192.168.5.2"}' http://localhost:8055/set-default-ipv4
|
||||
|
||||
cargo run -p mail-server --no-default-features --features "sqlite foundationdb postgres mysql rocks elastic s3 redis" -- --config=/tmp/stalwart-temp-data/config.toml
|
|
@ -308,7 +308,7 @@ impl common::listener::SessionManager for SessionManager {
|
|||
session
|
||||
.instance
|
||||
.acceptor
|
||||
.accept(session.stream, false)
|
||||
.accept(session.stream, None)
|
||||
.await
|
||||
.unwrap_tls()
|
||||
.await
|
||||
|
|
|
@ -368,8 +368,7 @@ impl TestServerInstance for ServerInstance {
|
|||
id: "smtp".to_string(),
|
||||
protocol: ServerProtocol::Smtp,
|
||||
acceptor: TcpAcceptor::Tls {
|
||||
acme_config: tls_config.clone(),
|
||||
default_config: tls_config.clone(),
|
||||
config: tls_config.clone(),
|
||||
acceptor: TlsAcceptor::from(tls_config),
|
||||
implicit: false,
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue