Installation binary first part.

This commit is contained in:
mdecimus 2023-07-09 11:06:37 +02:00
parent 705762c312
commit 843e61139a
27 changed files with 848 additions and 142 deletions

44
Cargo.lock generated
View file

@ -987,6 +987,18 @@ dependencies = [
"rusticata-macros",
]
[[package]]
name = "dialoguer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87"
dependencies = [
"console",
"shell-words",
"tempfile",
"zeroize",
]
[[package]]
name = "digest"
version = "0.9.0"
@ -2722,6 +2734,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "111.26.0+1.1.1u"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efc62c9f12b22b8f5208c23a7200a442b2e5999f8bdf80233852122b5a4f6f37"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.90"
@ -2730,6 +2751,7 @@ checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@ -3971,6 +3993,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shlex"
version = "1.1.0"
@ -4352,6 +4380,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "stalwart-install"
version = "0.3.0"
dependencies = [
"base64 0.21.2",
"dialoguer",
"indicatif",
"libc",
"openssl",
"pwhash",
"rand",
"reqwest",
"rpassword",
"rusqlite",
]
[[package]]
name = "static_assertions"
version = "1.1.0"

View file

@ -13,6 +13,7 @@ members = [
"crates/utils",
"crates/maybe-async",
"crates/cli",
"crates/install",
"tests",
]

View file

