diff --git a/Cargo.lock b/Cargo.lock index 033e4772..deec4070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 167b196c..dac97be2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/utils", "crates/maybe-async", "crates/cli", + "crates/install", "tests", ] diff --git a/crates/directory/src/config.rs b/crates/directory/src/config.rs index 2d150f86..940720f9 100644 --- a/crates/directory/src/config.rs +++ b/crates/directory/src/config.rs @@ -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(), }) } } diff --git a/crates/directory/src/imap/config.rs b/crates/directory/src/imap/config.rs index 2f6923ec..c6172dd1 100644 --- a/crates/directory/src/imap/config.rs +++ b/crates/directory/src/imap/config.rs @@ -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}"), diff --git a/crates/directory/src/ldap/lookup.rs b/crates/directory/src/ldap/lookup.rs index ccec0e34..14dacb50 100644 --- a/crates/directory/src/ldap/lookup.rs +++ b/crates/directory/src/ldap/lookup.rs @@ -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; diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index fa04ad30..b717c259 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -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)] diff --git a/crates/directory/src/memory/config.rs b/crates/directory/src/memory/config.rs index 8d6c16c2..4909b610 100644 --- a/crates/directory/src/memory/config.rs +++ b/crates/directory/src/memory/config.rs @@ -12,12 +12,26 @@ impl MemoryDirectory { prefix: impl AsKey, ) -> utils::config::Result> { 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)) } diff --git a/crates/directory/src/sql/lookup.rs b/crates/directory/src/sql/lookup.rs index b4df99a0..c3afbbd0 100644 --- a/crates/directory/src/sql/lookup.rs +++ b/crates/directory/src/sql/lookup.rs @@ -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) diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index d7e3b089..19c55d4b 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.rs @@ -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); diff --git a/crates/imap/src/lib.rs b/crates/imap/src/lib.rs index 5b511e95..251dbe93 100644 --- a/crates/imap/src/lib.rs +++ b/crates/imap/src/lib.rs @@ -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::("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")?, })) diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml new file mode 100644 index 00000000..b3c7119e --- /dev/null +++ b/crates/install/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "stalwart-install" +description = "Stalwart Mail Server installer" +authors = ["Stalwart Labs Ltd. "] +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" diff --git a/crates/install/src/main.rs b/crates/install/src/main.rs new file mode 100644 index 00000000..a06481d0 --- /dev/null +++ b/crates/install/src/main.rs @@ -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::( + "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::( + "Which database engine would you like to use?", + &[ + "SQLite (single node, replicated with Litestream)", + "FoundationDB (distributed and fault-tolerant)", + ], + Backend::SQLite, + )?; + let blob = select::( + "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::( + "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::(), + ); + + directory + } else { + let smtp_directory = select::( + "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(prompt: &str, items: &[&str], default: T) -> std::io::Result { + 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 { + 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 { + 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::(); + 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 { + 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", + } + } +} diff --git a/crates/jmap/src/api/config.rs b/crates/jmap/src/api/config.rs index a28f5802..845ec07e 100644 --- a/crates/jmap/src/api/config.rs +++ b/crates/jmap/src/api/config.rs @@ -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), diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index 06048691..58fbde00 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -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) -> Result, 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) } diff --git a/crates/jmap/src/auth/mod.rs b/crates/jmap/src/auth/mod.rs index 41344a11..db674027 100644 --- a/crates/jmap/src/auth/mod.rs +++ b/crates/jmap/src/auth/mod.rs @@ -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, 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 { diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index 8f20ebcf..559cf5e5 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -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, diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 2408df7b..4274a71f 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -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); } diff --git a/crates/smtp/src/core/management.rs b/crates/smtp/src/core/management.rs index d0a42cf3..220702b9 100644 --- a/crates/smtp/src/core/management.rs +++ b/crates/smtp/src/core/management.rs @@ -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", diff --git a/crates/store/src/backend/sqlite/main.rs b/crates/store/src/backend/sqlite/main.rs index 00fd623f..29c8986a 100644 --- a/crates/store/src/backend/sqlite/main.rs +++ b/crates/store/src/backend/sqlite/main.rs @@ -39,7 +39,7 @@ impl Store { pub async fn open(config: &Config) -> crate::Result { 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::("store.db.worker-pool.size")? + .property::("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?, }; diff --git a/resources/config/common.toml b/resources/config/common.toml index 7cd34aae..1d4dd024 100644 --- a/resources/config/common.toml +++ b/resources/config/common.toml @@ -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__" diff --git a/resources/config/directory.toml b/resources/config/directory.toml index 962b0376..eaf14470 100644 --- a/resources/config/directory.toml +++ b/resources/config/directory.toml @@ -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__"] diff --git a/resources/config/imap.toml b/resources/config/imap.toml index 9567f156..d7af9dd1 100644 --- a/resources/config/imap.toml +++ b/resources/config/imap.toml @@ -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 diff --git a/resources/config/jmap.toml b/resources/config/jmap.toml index a765b795..61db0874 100644 --- a/resources/config/jmap.toml +++ b/resources/config/jmap.toml @@ -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"] diff --git a/resources/config/smtp.toml b/resources/config/smtp.toml index 993dbbc0..dce1797e 100644 --- a/resources/config/smtp.toml +++ b/resources/config/smtp.toml @@ -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__" diff --git a/tests/resources/test_config.toml b/tests/resources/test_config.toml index bc4f5822..e6b9928c 100644 --- a/tests/resources/test_config.toml +++ b/tests/resources/test_config.toml @@ -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 diff --git a/tests/src/smtp/management/queue.rs b/tests/src/smtp/management/queue.rs index 223ced46..800168ef 100644 --- a/tests/src/smtp/management/queue.rs +++ b/tests/src/smtp/management/queue.rs @@ -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"] "#; diff --git a/tests/src/smtp/management/report.rs b/tests/src/smtp/management/report.rs index c77dca1e..7e0af0d0 100644 --- a/tests/src/smtp/management/report.rs +++ b/tests/src/smtp/management/report.rs @@ -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"] "#;