Support for DNS-01 and HTTP-01 ACME challenges (closes #226)

This commit is contained in:
mdecimus 2024-04-17 09:55:19 +02:00
parent 0267f28156
commit 929d84468f
34 changed files with 764 additions and 246 deletions

89
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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"

View file

@ -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),

View file

@ -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>>,

View file

@ -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();

View file

@ -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)]

View file

@ -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)]

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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
}
}
}
}

View file

@ -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();

View file

@ -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"),

View file

@ -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(),
}
}

View file

@ -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()
})
}

View file

@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -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();
}

View file

@ -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"

View file

@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -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"

View file

@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -1,6 +1,6 @@
[package]
name = "utils"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
resolver = "2"

View file

@ -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 {

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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,
},