mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-09-13 07:24:15 +08:00
This commit is contained in:
parent
0693253dff
commit
d8a73cd0e4
30 changed files with 250 additions and 85 deletions
114
Cargo.lock
generated
114
Cargo.lock
generated
|
@ -480,6 +480,12 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base32"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
@ -632,7 +638,7 @@ dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"constant_time_eq",
|
"constant_time_eq 0.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1116,6 +1122,12 @@ version = "0.2.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6051f239ecec86fde3410901ab7860d458d160371533842974fc61f96d15879b"
|
checksum = "6051f239ecec86fde3410901ab7860d458d160371533842974fc61f96d15879b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "constant_time_eq"
|
name = "constant_time_eq"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
@ -1401,11 +1413,12 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "5.5.3"
|
version = "6.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
"hashbrown 0.14.5",
|
"hashbrown 0.14.5",
|
||||||
"lock_api",
|
"lock_api",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
@ -1427,18 +1440,6 @@ dependencies = [
|
||||||
"generic-array 0.14.7",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deadpool"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"deadpool-runtime",
|
|
||||||
"num_cpus",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deadpool"
|
name = "deadpool"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
@ -1457,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ab8a4ea925ce79678034870834602a2980f4b88c09e97feb266496dbb4493d2"
|
checksum = "1ab8a4ea925ce79678034870834602a2980f4b88c09e97feb266496dbb4493d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"deadpool 0.12.1",
|
"deadpool",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
"tokio-postgres",
|
||||||
|
@ -1617,8 +1618,7 @@ version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash 0.8.11",
|
"ahash 0.8.11",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"deadpool",
|
||||||
"deadpool 0.10.0",
|
|
||||||
"futures",
|
"futures",
|
||||||
"jmap_proto",
|
"jmap_proto",
|
||||||
"ldap3",
|
"ldap3",
|
||||||
|
@ -1642,6 +1642,7 @@ dependencies = [
|
||||||
"store",
|
"store",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.25.0",
|
"tokio-rustls 0.25.0",
|
||||||
|
"totp-rs",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utils",
|
"utils",
|
||||||
]
|
]
|
||||||
|
@ -3158,7 +3159,7 @@ dependencies = [
|
||||||
"nlp",
|
"nlp",
|
||||||
"p256",
|
"p256",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"quick-xml 0.31.0",
|
"quick-xml 0.34.0",
|
||||||
"rand",
|
"rand",
|
||||||
"rasn",
|
"rasn",
|
||||||
"rasn-cms",
|
"rasn-cms",
|
||||||
|
@ -3176,9 +3177,9 @@ dependencies = [
|
||||||
"smtp-proto",
|
"smtp-proto",
|
||||||
"store",
|
"store",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.23.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tungstenite",
|
"tungstenite 0.23.0",
|
||||||
"utils",
|
"utils",
|
||||||
"x509-parser 0.16.0",
|
"x509-parser 0.16.0",
|
||||||
]
|
]
|
||||||
|
@ -3202,7 +3203,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.21.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -4645,15 +4646,6 @@ version = "1.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.31.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.32.0"
|
version = "0.32.0"
|
||||||
|
@ -4664,6 +4656,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.2"
|
version = "0.11.2"
|
||||||
|
@ -6131,7 +6132,7 @@ dependencies = [
|
||||||
"bitpacking",
|
"bitpacking",
|
||||||
"blake3",
|
"blake3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"deadpool 0.12.1",
|
"deadpool",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"elasticsearch",
|
"elasticsearch",
|
||||||
"farmhash",
|
"farmhash",
|
||||||
|
@ -6613,10 +6614,22 @@ dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.25.0",
|
"tokio-rustls 0.25.0",
|
||||||
"tungstenite",
|
"tungstenite 0.21.0",
|
||||||
"webpki-roots 0.26.3",
|
"webpki-roots 0.26.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite 0.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.11"
|
version = "0.7.11"
|
||||||
|
@ -6674,6 +6687,21 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "totp-rs"
|
||||||
|
version = "5.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c4ae9724c5888c0417d2396037ed3b60665925624766416e3e342b6ba5dbd3f"
|
||||||
|
dependencies = [
|
||||||
|
"base32",
|
||||||
|
"constant_time_eq 0.2.6",
|
||||||
|
"hmac 0.12.1",
|
||||||
|
"sha1",
|
||||||
|
"sha2 0.10.8",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
|
@ -6847,6 +6875,24 @@ dependencies = [
|
||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http 1.1.0",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "twofish"
|
name = "twofish"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
@ -7700,7 +7746,7 @@ dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"constant_time_eq",
|
"constant_time_eq 0.3.0",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
"deflate64",
|
"deflate64",
|
||||||
|
|
|
@ -119,7 +119,7 @@ All documentation is available at [stalw.art/docs/get-started](https://stalw.art
|
||||||
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
|
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
|
||||||
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
||||||
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
||||||
Additionally you may become a sponsor to obtain priority support from Stalwart Labs Ltd.
|
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs Ltd.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
name = "stalwart-cli"
|
name = "stalwart-cli"
|
||||||
description = "Stalwart Mail Server CLI"
|
description = "Stalwart Mail Server CLI"
|
||||||
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
|
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
repository = "https://github.com/stalwartlabs/cli"
|
repository = "https://github.com/stalwartlabs/cli"
|
||||||
homepage = "https://github.com/stalwartlabs/cli"
|
homepage = "https://github.com/stalwartlabs/cli"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
|
@ -66,9 +66,9 @@ impl AccountCommands {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(password) = password {
|
if let Some(password) = password {
|
||||||
changes.push(PrincipalUpdate::set(
|
changes.push(PrincipalUpdate::add_item(
|
||||||
PrincipalField::Secrets,
|
PrincipalField::Secrets,
|
||||||
PrincipalValue::StringList(vec![sha512_crypt::hash(password).unwrap()]),
|
PrincipalValue::String(sha512_crypt::hash(password).unwrap()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(description) = description {
|
if let Some(description) = description {
|
||||||
|
|
|
@ -20,7 +20,9 @@ use config::{
|
||||||
storage::Storage,
|
storage::Storage,
|
||||||
tracers::{OtelTracer, Tracer, Tracers},
|
tracers::{OtelTracer, Tracer, Tracers},
|
||||||
};
|
};
|
||||||
use directory::{core::secret::verify_secret_hash, Directory, Principal, QueryBy, Type};
|
use directory::{
|
||||||
|
core::secret::verify_secret_hash, Directory, DirectoryError, Principal, QueryBy, Type,
|
||||||
|
};
|
||||||
use expr::if_block::IfBlock;
|
use expr::if_block::IfBlock;
|
||||||
use listener::{
|
use listener::{
|
||||||
blocked::{AllowedIps, BlockedIps},
|
blocked::{AllowedIps, BlockedIps},
|
||||||
|
@ -94,6 +96,7 @@ pub enum AuthResult<T> {
|
||||||
Success(T),
|
Success(T),
|
||||||
Failure,
|
Failure,
|
||||||
Banned,
|
Banned,
|
||||||
|
MissingTotp,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -267,6 +270,7 @@ impl Core {
|
||||||
return Ok(AuthResult::Success(principal));
|
return Ok(AuthResult::Success(principal));
|
||||||
}
|
}
|
||||||
Ok(None) => Ok(()),
|
Ok(None) => Ok(()),
|
||||||
|
Err(DirectoryError::MissingTotpCode) => return Ok(AuthResult::MissingTotp),
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,9 +17,8 @@ tokio-rustls = { version = "0.25.0"}
|
||||||
rustls = "0.22"
|
rustls = "0.22"
|
||||||
rustls-pki-types = { version = "1" }
|
rustls-pki-types = { version = "1" }
|
||||||
ldap3 = { version = "0.11.1", default-features = false, features = ["tls-rustls"] }
|
ldap3 = { version = "0.11.1", default-features = false, features = ["tls-rustls"] }
|
||||||
deadpool = { version = "0.10.0", features = ["managed", "rt_tokio_1"] }
|
deadpool = { version = "0.12", features = ["managed", "rt_tokio_1"] }
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
async-trait = "0.1.68"
|
|
||||||
ahash = { version = "0.8" }
|
ahash = { version = "0.8" }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
lru-cache = "0.1.2"
|
lru-cache = "0.1.2"
|
||||||
|
@ -34,6 +33,7 @@ md5 = "0.7.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
regex = "1.7.0"
|
regex = "1.7.0"
|
||||||
serde = { version = "1.0", features = ["derive"]}
|
serde = { version = "1.0", features = ["derive"]}
|
||||||
|
totp-rs = { version = "5.5.1", features = ["otpauth"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.23", features = ["full"] }
|
tokio = { version = "1.23", features = ["full"] }
|
||||||
|
|
|
@ -6,14 +6,12 @@
|
||||||
|
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use deadpool::managed;
|
use deadpool::managed;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio_rustls::client::TlsStream;
|
use tokio_rustls::client::TlsStream;
|
||||||
|
|
||||||
use super::{ImapClient, ImapConnectionManager, ImapError};
|
use super::{ImapClient, ImapConnectionManager, ImapError};
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl managed::Manager for ImapConnectionManager {
|
impl managed::Manager for ImapConnectionManager {
|
||||||
type Type = ImapClient<TlsStream<TcpStream>>;
|
type Type = ImapClient<TlsStream<TcpStream>>;
|
||||||
type Error = ImapError;
|
type Error = ImapError;
|
||||||
|
|
|
@ -59,7 +59,7 @@ impl DirectoryStore for Store {
|
||||||
.await?,
|
.await?,
|
||||||
secret,
|
secret,
|
||||||
) {
|
) {
|
||||||
(Some(mut principal), Some(secret)) if principal.verify_secret(secret).await => {
|
(Some(mut principal), Some(secret)) if principal.verify_secret(secret).await? => {
|
||||||
if return_member_of {
|
if return_member_of {
|
||||||
principal.member_of = self.get_member_of(principal.id).await?;
|
principal.member_of = self.get_member_of(principal.id).await?;
|
||||||
}
|
}
|
||||||
|
|
|
@ -414,6 +414,35 @@ impl ManageDirectory for Store {
|
||||||
) => {
|
) => {
|
||||||
principal.inner.secrets = secrets;
|
principal.inner.secrets = secrets;
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
PrincipalAction::AddItem,
|
||||||
|
PrincipalField::Secrets,
|
||||||
|
PrincipalValue::String(secret),
|
||||||
|
) => {
|
||||||
|
let mut do_add = true;
|
||||||
|
let mut new_secrets = Vec::with_capacity(principal.inner.secrets.len() + 1);
|
||||||
|
for prev_secret in principal.inner.secrets {
|
||||||
|
if prev_secret == secret {
|
||||||
|
do_add = false;
|
||||||
|
} else if prev_secret.starts_with("otpauth://")
|
||||||
|
|| prev_secret == "$disabled$"
|
||||||
|
|| prev_secret.starts_with("$app$")
|
||||||
|
{
|
||||||
|
new_secrets.push(prev_secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if do_add {
|
||||||
|
new_secrets.push(secret);
|
||||||
|
}
|
||||||
|
principal.inner.secrets = new_secrets;
|
||||||
|
}
|
||||||
|
(
|
||||||
|
PrincipalAction::RemoveItem,
|
||||||
|
PrincipalField::Secrets,
|
||||||
|
PrincipalValue::String(secret),
|
||||||
|
) => {
|
||||||
|
principal.inner.secrets.retain(|v| *v != secret);
|
||||||
|
}
|
||||||
(
|
(
|
||||||
PrincipalAction::Set,
|
PrincipalAction::Set,
|
||||||
PrincipalField::Description,
|
PrincipalField::Description,
|
||||||
|
|
|
@ -87,7 +87,7 @@ impl LdapDirectory {
|
||||||
.find_principal(&mut conn, &self.mappings.filter_name.build(username))
|
.find_principal(&mut conn, &self.mappings.filter_name.build(username))
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
if principal.verify_secret(secret).await {
|
if principal.verify_secret(secret).await? {
|
||||||
principal
|
principal
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|
|
@ -4,13 +4,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use deadpool::managed;
|
use deadpool::managed;
|
||||||
use ldap3::{exop::WhoAmI, Ldap, LdapConnAsync, LdapError};
|
use ldap3::{exop::WhoAmI, Ldap, LdapConnAsync, LdapError};
|
||||||
|
|
||||||
use super::LdapConnectionManager;
|
use super::LdapConnectionManager;
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl managed::Manager for LdapConnectionManager {
|
impl managed::Manager for LdapConnectionManager {
|
||||||
type Type = Ldap;
|
type Type = Ldap;
|
||||||
type Error = LdapError;
|
type Error = LdapError;
|
||||||
|
|
|
@ -36,7 +36,7 @@ impl MemoryDirectory {
|
||||||
|
|
||||||
for principal in &self.principals {
|
for principal in &self.principals {
|
||||||
if &principal.name == username {
|
if &principal.name == username {
|
||||||
return if principal.verify_secret(secret).await {
|
return if principal.verify_secret(secret).await? {
|
||||||
Ok(Some(principal.clone()))
|
Ok(Some(principal.clone()))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|
|
@ -4,13 +4,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use deadpool::managed;
|
use deadpool::managed;
|
||||||
use mail_send::{smtp::AssertReply, Error};
|
use mail_send::{smtp::AssertReply, Error};
|
||||||
|
|
||||||
use super::{SmtpClient, SmtpConnectionManager};
|
use super::{SmtpClient, SmtpConnectionManager};
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl managed::Manager for SmtpConnectionManager {
|
impl managed::Manager for SmtpConnectionManager {
|
||||||
type Type = SmtpClient;
|
type Type = SmtpClient;
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
@ -45,8 +43,10 @@ impl managed::Manager for SmtpConnectionManager {
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(managed::RecycleError::Backend)
|
.map_err(managed::RecycleError::Backend)
|
||||||
} else {
|
} else {
|
||||||
Err(managed::RecycleError::StaticMessage(
|
Err(managed::RecycleError::Message(
|
||||||
"No longer valid: Too many authentication failures",
|
"No longer valid: Too many authentication failures"
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ impl SqlDirectory {
|
||||||
|
|
||||||
// Validate password
|
// Validate password
|
||||||
if let Some(secret) = secret {
|
if let Some(secret) = secret {
|
||||||
if !principal.verify_secret(secret).await {
|
if !principal.verify_secret(secret).await? {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
context = "directory",
|
context = "directory",
|
||||||
event = "invalid_password",
|
event = "invalid_password",
|
||||||
|
|
|
@ -16,17 +16,51 @@ use sha1::Sha1;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use sha2::Sha512;
|
use sha2::Sha512;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
use totp_rs::TOTP;
|
||||||
|
|
||||||
|
use crate::DirectoryError;
|
||||||
use crate::Principal;
|
use crate::Principal;
|
||||||
|
|
||||||
impl<T: serde::Serialize + serde::de::DeserializeOwned> Principal<T> {
|
impl<T: serde::Serialize + serde::de::DeserializeOwned> Principal<T> {
|
||||||
pub async fn verify_secret(&self, secret: &str) -> bool {
|
pub async fn verify_secret(&self, mut code: &str) -> crate::Result<bool> {
|
||||||
for hashed_secret in &self.secrets {
|
let mut totp_token = None;
|
||||||
if verify_secret_hash(hashed_secret, secret).await {
|
|
||||||
return true;
|
for secret in &self.secrets {
|
||||||
|
let mut secret = secret.as_str();
|
||||||
|
|
||||||
|
if secret == "$disabled$" {
|
||||||
|
return Ok(false);
|
||||||
|
} else if secret.starts_with("otpauth://") && totp_token.is_none() {
|
||||||
|
let totp_token = if let Some(totp_token) = totp_token {
|
||||||
|
totp_token
|
||||||
|
} else {
|
||||||
|
let (_code, _totp_token) = code
|
||||||
|
.rsplit_once('$')
|
||||||
|
.filter(|(c, t)| !c.is_empty() && !t.is_empty())
|
||||||
|
.ok_or(DirectoryError::MissingTotpCode)?;
|
||||||
|
totp_token = Some(_totp_token);
|
||||||
|
code = _code;
|
||||||
|
_totp_token
|
||||||
|
};
|
||||||
|
if !TOTP::from_url(secret)
|
||||||
|
.map_err(DirectoryError::InvalidTotpUrl)?
|
||||||
|
.check_current(totp_token)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
} else if let Some((_, app_secret)) =
|
||||||
|
secret.strip_prefix("$app$").and_then(|s| s.split_once('$'))
|
||||||
|
{
|
||||||
|
secret = app_secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
if verify_secret_hash(secret, code).await {
|
||||||
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ use deadpool::managed::PoolError;
|
||||||
use ldap3::LdapError;
|
use ldap3::LdapError;
|
||||||
use mail_send::Credentials;
|
use mail_send::Credentials;
|
||||||
use store::Store;
|
use store::Store;
|
||||||
|
use totp_rs::TotpUrlError;
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
|
@ -81,6 +82,8 @@ pub enum DirectoryError {
|
||||||
Management(ManagementError),
|
Management(ManagementError),
|
||||||
TimedOut,
|
TimedOut,
|
||||||
Unsupported,
|
Unsupported,
|
||||||
|
InvalidTotpUrl(TotpUrlError),
|
||||||
|
MissingTotpCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
@ -309,6 +312,8 @@ impl Display for DirectoryError {
|
||||||
Self::Management(error) => write!(f, "Management error: {:?}", error),
|
Self::Management(error) => write!(f, "Management error: {:?}", error),
|
||||||
Self::TimedOut => write!(f, "Directory timed out"),
|
Self::TimedOut => write!(f, "Directory timed out"),
|
||||||
Self::Unsupported => write!(f, "Method not supported by directory"),
|
Self::Unsupported => write!(f, "Method not supported by directory"),
|
||||||
|
Self::InvalidTotpUrl(error) => write!(f, "Invalid TOTP URL: {}", error),
|
||||||
|
Self::MissingTotpCode => write!(f, "Missing TOTP code"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
ahash = { version = "0.8" }
|
ahash = { version = "0.8" }
|
||||||
md5 = "0.7.0"
|
md5 = "0.7.0"
|
||||||
dashmap = "5.4"
|
dashmap = "6.0"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
|
@ -101,6 +101,7 @@ impl<T: SessionStream> Session<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
|
let mut is_totp_error = false;
|
||||||
let access_token = match credentials {
|
let access_token = match credentials {
|
||||||
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
||||||
match self
|
match self
|
||||||
|
@ -110,6 +111,10 @@ impl<T: SessionStream> Session<T> {
|
||||||
{
|
{
|
||||||
AuthResult::Success(token) => Some(token),
|
AuthResult::Success(token) => Some(token),
|
||||||
AuthResult::Failure => None,
|
AuthResult::Failure => None,
|
||||||
|
AuthResult::MissingTotp => {
|
||||||
|
is_totp_error = true;
|
||||||
|
None
|
||||||
|
}
|
||||||
AuthResult::Banned => return Err(()),
|
AuthResult::Banned => return Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -174,10 +179,14 @@ impl<T: SessionStream> Session<T> {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
self.write_bytes(
|
self.write_bytes(
|
||||||
StatusResponse::no("Authentication failed")
|
StatusResponse::no(if is_totp_error {
|
||||||
.with_tag(tag)
|
"Missing TOTP code, try with 'secret$totp_code'."
|
||||||
.with_code(ResponseCode::AuthenticationFailed)
|
} else {
|
||||||
.into_bytes(),
|
"Authentication failed."
|
||||||
|
})
|
||||||
|
.with_tag(tag)
|
||||||
|
.with_code(ResponseCode::AuthenticationFailed)
|
||||||
|
.into_bytes(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
@ -39,10 +39,10 @@ hkdf = "0.12.3"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]}
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]}
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.23"
|
||||||
tungstenite = "0.21"
|
tungstenite = "0.23"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
dashmap = "5.4"
|
dashmap = "6.0"
|
||||||
aes = "0.8.3"
|
aes = "0.8.3"
|
||||||
cbc = { version = "0.1.2", features = ["alloc"] }
|
cbc = { version = "0.1.2", features = ["alloc"] }
|
||||||
sequoia-openpgp = { version = "1.16", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] }
|
sequoia-openpgp = { version = "1.16", default-features = false, features = ["crypto-rust", "allow-experimental-crypto", "allow-variable-time-crypto"] }
|
||||||
|
@ -56,7 +56,7 @@ async-trait = "0.1.68"
|
||||||
lz4_flex = { version = "0.11", default-features = false }
|
lz4_flex = { version = "0.11", default-features = false }
|
||||||
rev_lines = "0.3.0"
|
rev_lines = "0.3.0"
|
||||||
x509-parser = "0.16.0"
|
x509-parser = "0.16.0"
|
||||||
quick-xml = "0.31"
|
quick-xml = "0.34"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
test_mode = []
|
test_mode = []
|
||||||
|
|
|
@ -221,7 +221,7 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut reader = Reader::from_reader(bytes);
|
let mut reader = Reader::from_reader(bytes);
|
||||||
reader.trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
let mut buf = Vec::with_capacity(128);
|
let mut buf = Vec::with_capacity(128);
|
||||||
|
|
||||||
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
|
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
|
||||||
|
|
|
@ -331,9 +331,9 @@ impl JMAP {
|
||||||
.data
|
.data
|
||||||
.update_account(
|
.update_account(
|
||||||
QueryBy::Id(access_token.primary_id()),
|
QueryBy::Id(access_token.primary_id()),
|
||||||
vec![PrincipalUpdate::set(
|
vec![PrincipalUpdate::add_item(
|
||||||
PrincipalField::Secrets,
|
PrincipalField::Secrets,
|
||||||
PrincipalValue::StringList(vec![new_password]),
|
PrincipalValue::String(new_password),
|
||||||
)],
|
)],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -46,13 +46,22 @@ impl JMAP {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
if let AuthResult::Success(access_token) = self
|
match self
|
||||||
.authenticate_plain(&account, &secret, remote_ip, ServerProtocol::Http)
|
.authenticate_plain(&account, &secret, remote_ip, ServerProtocol::Http)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Some(access_token)
|
AuthResult::Success(access_token) => Some(access_token),
|
||||||
} else {
|
AuthResult::MissingTotp => {
|
||||||
None
|
return Err(RequestError::blank(
|
||||||
|
401,
|
||||||
|
"TOTP code required",
|
||||||
|
concat!(
|
||||||
|
"A TOTP code is required to authenticate this account. ",
|
||||||
|
"Try authenticating again using 'secret$totp_token'."
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
@ -161,6 +170,7 @@ impl JMAP {
|
||||||
AuthResult::Failure
|
AuthResult::Failure
|
||||||
}
|
}
|
||||||
Ok(AuthResult::Banned) => AuthResult::Banned,
|
Ok(AuthResult::Banned) => AuthResult::Banned,
|
||||||
|
Ok(AuthResult::MissingTotp) => AuthResult::MissingTotp,
|
||||||
Err(_) => AuthResult::Failure,
|
Err(_) => AuthResult::Failure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/jmap-server"
|
||||||
homepage = "https://stalw.art"
|
homepage = "https://stalw.art"
|
||||||
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
|
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
|
||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
|
@ -79,6 +79,7 @@ impl<T: SessionStream> Session<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
|
let mut is_totp_error = false;
|
||||||
let access_token = match credentials {
|
let access_token = match credentials {
|
||||||
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
||||||
match self
|
match self
|
||||||
|
@ -93,6 +94,10 @@ impl<T: SessionStream> Session<T> {
|
||||||
{
|
{
|
||||||
AuthResult::Success(token) => Some(token),
|
AuthResult::Success(token) => Some(token),
|
||||||
AuthResult::Failure => None,
|
AuthResult::Failure => None,
|
||||||
|
AuthResult::MissingTotp => {
|
||||||
|
is_totp_error = true;
|
||||||
|
None
|
||||||
|
}
|
||||||
AuthResult::Banned => {
|
AuthResult::Banned => {
|
||||||
return Err(StatusResponse::bye(
|
return Err(StatusResponse::bye(
|
||||||
"Too many authentication requests from this IP address.",
|
"Too many authentication requests from this IP address.",
|
||||||
|
@ -156,7 +161,12 @@ impl<T: SessionStream> Session<T> {
|
||||||
self.state = State::NotAuthenticated {
|
self.state = State::NotAuthenticated {
|
||||||
auth_failures: auth_failures + 1,
|
auth_failures: auth_failures + 1,
|
||||||
};
|
};
|
||||||
Ok(StatusResponse::no("Authentication failed").into_bytes())
|
Ok(StatusResponse::no(if is_totp_error {
|
||||||
|
"Missing TOTP code, try with 'secret$totp_code'."
|
||||||
|
} else {
|
||||||
|
"Authentication failed."
|
||||||
|
})
|
||||||
|
.into_bytes())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|
|
@ -83,6 +83,7 @@ impl<T: SessionStream> Session<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
|
let mut is_totp_error = false;
|
||||||
let access_token = match credentials {
|
let access_token = match credentials {
|
||||||
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
Credentials::Plain { username, secret } | Credentials::XOauth2 { username, secret } => {
|
||||||
match self
|
match self
|
||||||
|
@ -92,6 +93,10 @@ impl<T: SessionStream> Session<T> {
|
||||||
{
|
{
|
||||||
AuthResult::Success(token) => Some(token),
|
AuthResult::Success(token) => Some(token),
|
||||||
AuthResult::Failure => None,
|
AuthResult::Failure => None,
|
||||||
|
AuthResult::MissingTotp => {
|
||||||
|
is_totp_error = true;
|
||||||
|
None
|
||||||
|
}
|
||||||
AuthResult::Banned => {
|
AuthResult::Banned => {
|
||||||
self.write_err("Too many authentication requests from this IP address.")
|
self.write_err("Too many authentication requests from this IP address.")
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -164,7 +169,12 @@ impl<T: SessionStream> Session<T> {
|
||||||
auth_failures: auth_failures + 1,
|
auth_failures: auth_failures + 1,
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
};
|
};
|
||||||
self.write_err("Authentication failed").await
|
self.write_err(if is_totp_error {
|
||||||
|
"Missing TOTP code, try with 'secret$totp_code'."
|
||||||
|
} else {
|
||||||
|
"Authentication failed."
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|
|
@ -90,10 +90,8 @@ impl<T: SessionStream> Session<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.write_ok(format!(
|
self.write_ok("Stalwart POP3 bids you farewell (no messages deleted).")
|
||||||
"Stalwart POP3 bids you farewell (no messages deleted)."
|
.await?;
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.write_ok("Stalwart POP3 bids you farewell.").await?;
|
self.write_ok("Stalwart POP3 bids you farewell.").await?;
|
||||||
|
|
|
@ -6,7 +6,7 @@ repository = "https://github.com/stalwartlabs/smtp-server"
|
||||||
homepage = "https://stalw.art/smtp"
|
homepage = "https://stalw.art/smtp"
|
||||||
keywords = ["smtp", "email", "mail", "server"]
|
keywords = ["smtp", "email", "mail", "server"]
|
||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
@ -41,7 +41,7 @@ rayon = "1.5"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
regex = "1.7.0"
|
regex = "1.7.0"
|
||||||
dashmap = "5.4"
|
dashmap = "6.0"
|
||||||
blake3 = "1.3"
|
blake3 = "1.3"
|
||||||
lru-cache = "0.1.2"
|
lru-cache = "0.1.2"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
|
|
@ -217,6 +217,20 @@ impl<T: SessionStream> Session<T> {
|
||||||
|
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
|
Ok(AuthResult::MissingTotp) => {
|
||||||
|
tracing::debug!(
|
||||||
|
parent: &self.span,
|
||||||
|
context = "auth",
|
||||||
|
event = "authenticate",
|
||||||
|
result = "missing-totp"
|
||||||
|
);
|
||||||
|
|
||||||
|
return self
|
||||||
|
.auth_error(
|
||||||
|
b"334 5.7.8 Missing TOTP token, try with 'secret$totp_code'.\r\n",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -15,7 +15,7 @@ tracing = "0.1"
|
||||||
mail-auth = { version = "0.4" }
|
mail-auth = { version = "0.4" }
|
||||||
smtp-proto = { version = "0.1" }
|
smtp-proto = { version = "0.1" }
|
||||||
mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] }
|
mail-send = { version = "0.4", default-features = false, features = ["cram-md5"] }
|
||||||
dashmap = "5.4"
|
dashmap = "6.0"
|
||||||
ahash = { version = "0.8" }
|
ahash = { version = "0.8" }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
|
|
@ -55,7 +55,7 @@ hyper = { version = "1.0.1", features = ["server", "http1", "http2"] }
|
||||||
hyper-util = { version = "0.1.1", features = ["tokio"] }
|
hyper-util = { version = "0.1.1", features = ["tokio"] }
|
||||||
http-body-util = "0.1.0"
|
http-body-util = "0.1.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
dashmap = "5.4"
|
dashmap = "6.0"
|
||||||
ahash = { version = "0.8" }
|
ahash = { version = "0.8" }
|
||||||
serial_test = "3.0.0"
|
serial_test = "3.0.0"
|
||||||
num_cpus = "1.15.0"
|
num_cpus = "1.15.0"
|
||||||
|
|
Loading…
Add table
Reference in a new issue