This commit is contained in:
mdecimus
2024-11-07 16:32:54 +01:00
parent 45ad45f0b7
commit afef1552b8
21 changed files with 283 additions and 115 deletions

View File

@@ -2,6 +2,20 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.10.6] - 2024-11-07
To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin.
### Added
- Enterprise license automatic renewals before expiration (disabled by default).
- Allow to LDAP search using bind dn instead of auth bind connection when bind auth is enabled (#873)
### Changed
### Fixed
- Include `preferred_username` and `email` in OIDC `id_token`.
- Verify roles and permissions when creating or modifying accounts (#874)
## [0.10.5] - 2024-10-15
To upgrade replace the `stalwart-mail` binary.

28
Cargo.lock generated
View File

@@ -1051,7 +1051,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"aes-gcm-siv",
"ahash 0.8.11",
@@ -1667,7 +1667,7 @@ dependencies = [
[[package]]
name = "directory"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"argon2",
@@ -3010,7 +3010,7 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "imap"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"common",
@@ -3222,7 +3222,7 @@ dependencies = [
[[package]]
name = "jmap"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"aes",
"aes-gcm",
@@ -3303,7 +3303,7 @@ dependencies = [
[[package]]
name = "jmap_proto"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"fast-float",
@@ -3654,7 +3654,7 @@ dependencies = [
[[package]]
name = "mail-server"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"common",
"directory",
@@ -3673,7 +3673,7 @@ dependencies = [
[[package]]
name = "managesieve"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"bincode",
@@ -3951,7 +3951,7 @@ dependencies = [
[[package]]
name = "nlp"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"bincode",
@@ -4502,7 +4502,7 @@ dependencies = [
[[package]]
name = "pop3"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"common",
"directory",
@@ -6059,7 +6059,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "smtp"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"bincode",
@@ -6175,7 +6175,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stalwart-cli"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"clap",
"console",
@@ -6206,7 +6206,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "store"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"arc-swap",
@@ -6850,7 +6850,7 @@ dependencies = [
[[package]]
name = "trc"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"base64 0.22.1",
@@ -7093,7 +7093,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utils"
version = "0.10.5"
version = "0.10.6"
dependencies = [
"ahash 0.8.11",
"base64 0.22.1",

View File

@@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
repository = "https://github.com/stalwartlabs/cli"
homepage = "https://github.com/stalwartlabs/cli"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
readme = "README.md"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
resolver = "2"

View File

@@ -74,7 +74,8 @@ impl Core {
// SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
let enterprise = crate::enterprise::Enterprise::parse(config, &stores, &data).await;
let enterprise =
crate::enterprise::Enterprise::parse(config, &config_manager, &stores, &data).await;
#[cfg(feature = "enterprise")]
let is_enterprise = enterprise.is_some();

View File

@@ -17,30 +17,93 @@ use trc::{EventType, MetricType, TOTAL_EVENT_COUNT};
use utils::config::{
cron::SimpleCron,
utils::{AsKey, ParseValue},
Config,
Config, ConfigKey,
};
use crate::expr::{tokenizer::TokenMap, Expression};
use crate::{
expr::{tokenizer::TokenMap, Expression},
manager::config::ConfigManager,
};
use super::{
license::LicenseValidator, llm::AiApiConfig, AlertContent, AlertContentToken, AlertMethod,
license::LicenseKey, llm::AiApiConfig, AlertContent, AlertContentToken, AlertMethod,
Enterprise, MetricAlert, MetricStore, TraceStore, Undelete,
};
impl Enterprise {
pub async fn parse(config: &mut Config, stores: &Stores, data: &Store) -> Option<Self> {
let license = match LicenseValidator::new()
.try_parse(config.value("enterprise.license-key")?)
.and_then(|key| {
key.into_validated_key(config.value("lookup.default.hostname").unwrap_or_default())
}) {
Ok(key) => key,
pub async fn parse(
config: &mut Config,
config_manager: &ConfigManager,
stores: &Stores,
data: &Store,
) -> Option<Self> {
let server_hostname = config.value("lookup.default.hostname")?;
let mut update_license = None;
let license_result = match (
config.value("enterprise.license-key"),
config.value("enterprise.api-key"),
) {
(Some(license_key), maybe_api_key) => {
match (LicenseKey::new(license_key, server_hostname), maybe_api_key) {
(Ok(license), Some(api_key)) if license.is_near_expiration() => Ok(license
.try_renew(api_key)
.await
.map(|result| {
update_license = Some(result.encoded_key);
result.key
})
.unwrap_or(license)),
(Ok(license), None) => Ok(license),
(Err(_), Some(api_key)) => LicenseKey::invalid(server_hostname)
.try_renew(api_key)
.await
.map(|result| {
update_license = Some(result.encoded_key);
result.key
}),
(maybe_license, _) => maybe_license,
}
}
(None, Some(api_key)) => LicenseKey::invalid(server_hostname)
.try_renew(api_key)
.await
.map(|result| {
update_license = Some(result.encoded_key);
result.key
}),
(None, None) => {
return None;
}
};
// Report error
let license = match license_result {
Ok(license) => license,
Err(err) => {
config.new_build_warning("enterprise.license-key", err.to_string());
return None;
}
};
// Update the license if a new one was obtained
if let Some(license) = update_license {
config
.keys
.insert("enterprise.license-key".to_string(), license.clone());
if let Err(err) = config_manager
.set([ConfigKey {
key: "enterprise.license-key".to_string(),
value: license.to_string(),
}])
.await
{
trc::error!(err
.caused_by(trc::location!())
.details("Failed to update license key"));
}
}
match data
.count_principals(None, Type::Individual.into(), None)
.await

View File

@@ -21,21 +21,26 @@
use std::{
fmt::{Display, Formatter},
time::{Duration, SystemTime},
time::Duration,
};
use ring::signature::{Ed25519KeyPair, UnparsedPublicKey, ED25519};
use hyper::{header::AUTHORIZATION, HeaderMap};
use ring::signature::{UnparsedPublicKey, ED25519};
use base64::{engine::general_purpose::STANDARD, Engine};
use store::write::now;
use trc::ServerEvent;
use crate::manager::fetch_resource;
//const LICENSING_API: &str = "https://localhost:444/api/license/";
const LICENSING_API: &str = "https://license.stalw.art/api/license/";
const RENEW_THRESHOLD: u64 = 60 * 60 * 24 * 4; // 4 days
pub struct LicenseValidator {
public_key: UnparsedPublicKey<Vec<u8>>,
}
pub struct LicenseGenerator {
key_pair: Ed25519KeyPair,
}
#[derive(Debug, Clone)]
pub struct LicenseKey {
pub valid_to: u64,
@@ -47,11 +52,18 @@ pub struct LicenseKey {
#[derive(Debug)]
pub enum LicenseError {
Expired,
InvalidDomain { domain: String },
DomainMismatch { issued_to: String, current: String },
Parse,
Validation,
Decode,
InvalidParameters,
RenewalFailed { reason: String },
}
pub struct RenewedLicense {
pub key: LicenseKey,
pub encoded_key: String,
}
const U64_LEN: usize = std::mem::size_of::<u64>();
@@ -142,68 +154,102 @@ impl LicenseValidator {
}
impl LicenseKey {
pub fn new(domain: String, accounts: u32, expires_in: u64) -> Self {
let now = SystemTime::UNIX_EPOCH
.elapsed()
.unwrap_or_default()
.as_secs();
LicenseKey {
valid_from: now - 300,
valid_to: now + expires_in + 300,
domain,
accounts,
pub fn new(
license_key: impl AsRef<str>,
hostname: impl AsRef<str>,
) -> Result<Self, LicenseError> {
LicenseValidator::new()
.try_parse(license_key)
.and_then(|key| {
let local_domain = Self::base_domain(hostname)?;
let license_domain = Self::base_domain(&key.domain)?;
if local_domain == license_domain {
Ok(key)
} else {
Err(LicenseError::DomainMismatch {
issued_to: license_domain,
current: local_domain,
})
}
})
}
pub fn invalid(domain: impl AsRef<str>) -> Self {
LicenseKey {
valid_from: 0,
valid_to: 0,
domain: Self::base_domain(domain).unwrap_or_default(),
accounts: 0,
}
}
pub async fn try_renew(&self, api_key: &str) -> Result<RenewedLicense, LicenseError> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
format!("Bearer {api_key}")
.parse()
.map_err(|_| LicenseError::Validation)?,
);
trc::event!(
Server(ServerEvent::Licensing),
Details = "Attempting to renew Enterprise license from license.stalw.art",
);
match fetch_resource(&format!("{}{}", LICENSING_API, self.domain), headers.into())
.await
.and_then(|bytes| {
String::from_utf8(bytes)
.map_err(|_| String::from("Failed to UTF-8 decode server response"))
}) {
Ok(encoded_key) => match LicenseKey::new(&encoded_key, &self.domain) {
Ok(key) => Ok(RenewedLicense { key, encoded_key }),
Err(err) => {
trc::event!(
Server(ServerEvent::Licensing),
Details = "Failed to decode license renewal",
Reason = err.to_string(),
);
Err(err)
}
},
Err(err) => {
trc::event!(
Server(ServerEvent::Licensing),
Details = "Failed to renew Enterprise license",
Reason = err.clone(),
);
Err(LicenseError::RenewalFailed { reason: err })
}
}
}
pub fn is_near_expiration(&self) -> bool {
let now = now();
self.valid_to.saturating_sub(now) <= RENEW_THRESHOLD
}
pub fn expires_in(&self) -> Duration {
Duration::from_secs(
self.valid_to.saturating_sub(
SystemTime::UNIX_EPOCH
.elapsed()
.unwrap_or_default()
.as_secs(),
),
)
Duration::from_secs(self.valid_to.saturating_sub(now()))
}
pub fn renew_in(&self) -> Duration {
Duration::from_secs(self.valid_to.saturating_sub(now() + RENEW_THRESHOLD))
}
pub fn is_expired(&self) -> bool {
let now = SystemTime::UNIX_EPOCH
.elapsed()
.unwrap_or_default()
.as_secs();
let now = now();
now >= self.valid_to || now < self.valid_from
}
pub fn into_validated_key(self, hostname: impl AsRef<str>) -> Result<Self, LicenseError> {
let local_domain = psl::domain_str(hostname.as_ref()).unwrap_or("invalid-hostname");
let license_domain = psl::domain_str(&self.domain).expect("Invalid license domain");
if local_domain != license_domain {
Err(LicenseError::DomainMismatch {
issued_to: license_domain.to_string(),
current: local_domain.to_string(),
pub fn base_domain(domain: impl AsRef<str>) -> Result<String, LicenseError> {
let domain = domain.as_ref();
psl::domain_str(domain)
.map(|d| d.to_string())
.ok_or(LicenseError::InvalidDomain {
domain: domain.to_string(),
})
} else {
Ok(self)
}
}
}
impl LicenseGenerator {
pub fn new(pkcs8_der: impl AsRef<[u8]>) -> Self {
Self {
key_pair: Ed25519KeyPair::from_pkcs8(pkcs8_der.as_ref()).unwrap(),
}
}
pub fn generate(&self, key: LicenseKey) -> String {
let mut bytes = Vec::new();
bytes.extend_from_slice(&key.valid_from.to_le_bytes());
bytes.extend_from_slice(&key.valid_to.to_le_bytes());
bytes.extend_from_slice(&key.accounts.to_le_bytes());
bytes.extend_from_slice(&(key.domain.len() as u32).to_le_bytes());
bytes.extend_from_slice(key.domain.as_bytes());
bytes.extend_from_slice(self.key_pair.sign(&bytes).as_ref());
STANDARD.encode(&bytes)
}
}
@@ -221,6 +267,12 @@ impl Display for LicenseError {
"License issued to domain {issued_to:?} does not match {current:?}",
)
}
LicenseError::InvalidDomain { domain } => {
write!(f, "Invalid domain {domain:?}")
}
LicenseError::RenewalFailed { reason } => {
write!(f, "Failed to renew license: {reason}")
}
}
}
}

View File

@@ -6,6 +6,8 @@
use std::time::Duration;
use hyper::HeaderMap;
use crate::USER_AGENT;
use self::config::ConfigManager;
@@ -32,35 +34,57 @@ impl ConfigManager {
format!("Failed to fetch configuration key 'resource.{resource_id}': {err}",)
})?
{
fetch_resource(&url).await
fetch_resource(&url, None).await
} else {
match resource_id {
"spam-filter" => fetch_resource(DEFAULT_SPAMFILTER_URL).await,
"webadmin" => fetch_resource(DEFAULT_WEBADMIN_URL).await,
"spam-filter" => fetch_resource(DEFAULT_SPAMFILTER_URL, None).await,
"webadmin" => fetch_resource(DEFAULT_WEBADMIN_URL, None).await,
_ => Err(format!("Unknown resource: {resource_id}")),
}
}
}
}
async fn fetch_resource(url: &str) -> Result<Vec<u8>, String> {
pub async fn fetch_resource(url: &str, headers: Option<HeaderMap>) -> Result<Vec<u8>, String> {
if let Some(path) = url.strip_prefix("file://") {
tokio::fs::read(path)
.await
.map_err(|err| format!("Failed to read {path}: {err}"))
} else {
reqwest::Client::builder()
let response = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.danger_accept_invalid_certs(is_localhost_url(url))
.user_agent(USER_AGENT)
.build()
.unwrap_or_default()
.get(url)
.headers(headers.unwrap_or_default())
.send()
.await
.map_err(|err| format!("Failed to fetch {url}: {err}"))?
.map_err(|err| format!("Failed to fetch {url}: {err}"))?;
if response.status().is_success() {
response
.bytes()
.await
.map_err(|err| format!("Failed to fetch {url}: {err}"))
.map(|bytes| bytes.to_vec())
} else {
let code = response.status().canonical_reason().unwrap_or_default();
let reason = response.text().await.unwrap_or_default();
Err(format!(
"Failed to fetch {url}: Code: {code}, Details: {reason}",
))
}
}
}
pub fn is_localhost_url(url: &str) -> bool {
url.split_once("://")
.map(|(_, url)| url.split_once('/').map_or(url, |(host, _)| host))
.map_or(false, |host| {
let host = host.rsplit_once(':').map_or(host, |(host, _)| host);
host == "localhost" || host == "127.0.0.1" || host == "[::1]"
})
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "jmap_proto"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
resolver = "2"

View File

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

View File

@@ -50,7 +50,7 @@ enum ActionClass {
#[cfg(feature = "enterprise")]
AlertMetrics,
#[cfg(feature = "enterprise")]
ValidateLicense,
RenewLicense,
}
#[derive(Default)]
@@ -123,8 +123,8 @@ pub fn spawn_housekeeper(inner: Arc<Inner>, mut rx: mpsc::Receiver<HousekeeperEv
#[cfg(feature = "enterprise")]
if let Some(enterprise) = &server.core.enterprise {
queue.schedule(
Instant::now() + enterprise.license.expires_in(),
ActionClass::ValidateLicense,
Instant::now() + enterprise.license.renew_in(),
ActionClass::RenewLicense,
);
if let Some(metrics_store) = enterprise.metrics_store.as_ref() {
@@ -173,10 +173,10 @@ pub fn spawn_housekeeper(inner: Arc<Inner>, mut rx: mpsc::Receiver<HousekeeperEv
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
if let Some(enterprise) = &server.core.enterprise {
if !queue.has_action(&ActionClass::ValidateLicense) {
if !queue.has_action(&ActionClass::RenewLicense) {
queue.schedule(
Instant::now() + enterprise.license.expires_in(),
ActionClass::ValidateLicense,
Instant::now() + enterprise.license.renew_in(),
ActionClass::RenewLicense,
);
}
@@ -616,15 +616,29 @@ pub fn spawn_housekeeper(inner: Arc<Inner>, mut rx: mpsc::Receiver<HousekeeperEv
}
#[cfg(feature = "enterprise")]
ActionClass::ValidateLicense => {
ActionClass::RenewLicense => {
match server.reload().await {
Ok(result) => {
if let Some(new_core) = result.new_core {
if let Some(enterprise) = &new_core.enterprise {
let renew_in =
if enterprise.license.is_near_expiration() {
// Something went wrong during renewal, try again in 1 day or 1 hour,
// depending on the time left on the license
if enterprise.license.expires_in()
< Duration::from_secs(86400)
{
Duration::from_secs(3600)
} else {
Duration::from_secs(86400)
}
} else {
enterprise.license.renew_in()
};
queue.schedule(
Instant::now()
+ enterprise.license.expires_in(),
ActionClass::ValidateLicense,
Instant::now() + renew_in,
ActionClass::RenewLicense,
);
}

View File

@@ -7,7 +7,7 @@ homepage = "https://stalw.art"
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
resolver = "2"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "pop3"
version = "0.10.5"
version = "0.10.6"
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 OR LicenseRef-SEL"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
resolver = "2"

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "trc"
version = "0.10.5"
version = "0.10.6"
edition = "2021"
resolver = "2"

View File

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