@ -42,7 +42,8 @@ impl ConfigDirectory for Config {
};
// Add queries/filters as lookups
if ["sql", "ldap"].contains(&protocol) {
let is_directory = ["sql", "ldap"].contains(&protocol);
if is_directory {
let name = if protocol == "sql" { "query" } else { "filter" };
for lookup_id in self.sub_keys(("directory", id, name)) {
config.lookups.insert(
@ -58,9 +59,8 @@ impl ConfigDirectory for Config {
}
// Parse lookups
let is_remote = protocol != "memory";
for lookup_id in self.sub_keys(("directory", id, "lookup")) {
let lookup = if is_remote {
let lookup = if is_directory {
Lookup::Directory {
directory: directory.clone(),
query: self
@ -120,6 +120,10 @@ impl DirectoryOptions {
Ok(DirectoryOptions {
catch_all: config.property_or_static((&key, "options.catch-all"), "false")?,
subaddressing: config.property_or_static((&key, "options.subaddressing"), "true")?,
superuser_group: config
.value("options.superuser-group")
.unwrap_or("superusers")
.to_string(),
})
}
}

View file

@ -21,7 +21,7 @@ impl ImapDirectory {
let address = config.value_require((&prefix, "address"))?;
let tls_implicit: bool = config.property_or_static((&prefix, "tls.implicit"), "false")?;
let port: u16 = config
.property_or_static((&prefix, "port"), if tls_implicit { "443" } else { "143" })?;
.property_or_static((&prefix, "port"), if tls_implicit { "993" } else { "143" })?;
let manager = ImapConnectionManager {
addr: format!("{address}:{port}"),

View file

@ -304,17 +304,25 @@ impl LdapDirectory {
for entry in rs {
'outer: for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_name.contains(&attr) {
if let Some(name) = value.first() {
if !name.is_empty() {
names.push(name.to_string());
if let Some(group) = value.first() {
if !group.is_empty() {
if !group
.eq_ignore_ascii_case(&self.opt.superuser_group)
{
names.push(group.to_string());
} else {
principal.typ = Type::Superuser;
}
break 'outer;
}
}
}
}
}
} else {
} else if !group.eq_ignore_ascii_case(&self.opt.superuser_group) {
names.push(group);
} else {
principal.typ = Type::Superuser;
}
}
principal.member_of = names;

View file

@ -33,6 +33,7 @@ pub enum Type {
Location,
#[default]
Other,
Superuser,
}
#[derive(Debug)]
@ -135,7 +136,7 @@ impl Debug for Lookup {
impl Type {
pub fn to_jmap(&self) -> &'static str {
match self {
Self::Individual => "individual",
Self::Individual | Self::Superuser => "individual",
Self::Group => "group",
Self::Resource => "resource",
Self::Location => "location",
@ -148,6 +149,7 @@ impl Type {
struct DirectoryOptions {
catch_all: bool,
subaddressing: bool,
superuser_group: String,
}
#[derive(Default, Clone, Debug)]

View file

@ -12,12 +12,26 @@ impl MemoryDirectory {
prefix: impl AsKey,
) -> utils::config::Result<Arc<dyn Directory>> {
let prefix = prefix.as_key();
let mut directory = MemoryDirectory::default();
let mut directory = MemoryDirectory {
opt: DirectoryOptions::from_config(config, prefix.clone())?,
..Default::default()
};
for lookup_id in config.sub_keys((prefix.as_str(), "users")) {
let name = config
.value_require((prefix.as_str(), "users", lookup_id, "name"))?
.to_string();
let mut typ = Type::Individual;
let mut member_of = Vec::new();
for (_, group) in config.values((prefix.as_str(), "users", lookup_id, "member-of")) {
if !group.eq_ignore_ascii_case(&directory.opt.superuser_group) {
member_of.push(group.to_string());
} else {
typ = Type::Superuser;
}
}
directory.principals.insert(
name.clone(),
Principal {
@ -26,17 +40,14 @@ impl MemoryDirectory {
.values((prefix.as_str(), "users", lookup_id, "secret"))
.map(|(_, v)| v.to_string())
.collect(),
typ: Type::Individual,
typ,
description: config
.value((prefix.as_str(), "users", lookup_id, "description"))
.map(|v| v.to_string()),
quota: config
.property((prefix.as_str(), "users", lookup_id, "quota"))?
.unwrap_or(0),
member_of: config
.values((prefix.as_str(), "users", lookup_id, "member-of"))
.map(|(_, v)| v.to_string())
.collect(),
member_of,
},
);
let mut emails = Vec::new();
@ -105,7 +116,6 @@ impl MemoryDirectory {
directory
.domains
.extend(config.parse_lookup_list((&prefix, "lookup.domains"))?);
directory.opt = DirectoryOptions::from_config(config, prefix)?;
Ok(Arc::new(directory))
}

View file

@ -39,6 +39,16 @@ impl Directory for SqlDirectory {
.fetch_all(&self.pool)
.await?;
// Check whether the user is a superuser
if let Some(idx) = principal
.member_of
.iter()
.position(|group| group.eq_ignore_ascii_case(&self.opt.superuser_group))
{
principal.member_of.swap_remove(idx);
principal.typ = Type::Superuser;
}
Ok(Some(principal))
} else {
Ok(None)

View file

@ -5,7 +5,6 @@ use imap_proto::{protocol::list::Attribute, StatusResponse};
use jmap::{
auth::{acl::EffectiveAcl, AccessToken},
mailbox::INBOX_ID,
SUPERUSER_ID,
};
use jmap_proto::{
object::Object,
@ -43,31 +42,29 @@ impl SessionData {
// Fetch shared mailboxes
for &account_id in access_token.shared_accounts(Collection::Mailbox) {
if account_id != SUPERUSER_ID {
match session
.fetch_account_mailboxes(
account_id,
format!(
"{}/{}",
session.imap.name_shared,
session
.jmap
.get_account_name(account_id)
.await
.unwrap_or_default()
.unwrap_or_else(|| Id::from(account_id).to_string())
)
.into(),
access_token,
match session
.fetch_account_mailboxes(
account_id,
format!(
"{}/{}",
session.imap.name_shared,
session
.jmap
.get_account_name(account_id)
.await
.unwrap_or_default()
.unwrap_or_else(|| Id::from(account_id).to_string())
)
.await
{
Ok(account_mailboxes) => {
mailboxes.push(account_mailboxes);
}
Err(_) => {
tracing::warn!(parent: &session.span, account_id = account_id, event = "error", "Failed to retrieve mailboxes.");
}
.into(),
access_token,
)
.await
{
Ok(account_mailboxes) => {
mailboxes.push(account_mailboxes);
}
Err(_) => {
tracing::warn!(parent: &session.span, account_id = account_id, event = "error", "Failed to retrieve mailboxes.");
}
}
}
@ -305,11 +302,10 @@ impl SessionData {
// Add new shared account ids
for account_id in has_access_to {
if account_id != SUPERUSER_ID
&& !new_accounts
.iter()
.skip(1)
.any(|m| m.account_id == account_id)
if !new_accounts
.iter()
.skip(1)
.any(|m| m.account_id == account_id)
{
tracing::debug!(parent: &self.span, "Adding shared account {}", account_id);
added_account_ids.push(account_id);

View file

@ -42,14 +42,16 @@ impl IMAP {
})
.into_bytes(),
rate_limiter: DashMap::with_capacity_and_hasher_and_shard_amount(
config.property("imap.rate-limit.size")?.unwrap_or(2048),
config
.property("imap.rate-limit.cache.size")?
.unwrap_or(2048),
RandomState::default(),
config
.property::<u64>("global.shared-map.shard")?
.unwrap_or(32)
.next_power_of_two() as usize,
),
rate_requests: config.property_or_static("imap.rate-limit", "1000/1m")?,
rate_requests: config.property_or_static("imap.rate-limit.requests", "2000/1m")?,
rate_concurrent: config.property("imap.rate-limit.concurrent")?.unwrap_or(4),
allow_plain_auth: config.property_or_static("imap.auth.allow-plain-text", "false")?,
}))

25
crates/install/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "stalwart-install"
description = "Stalwart Mail Server installer"
authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only"
repository = "https://github.com/stalwartlabs/mail-server"
homepage = "https://github.com/stalwartlabs/mail-server"
version = "0.3.0"
edition = "2021"
readme = "README.md"
resolver = "2"
[dependencies]
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"]}
rusqlite = { version = "0.29.0", features = ["bundled"] }
rpassword = "7.0"
indicatif = "0.17.0"
dialoguer = "0.10.4"
openssl = { version = "0.10.55", features = ["vendored"] }
base64 = "0.21.2"
pwhash = "1.0.0"
rand = "0.8.5"
[target.'cfg(not(target_env = "msvc"))'.dependencies]
libc = "0.2.147"

562
crates/install/src/main.rs Normal file
View file

@ -0,0 +1,562 @@
use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use base64::{engine::general_purpose, Engine};
use dialoguer::{console::Term, theme::ColorfulTheme, Input, Select};
use openssl::rsa::Rsa;
use pwhash::sha512_crypt;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use rusqlite::{Connection, OpenFlags};
const CFG_COMMON: &str = include_str!("../../../resources/config/common.toml");
const CFG_DIRECTORY: &str = include_str!("../../../resources/config/directory.toml");
const CFG_JMAP: &str = include_str!("../../../resources/config/jmap.toml");
const CFG_IMAP: &str = include_str!("../../../resources/config/imap.toml");
const CFG_SMTP: &str = include_str!("../../../resources/config/smtp.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Component {
AllInOne,
Jmap,
Imap,
Smtp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Backend {
SQLite,
FoundationDB,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Blob {
Local,
MinIO,
S3,
GCS,
Azure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Directory {
SQL,
LDAP,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SmtpDirectory {
SQL,
LDAP,
LMTP,
IMAP,
}
const DIRECTORIES: [[&str; 2]; 6] = [
["bin", ""],
["etc", "dkim"],
["data", "blobs"],
["logs", ""],
["queue", ""],
["reports", ""],
];
fn main() -> std::io::Result<()> {
let c = "fix";
/*#[cfg(not(target_env = "msvc"))]
unsafe {
if libc::getuid() != 0 {
eprintln!("This program must be run as root.");
std::process::exit(1);
}
}*/
println!("\nWelcome to the Stalwart mail server installer\n");
let component = select::<Component>(
"Which components would you like to install?",
&[
"All-in-one mail server (JMAP + IMAP + SMTP)",
"JMAP server",
"IMAP server",
"SMTP server",
],
Component::AllInOne,
)?;
let mut cfg_file = match component {
Component::AllInOne | Component::Imap => {
[CFG_COMMON, CFG_DIRECTORY, CFG_JMAP, CFG_IMAP, CFG_SMTP].join("\n")
}
Component::Jmap => [CFG_COMMON, CFG_DIRECTORY, CFG_JMAP, CFG_SMTP].join("\n"),
Component::Smtp => [CFG_COMMON, CFG_DIRECTORY, CFG_SMTP].join("\n"),
};
let directory = if component != Component::Smtp {
let backend = select::<Backend>(
"Which database engine would you like to use?",
&[
"SQLite (single node, replicated with Litestream)",
"FoundationDB (distributed and fault-tolerant)",
],
Backend::SQLite,
)?;
let blob = select::<Blob>(
"Where would you like to store e-mails and blobs?",
&[
"Local disk using Maildir",
"MinIO (or any S3-compatible object storage)",
"Amazon S3",
"Google Cloud Storage",
"Azure Blob Storage",
],
Blob::Local,
)?;
let directory = select::<Directory>(
"Do you already have a directory or database containing your accounts?",
&[
"Yes, it's an SQL database",
"Yes, it's an LDAP directory",
"No, create a new directory for me",
],
Directory::None,
)?;
cfg_file = cfg_file
.replace(
"__BLOB_STORE__",
match blob {
Blob::Local => "local",
_ => "s3",
},
)
.replace("__NEXT_HOP__", "local")
.replace(
"__DIRECTORY__",
match directory {
Directory::SQL | Directory::None => "sql",
Directory::LDAP => "ldap",
},
)
.replace(
"__SMTP_DIRECTORY__",
match directory {
Directory::SQL | Directory::None => "sql",
Directory::LDAP => "ldap",
},
)
.replace(
"__OAUTH_KEY__",
&thread_rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect::<String>(),
);
directory
} else {
let smtp_directory = select::<SmtpDirectory>(
"How should your local accounts be validated?",
&[
"SQL database",
"LDAP directory",
"LMTP server",
"IMAP server",
],
SmtpDirectory::LMTP,
)?;
cfg_file = cfg_file
.replace("__NEXT_HOP__", "lmtp")
.replace(
"__SMTP_DIRECTORY__",
match smtp_directory {
SmtpDirectory::SQL => "sql",
SmtpDirectory::LDAP => "ldap",
SmtpDirectory::LMTP => "lmtp",
SmtpDirectory::IMAP => "imap",
},
)
.replace(
"__DIRECTORY__",
match smtp_directory {
SmtpDirectory::SQL | SmtpDirectory::LMTP | SmtpDirectory::IMAP => "sql",
SmtpDirectory::LDAP => "ldap",
},
)
.replace("__NEXT_HOP__", "lmtp");
match smtp_directory {
SmtpDirectory::SQL => Directory::SQL,
SmtpDirectory::LDAP => Directory::LDAP,
SmtpDirectory::LMTP | SmtpDirectory::IMAP => Directory::None,
}
};
let base_path = PathBuf::from(input(
"Installation directory",
component.default_base_path(),
dir_create_if_missing,
)?);
create_directories(&base_path)?;
let domain = input(
"What is your main domain name? (you can add others later)",
"yourdomain.org",
not_empty,
)?
.trim()
.to_lowercase();
let hostname = input(
"What is your server hostname?",
&format!("mail.{domain}"),
not_empty,
)?
.trim()
.to_lowercase();
let cert_path = input(
&format!("Where is the TLS certificate for '{hostname}' located?"),
&format!("/etc/letsencrypt/live/{hostname}/fullchain.pem"),
file_exists,
)?;
let pk_path = input(
&format!("Where is the TLS private key for '{hostname}' located?"),
&format!("/etc/letsencrypt/live/{hostname}/privkey.pem"),
file_exists,
)?;
let dkim_instructions = generate_dkim(&base_path, &domain, &hostname)?;
let admin_password = if matches!(directory, Directory::None) {
create_auth_db(&base_path, &domain)?.into()
} else {
None
};
// Write config file
let cfg_path = base_path.join("etc").join("config.toml");
if cfg_path.exists() {
// Rename existing config file
let backup_path = base_path.join("etc").join(format!(
"config.toml.bak.{}",
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
));
fs::rename(&cfg_path, backup_path)?;
}
fs::write(
cfg_path,
cfg_file
.replace("__PATH__", base_path.to_str().unwrap())
.replace("__DOMAIN__", &domain)
.replace("__HOST__", &hostname)
.replace("__CERT_PATH__", &cert_path)
.replace("__PK_PATH__", &pk_path),
)?;
eprintln!("\n🎉 Installation completed!\n\n{dkim_instructions}\n");
if let Some(admin_password) = admin_password {
eprintln!("🔑 The administrator account is 'admin' with password '{admin_password}'.\n",);
}
Ok(())
}
fn select<T: SelectItem>(prompt: &str, items: &[&str], default: T) -> std::io::Result<T> {
if let Some(index) = Select::with_theme(&ColorfulTheme::default())
.items(items)
.with_prompt(prompt)
.default(default.to_index())
.interact_on_opt(&Term::stderr())?
{
Ok(T::from_index(index))
} else {
eprintln!("Aborted.");
std::process::exit(1);
}
}
fn input(
prompt: &str,
default: &str,
validator: fn(&String) -> Result<(), String>,
) -> std::io::Result<String> {
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.default(default.to_string())
.validate_with(validator)
.interact_text_on(&Term::stderr())
}
fn dir_create_if_missing(path: &String) -> Result<(), String> {
let path = Path::new(path);
if path.is_dir() {
Ok(())
} else if let Err(e) = std::fs::create_dir_all(path) {
Err(format!(
"Failed to create directory {}: {}",
path.display(),
e
))
} else {
Ok(())
}
}
fn file_exists(path: &String) -> Result<(), String> {
let path = Path::new(path);
if path.is_file() {
Ok(())
} else {
Err(format!("File {} does not exist", path.display()))
}
}
#[allow(clippy::ptr_arg)]
fn not_empty(value: &String) -> Result<(), String> {
if value.trim().is_empty() {
Err("Value cannot be empty".to_string())
} else {
Ok(())
}
}
fn create_directories(path: &Path) -> std::io::Result<()> {
for dir in &DIRECTORIES {
let mut path = PathBuf::from(path);
path.push(dir[0]);
if !path.exists() {
fs::create_dir_all(&path)?;
}
if !dir[1].is_empty() {
path.push(dir[1]);
if !path.exists() {
fs::create_dir_all(&path)?;
}
}
}
Ok(())
}
fn create_auth_db(path: &Path, domain: &str) -> std::io::Result<String> {
let mut path = PathBuf::from(path);
path.push("data");
if !path.exists() {
fs::create_dir_all(&path)?;
}
path.push("accounts.sqlite3");
let conn = Connection::open_with_flags(path, OpenFlags::default()).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to open database: {}", err),
)
})?;
let secret = thread_rng()
.sample_iter(Alphanumeric)
.take(12)
.map(char::from)
.collect::<String>();
let hashed_secret = sha512_crypt::hash(&secret).unwrap();
for query in [
"CREATE TABLE IF NOT EXISTS accounts (name TEXT PRIMARY KEY, secret TEXT, description TEXT, type TEXT NOT NULL, quota INTEGER DEFAULT 0, active BOOLEAN DEFAULT 1)".to_string(),
"CREATE TABLE IF NOT EXISTS group_members (name TEXT NOT NULL, member_of TEXT NOT NULL, PRIMARY KEY (name, member_of))".to_string(),
"CREATE TABLE IF NOT EXISTS emails (name TEXT NOT NULL, address TEXT NOT NULL, type TEXT, PRIMARY KEY (name, address))".to_string(),
format!("INSERT OR REPLACE INTO accounts (name, secret, description, type) VALUES ('admin', '{hashed_secret}', 'Postmaster', 'individual')"),
format!("INSERT OR REPLACE INTO emails (name, address, type) VALUES ('admin', 'postmaster@{domain}', 'primary')"),
"INSERT OR IGNORE INTO group_members (name, member_of) VALUES ('admin', 'superusers')".to_string()
] {
conn.execute(&query, []).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to create database: {}", err),
)
})?;
}
Ok(secret)
}
fn generate_dkim(path: &Path, domain: &str, hostname: &str) -> std::io::Result<String> {
let mut path = PathBuf::from(path);
path.push("etc");
path.push("dkim");
fs::create_dir_all(&path)?;
// Generate key
let rsa = Rsa::generate(2048)?;
let mut public = String::new();
general_purpose::STANDARD.encode_string(rsa.public_key_to_der()?, &mut public);
let private = rsa.private_key_to_pem()?;
// Write private key
let mut pk_path = path.clone();
pk_path.push(&format!("{domain}.key"));
fs::write(pk_path, private)?;
// Write public key
let mut pub_path = path.clone();
pub_path.push(&format!("{domain}.cert"));
fs::write(pub_path, public.as_bytes())?;
// Write instructions
let instructions = format!(
"Add the following DNS records to your domain in order to enable DKIM, SPF and DMARC:\n\
\n\
stalwart._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p={public}\"\n\
{domain}. IN TXT \"v=spf1 a:{hostname} mx -all ra=postmaster\"\n\
{hostname}. IN TXT \"v=spf1 a -all ra=postmaster\"\n\
_dmarc.{domain}. IN TXT \"v=DMARC1; p=none; rua=mailto:postmaster@{domain}; ruf=mailto:postmaster@{domain}\"\n\
",
);
let mut txt_path = path.clone();
txt_path.push(&format!("{domain}.readme"));
fs::write(txt_path, instructions.as_bytes())?;
Ok(instructions)
}
#[cfg(not(target_env = "msvc"))]
unsafe fn get_uid_gid() -> (libc::uid_t, libc::gid_t) {
use std::process::Command;
let pw = libc::getpwnam("stalwart-mail".as_ptr() as *const i8);
let gr = libc::getgrnam("stalwart-mail".as_ptr() as *const i8);
if pw.is_null() || gr.is_null() {
let mut cmd = Command::new("useradd");
cmd.arg("-r")
.arg("-s")
.arg("/sbin/nologin")
.arg("-M")
.arg("stalwart-mail");
if let Err(e) = cmd.status() {
eprintln!("Failed to create stalwart system account: {}", e);
std::process::exit(1);
}
let pw = libc::getpwnam("stalwart-mail".as_ptr() as *const i8);
let gr = libc::getgrnam("stalwart-mail".as_ptr() as *const i8);
(pw.as_ref().unwrap().pw_uid, gr.as_ref().unwrap().gr_gid)
} else {
((*pw).pw_uid, ((*gr).gr_gid))
}
}
trait SelectItem {
fn from_index(index: usize) -> Self;
fn to_index(&self) -> usize;
}
impl SelectItem for Component {
fn from_index(index: usize) -> Self {
match index {
0 => Self::AllInOne,
1 => Self::Jmap,
2 => Self::Imap,
3 => Self::Smtp,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::AllInOne => 0,
Self::Jmap => 1,
Self::Imap => 2,
Self::Smtp => 3,
}
}
}
impl SelectItem for Backend {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQLite,
1 => Self::FoundationDB,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::SQLite => 0,
Self::FoundationDB => 1,
}
}
}
impl SelectItem for Directory {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQL,
1 => Self::LDAP,
2 => Self::None,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Self::SQL => 0,
Self::LDAP => 1,
Self::None => 2,
}
}
}
impl SelectItem for SmtpDirectory {
fn from_index(index: usize) -> Self {
match index {
0 => Self::SQL,
1 => Self::LDAP,
2 => Self::LMTP,
3 => Self::IMAP,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
SmtpDirectory::SQL => 0,
SmtpDirectory::LDAP => 1,
SmtpDirectory::LMTP => 2,
SmtpDirectory::IMAP => 3,
}
}
}
impl SelectItem for Blob {
fn from_index(index: usize) -> Self {
match index {
0 => Blob::Local,
1 => Blob::MinIO,
2 => Blob::S3,
3 => Blob::GCS,
4 => Blob::Azure,
_ => unreachable!(),
}
}
fn to_index(&self) -> usize {
match self {
Blob::Local => 0,
Blob::MinIO => 1,
Blob::S3 => 2,
Blob::GCS => 3,
Blob::Azure => 4,
}
}
}
impl Component {
fn default_base_path(&self) -> &'static str {
match self {
Self::AllInOne => "/opt/stalwart-mail",
Self::Jmap => "/opt/stalwart-jmap",
Self::Imap => "/opt/stalwart-imap",
Self::Smtp => "/opt/stalwart-smtp",
}
}
}

View file

@ -136,10 +136,6 @@ impl crate::Config {
web_socket_timeout: settings.property_or_static("jmap.web-socket.timeout", "10m")?,
web_socket_heartbeat: settings.property_or_static("jmap.web-socket.heartbeat", "1m")?,
push_max_total: settings.property_or_static("jmap.push.max-total", "100")?,
principal_superusers: settings
.value("jmap.principal.superusers")
.unwrap_or("superusers")
.to_string(),
principal_allow_lookups: settings
.property("jmap.principal.allow-lookups")?
.unwrap_or(true),

View file

@ -40,7 +40,7 @@ use store::{
};
use utils::{listener::limiter::InFlight, map::ttl_dashmap::TtlMap};
use crate::{JMAP, SUPERUSER_ID};
use crate::JMAP;
use super::{rate_limit::RemoteAddress, AccessToken};
@ -226,11 +226,7 @@ impl JMAP {
pub async fn map_member_of(&self, names: Vec<String>) -> Result<Vec<u32>, MethodError> {
let mut ids = Vec::with_capacity(names.len());
for name in names {
if !name.eq_ignore_ascii_case(&self.config.principal_superusers) {
ids.push(self.get_account_id(&name).await?);
} else {
ids.push(SUPERUSER_ID);
}
ids.push(self.get_account_id(&name).await?);
}
Ok(ids)
}

View file

@ -31,7 +31,7 @@ use aes_gcm_siv::{
AeadInPlace, Aes256GcmSiv, KeyInit, Nonce,
};
use directory::Principal;
use directory::{Principal, Type};
use jmap_proto::{
error::method::MethodError,
types::{collection::Collection, id::Id},
@ -39,8 +39,6 @@ use jmap_proto::{
use store::blake3;
use utils::map::bitmap::Bitmap;
use crate::SUPERUSER_ID;
pub mod acl;
pub mod authenticate;
pub mod oauth;
@ -54,6 +52,7 @@ pub struct AccessToken {
pub name: String,
pub description: Option<String>,
pub quota: u32,
pub is_superuser: bool,
}
impl AccessToken {
@ -65,6 +64,7 @@ impl AccessToken {
name: principal.name,
description: principal.description,
quota: principal.quota,
is_superuser: principal.typ == Type::Superuser,
}
}
@ -95,9 +95,7 @@ impl AccessToken {
}
pub fn is_member(&self, account_id: u32) -> bool {
self.primary_id == account_id
|| self.member_of.contains(&account_id)
|| self.member_of.contains(&SUPERUSER_ID)
self.primary_id == account_id || self.member_of.contains(&account_id) || self.is_superuser
}
pub fn is_primary_id(&self, account_id: u32) -> bool {
@ -105,7 +103,7 @@ impl AccessToken {
}
pub fn is_super_user(&self) -> bool {
self.member_of.contains(&SUPERUSER_ID)
self.is_superuser
}
pub fn is_shared(&self, account_id: u32) -> bool {

View file

@ -78,7 +78,6 @@ pub mod thread;
pub mod vacation;
pub mod websocket;
pub const SUPERUSER_ID: u32 = 0;
pub const LONG_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24);
pub struct JMAP {
@ -151,7 +150,6 @@ pub struct Config {
pub oauth_expiry_refresh_token_renew: u64,
pub oauth_max_auth_attempts: u32,
pub principal_superusers: String,
pub principal_allow_lookups: bool,
pub capabilities: BaseCapabilities,

View file

@ -755,7 +755,7 @@ impl JMAP {
}
#[cfg(feature = "test_mode")]
if mailbox_ids.is_empty() && account_id == crate::SUPERUSER_ID {
if mailbox_ids.is_empty() && account_id == 0 {
return Ok(mailbox_ids);
}

View file

@ -23,6 +23,7 @@
use std::{borrow::Cow, fmt::Display, net::IpAddr, sync::Arc, time::Instant};
use directory::Type;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full};
use hyper::{
body::{self, Bytes},
@ -256,9 +257,16 @@ impl SMTP {
.authenticate(&Credentials::Plain { username, secret })
.await
{
Ok(Some(_)) => {
Ok(Some(principal)) if principal.typ == Type::Superuser => {
is_authenticated = true;
}
Ok(Some(_)) => {
tracing::debug!(
context = "management",
event = "auth-error",
"Insufficient privileges."
);
}
Ok(None) => {
tracing::debug!(
context = "management",

View file

@ -39,7 +39,7 @@ impl Store {
pub async fn open(config: &Config) -> crate::Result<Self> {
let db = Self {
conn_pool: Pool::builder()
.max_size(config.property_or_static("store.db.connection-pool.size", "10")?)
.max_size(config.property_or_static("store.db.pool.max-connections", "10")?)
.build(
SqliteConnectionManager::file(
config
@ -58,7 +58,7 @@ impl Store {
worker_pool: rayon::ThreadPoolBuilder::new()
.num_threads(
config
.property::<usize>("store.db.worker-pool.size")?
.property::<usize>("store.db.pool.workers")?
.filter(|v| *v > 0)
.unwrap_or_else(num_cpus::get),
)
@ -67,7 +67,7 @@ impl Store {
crate::Error::InternalError(format!("Failed to build worker pool: {}", err))
})?,
id_assigner: Arc::new(Mutex::new(LruCache::new(
config.property_or_static("store.db.id-cache.size", "1000")?,
config.property_or_static("store.db.cache.size", "1000")?,
))),
blob: BlobStore::new(config).await?,
};

View file

@ -1,10 +1,14 @@
#############################################
# Server configuration
#############################################
[server]
hostname = "__HOST__"
max-connections = 8192
[server.run-as]
user = "__RUN_AS_USER__"
group = "__RUN_AS_GROUP__"
user = "stalwart-mail"
group = "stalwart-mail"
[server.tls]
enable = true
@ -49,5 +53,5 @@ rotate = "daily"
level = "info"
[certificate."default"]
cert = "file://__PATH__/etc/certs/tls.crt"
private-key = "file://__PATH__/etc/private/tls.key"
cert = "file://__CERT_PATH__"
private-key = "file://__PK_PATH__"

View file

@ -1,13 +1,24 @@
#############################################
# Directory configuration
#############################################
[directory."sql"]
type = "sql"
address = "sqlite::memory:"
address = "sqlite://__PATH__/data/accounts.sqlite3?mode=rwc"
[directory."sql".options]
catch-all = true
subaddressing = true
superuser-group = "superusers"
[directory."sql".pool]
max-connections = 10
min-connections = 0
#idle-timeout = "10m"
[directory."sql".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."sql".query]
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
@ -35,9 +46,21 @@ base-dn = "dc=example,dc=org"
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
secret = "mysecret"
[directory."ldap".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."ldap".options]
catch-all = true
subaddressing = true
superuser-group = "superusers"
[directory."ldap".pool]
max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."ldap".filter]
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
@ -62,40 +85,52 @@ quota = "diskQuota"
[directory."imap"]
type = "imap"
address = "127.0.0.1"
port = 9198
port = 993
[directory."imap".pool]
max-connections = 5
max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."imap".tls]
implicit = true
allow-invalid-certs = true
[directory."imap".lookup]
domains = ["example.org"]
[directory."imap".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."smtp"]
[directory."imap".lookup]
domains = ["__DOMAIN__"]
[directory."lmtp"]
type = "lmtp"
address = "127.0.0.1"
port = 9199
port = 11200
[directory."smtp".limits]
[directory."lmtp".limits]
auth-errors = 3
rcpt = 5
[directory."smtp".pool]
max-connections = 5
[directory."lmtp".pool]
max-connections = 10
min-connections = 0
max-lifetime = "30m"
idle-timeout = "10m"
connect-timeout = "30s"
[directory."smtp".tls]
implicit = true
[directory."lmtp".tls]
implicit = false
allow-invalid-certs = true
[directory."smtp".cache]
[directory."lmtp".cache]
entries = 500
ttl = {positive = '10s', negative = '5s'}
ttl = {positive = '1h', negative = '10m'}
[directory."smtp".lookup]
domains = ["example.org"]
[directory."lmtp".lookup]
domains = ["__DOMAIN__"]
[directory."memory"]
type = "memory"
@ -103,29 +138,30 @@ type = "memory"
[directory."memory".options]
catch-all = true
subaddressing = true
superuser-group = "superusers"
[[directory."memory".users]]
name = "admin"
description = "Superuser"
secret = "changeme"
email = ["admin@example.org"]
email = ["postmaster@__DOMAIN__"]
member-of = ["superusers"]
[[directory."memory".users]]
name = "jane"
description = "Jane Doe"
secret = "abcde"
email = ["jane@example.org", "jane.doe@example.org"]
email-list = ["info@example.org"]
email = ["jane@__DOMAIN__", "jane.doe@__DOMAIN__"]
email-list = ["info@__DOMAIN__"]
member-of = ["sales", "support"]
[[directory."memory".users]]
name = "bill"
description = "Bill Foobar"
secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"
quota = 500000
email = "bill@example.org"
email-list = ["info@example.org"]
quota = 50000000
email = ["bill@__DOMAIN__", "bill.foobar@__DOMAIN__"]
email-list = ["info@__DOMAIN__"]
[[directory."memory".groups]]
name = "sales"
@ -136,4 +172,4 @@ name = "support"
description = "Support Team"
[directory."memory".lookup]
domains = ["example.org"]
domains = ["__DOMAIN__"]

View file

@ -1,9 +1,13 @@
#############################################
# IMAP server configuration
#############################################
[server.listener."imap"]
bind = ["0.0.0.0:143"]
protocol = "imap"
[server.listener."imaptls"]
bind = ["0.0.0.0:9993"]
bind = ["0.0.0.0:993"]
protocol = "imap"
tls.implicit = true

View file

@ -1,16 +1,27 @@
#############################################
# JMAP server configuration
#############################################
[server.listener."jmap"]
bind = ["0.0.0.0:__BIND_PORT__"]
url = "https://127.0.0.1:__BIND_PORT__"
bind = ["0.0.0.0:8080"]
url = "https://__HOST__:8080"
protocol = "jmap"
[store.db]
path = "__PATH__/db"
path = "__PATH__/data/index.sqlite3"
[store.db.pool]
max-connections = 10
#workers = 8
[store.db.cache]
size = 1000
[store.blob]
type = "local"
type = "__BLOB_STORE__"
[store.blob.local]
path = "__PATH__/blobs"
path = "__PATH__/data/blobs"
[store.blob.s3]
bucket = "stalwart"
@ -23,7 +34,7 @@ secret-key = "minioadmin"
timeout = "30s"
[jmap]
directory = "sql"
directory = "__DIRECTORY__"
[jmap.session.cache]
ttl = "1h"
@ -74,6 +85,9 @@ max-size = 75000000
[jmap.email.parse]
max-items = 10
[jmap.principal]
allow-lookups = true
[jmap.sieve]
disable-capabilities = []
notification-uris = ["mailto"]

View file

@ -1,3 +1,7 @@
#############################################
# SMTP server configuration
#############################################
[server.listener."smtp"]
bind = ["0.0.0.0:25"]
greeting = "Stalwart SMTP at your service"
@ -51,7 +55,7 @@ mt-priority = [ { if = "authenticated-as", ne = "", then = "mixer"},
[session.auth]
mechanisms = [ { if = "listener", ne = "smtp", then = ["plain", "login"]},
{ else = [] } ]
directory = [ { if = "listener", ne = "smtp", then = "local" },
directory = [ { if = "listener", ne = "smtp", then = "__SMTP_DIRECTORY__" },
{ else = false } ]
require = [ { if = "listener", ne = "smtp", then = true},
{ else = false } ]
@ -68,7 +72,7 @@ wait = "5s"
relay = [ { if = "authenticated-as", ne = "", then = true },
{ else = false } ]
max-recipients = 25
directory = [ { if = "authenticated-as", ne = "", then = "local" },
directory = [ { if = "authenticated-as", ne = "", then = "__SMTP_DIRECTORY__" },
{ else = false } ]
[session.rcpt.cache]
@ -157,7 +161,7 @@ expire = "5d"
[queue.outbound]
#hostname = "__HOST__"
next-hop = [ { if = "rcpt-domain", in-list = "local/domains", then = "lmtp" },
next-hop = [ { if = "rcpt-domain", in-list = "__SMTP_DIRECTORY__/domains", then = "__NEXT_HOP__" },
{ else = false } ]
ip-strategy = "ipv4-then-ipv6"
@ -267,10 +271,10 @@ max-size = 26214400 # 25 mb
sign = ["rsa"]
[signature."rsa"]
#public-key = "file://__PATH__/etc/certs/dkim.crt"
private-key = "file://__PATH__/etc/private/dkim.key"
#public-key = "file://__PATH__/etc/dkim/__DOMAIN__.cert"
private-key = "file://__PATH__/etc/dkim/__DOMAIN__.key"
domain = "__DOMAIN__"
selector = "stalwart_smtp"
selector = "stalwart"
headers = ["From", "To", "Date", "Subject", "Message-ID"]
algorithm = "rsa-sha256"
canonicalization = "relaxed/relaxed"
@ -282,8 +286,8 @@ set-body-length = false
report = true
[remote."lmtp"]
address = "__LMTP_HOST__"
port = __LMTP_PORT__
address = "127.0.0.1"
port = 11200
protocol = "lmtp"
concurrency = 10
timeout = "1m"
@ -296,32 +300,13 @@ allow-invalid-certs = true
#username = ""
#secret = ""
[database."sql"]
#address = "sqlite://__PATH__/etc/sqlite.db?mode=rwc"
address = "postgres://postgres:password@localhost/test"
max-connections = 10
min-connections = 0
idle-timeout = "5m"
[database."sql".lookup]
auth = "SELECT secret FROM users WHERE email=?"
rcpt = "SELECT EXISTS(SELECT 1 FROM users WHERE email=? LIMIT 1)"
vrfy = "SELECT email FROM users WHERE email LIKE '%' || ? || '%' LIMIT 5"
expn = "SELECT member FROM mailing_lists WHERE id = ?"
domains = "SELECT EXISTS(SELECT 1 FROM domains WHERE name=? LIMIT 1)"
[database."sql".cache]
enable = ["rcpt", "domains"]
entries = 1000
ttl = {positive = "1d", negative = "1h"}
[sieve]
from-name = "Automated Message"
from-addr = "no-reply@__DOMAIN__"
return-path = ""
#hostname = "__HOST__"
sign = ["rsa"]
use-directory = "sql"
use-directory = "__SMTP_DIRECTORY__"
[sieve.limits]
redirects = 3
@ -337,14 +322,14 @@ duplicate-expiry = "7d"
connect = '''
require ["variables", "extlists", "reject"];
if string :list "${env.remote_ip}" "local/blocked-ips" {
if string :list "${env.remote_ip}" "__SMTP_DIRECTORY__/blocked-ips" {
reject "Your IP '${env.remote_ip}' is not welcomed here.";
}
'''
ehlo = '''
require ["variables", "extlists", "reject"];
if string :list "${env.helo_domain}" "local/blocked-domains" {
if string :list "${env.helo_domain}" "__SMTP_DIRECTORY__/blocked-domains" {
reject "551 5.1.1 Your domain '${env.helo_domain}' has been blacklisted.";
}
'''
@ -383,5 +368,4 @@ data = '''
'''
[management]
directory = "local"
directory = "__DIRECTORY__"

View file

@ -182,10 +182,6 @@ description = "Sales Team"
name = "support"
description = "Support Team"
[[directory."local".groups]]
name = "superusers"
description = "Superusers"
[oauth]
key = "parerga_und_paralipomena"
oauth.auth.max-attempts = 1

View file

@ -50,10 +50,14 @@ const DIRECTORY: &str = r#"
[directory."local"]
type = "memory"
[directory."local".options]
superuser-group = "superusers"
[[directory."local".users]]
name = "admin"
description = "Superuser"
secret = "secret"
member-of = ["superusers"]
"#;

View file

@ -53,10 +53,14 @@ const DIRECTORY: &str = r#"
[directory."local"]
type = "memory"
[directory."local".options]
superuser-group = "superusers"
[[directory."local".users]]
name = "admin"
description = "Superuser"
secret = "secret"
member-of = ["superusers"]
"#;