diff --git a/.gitignore b/.gitignore index d4c977ea..c9c42ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ .vscode *.failed *_failed -/resources/config/config.toml run.sh _ignore .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 138ff1e7..ec021bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,7 @@ dependencies = [ "decancer", "directory", "futures", + "hostname 0.4.0", "hyper 1.2.0", "idna 0.5.0", "imagesize", @@ -1013,6 +1014,7 @@ dependencies = [ "pem", "privdrop", "proxy-header", + "pwhash", "rcgen 0.12.1", "regex", "reqwest 0.12.2", @@ -1038,6 +1040,7 @@ dependencies = [ "utils", "whatlang", "x509-parser 0.16.0", + "zip", ] [[package]] @@ -2426,6 +2429,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if", + "libc", + "windows", +] + [[package]] name = "http" version = "0.2.12" @@ -4748,7 +4762,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" dependencies = [ - "hostname", + "hostname 0.3.1", "quick-error", ] @@ -6923,6 +6937,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.4", +] + [[package]] name = "windows-core" version = "0.52.0" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index b51b8963..ffae666b 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -54,7 +54,9 @@ decancer = "3.0.1" unicode-security = "0.1.0" infer = "0.15.0" bincode = "1.3.1" - +hostname = "0.4.0" +zip = "0.6.6" +pwhash = "1.0.0" [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index ec8383ea..6e5a9864 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -61,6 +61,7 @@ pub struct JmapConfig { pub oauth_expiry_refresh_token: u64, pub oauth_expiry_refresh_token_renew: u64, pub oauth_max_auth_attempts: u32, + pub fallback_admin: Option<(String, String)>, pub spam_header: Option<(HeaderName<'static>, String)>, @@ -78,6 +79,59 @@ pub struct JmapConfig { impl JmapConfig { pub fn parse(config: &mut Config) -> Self { + // Parse HTTP headers + let mut http_headers = config + .values("server.http.headers") + .map(|(_, v)| { + if let Some((k, v)) = v.split_once(':') { + Ok(( + hyper::header::HeaderName::from_str(k.trim()).map_err(|err| { + format!( + "Invalid header found in property \"server.http.headers\": {}", + err + ) + })?, + hyper::header::HeaderValue::from_str(v.trim()).map_err(|err| { + format!( + "Invalid header found in property \"server.http.headers\": {}", + err + ) + })?, + )) + } else { + Err(format!( + "Invalid header found in property \"server.http.headers\": {}", + v + )) + } + }) + .collect::, String>>() + .map_err(|e| config.new_parse_error("server.http.headers", e)) + .unwrap_or_default(); + + // Add permissive CORS headers + if config + .property::("server.http.permissive-cors") + .unwrap_or(false) + { + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, + hyper::header::HeaderValue::from_static("*"), + )); + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_HEADERS, + hyper::header::HeaderValue::from_static( + "Authorization, Content-Type, Accept, X-Requested-With", + ), + )); + http_headers.push(( + hyper::header::ACCESS_CONTROL_ALLOW_METHODS, + hyper::header::HeaderValue::from_static( + "POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS", + ), + )); + } + let mut jmap = JmapConfig { default_language: Language::from_iso_639( config @@ -210,45 +264,21 @@ impl JmapConfig { encrypt_append: config .property_or_default("storage.encryption.append", "false") .unwrap_or(false), - spam_header: config.value("spam.header.is-spam").and_then(|v| { - v.split_once(':').map(|(k, v)| { - ( - mail_parser::HeaderName::parse(k.trim().to_string()).unwrap(), - v.trim().to_string(), - ) - }) - }), + spam_header: config + .property_or_default::>("spam.header.is-spam", "X-Spam-Status: Yes") + .unwrap_or_default() + .and_then(|v| { + v.split_once(':').map(|(k, v)| { + ( + mail_parser::HeaderName::parse(k.trim().to_string()).unwrap(), + v.trim().to_string(), + ) + }) + }), http_use_forwarded: config .property("server.http.use-x-forwarded") .unwrap_or(false), - http_headers: config - .values("server.http.headers") - .map(|(_, v)| { - if let Some((k, v)) = v.split_once(':') { - Ok(( - hyper::header::HeaderName::from_str(k.trim()).map_err(|err| { - format!( - "Invalid header found in property \"server.http.headers\": {}", - err - ) - })?, - hyper::header::HeaderValue::from_str(v.trim()).map_err(|err| { - format!( - "Invalid header found in property \"server.http.headers\": {}", - err - ) - })?, - )) - } else { - Err(format!( - "Invalid header found in property \"server.http.headers\": {}", - v - )) - } - }) - .collect::, String>>() - .map_err(|e| config.new_parse_error("server.http.headers", e)) - .unwrap_or_default(), + http_headers, push_attempt_interval: config .property_or_default("jmap.push.attempts.interval", "1m") .unwrap_or_else(|| Duration::from_secs(60)), @@ -270,6 +300,13 @@ impl JmapConfig { session_purge_frequency: config .property_or_default::("jmap.session.purge.frequency", "15 * *") .unwrap_or_else(|| SimpleCron::parse_value("15 * *").unwrap()), + fallback_admin: config + .value("authentication.fallback-admin.user") + .and_then(|u| { + config + .value("authentication.fallback-admin.secret") + .map(|p| (u.to_string(), p.to_string())) + }), }; // Add capabilities diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 352b6627..e2540357 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -5,16 +5,15 @@ use directory::{Directories, Directory}; use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores}; use utils::config::Config; -use crate::{expr::*, listener::tls::TlsManager, Core, Network}; +use crate::{expr::*, listener::tls::TlsManager, manager::config::ConfigManager, Core, Network}; use self::{ - imap::ImapConfig, jmap::settings::JmapConfig, manager::ConfigManager, scripts::Scripting, - smtp::SmtpConfig, storage::Storage, + imap::ImapConfig, jmap::settings::JmapConfig, scripts::Scripting, smtp::SmtpConfig, + storage::Storage, }; pub mod imap; pub mod jmap; -pub mod manager; pub mod network; pub mod scripts; pub mod server; diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index 663544ec..a4ab8706 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -629,7 +629,14 @@ impl Default for SessionConfig { subaddressing: AddressMapping::Enable, }, data: Data { + #[cfg(not(feature = "test_mode"))] script: IfBlock::empty("session.data.script"), + #[cfg(feature = "test_mode")] + script: IfBlock::new::<()>( + "session.data.script", + [("is_empty(authenticated_as)", "'spam-filter'")], + "'track-replies'", + ), pipe_commands: Default::default(), milters: Default::default(), max_messages: IfBlock::new::<()>("session.data.limits.messages", [], "10"), diff --git a/crates/common/src/config/storage.rs b/crates/common/src/config/storage.rs index 30d9ebfb..30e8575b 100644 --- a/crates/common/src/config/storage.rs +++ b/crates/common/src/config/storage.rs @@ -4,7 +4,7 @@ use ahash::AHashMap; use directory::Directory; use store::{write::purge::PurgeSchedule, BlobStore, FtsStore, LookupStore, Store}; -use super::manager::ConfigManager; +use crate::manager::config::ConfigManager; #[derive(Default, Clone)] pub struct Storage { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 83c155e7..8ee47029 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -13,7 +13,7 @@ use config::{ storage::Storage, tracers::{OtelTracer, Tracer, Tracers}, }; -use directory::{Directory, Principal, QueryBy}; +use directory::{core::secret::verify_secret_hash, Directory, Principal, QueryBy}; use expr::if_block::IfBlock; use listener::{blocked::BlockedIps, tls::TlsManager}; use mail_send::Credentials; @@ -26,15 +26,17 @@ use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION use sieve::Sieve; use store::LookupStore; use tokio::sync::oneshot; -use tracing::{level_filters::LevelFilter, Level}; use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; +use tracing_subscriber::{ + layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, Registry, +}; use utils::{config::Config, BlobHash}; pub mod addresses; pub mod config; pub mod expr; pub mod listener; +pub mod manager; pub mod scripts; pub static USER_AGENT: &str = concat!("StalwartMail/", env!("CARGO_PKG_VERSION"),); @@ -205,11 +207,29 @@ impl Core { remote_ip: IpAddr, return_member_of: bool, ) -> directory::Result>> { - if let Some(principal) = directory + // First try to authenticate the user against the default directory + let result = match directory .query(QueryBy::Credentials(credentials), return_member_of) - .await? + .await { - Ok(AuthResult::Success(principal)) + Ok(Some(principal)) => return Ok(AuthResult::Success(principal)), + Ok(None) => Ok(()), + Err(err) => Err(err), + }; + + // Then check if the credentials match the fallback admin + if let (Some((fallback_admin, fallback_pass)), Credentials::Plain { username, secret }) = + (&self.jmap.fallback_admin, credentials) + { + if username == fallback_admin && verify_secret_hash(fallback_pass, secret).await { + return Ok(AuthResult::Success(Principal::fallback_admin( + fallback_pass, + ))); + } + } + + if let Err(err) = result { + Err(err) } else if self.has_fail2ban() { let login = match credentials { Credentials::Plain { username, .. } @@ -237,60 +257,42 @@ impl Core { impl Tracers { pub fn enable(self, config: &mut Config) -> Option> { - let mut layers = Vec::new(); - let mut level = Level::TRACE; - - for tracer in &self.tracers { - let tracer_level = *match tracer { - Tracer::Stdout { level, .. } - | Tracer::Log { level, .. } - | Tracer::Journal { level } - | Tracer::Otel { level, .. } => level, - }; - - if tracer_level < level { - level = tracer_level; - } - } - + let mut layers: Option + Sync + Send>> = None; let mut guards = Vec::new(); - match EnvFilter::builder().parse(format!( - "smtp={level},imap={level},jmap={level},store={level},common={level},utils={level},directory={level}" - )) { - Ok(layer) => { - layers.push(layer.boxed()); - } - Err(err) => { - config.new_build_error("tracer", format!("Failed to set env filter: {err}")); - } - } for tracer in self.tracers { - match tracer { - Tracer::Stdout { level, ansi } => { - layers.push( - tracing_subscriber::fmt::layer() - .with_ansi(ansi) - .with_filter(LevelFilter::from_level(level)) - .boxed(), - ); + let (Tracer::Stdout { level, .. } + | Tracer::Log { level, .. } + | Tracer::Journal { level } + | Tracer::Otel { level, .. }) = tracer; + + let filter = match EnvFilter::builder().parse(format!( + "smtp={level},imap={level},jmap={level},store={level},common={level},utils={level},directory={level}" + )) { + Ok(filter) => { + filter } - Tracer::Log { - level, - appender, - ansi, - } => { + Err(err) => { + config.new_build_error("tracer", format!("Failed to set env filter: {err}")); + continue; + } + }; + + let layer = match tracer { + Tracer::Stdout { ansi, .. } => tracing_subscriber::fmt::layer() + .with_ansi(ansi) + .with_filter(filter) + .boxed(), + Tracer::Log { appender, ansi, .. } => { let (non_blocking, guard) = tracing_appender::non_blocking(appender); guards.push(guard); - layers.push( - tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - .with_ansi(ansi) - .with_filter(LevelFilter::from_level(level)) - .boxed(), - ); + tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(ansi) + .with_filter(filter) + .boxed() } - Tracer::Otel { level, tracer } => { + Tracer::Otel { tracer, .. } => { let tracer = match tracer { OtelTracer::Gprc(exporter) => opentelemetry_otlp::new_pipeline() .tracing() @@ -313,36 +315,30 @@ impl Tracers { .install_batch(opentelemetry_sdk::runtime::Tokio); match tracer { - Ok(tracer) => { - layers.push( - tracing_opentelemetry::layer() - .with_tracer(tracer) - .with_filter(LevelFilter::from_level(level)) - .boxed(), - ); - } + Ok(tracer) => tracing_opentelemetry::layer() + .with_tracer(tracer) + .with_filter(filter) + .boxed(), Err(err) => { config.new_build_error( "tracer", format!("Failed to start OpenTelemetry: {err}"), ); + continue; } } } - Tracer::Journal { level } => { + Tracer::Journal { .. } => { #[cfg(unix)] { match tracing_journald::layer() { - Ok(layer) => { - layers.push( - layer.with_filter(LevelFilter::from_level(level)).boxed(), - ); - } + Ok(layer) => layer.with_filter(filter).boxed(), Err(err) => { config.new_build_error( "tracer", format!("Failed to start Journald: {err}"), ); + continue; } } } @@ -353,21 +349,23 @@ impl Tracers { "tracer", "Journald is only available on Unix systems.", ); + continue; } } - } + }; + + layers = Some(match layers { + Some(layers) => layers.and_then(layer).boxed(), + None => layer, + }); } - if layers.len() > 1 { - match tracing_subscriber::registry().with(layers).try_init() { - Ok(_) => Some(guards), - Err(err) => { - config.new_build_error("tracer", format!("Failed to start tracing: {err}")); - None - } + match tracing_subscriber::registry().with(layers?).try_init() { + Ok(_) => Some(guards), + Err(err) => { + config.new_build_error("tracer", format!("Failed to start tracing: {err}")); + None } - } else { - None } } } diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs new file mode 100644 index 00000000..c2c07ec3 --- /dev/null +++ b/crates/common/src/manager/boot.rs @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::path::PathBuf; + +use arc_swap::ArcSwap; +use pwhash::sha512_crypt; +use store::{ + rand::{distributions::Alphanumeric, thread_rng, Rng}, + Stores, +}; +use tracing_appender::non_blocking::WorkerGuard; +use utils::{ + config::{Config, ConfigKey}, + failed, UnwrapFailure, +}; + +use crate::{ + config::{server::Servers, tracers::Tracers}, + manager::SPAMFILTER_URL, + Core, SharedCore, +}; + +use super::{ + config::{ConfigManager, Patterns}, + download_resource, WEBADMIN_KEY, WEBADMIN_URL, +}; + +pub struct BootManager { + pub config: Config, + pub core: SharedCore, + pub servers: Servers, + pub guards: Option>, +} + +impl BootManager { + pub async fn init() -> Self { + let mut config_path = std::env::var("CONFIG_PATH").ok(); + + if config_path.is_none() { + let mut args = std::env::args().skip(1); + + if let Some(arg) = args + .next() + .and_then(|arg| arg.strip_prefix("--").map(|arg| arg.to_string())) + { + let (key, value) = if let Some((key, value)) = arg.split_once('=') { + (key.to_string(), value.trim().to_string()) + } else if let Some(value) = args.next() { + (arg, value) + } else { + failed(&format!("Invalid command line argument: {arg}")); + }; + + match key.as_str() { + "config" => { + config_path = Some(value); + } + "init" => { + quickstart(value); + std::process::exit(0); + } + _ => { + failed(&format!("Invalid command line argument: {key}")); + } + } + } + } + + // Read main configuration file + let cfg_local_path = + PathBuf::from(config_path.failed("Missing parameter --config=.")); + let mut config = Config::default(); + match std::fs::read_to_string(&cfg_local_path) { + Ok(value) => { + config.parse(&value).failed("Invalid configuration file"); + } + Err(err) => { + config.new_build_error("*", format!("Could not read configuration file: {err}")); + } + } + let cfg_local = config.keys.clone(); + + // Resolve macros + config.resolve_macros().await; + + // Parser servers + let mut servers = Servers::parse(&mut config); + + // Bind ports and drop privileges + servers.bind_and_drop_priv(&mut config); + + // Load stores + let mut stores = Stores::parse(&mut config).await; + + // Build manager + let manager = ConfigManager { + cfg_local: ArcSwap::from_pointee(cfg_local), + cfg_local_path, + cfg_local_patterns: Patterns::parse(&mut config).into(), + cfg_store: config + .value("storage.data") + .and_then(|id| stores.stores.get(id)) + .cloned() + .unwrap_or_default(), + }; + + // Extend configuration with settings stored in the db + if !manager.cfg_store.is_none() { + manager + .extend_config(&mut config, "") + .await + .failed("Failed to read configuration"); + } + + // Enable tracing + let guards = Tracers::parse(&mut config).enable(&mut config); + + // Add hostname lookup if missing + let mut insert_keys = Vec::new(); + if config + .value("lookup.default.hostname") + .filter(|v| !v.is_empty()) + .is_none() + { + insert_keys.push(ConfigKey::from(( + "lookup.default.hostname", + hostname::get() + .map(|v| v.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "localhost".to_string()), + ))); + } + + // Generate an OAuth key if missing + if config + .value("oauth.key") + .filter(|v| !v.is_empty()) + .is_none() + { + insert_keys.push(ConfigKey::from(( + "oauth.key", + thread_rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::(), + ))); + } + + // Download SPAM filters if missing + if config + .value("version.spam-filter") + .filter(|v| !v.is_empty()) + .is_none() + { + match manager.fetch_external_config(SPAMFILTER_URL).await { + Ok(external_config) => { + tracing::info!( + context = "config", + event = "import", + url = SPAMFILTER_URL, + version = external_config.version, + "Imported spam filter rules" + ); + insert_keys.extend(external_config.keys); + } + Err(err) => { + config.new_build_error("*", format!("Failed to fetch spam filter: {err}")); + } + } + + // Add default settings + for key in [ + ("queue.quota.size.messages", "100000"), + ("queue.quota.size.size", "10737418240"), + ("queue.quota.size.enable", "true"), + ("queue.throttle.rcpt.key", "rcpt_domain"), + ("queue.throttle.rcpt.concurrency", "5"), + ("queue.throttle.rcpt.enable", "true"), + ("session.throttle.ip.key", "remote_ip"), + ("session.throttle.ip.concurrency", "5"), + ("session.throttle.ip.enable", "true"), + ("session.throttle.sender.key.0", "sender_domain"), + ("session.throttle.sender.key.1", "rcpt"), + ("session.throttle.sender.rate", "25/1h"), + ("session.throttle.sender.enable", "true"), + ("report.analysis.addresses", "postmaster@*"), + ] { + insert_keys.push(ConfigKey::from(key)); + } + } + + // Download webadmin if missing + if let Some(blob_store) = config + .value("storage.blob") + .and_then(|id| stores.blob_stores.get(id)) + { + match blob_store.get_blob(WEBADMIN_KEY, 0..usize::MAX).await { + Ok(Some(_)) => (), + Ok(None) => match download_resource(WEBADMIN_URL).await { + Ok(bytes) => match blob_store.put_blob(WEBADMIN_KEY, &bytes).await { + Ok(_) => { + tracing::info!( + context = "webadmin", + event = "download", + url = WEBADMIN_URL, + "Downloaded webadmin bundle" + ); + } + Err(err) => { + config.new_build_error( + "*", + format!("Failed to store webadmin blob: {err}"), + ); + } + }, + Err(err) => { + config.new_build_error("*", format!("Failed to download webadmin: {err}")); + } + }, + Err(err) => { + config.new_build_error("*", format!("Failed to access webadmin blob: {err}")) + } + } + } + + // Add missing settings + if !insert_keys.is_empty() { + for item in &insert_keys { + config.keys.insert(item.key.clone(), item.value.clone()); + } + + if let Err(err) = manager.set(insert_keys).await { + config.new_build_error("*", format!("Failed to update configuration: {err}")); + } + } + + // Parse lookup stores + stores.parse_lookups(&mut config).await; + + // Parse settings and build shared core + let core = Core::parse(&mut config, stores, manager) + .await + .into_shared(); + + // Parse TCP acceptors + servers.parse_tcp_acceptors(&mut config, core.clone()); + + BootManager { + core, + guards, + config, + servers, + } + } +} + +fn quickstart(path: impl Into) { + let path = path.into(); + + if !path.exists() { + std::fs::create_dir_all(&path).failed("Failed to create directory"); + } + + for dir in &["etc", "data", "logs"] { + std::fs::create_dir(path.join(dir)).failed(&format!("Failed to create {dir} directory")); + } + + let admin_pass = thread_rng() + .sample_iter(Alphanumeric) + .take(10) + .map(char::from) + .collect::(); + + std::fs::write( + path.join("etc").join("config.toml"), + QUICKSTART_CONFIG + .replace("_P_", &path.to_string_lossy()) + .replace("_S_", &sha512_crypt::hash(&admin_pass).unwrap()), + ) + .failed("Failed to write configuration file"); + + eprintln!( + "✅ Configuration file written to {}/etc/config.toml", + path.to_string_lossy() + ); + eprintln!("🔑 Your administrator account is 'admin' with password '{admin_pass}'."); +} + +const QUICKSTART_CONFIG: &str = r#"[server.listener.smtp] +bind = "[::]:25" +protocol = "smtp" + +[server.listener.submission] +bind = "[::]:587" +protocol = "smtp" + +[server.listener.submissions] +bind = "[::]:465" +protocol = "smtp" +tls.implicit = true + +[server.listener.imap] +bind = "[::]:143" +protocol = "imap" + +[server.listener.imaptls] +bind = "[::]:993" +protocol = "imap" +tls.implicit = true + +[server.listener.sieve] +bind = "[::]:4190" +protocol = "managesieve" + +[server.listener.https] +protocol = "http" +bind = "[::]:443" +tls.implicit = true + +[server.listener.http] +protocol = "http" +bind = "[::]:8080" + +[storage] +data = "rocksdb" +fts = "rocksdb" +blob = "rocksdb" +lookup = "rocksdb" +directory = "internal" + +[store.rocksdb] +type = "rocksdb" +path = "_P_/data" +compression = "lz4" + +[directory.internal] +type = "internal" +store = "rocksdb" + +[tracer.log] +type = "log" +level = "info" +path = "_P_/logs" +prefix = "stalwart.log" +rotate = "daily" +ansi = false +enable = true + +[authentication.fallback-admin] +user = "admin" +secret = "_S_" +"#; diff --git a/crates/common/src/config/manager.rs b/crates/common/src/manager/config.rs similarity index 58% rename from crates/common/src/config/manager.rs rename to crates/common/src/manager/config.rs index f4c916ef..e1653ccf 100644 --- a/crates/common/src/config/manager.rs +++ b/crates/common/src/manager/config.rs @@ -1,37 +1,50 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + use std::{ collections::{btree_map::Entry, BTreeMap}, path::PathBuf, sync::Arc, }; -use ahash::AHashSet; use arc_swap::ArcSwap; -use parking_lot::RwLock; use store::{ write::{BatchBuilder, ValueClass}, - Deserialize, IterateParams, Store, Stores, ValueKey, + Deserialize, IterateParams, Store, ValueKey, }; -use tracing_appender::non_blocking::WorkerGuard; use utils::{ - config::{ipmask::IpAddrOrMask, utils::ParseValue, Config, ConfigKey}, - failed, + config::{Config, ConfigKey}, glob::GlobPattern, - UnwrapFailure, }; -use crate::{listener::blocked::BLOCKED_IP_KEY, Core, SharedCore}; - -use super::{ - server::{tls::parse_certificates, Servers}, - tracers::Tracers, -}; +use super::download_resource; #[derive(Default)] pub struct ConfigManager { - cfg_local: ArcSwap>, - cfg_local_path: PathBuf, - cfg_local_patterns: Arc, - cfg_store: Store, + pub cfg_local: ArcSwap>, + pub cfg_local_path: PathBuf, + pub cfg_local_patterns: Arc, + pub cfg_store: Store, } #[derive(Default)] @@ -44,11 +57,6 @@ enum Pattern { Exclude(MatchType), } -pub struct ReloadResult { - pub config: Config, - pub new_core: Option, -} - enum MatchType { Equal(String), StartsWith(String), @@ -57,102 +65,10 @@ enum MatchType { All, } -pub struct BootManager { - pub config: Config, - pub core: SharedCore, - pub servers: Servers, - pub guards: Option>, -} - -impl BootManager { - pub async fn init() -> Self { - let mut config_path = std::env::var("CONFIG_PATH").ok(); - let mut found_param = false; - - if config_path.is_none() { - for arg in std::env::args().skip(1) { - if let Some((key, value)) = arg.split_once('=') { - if key.starts_with("--config") { - config_path = value.trim().to_string().into(); - break; - } else { - failed(&format!("Invalid command line argument: {key}")); - } - } else if found_param { - config_path = arg.into(); - break; - } else if arg.starts_with("--config") { - found_param = true; - } else { - failed(&format!("Invalid command line argument: {arg}")); - } - } - } - - // Read main configuration file - let cfg_local_path = - PathBuf::from(config_path.failed("Missing parameter --config=.")); - let mut config = Config::default(); - match std::fs::read_to_string(&cfg_local_path) { - Ok(value) => { - config.parse(&value).failed("Invalid configuration file"); - } - Err(err) => { - config.new_build_error("*", format!("Could not read configuration file: {err}")); - } - } - let cfg_local = config.keys.clone(); - - // Resolve macros - config.resolve_macros().await; - - // Parser servers - let mut servers = Servers::parse(&mut config); - - // Bind ports and drop privileges - servers.bind_and_drop_priv(&mut config); - - // Load stores - let mut stores = Stores::parse(&mut config).await; - - // Build manager - let manager = ConfigManager { - cfg_local: ArcSwap::from_pointee(cfg_local), - cfg_local_path, - cfg_local_patterns: Patterns::parse(&mut config).into(), - cfg_store: config - .value("storage.data") - .and_then(|id| stores.stores.get(id)) - .cloned() - .unwrap_or_default(), - }; - - // Extend configuration with settings stored in the db - if !manager.cfg_store.is_none() { - manager - .extend_config(&mut config, "") - .await - .failed("Failed to read configuration"); - } - - // Parse lookup stores - stores.parse_lookups(&mut config).await; - - // Parse settings and build shared core - let core = Core::parse(&mut config, stores, manager) - .await - .into_shared(); - - // Parse TCP acceptors - servers.parse_tcp_acceptors(&mut config, core.clone()); - - BootManager { - core, - guards: Tracers::parse(&mut config).enable(&mut config), - config, - servers, - } - } +pub(crate) struct ExternalConfig { + pub id: String, + pub version: String, + pub keys: Vec, } impl ConfigManager { @@ -167,7 +83,11 @@ impl ConfigManager { .map(|_| config) } - async fn extend_config(&self, config: &mut Config, prefix: &str) -> store::Result<()> { + pub(crate) async fn extend_config( + &self, + config: &mut Config, + prefix: &str, + ) -> store::Result<()> { for (key, value) in self.db_list(prefix, false).await? { config.keys.entry(key).or_insert(value); } @@ -399,124 +319,73 @@ impl ConfigManager { )) }) } -} -impl Core { - pub async fn reload_blocked_ips(&self) -> store::Result { - let mut ip_addresses = AHashSet::new(); - let mut config = self.storage.config.build_config(BLOCKED_IP_KEY).await?; + pub async fn update_external_config(&self, url: &str) -> store::Result> { + let external = self + .fetch_external_config(url) + .await + .map_err(store::Error::InternalError)?; - for ip in config - .set_values(BLOCKED_IP_KEY) - .map(IpAddrOrMask::parse_value) - .collect::>() + if self + .get(&external.id) + .await? + .map_or(true, |v| v != external.version) { - match ip { - Ok(IpAddrOrMask::Ip(ip)) => { - ip_addresses.insert(ip); - } - Ok(IpAddrOrMask::Mask(_)) => {} - Err(err) => { - config.new_parse_error(BLOCKED_IP_KEY, err); - } - } - } - - *self.network.blocked_ips.ip_addresses.write() = ip_addresses; - - Ok(config.into()) - } - - pub async fn reload_certificates(&self) -> store::Result { - let mut config = self.storage.config.build_config("certificate").await?; - let mut certificates = self.tls.certificates.load().as_ref().clone(); - - parse_certificates(&mut config, &mut certificates, &mut Default::default()); - - self.tls.certificates.store(certificates.into()); - - Ok(config.into()) - } - - pub async fn reload_lookups(&self) -> store::Result { - let mut config = self.storage.config.build_config("certificate").await?; - let mut stores = Stores::default(); - stores.parse_memory_stores(&mut config); - - let mut core = self.clone(); - for (id, store) in stores.lookup_stores { - core.storage.lookups.insert(id, store); - } - - Ok(ReloadResult { - config, - new_core: core.into(), - }) - } - - pub async fn reload(&self) -> store::Result { - let mut config = self.storage.config.build_config("").await?; - - // Parse tracers - Tracers::parse(&mut config); - - // Load stores - let mut stores = Stores { - stores: self.storage.stores.clone(), - blob_stores: self.storage.blobs.clone(), - fts_stores: self.storage.ftss.clone(), - lookup_stores: self.storage.lookups.clone(), - purge_schedules: Default::default(), - }; - stores.parse_stores(&mut config).await; - stores.parse_lookups(&mut config).await; - if !config.errors.is_empty() { - return Ok(config.into()); - } - - // Build manager - let manager = ConfigManager { - cfg_local: ArcSwap::from_pointee(self.storage.config.cfg_local.load().as_ref().clone()), - cfg_local_path: self.storage.config.cfg_local_path.clone(), - cfg_local_patterns: Patterns::parse(&mut config).into(), - cfg_store: config - .value("storage.data") - .and_then(|id| stores.stores.get(id)) - .cloned() - .unwrap_or_default(), - }; - - // Parse settings and build shared core - let mut core = Core::parse(&mut config, stores, manager).await; - if !config.errors.is_empty() { - return Ok(config.into()); - } - // Transfer Sieve cache - core.sieve.bayes_cache = self.sieve.bayes_cache.clone(); - core.sieve.remote_lists = RwLock::new(self.sieve.remote_lists.read().clone()); - - // Copy ACME certificates - let mut certificates = core.tls.certificates.load().as_ref().clone(); - for (cert_id, cert) in self.tls.certificates.load().iter() { - certificates - .entry(cert_id.to_string()) - .or_insert(cert.clone()); - } - core.tls.certificates.store(certificates.into()); - core.tls.self_signed_cert = self.tls.self_signed_cert.clone(); - - // Parser servers - let mut servers = Servers::parse(&mut config); - servers.parse_tcp_acceptors(&mut config, core.clone().into_shared()); - - Ok(if config.errors.is_empty() { - ReloadResult { - config, - new_core: core.into(), - } + self.set(external.keys).await?; + Ok(Some(external.version)) } else { - config.into() - }) + tracing::debug!( + context = "config", + event = "update", + url = url, + version = external.version, + "Configuration version is up-to-date" + ); + Ok(None) + } + } + + pub(crate) async fn fetch_external_config(&self, url: &str) -> Result { + let config = String::from_utf8(download_resource(url).await?) + .map_err(|err| format!("Configuration file has invalid UTF-8: {err}"))?; + let config = Config::new(config) + .map_err(|err| format!("Failed to parse external configuration: {err}"))?; + + // Import configuration + let mut external = ExternalConfig { + id: String::new(), + version: String::new(), + keys: Vec::new(), + }; + for (key, value) in config.keys { + if key.starts_with("version.") { + external.id = key.clone(); + external.version = value.clone(); + external.keys.push(ConfigKey::from((key, value))); + } else if key.starts_with("queue.quota.") + || key.starts_with("queue.throttle.") + || key.starts_with("session.throttle.") + || (key.starts_with("lookup.") && !key.starts_with("lookup.default.")) + || key.starts_with("sieve.trusted.scripts.") + { + external.keys.push(ConfigKey::from((key, value))); + } else { + tracing::debug!( + context = "config", + event = "import", + key = key, + value = value, + url = url, + "Ignoring key" + ); + } + } + + if !external.version.is_empty() { + Ok(external) + } else { + Err("External configuration file does not contain a version key".to_string()) + } } } @@ -560,20 +429,19 @@ impl Patterns { if cfg_local_patterns.is_empty() { cfg_local_patterns = vec![ Pattern::Include(MatchType::StartsWith("store.".to_string())), - Pattern::Include(MatchType::StartsWith("server.listener.".to_string())), - Pattern::Include(MatchType::StartsWith("server.socket.".to_string())), - Pattern::Include(MatchType::StartsWith("server.tls.".to_string())), + Pattern::Include(MatchType::StartsWith("directory.".to_string())), + Pattern::Include(MatchType::StartsWith("tracer.".to_string())), + Pattern::Include(MatchType::StartsWith("server.".to_string())), + Pattern::Include(MatchType::StartsWith( + "authentication.fallback-admin.".to_string(), + )), Pattern::Include(MatchType::Equal("cluster.node-id".to_string())), Pattern::Include(MatchType::Equal("storage.data".to_string())), Pattern::Include(MatchType::Equal("storage.blob".to_string())), Pattern::Include(MatchType::Equal("storage.lookup".to_string())), Pattern::Include(MatchType::Equal("storage.fts".to_string())), - Pattern::Include(MatchType::Equal("server.run-as.user".to_string())), - Pattern::Include(MatchType::Equal("server.run-as.group".to_string())), - Pattern::Exclude(MatchType::Matches(GlobPattern::compile( - "store.*.query.*", - false, - ))), + Pattern::Include(MatchType::Equal("storage.directory".to_string())), + Pattern::Include(MatchType::Equal("lookup.default.hostname".to_string())), ]; } @@ -626,12 +494,3 @@ impl Clone for ConfigManager { } } } - -impl From for ReloadResult { - fn from(config: Config) -> Self { - Self { - config, - new_core: None, - } - } -} diff --git a/crates/common/src/manager/mod.rs b/crates/common/src/manager/mod.rs new file mode 100644 index 00000000..d4255721 --- /dev/null +++ b/crates/common/src/manager/mod.rs @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::time::Duration; + +use crate::USER_AGENT; + +pub mod boot; +pub mod config; +pub mod reload; +pub mod webadmin; + +pub const SPAMFILTER_URL: &str = "https://get.stalw.art/resources/config/spamfilter.toml"; +pub const WEBADMIN_URL: &str = "file://get.stalw.art/resources/config/webadmin.toml"; +pub const WEBADMIN_KEY: &[u8] = "STALWART_WEBADMIN".as_bytes(); + +async fn download_resource(url: &str) -> Result, String> { + let todo = "remove"; + if url == WEBADMIN_URL { + return Ok(tokio::fs::read("/tmp/dist.zip").await.unwrap()); + } + + reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .user_agent(USER_AGENT) + .build() + .unwrap_or_default() + .get(url) + .send() + .await + .map_err(|err| format!("Failed to fetch {url}: {err}"))? + .bytes() + .await + .map_err(|err| format!("Failed to fetch {url}: {err}")) + .map(|bytes| bytes.to_vec()) +} diff --git a/crates/common/src/manager/reload.rs b/crates/common/src/manager/reload.rs new file mode 100644 index 00000000..3bded2a3 --- /dev/null +++ b/crates/common/src/manager/reload.rs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use ahash::AHashSet; +use arc_swap::ArcSwap; +use parking_lot::RwLock; +use store::Stores; +use utils::config::{ipmask::IpAddrOrMask, utils::ParseValue, Config}; + +use crate::{ + config::{ + server::{tls::parse_certificates, Servers}, + tracers::Tracers, + }, + listener::blocked::BLOCKED_IP_KEY, + Core, +}; + +use super::config::{ConfigManager, Patterns}; + +pub struct ReloadResult { + pub config: Config, + pub new_core: Option, +} + +impl Core { + pub async fn reload_blocked_ips(&self) -> store::Result { + let mut ip_addresses = AHashSet::new(); + let mut config = self.storage.config.build_config(BLOCKED_IP_KEY).await?; + + for ip in config + .set_values(BLOCKED_IP_KEY) + .map(IpAddrOrMask::parse_value) + .collect::>() + { + match ip { + Ok(IpAddrOrMask::Ip(ip)) => { + ip_addresses.insert(ip); + } + Ok(IpAddrOrMask::Mask(_)) => {} + Err(err) => { + config.new_parse_error(BLOCKED_IP_KEY, err); + } + } + } + + *self.network.blocked_ips.ip_addresses.write() = ip_addresses; + + Ok(config.into()) + } + + pub async fn reload_certificates(&self) -> store::Result { + let mut config = self.storage.config.build_config("certificate").await?; + let mut certificates = self.tls.certificates.load().as_ref().clone(); + + parse_certificates(&mut config, &mut certificates, &mut Default::default()); + + self.tls.certificates.store(certificates.into()); + + Ok(config.into()) + } + + pub async fn reload_lookups(&self) -> store::Result { + let mut config = self.storage.config.build_config("certificate").await?; + let mut stores = Stores::default(); + stores.parse_memory_stores(&mut config); + + let mut core = self.clone(); + for (id, store) in stores.lookup_stores { + core.storage.lookups.insert(id, store); + } + + Ok(ReloadResult { + config, + new_core: core.into(), + }) + } + + pub async fn reload(&self) -> store::Result { + let mut config = self.storage.config.build_config("").await?; + + // Parse tracers + Tracers::parse(&mut config); + + // Load stores + let mut stores = Stores { + stores: self.storage.stores.clone(), + blob_stores: self.storage.blobs.clone(), + fts_stores: self.storage.ftss.clone(), + lookup_stores: self.storage.lookups.clone(), + purge_schedules: Default::default(), + }; + stores.parse_stores(&mut config).await; + stores.parse_lookups(&mut config).await; + if !config.errors.is_empty() { + return Ok(config.into()); + } + + // Build manager + let manager = ConfigManager { + cfg_local: ArcSwap::from_pointee(self.storage.config.cfg_local.load().as_ref().clone()), + cfg_local_path: self.storage.config.cfg_local_path.clone(), + cfg_local_patterns: Patterns::parse(&mut config).into(), + cfg_store: config + .value("storage.data") + .and_then(|id| stores.stores.get(id)) + .cloned() + .unwrap_or_default(), + }; + + // Parse settings and build shared core + let mut core = Core::parse(&mut config, stores, manager).await; + if !config.errors.is_empty() { + return Ok(config.into()); + } + // Transfer Sieve cache + core.sieve.bayes_cache = self.sieve.bayes_cache.clone(); + core.sieve.remote_lists = RwLock::new(self.sieve.remote_lists.read().clone()); + + // Copy ACME certificates + let mut certificates = core.tls.certificates.load().as_ref().clone(); + for (cert_id, cert) in self.tls.certificates.load().iter() { + certificates + .entry(cert_id.to_string()) + .or_insert(cert.clone()); + } + core.tls.certificates.store(certificates.into()); + core.tls.self_signed_cert = self.tls.self_signed_cert.clone(); + + // Parser servers + let mut servers = Servers::parse(&mut config); + servers.parse_tcp_acceptors(&mut config, core.clone().into_shared()); + + Ok(if config.errors.is_empty() { + ReloadResult { + config, + new_core: core.into(), + } + } else { + config.into() + }) + } +} + +impl From for ReloadResult { + fn from(config: Config) -> Self { + Self { + config, + new_core: None, + } + } +} diff --git a/crates/common/src/manager/webadmin.rs b/crates/common/src/manager/webadmin.rs new file mode 100644 index 00000000..06dea06b --- /dev/null +++ b/crates/common/src/manager/webadmin.rs @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::{ + io::{self, Cursor, Read}, + path::PathBuf, +}; + +use ahash::AHashMap; +use arc_swap::ArcSwap; +use store::BlobStore; + +use super::{download_resource, WEBADMIN_KEY, WEBADMIN_URL}; + +pub struct WebAdminManager { + bundle_path: TempDir, + routes: ArcSwap>>, +} + +#[derive(Default)] +pub struct Resource { + pub content_type: &'static str, + pub contents: T, +} + +impl WebAdminManager { + pub fn new() -> Self { + Self { + bundle_path: TempDir::new(), + routes: ArcSwap::from_pointee(Default::default()), + } + } + + pub async fn get(&self, path: &str) -> io::Result>> { + let routes = self.routes.load(); + if let Some(resource) = routes.get(path).or_else(|| routes.get("index.html")) { + tokio::fs::read(&resource.contents) + .await + .map(|contents| Resource { + content_type: resource.content_type, + contents, + }) + } else { + Ok(Resource::default()) + } + } + + pub async fn unpack(&self, blob_store: &BlobStore) -> store::Result<()> { + // Delete any existing bundles + self.bundle_path.clean().await?; + + // Obtain webadmin bundle + let bundle = blob_store + .get_blob(WEBADMIN_KEY, 0..usize::MAX) + .await? + .ok_or_else(|| store::Error::InternalError("WebAdmin bundle not found".to_string()))?; + + // Uncompress + let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)) + .map_err(|err| store::Error::InternalError(format!("Unzip error: {err}")))?; + let mut routes = AHashMap::new(); + for i in 0..bundle.len() { + let (file_name, contents) = { + let mut file = bundle + .by_index(i) + .map_err(|err| store::Error::InternalError(format!("Unzip error: {err}")))?; + if file.is_dir() { + continue; + } + + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + (file.name().to_string(), contents) + }; + let path = self.bundle_path.path.join(format!("{i:02}")); + tokio::fs::write(&path, contents).await?; + + let resource = Resource { + content_type: match file_name + .rsplit_once('.') + .map(|(_, ext)| ext) + .unwrap_or_default() + { + "html" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + "json" => "application/json", + "png" => "image/png", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + _ => "application/octet-stream", + }, + contents: path, + }; + + routes.insert(file_name, resource); + } + + // Update routes + self.routes.store(routes.into()); + + Ok(()) + } + + pub async fn update_and_unpack(&self, blob_store: &BlobStore) -> store::Result<()> { + let bytes = download_resource(WEBADMIN_URL).await.map_err(|err| { + store::Error::InternalError(format!("Failed to download webadmin: {err}")) + })?; + blob_store.put_blob(WEBADMIN_KEY, &bytes).await?; + self.unpack(blob_store).await + } +} + +impl Resource> { + pub fn is_empty(&self) -> bool { + self.content_type.is_empty() && self.contents.is_empty() + } +} + +pub struct TempDir { + pub path: PathBuf, +} + +impl TempDir { + pub fn new() -> TempDir { + TempDir { + path: std::env::temp_dir().join(std::str::from_utf8(WEBADMIN_KEY).unwrap()), + } + } + + pub async fn clean(&self) -> io::Result<()> { + if tokio::fs::metadata(&self.path).await.is_ok() { + let _ = tokio::fs::remove_dir_all(&self.path).await; + } + tokio::fs::create_dir(&self.path).await + } +} + +impl Default for WebAdminManager { + fn default() -> Self { + Self::new() + } +} + +impl Default for TempDir { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 88376550..1b67d546 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -22,14 +22,11 @@ */ use jmap_proto::types::collection::Collection; -use pwhash::sha512_crypt; use store::{ - rand::{distributions::Alphanumeric, thread_rng, Rng}, write::{ - assert::HashedValue, key::DeserializeBigEndian, BatchBuilder, BitmapClass, DirectoryClass, - ValueClass, + assert::HashedValue, key::DeserializeBigEndian, BatchBuilder, DirectoryClass, ValueClass, }, - BitmapKey, Deserialize, IterateParams, Serialize, Store, ValueKey, U32_LEN, + Deserialize, IterateParams, Serialize, Store, ValueKey, U32_LEN, }; use crate::{DirectoryError, ManagementError, Principal, QueryBy, Type}; @@ -76,7 +73,6 @@ pub trait ManageDirectory: Sized { async fn create_domain(&self, domain: &str) -> crate::Result<()>; async fn delete_domain(&self, domain: &str) -> crate::Result<()>; async fn list_domains(&self, filter: Option<&str>) -> crate::Result>; - async fn init(self) -> crate::Result; } impl ManageDirectory for Store { @@ -973,85 +969,6 @@ impl ManageDirectory for Store { .await?; Ok(results) } - - async fn init(self) -> crate::Result { - // Create admin account if requested - if let (Ok(admin_user), Ok(admin_pass)) = ( - std::env::var("SET_ADMIN_USER"), - std::env::var("SET_ADMIN_PASS"), - ) { - if let Some(account_id) = self.get_account_id(&admin_user).await? { - self.update_account( - QueryBy::Id(account_id), - vec![PrincipalUpdate { - action: PrincipalAction::Set, - field: PrincipalField::Secrets, - value: PrincipalValue::StringList(vec![admin_pass]), - }], - ) - .await?; - eprintln!("Successfully updated password for {admin_user:?}."); - } else { - self.create_account( - Principal { - typ: Type::Superuser, - quota: 0, - name: admin_user.clone(), - secrets: vec![admin_pass], - emails: vec![], - member_of: vec![], - description: "Superuser".to_string().into(), - ..Default::default() - }, - vec![], - ) - .await?; - eprintln!("Successfully created administrator account {admin_user:?}."); - } - std::process::exit(0); - } - - // Create a default administrator account if none exists - if self - .get_bitmap(BitmapKey { - account_id: u32::MAX, - collection: Collection::Principal.into(), - class: BitmapClass::DocumentIds, - block_num: 0, - }) - .await? - .unwrap_or_default() - .is_empty() - { - let secret = thread_rng() - .sample_iter(Alphanumeric) - .take(12) - .map(char::from) - .collect::(); - let hashed_secret = sha512_crypt::hash(&secret).unwrap(); - - self.create_account( - Principal { - typ: Type::Superuser, - quota: 0, - name: "admin".to_string(), - secrets: vec![hashed_secret], - emails: vec![], - member_of: vec![], - description: "Superuser".to_string().into(), - ..Default::default() - }, - vec![], - ) - .await?; - - tracing::info!( - "Created default administrator account \"admin\" with password {secret:?}." - ) - } - - Ok(self) - } } impl From> for Principal { diff --git a/crates/directory/src/backend/internal/mod.rs b/crates/directory/src/backend/internal/mod.rs index 21c4f68b..2fed4d33 100644 --- a/crates/directory/src/backend/internal/mod.rs +++ b/crates/directory/src/backend/internal/mod.rs @@ -155,9 +155,9 @@ pub enum PrincipalField { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PrincipalUpdate { - action: PrincipalAction, - field: PrincipalField, - value: PrincipalValue, + pub action: PrincipalAction, + pub field: PrincipalField, + pub value: PrincipalValue, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs index ce4542b3..665498c0 100644 --- a/crates/directory/src/core/config.rs +++ b/crates/directory/src/core/config.rs @@ -33,8 +33,8 @@ use ahash::AHashMap; use crate::{ backend::{ - imap::ImapDirectory, internal::manage::ManageDirectory, ldap::LdapDirectory, - memory::MemoryDirectory, smtp::SmtpDirectory, sql::SqlDirectory, + imap::ImapDirectory, ldap::LdapDirectory, memory::MemoryDirectory, smtp::SmtpDirectory, + sql::SqlDirectory, }, Directories, Directory, DirectoryInner, }; @@ -68,15 +68,7 @@ impl Directories { "internal" => Some(DirectoryInner::Internal( if let Some(store_id) = config.value_require(("directory", id, "store")) { if let Some(data) = stores.stores.get(store_id) { - match data.clone().init().await { - Ok(data) => data, - Err(err) => { - let err = - format!("Failed to initialize store {store_id:?}: {err:?}"); - config.new_parse_error(("directory", id, "store"), err); - continue; - } - } + data.clone() } else { config.new_parse_error( ("directory", id, "store"), diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index 4cb0bb7c..05bf27c6 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -109,7 +109,7 @@ async fn verify_hash_prefix(hashed_secret: &str, secret: &str) -> bool { } } -async fn verify_secret_hash(hashed_secret: &str, secret: &str) -> bool { +pub async fn verify_secret_hash(hashed_secret: &str, secret: &str) -> bool { if hashed_secret.starts_with('$') { verify_hash_prefix(hashed_secret, secret).await } else if hashed_secret.starts_with('_') { diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 26dc2261..b82fe222 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -201,6 +201,19 @@ impl From> for DirectoryError { } } +impl Principal { + pub fn fallback_admin(fallback_pass: impl Into) -> Self { + Principal { + id: u32::MAX, + typ: Type::Superuser, + quota: 0, + name: "Fallback Administrator".to_string(), + secrets: vec![fallback_pass.into()], + ..Default::default() + } + } +} + impl From for DirectoryError { fn from(error: LdapError) -> Self { tracing::warn!( diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index cce8bc2c..b5dafe7d 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -26,6 +26,7 @@ use std::{net::IpAddr, sync::Arc}; use common::{ expr::{functions::ResolveVariable, *}, listener::{ServerInstance, SessionData, SessionManager, SessionStream}, + manager::webadmin::Resource, Core, }; use http_body_util::{BodyExt, Full}; @@ -270,7 +271,19 @@ impl JMAP { Err(err) => err.into_http_response(), }; } - _ => (), + _ => { + let path = req.uri().path(); + return match self + .inner + .webadmin + .get(path.strip_prefix('/').unwrap_or(path)) + .await + { + Ok(resource) if !resource.is_empty() => resource.into_http_response(), + Err(err) => err.into_http_response(), + _ => RequestError::not_found().into_http_response(), + }; + } } RequestError::not_found().into_http_response() } @@ -451,6 +464,14 @@ impl ToHttpResponse for store::Error { } } +impl ToHttpResponse for std::io::Error { + fn into_http_response(self) -> HttpResponse { + tracing::error!(context = "i/o", error = %self, "I/O error"); + + RequestError::internal_server_error().into_http_response() + } +} + impl ToHttpResponse for serde_json::Error { fn into_http_response(self) -> HttpResponse { RequestError::blank( @@ -527,6 +548,20 @@ impl ToHttpResponse for DownloadResponse { } } +impl ToHttpResponse for Resource> { + fn into_http_response(self) -> HttpResponse { + hyper::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, self.content_type) + .body( + Full::new(Bytes::from(self.contents)) + .map_err(|never| match never {}) + .boxed(), + ) + .unwrap() + } +} + impl ToHttpResponse for UploadResponse { fn into_http_response(self) -> HttpResponse { JsonResponse::new(self).into_http_response() diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs index 1898de6c..f19c8a87 100644 --- a/crates/jmap/src/api/management/domain.rs +++ b/crates/jmap/src/api/management/domain.rs @@ -22,8 +22,8 @@ */ use directory::backend::internal::manage::ManageDirectory; -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; + +use hyper::Method; use jmap_proto::error::request::RequestError; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -34,7 +34,7 @@ use crate::{ api::{ http::ToHttpResponse, management::dkim::{obtain_dkim_public_key, Algorithm}, - HttpRequest, JsonResponse, + HttpRequest, HttpResponse, JsonResponse, }, JMAP, }; @@ -48,11 +48,7 @@ struct DnsRecord { } impl JMAP { - pub async fn handle_manage_domain( - &self, - req: &HttpRequest, - path: Vec<&str>, - ) -> hyper::Response> { + pub async fn handle_manage_domain(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { match (path.get(1), req.method()) { (None, &Method::GET) => { // List domains @@ -97,10 +93,28 @@ impl JMAP { (Some(domain), &Method::POST) => { // Create domain match self.core.storage.data.create_domain(domain).await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), + Ok(_) => { + // Set default domain name if missing + if matches!( + self.core.storage.config.get("lookup.default.domain").await, + Ok(None) + ) { + if let Err(err) = self + .core + .storage + .config + .set([("lookup.default.domain", *domain)]) + .await + { + tracing::error!("Failed to set default domain name: {}", err); + } + } + + JsonResponse::new(json!({ + "data": (), + })) + .into_http_response() + } Err(err) => err.into_http_response(), } } diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index d80acbc8..5e92c3a3 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -32,14 +32,13 @@ pub mod stores; use std::{borrow::Cow, sync::Arc}; -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use hyper::Method; use jmap_proto::error::request::RequestError; use serde::Serialize; use crate::{auth::AccessToken, JMAP}; -use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; +use super::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}; #[derive(Serialize)] #[serde(tag = "error")] @@ -72,7 +71,7 @@ impl JMAP { req: &HttpRequest, body: Option>, access_token: Arc, - ) -> hyper::Response> { + ) -> HttpResponse { let path = req.uri().path().split('/').skip(2).collect::>(); let is_superuser = access_token.is_super_user(); @@ -85,16 +84,16 @@ impl JMAP { "queue" if is_superuser => self.handle_manage_queue(req, path).await, "reports" if is_superuser => self.handle_manage_reports(req, path).await, "dkim" if is_superuser => self.handle_manage_dkim(req, path, body).await, + "update" if is_superuser => self.handle_manage_update(req, path).await, "oauth" => self.handle_oauth_api_request(access_token, body).await, "crypto" => match *req.method() { Method::POST => self.handle_crypto_post(access_token, body).await, Method::GET => self.handle_crypto_get(access_token).await, _ => RequestError::not_found().into_http_response(), }, - "password" => match *req.method() { - Method::POST => self.handle_change_password(req, access_token, body).await, - _ => RequestError::not_found().into_http_response(), - }, + "password" if req.method() == Method::POST => { + self.handle_change_password(req, access_token, body).await + } _ => RequestError::not_found().into_http_response(), } } diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index f4ffeac6..625b7179 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -30,8 +30,8 @@ use directory::{ }, DirectoryError, DirectoryInner, ManagementError, Principal, QueryBy, Type, }; -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, header, Method, StatusCode}; + +use hyper::{header, Method, StatusCode}; use jmap_proto::error::request::RequestError; use serde_json::json; use utils::url_params::UrlParams; @@ -76,7 +76,7 @@ impl JMAP { req: &HttpRequest, path: Vec<&str>, body: Option>, - ) -> hyper::Response> { + ) -> HttpResponse { match (path.get(1), req.method()) { (None, &Method::POST) => { // Make sure the current directory supports updates @@ -251,6 +251,18 @@ impl JMAP { body.as_deref().unwrap_or_default(), ) { Ok(changes) => { + // Make sure the current directory supports updates + if let Some(response) = self.assert_supported_directory() { + if changes.iter().any(|change| { + !matches!( + change.field, + PrincipalField::Quota | PrincipalField::Description + ) + }) { + return response; + } + } + match self .core .storage @@ -281,7 +293,7 @@ impl JMAP { req: &HttpRequest, access_token: Arc, body: Option>, - ) -> hyper::Response> { + ) -> HttpResponse { // Make sure the user authenticated using Basic auth if req .headers() @@ -295,11 +307,7 @@ impl JMAP { .into_http_response(); } - // Make sure the current directory supports updates - if let Some(response) = self.assert_supported_directory() { - return response; - } - + // Obtain new password let new_password = match String::from_utf8(body.unwrap_or_default()) { Ok(new_password) if !new_password.is_empty() => new_password, _ => { @@ -310,6 +318,28 @@ impl JMAP { } }; + // Handle Fallback admin password changes + if access_token.is_super_user() && access_token.primary_id() == u32::MAX { + return match self + .core + .storage + .config + .set([("authentication.fallback-admin.secret", new_password)]) + .await + { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + }; + } + + // Make sure the current directory supports updates + if let Some(response) = self.assert_supported_directory() { + return response; + } + // Update password match self .core @@ -332,9 +362,7 @@ impl JMAP { } } - pub fn assert_supported_directory( - &self, - ) -> Option>> { + pub fn assert_supported_directory(&self) -> Option { ManagementApiError::UnsupportedDirectoryOperation { class: match &self.core.storage.directory.store { DirectoryInner::Internal(_) => return None, diff --git a/crates/jmap/src/api/management/queue.rs b/crates/jmap/src/api/management/queue.rs index 2a17f860..7446bebc 100644 --- a/crates/jmap/src/api/management/queue.rs +++ b/crates/jmap/src/api/management/queue.rs @@ -23,8 +23,7 @@ use std::str::FromStr; -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use hyper::Method; use jmap_proto::error::request::RequestError; use mail_auth::{ dmarc::URI, @@ -42,7 +41,7 @@ use store::{ use utils::url_params::UrlParams; use crate::{ - api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, JMAP, }; @@ -118,11 +117,7 @@ pub enum Report { } impl JMAP { - pub async fn handle_manage_queue( - &self, - req: &HttpRequest, - path: Vec<&str>, - ) -> hyper::Response> { + pub async fn handle_manage_queue(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { let params = UrlParams::new(req.uri().query()); match ( diff --git a/crates/jmap/src/api/management/reload.rs b/crates/jmap/src/api/management/reload.rs index 5bd9f0af..3f84d561 100644 --- a/crates/jmap/src/api/management/reload.rs +++ b/crates/jmap/src/api/management/reload.rs @@ -21,23 +21,19 @@ * for more details. */ -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use common::manager::SPAMFILTER_URL; +use hyper::Method; use jmap_proto::error::request::RequestError; use serde_json::json; use utils::url_params::UrlParams; use crate::{ - api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, JMAP, }; impl JMAP { - pub async fn handle_manage_reload( - &self, - req: &HttpRequest, - path: Vec<&str>, - ) -> hyper::Response> { + pub async fn handle_manage_reload(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { match (path.get(1).copied(), req.method()) { (Some("lookup"), &Method::GET) => { match self.core.reload_lookups().await { @@ -92,4 +88,39 @@ impl JMAP { _ => RequestError::not_found().into_http_response(), } } + + pub async fn handle_manage_update(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { + match (path.get(1).copied(), req.method()) { + (Some("spam-filter"), &Method::GET) => { + match self + .core + .storage + .config + .update_external_config(SPAMFILTER_URL) + .await + { + Ok(result) => JsonResponse::new(json!({ + "data": result, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + (Some("webadmin"), &Method::GET) => { + match self + .inner + .webadmin + .update_and_unpack(&self.core.storage.blob) + .await + { + Ok(_) => JsonResponse::new(json!({ + "data": (), + })) + .into_http_response(), + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } } diff --git a/crates/jmap/src/api/management/report.rs b/crates/jmap/src/api/management/report.rs index 0e29dd00..14cfb5d6 100644 --- a/crates/jmap/src/api/management/report.rs +++ b/crates/jmap/src/api/management/report.rs @@ -21,8 +21,7 @@ * for more details. */ -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use hyper::Method; use jmap_proto::error::request::RequestError; use mail_auth::report::{ tlsrpt::{FailureDetails, Policy, TlsReport}, @@ -37,7 +36,7 @@ use store::{ use utils::url_params::UrlParams; use crate::{ - api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, JMAP, }; @@ -48,11 +47,7 @@ enum ReportType { } impl JMAP { - pub async fn handle_manage_reports( - &self, - req: &HttpRequest, - path: Vec<&str>, - ) -> hyper::Response> { + pub async fn handle_manage_reports(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { match ( path.get(1).copied().unwrap_or_default(), path.get(2).copied(), diff --git a/crates/jmap/src/api/management/settings.rs b/crates/jmap/src/api/management/settings.rs index d13ec567..5e3952f4 100644 --- a/crates/jmap/src/api/management/settings.rs +++ b/crates/jmap/src/api/management/settings.rs @@ -21,15 +21,14 @@ * for more details. */ -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use hyper::Method; use jmap_proto::error::request::RequestError; use serde_json::json; use store::ahash::AHashMap; use utils::{config::ConfigKey, url_params::UrlParams}; use crate::{ - api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, JMAP, }; @@ -57,7 +56,7 @@ impl JMAP { req: &HttpRequest, path: Vec<&str>, body: Option>, - ) -> hyper::Response> { + ) -> HttpResponse { match (path.get(1).copied(), req.method()) { (Some("group"), &Method::GET) => { // List settings diff --git a/crates/jmap/src/api/management/stores.rs b/crates/jmap/src/api/management/stores.rs index d49dcc1f..23511200 100644 --- a/crates/jmap/src/api/management/stores.rs +++ b/crates/jmap/src/api/management/stores.rs @@ -21,22 +21,17 @@ * for more details. */ -use http_body_util::combinators::BoxBody; -use hyper::{body::Bytes, Method}; +use hyper::Method; use jmap_proto::error::request::RequestError; use serde_json::json; use crate::{ - api::{http::ToHttpResponse, HttpRequest, JsonResponse}, + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, JMAP, }; impl JMAP { - pub async fn handle_manage_store( - &self, - req: &HttpRequest, - path: Vec<&str>, - ) -> hyper::Response> { + pub async fn handle_manage_store(&self, req: &HttpRequest, path: Vec<&str>) -> HttpResponse { match (path.get(1).copied(), req.method()) { (Some("maintenance"), &Method::GET) => { match self diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index f3494e17..4742992a 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -24,7 +24,7 @@ use std::{net::IpAddr, sync::Arc, time::Instant}; use common::{listener::limiter::InFlight, AuthResult}; -use directory::QueryBy; +use directory::{Principal, QueryBy}; use hyper::header; use jmap_proto::error::request::RequestError; use mail_parser::decoders::base64::base64_decode; @@ -179,15 +179,21 @@ impl JMAP { } pub async fn get_access_token(&self, account_id: u32) -> Option { - // Create access token - self.update_access_token(AccessToken::new( - self.core - .storage - .directory - .query(QueryBy::Id(account_id), true) - .await - .ok()??, - )) - .await + match self + .core + .storage + .directory + .query(QueryBy::Id(account_id), true) + .await + { + Ok(Some(principal)) => self.update_access_token(AccessToken::new(principal)).await, + _ => match &self.core.jmap.fallback_admin { + Some((_, secret)) if account_id == u32::MAX => { + self.update_access_token(AccessToken::new(Principal::fallback_admin(secret))) + .await + } + _ => None, + }, + } } } diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 4728c18f..49a41e45 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -198,24 +198,33 @@ impl JMAP { .into_http_response() } + async fn password_hash(&self, account_id: u32) -> Result { + if account_id != u32::MAX { + self.core + .storage + .directory + .query(QueryBy::Id(account_id), false) + .await + .map_err(|_| "Temporary lookup error")? + .ok_or("Account no longer exists")? + .secrets + .into_iter() + .next() + .ok_or("Failed to obtain password hash") + } else if let Some((_, secret)) = &self.core.jmap.fallback_admin { + Ok(secret.clone()) + } else { + Err("Invalid account id.") + } + } + pub async fn issue_token( &self, account_id: u32, client_id: &str, with_refresh_token: bool, ) -> Result { - let password_hash = self - .core - .storage - .directory - .query(QueryBy::Id(account_id), false) - .await - .map_err(|_| "Temporary lookup error")? - .ok_or("Account no longer exists")? - .secrets - .into_iter() - .next() - .ok_or("Failed to obtain password hash")?; + let password_hash = self.password_hash(account_id).await?; Ok(OAuthResponse { access_token: self.encode_access_token( @@ -324,18 +333,7 @@ impl JMAP { } // Obtain password hash - let password_hash = self - .core - .storage - .directory - .query(QueryBy::Id(account_id), false) - .await - .map_err(|_| "Temporary lookup error")? - .ok_or("Account no longer exists")? - .secrets - .into_iter() - .next() - .ok_or("Failed to obtain password hash")?; + let password_hash = self.password_hash(account_id).await?; // Build context let key = self.core.jmap.oauth_key.clone(); diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index f650d29f..f8f25775 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -24,7 +24,7 @@ use std::{collections::hash_map::RandomState, fmt::Display, sync::Arc, time::Duration}; use auth::{rate_limit::ConcurrencyLimiters, AccessToken}; -use common::{Core, DeliveryEvent, SharedCore}; +use common::{manager::webadmin::WebAdminManager, Core, DeliveryEvent, SharedCore}; use dashmap::DashMap; use directory::QueryBy; use email::cache::Threads; @@ -98,6 +98,7 @@ pub struct Inner { pub sessions: TtlDashMap, pub access_tokens: TtlDashMap>, pub snowflake_id: SnowflakeIdGenerator, + pub webadmin: WebAdminManager, pub concurrency_limiter: DashMap>, @@ -131,6 +132,7 @@ impl JMAP { let capacity = config.property("cache.capacity").unwrap_or(100); let inner = Inner { + webadmin: WebAdminManager::new(), sessions: TtlDashMap::with_capacity(capacity, shard_amount), access_tokens: TtlDashMap::with_capacity(capacity, shard_amount), snowflake_id: config @@ -149,6 +151,11 @@ impl JMAP { ), }; + // Unpack webadmin + if let Err(err) = inner.webadmin.unpack(&core.load().storage.blob).await { + tracing::warn!(event = "error", error = ?err, "Failed to unpack webadmin bundle."); + } + let jmap_instance = JmapInstance { core, jmap_inner: Arc::new(inner), diff --git a/crates/main/src/main.rs b/crates/main/src/main.rs index c45c2af1..6325ced0 100644 --- a/crates/main/src/main.rs +++ b/crates/main/src/main.rs @@ -23,7 +23,7 @@ use std::time::Duration; -use common::config::{manager::BootManager, server::ServerProtocol}; +use common::{config::server::ServerProtocol, manager::boot::BootManager}; use imap::core::{ImapSessionManager, IMAP}; use jmap::{api::JmapSessionManager, services::IPC_CHANNEL_BUFFER, JMAP}; use managesieve::core::ManageSieveSessionManager; diff --git a/tests/resources/scripts/create_test_env.sh b/tests/resources/scripts/create_test_env.sh index 2e1ea094..e044d3bd 100644 --- a/tests/resources/scripts/create_test_env.sh +++ b/tests/resources/scripts/create_test_env.sh @@ -1,30 +1,14 @@ #!/bin/bash BASE_DIR="/Users/me/Downloads/stalwart-test" -DOMAIN="example.org" FEATURES="sqlite foundationdb postgres mysql rocks elastic s3 redis" # Delete previous tests rm -rf $BASE_DIR -# Create directories -mkdir -p $BASE_DIR $BASE_DIR/data $BASE_DIR/etc - -# Copy resources -cp -r resources/config/config.toml $BASE_DIR/etc - -# Replace settings - -sed -i '' -e "s|%{env:STALWART_PATH}%|$BASE_DIR|g" \ - -e "s|%{env:DOMAIN}%|$DOMAIN|g" \ - -e "s|%{env:HOSTNAME}%|mail.$DOMAIN|g" \ - -e "s|%{env:OAUTH_KEY}%|12345|g" \ - -e 's/level = "info"/level = "trace"/g' "$BASE_DIR/etc/config.toml" - -#sed -i '' -e 's/allow-plain-text = false/allow-plain-text = true/g' \ -# -e 's/2000\/1m/9999999\/100m/g' \ -# -e 's/concurrent = 4/concurrent = 90000/g' "$BASE_DIR/etc/imap/settings.toml" - # Create admin user -SET_ADMIN_USER="admin" SET_ADMIN_PASS="secret" cargo run -p mail-server --no-default-features --features "$FEATURES" -- --config=$BASE_DIR/etc/config.toml -cargo run -p mail-server --no-default-features --features "$FEATURES" -- --config=$BASE_DIR/etc/config.toml +cargo run -p mail-server --no-default-features --features "$FEATURES" -- --init=$BASE_DIR + +echo "[server.http]\npermissive-cors = true\n" >> $BASE_DIR/etc/config.toml +echo "[tracer.stdout]\ntype = 'stdout'\nlevel = 'info'\nansi = true\nenable = true" >> $BASE_DIR/etc/config.toml +#cargo run -p mail-server --no-default-features --features "$FEATURES" -- --config=$BASE_DIR/etc/config.toml