diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 953aa317..e38d0ea8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,21 +88,19 @@ jobs: run: | cargo build -p mail-server --target=${{ matrix.target }} --no-default-features --features "foundationdb elastic s3 redis" --release cd target/${{ matrix.target }}/release && tar czvf ../../../stalwart-mail-foundationdb-${{ matrix.target }}.tar.gz stalwart-mail && cd - - cargo build -p mail-server -p stalwart-cli -p stalwart-install --target=${{ matrix.target }} --release + cargo build -p mail-server -p stalwart-cli --target=${{ matrix.target }} --release cd target/${{ matrix.target }}/release tar czvf ../../../stalwart-mail-${{ matrix.target }}.tar.gz stalwart-mail tar czvf ../../../stalwart-cli-${{ matrix.target }}.tar.gz stalwart-cli - tar czvf ../../../stalwart-install-${{ matrix.target }}.tar.gz stalwart-install cd - - name: Building binary (Windows version) if: ${{ contains(matrix.host_os, 'windows') }} run: | - cargo build -p mail-server -p stalwart-cli -p stalwart-install --target=${{ matrix.target }} --release + cargo build -p mail-server -p stalwart-cli --target=${{ matrix.target }} --release cd target/${{ matrix.target }}/release 7z a ../../../stalwart-mail-${{ matrix.target }}.zip stalwart-mail.exe 7z a ../../../stalwart-cli-${{ matrix.target }}.zip stalwart-cli.exe - 7z a ../../../stalwart-install-${{ matrix.target }}.zip stalwart-install.exe cd - - name: Publish Release @@ -158,10 +156,9 @@ jobs: run: | export PATH="$HOME/.cargo/bin:$PATH" - cargo build -p stalwart-cli -p stalwart-install --target=${target} --release + cargo build -p stalwart-cli --target=${target} --release cd target/${target}/release tar czvf /artifacts/stalwart-cli-${target}.tar.gz stalwart-cli - tar czvf /artifacts/stalwart-install-${target}.tar.gz stalwart-install cd - - name: Move packages diff --git a/Cargo.lock b/Cargo.lock index ec021bdc..bb00aaeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,19 +1473,6 @@ dependencies = [ "cipher 0.4.4", ] -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror", - "zeroize", -] - [[package]] name = "digest" version = "0.9.0" @@ -1853,12 +1840,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95765f67b4b18863968b4a1bd5bb576f732b29a4a28c7cd84c09fa3e2875f33c" -[[package]] -name = "fastrand" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" - [[package]] name = "ff" version = "0.13.0" @@ -1875,18 +1856,6 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c007b1ae3abe1cb6f85a16305acd418b7ca6343b953633fee2b76d8f108b830f" -[[package]] -name = "filetime" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "windows-sys 0.52.0", -] - [[package]] name = "finl_unicode" version = "1.2.0" @@ -2920,6 +2889,7 @@ dependencies = [ "rasn-cms", "rasn-pkix", "reqwest 0.12.2", + "rev_lines", "rsa", "sequoia-openpgp", "serde", @@ -3780,15 +3750,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" -[[package]] -name = "openssl-src" -version = "300.2.3+3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cff92b6f71555b61bb9315f7c64da3ca43d87531622120fea0195fc761b4843" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.102" @@ -3797,7 +3758,6 @@ checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -4766,6 +4726,15 @@ dependencies = [ "quick-error", ] +[[package]] +name = "rev_lines" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed62916ac7a5ccbf13fa5e1d303029ff015600fee841756dfc134a1ac62bf05f" +dependencies = [ + "thiserror", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -5521,12 +5490,6 @@ 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.3.0" @@ -5741,26 +5704,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "stalwart-install" -version = "0.7.0" -dependencies = [ - "base64 0.22.0", - "clap", - "dialoguer", - "flate2", - "indicatif", - "libc", - "openssl", - "pwhash", - "rand", - "rcgen 0.13.0", - "reqwest 0.12.2", - "rpassword", - "tar", - "zip-extract", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -5955,29 +5898,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tar" -version = "0.4.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" -dependencies = [ - "cfg-if", - "fastrand", - "rustix", - "windows-sys 0.52.0", -] - [[package]] name = "term" version = "0.7.0" @@ -7161,17 +7081,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" -dependencies = [ - "libc", - "linux-raw-sys", - "rustix", -] - [[package]] name = "xml-rs" version = "0.8.20" @@ -7253,17 +7162,6 @@ dependencies = [ "zstd 0.11.2+zstd.1.5.2", ] -[[package]] -name = "zip-extract" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e109e5a291403b4c1e514d39f8a22d3f98d257e691a52bb1f16051bb1ffed63e" -dependencies = [ - "log", - "thiserror", - "zip", -] - [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" diff --git a/Cargo.toml b/Cargo.toml index cd41a96a..d04b4b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ members = [ "crates/utils", "crates/common", "crates/cli", - "crates/install", "tests", ] diff --git a/Dockerfile b/Dockerfile index e9643571..ad68bd17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,17 +2,11 @@ FROM debian:bullseye-slim RUN apt-get update -y && apt-get install -yq ca-certificates curl -COPY resources/docker/configure.sh /usr/local/bin/configure.sh COPY resources/docker/entrypoint.sh /usr/local/bin/entrypoint.sh - -RUN sed -i -e 's/__C__/all-in-one/g' /usr/local/bin/configure.sh && \ - sed -i -e 's/__R__/mail-server/g' /usr/local/bin/configure.sh && \ - sed -i -e 's/__N__/mail/g' /usr/local/bin/configure.sh && \ - sed -i -e 's/__B__/stalwart-mail/g' /usr/local/bin/entrypoint.sh - +COPY resources/docker/download.sh /usr/local/bin/download.sh RUN chmod a+rx /usr/local/bin/*.sh - -RUN /usr/local/bin/configure.sh --download +RUN /usr/local/bin/download.sh +RUN rm /usr/local/bin/download.sh RUN useradd stalwart-mail -s /sbin/nologin -M RUN mkdir -p /opt/stalwart-mail @@ -20,6 +14,6 @@ RUN chown stalwart-mail:stalwart-mail /opt/stalwart-mail VOLUME [ "/opt/stalwart-mail" ] -EXPOSE 443 25 587 465 143 993 4190 +EXPOSE 443 25 587 465 143 993 4190 8080 ENTRYPOINT ["/bin/sh", "/usr/local/bin/entrypoint.sh"] diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 70918e1f..440f83a1 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -29,7 +29,7 @@ impl Network { }; let token_map = &TokenMap::default().with_variables(CONNECTION_VARS); - for (value, key) in [(&mut network.url, "server.url")] { + for (value, key) in [(&mut network.url, "server.http.url")] { if let Some(if_block) = IfBlock::try_parse(config, key, token_map) { *value = if_block; } diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index d314c848..ef75ff9f 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -179,7 +179,7 @@ impl Resolvers { let mut capacities = [1024usize; 5]; for (pos, key) in ["txt", "mx", "ipv4", "ipv6", "ptr"].into_iter().enumerate() { - if let Some(capacity) = config.property(("cache.resolver", key)) { + if let Some(capacity) = config.property(("cache.resolver", key, "size")) { capacities[pos] = capacity; } } diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index a4ab8706..c7cb3f2d 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -629,9 +629,9 @@ 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::empty("session.data.script"), + #[cfg(not(feature = "test_mode"))] script: IfBlock::new::<()>( "session.data.script", [("is_empty(authenticated_as)", "'spam-filter'")], diff --git a/crates/common/src/expr/eval.rs b/crates/common/src/expr/eval.rs index 9a063eaa..ccc1bcca 100644 --- a/crates/common/src/expr/eval.rs +++ b/crates/common/src/expr/eval.rs @@ -38,6 +38,8 @@ impl Core { resolver: &'x V, ) -> Option { if if_block.is_empty() { + tracing::trace!(context = "eval_if", property = if_block.key, result = ""); + return None; } diff --git a/crates/common/src/listener/acme/directory.rs b/crates/common/src/listener/acme/directory.rs index fafd3b85..bcf08762 100644 --- a/crates/common/src/listener/acme/directory.rs +++ b/crates/common/src/listener/acme/directory.rs @@ -281,7 +281,9 @@ async fn https( body: Option, ) -> Result { let url = url.as_ref(); - let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(30)); + let mut builder = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .http1_only(); #[cfg(debug_assertions)] { diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index c2c07ec3..84e85291 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -135,6 +135,10 @@ impl BootManager { // Enable tracing let guards = Tracers::parse(&mut config).enable(&mut config); + tracing::info!( + "Starting Stalwart Mail Server v{}...", + env!("CARGO_PKG_VERSION") + ); // Add hostname lookup if missing let mut insert_keys = Vec::new(); @@ -283,7 +287,10 @@ fn quickstart(path: impl Into) { } for dir in &["etc", "data", "logs"] { - std::fs::create_dir(path.join(dir)).failed(&format!("Failed to create {dir} directory")); + let sub_path = path.join(dir); + if !sub_path.exists() { + std::fs::create_dir(sub_path).failed(&format!("Failed to create {dir} directory")); + } } let admin_pass = thread_rng() diff --git a/crates/common/src/manager/config.rs b/crates/common/src/manager/config.rs index e1653ccf..a17cb1ee 100644 --- a/crates/common/src/manager/config.rs +++ b/crates/common/src/manager/config.rs @@ -431,6 +431,7 @@ impl Patterns { Pattern::Include(MatchType::StartsWith("store.".to_string())), Pattern::Include(MatchType::StartsWith("directory.".to_string())), Pattern::Include(MatchType::StartsWith("tracer.".to_string())), + Pattern::Exclude(MatchType::StartsWith("server.blocked-ip.".to_string())), Pattern::Include(MatchType::StartsWith("server.".to_string())), Pattern::Include(MatchType::StartsWith( "authentication.fallback-admin.".to_string(), diff --git a/crates/common/src/manager/webadmin.rs b/crates/common/src/manager/webadmin.rs index 06dea06b..1ccbab31 100644 --- a/crates/common/src/manager/webadmin.rs +++ b/crates/common/src/manager/webadmin.rs @@ -119,6 +119,11 @@ impl WebAdminManager { // Update routes self.routes.store(routes.into()); + tracing::debug!( + path = self.bundle_path.path.to_string_lossy().as_ref(), + "WebAdmin successfully unpacked" + ); + Ok(()) } diff --git a/crates/common/src/scripts/plugins/lookup.rs b/crates/common/src/scripts/plugins/lookup.rs index 46299b5b..d12c71e4 100644 --- a/crates/common/src/scripts/plugins/lookup.rs +++ b/crates/common/src/scripts/plugins/lookup.rs @@ -83,7 +83,7 @@ pub fn exec(ctx: PluginContext<'_>) -> Variable { _ => false, } } else { - tracing::warn!( + tracing::debug!( parent: ctx.span, context = "sieve:lookup", event = "failed", @@ -112,7 +112,7 @@ pub fn exec_get(ctx: PluginContext<'_>) -> Variable { .map(|v| v.into_inner()) .unwrap_or_default() } else { - tracing::warn!( + tracing::debug!( parent: ctx.span, context = "sieve:key_get", event = "failed", diff --git a/crates/directory/src/core/cache.rs b/crates/directory/src/core/cache.rs index c82e475e..8e928789 100644 --- a/crates/directory/src/core/cache.rs +++ b/crates/directory/src/core/cache.rs @@ -52,7 +52,7 @@ impl CachedDirectory { .property((&prefix, "cache.ttl.positive")) .unwrap_or(Duration::from_secs(86400)); let cache_ttl_negative = config - .property((&prefix, "cache.ttl.positive")) + .property((&prefix, "cache.ttl.negative")) .unwrap_or_else(|| Duration::from_secs(3600)); Some(CachedDirectory { diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml deleted file mode 100644 index 4ceb56e3..00000000 --- a/crates/install/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[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.7.0" -edition = "2021" -readme = "README.md" -resolver = "2" - -[dependencies] -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "blocking", "http2"] } -rpassword = "7.0" -indicatif = "0.17.0" -dialoguer = "0.11" -openssl = { version = "0.10.55", features = ["vendored"] } -base64 = "0.22" -pwhash = "1.0.0" -rand = "0.8.5" -clap = { version = "4.1.6", features = ["derive"] } -zip-extract = "0.1.2" -rcgen = "0.13" - -[target.'cfg(not(target_env = "msvc"))'.dependencies] -libc = "0.2.147" -flate2 = "1.0.26" -tar = "0.4.38" diff --git a/crates/install/build.rs b/crates/install/build.rs deleted file mode 100644 index bbb40c62..00000000 --- a/crates/install/build.rs +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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. -*/ - -fn main() { - println!( - "cargo:rustc-env=TARGET={}", - std::env::var("TARGET").unwrap() - ); -} diff --git a/crates/install/src/main.rs b/crates/install/src/main.rs deleted file mode 100644 index 4d9def3a..00000000 --- a/crates/install/src/main.rs +++ /dev/null @@ -1,1140 +0,0 @@ -/* - * 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::{ - fmt::{Display, Formatter}, - fs, - io::Cursor, - path::{Path, PathBuf}, - process::exit, - time::Duration, -}; - -use base64::{engine::general_purpose, Engine}; -use clap::{Parser, ValueEnum}; -use dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Select}; -use openssl::rsa::Rsa; -use rand::{distributions::Alphanumeric, thread_rng, Rng}; -use rcgen::{generate_simple_self_signed, CertifiedKey}; - -const CONFIG_URL: &str = "https://get.stalw.art/resources/config.zip"; - -#[cfg(target_os = "linux")] -const SERVICE: &str = include_str!("../../../resources/systemd/stalwart-mail.service"); -#[cfg(target_os = "macos")] -const SERVICE: &str = include_str!("../../../resources/systemd/stalwart.mail.plist"); - -#[cfg(target_os = "linux")] -const ACCOUNT_NAME: &str = "stalwart-mail"; -#[cfg(target_os = "macos")] -const ACCOUNT_NAME: &str = "_stalwart-mail"; - -#[cfg(not(target_env = "msvc"))] -const PKG_EXTENSION: &str = "tar.gz"; - -#[cfg(target_env = "msvc")] -const PKG_EXTENSION: &str = "zip"; - -static TARGET: &str = env!("TARGET"); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -enum Component { - AllInOne, - Jmap, - Imap, - Smtp, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Store { - RocksDB, - FoundationDB, - SQLite, - PostgreSQL, - MySQL, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Blob { - Internal, - Filesystem, - S3, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Fts { - Internal, - ElasticSearch, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SpamDb { - Internal, - Redis, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Directory { - Internal, - Ldap, - PostgreSQL, - MySQL, - SQLite, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SmtpDirectory { - PostgreSQL, - MySQL, - SQLite, - Ldap, - Lmtp, - Imap, -} - -const DIRECTORIES: [[&str; 2]; 5] = [ - ["bin", ""], - ["etc", "dkim"], - ["etc", "acme"], - ["data", "blobs"], - ["logs", ""], -]; - -#[derive(Debug, Parser)] -#[clap(version, about, long_about = None)] -#[clap(name = "stalwart-cli")] -pub struct Arguments { - #[clap(long, short = 'p')] - path: Option, - #[clap(long, short = 'c')] - component: Option, - #[clap(long, short = 'd')] - docker: bool, -} - -fn main() -> std::io::Result<()> { - let args = Arguments::parse(); - - #[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"); - - // Obtain component to install - let (component, skip_download) = if let Some(component) = args.component { - (component, true) - } else { - ( - select::( - "Which components would you like to install?", - &[ - "All-in-one mail server (JMAP + IMAP + SMTP)", - "JMAP server", - "IMAP server", - "SMTP server", - ], - Component::AllInOne, - )?, - false, - ) - }; - - // Obtain base path - let base_path = if let Some(base_path) = args.path { - base_path - } else { - PathBuf::from(input( - "Installation directory", - component.default_base_path(), - dir_create_if_missing, - )?) - }; - create_directories(&base_path)?; - - // Download and unpack configuration files - let cfg_path = base_path.join("etc"); - if let Err(err) = zip_extract::extract(Cursor::new(download(CONFIG_URL)), &cfg_path, true) { - eprintln!( - "āŒ Failed to unpack configuration bundle {}: {}", - CONFIG_URL, err - ); - return Ok(()); - } - - // Build configuration file - let mut download_url = None; - - // Obtain database engine - let mut is_internal = false; - if component != Component::Smtp { - let backend = select::( - "Which database would you like to use?", - &[ - "RocksDB (recommended for single-node setups)", - "FoundationDB (recommended for distributed environments)", - "SQLite", - "PostgreSQL", - "MySQL", - ], - Store::RocksDB, - )?; - - if !skip_download { - download_url = format!( - concat!( - "https://github.com/stalwartlabs/{}", - "/releases/latest/download/stalwart-{}{}-{}.{}" - ), - match component { - Component::AllInOne => "mail-server", - Component::Jmap => "jmap-server", - Component::Imap => "imap-server", - Component::Smtp => unreachable!(), - }, - match component { - Component::AllInOne => "mail", - Component::Jmap => "jmap", - Component::Imap => "imap", - Component::Smtp => unreachable!(), - }, - match backend { - Store::FoundationDB => "-foundationdb", - _ => "", - }, - TARGET, - PKG_EXTENSION - ) - .into(); - } - let store = backend.to_string(); - let blob = select::( - "Where would you like to store e-mails and other large binaries?", - &[ - &store, - "Local file system", - "S3, MinIO or any S3-compatible object storage", - ], - Blob::Internal, - )?; - - let fts = select::( - "Where would you like to store the full-text index?", - &[&store, "ElasticSearch"], - Fts::Internal, - )?; - - let spamdb = select::( - "Where would you like to store the anti-spam database?", - &[&store, "Redis"], - SpamDb::Internal, - )?; - - let directory = select::( - "Do you already have a directory or database containing your user accounts?", - &[ - &format!("No, I want Stalwart to store my user accounts in {store}"), - "Yes, it's an LDAP server", - "Yes, it's an PostgreSQL database", - "Yes, it's an MySQL database", - "Yes, it's an SQLite database", - ], - Directory::Internal, - )?; - is_internal = matches!(directory, Directory::Internal); - - // Update settings - sed( - cfg_path.join("config.toml"), - &[ - ("__STORE__", backend.id()), - ("__DIRECTORY__", directory.id()), - ], - ); - if let Some(blob) = blob.id() { - sed( - cfg_path.join("common").join("store.toml"), - &[("blob = \"%{DEFAULT_STORE}%", &format!("blob = \"{blob}"))], - ); - } - if let Some(fts) = fts.id() { - sed( - cfg_path.join("common").join("store.toml"), - &[("fts = \"%{DEFAULT_STORE}%", &format!("fts = \"{fts}"))], - ); - } - if let Some(id) = spamdb.id() { - sed( - cfg_path.join("common").join("sieve.toml"), - &[("%{DEFAULT_STORE}%", id)], - ); - } - sed( - cfg_path.join("jmap").join("oauth.toml"), - &[( - "__OAUTH_KEY__", - thread_rng() - .sample_iter(Alphanumeric) - .take(64) - .map(char::from) - .collect::(), - )], - ); - - // Enable stores - for store in [ - backend.id().into(), - blob.id(), - fts.id(), - spamdb.id(), - directory.sql_store_id(), - ] - .into_iter() - .flatten() - { - sed( - cfg_path.join("store").join(format!("{store}.toml")), - &[("disable = true", "disable = false")], - ); - } - - // Enable directory - if let Some(sql_id) = directory.sql_store_id() { - sed( - cfg_path.join("directory").join("sql.toml"), - &[ - ("disable = true", "disable = false"), - ("__SQL_STORE__", sql_id), - ], - ); - } else { - sed( - cfg_path - .join("directory") - .join(format!("{}.toml", directory.id())), - &[("disable = true", "disable = false")], - ); - } - } else { - let smtp_directory = select::( - "How should your local accounts be validated?", - &[ - "PostgreSQL database", - "MySQL database", - "SQLite database", - "LDAP directory", - "LMTP server", - "IMAP server", - ], - SmtpDirectory::Lmtp, - )?; - - let spamdb = select::( - "Where would you like to store the anti-spam database?", - &["Local database", "Redis"], - SpamDb::Internal, - )?; - - // Update settings - sed( - cfg_path.join("config.toml"), - &[ - ("__STORE__", "rocksdb"), - ("__DIRECTORY__", smtp_directory.id()), - ], - ); - if let Some(id) = spamdb.id() { - sed( - cfg_path.join("common").join("sieve.toml"), - &[("%{DEFAULT_STORE}%", id)], - ); - } - - // Enable directory - if let Some(sql_id) = smtp_directory.sql_store_id() { - sed( - cfg_path.join("directory").join("sql.toml"), - &[ - ("disable = true", "disable = false"), - ("__SQL_STORE__", sql_id), - ], - ); - } else { - sed( - cfg_path - .join("directory") - .join(format!("{}.toml", smtp_directory.id())), - &[("disable = true", "disable = false")], - ); - } - - // Enable stores - for store in [smtp_directory.sql_store_id(), spamdb.id(), "rocksdb".into()] - .into_iter() - .flatten() - { - sed( - cfg_path.join("store").join(format!("{store}.toml")), - &[("disable = true", "disable = false")], - ); - } - - if !skip_download { - download_url = format!( - concat!( - "https://github.com/stalwartlabs/smtp-server", - "/releases/latest/download/stalwart-smtp-{}.{}" - ), - TARGET, PKG_EXTENSION - ) - .into(); - } - } - - // Download binary - if let Some(download_url) = download_url { - eprintln!("šŸ“¦ Downloading components..."); - for url in [ - download_url, - format!( - concat!( - "https://github.com/stalwartlabs/mail-server", - "/releases/latest/download/stalwart-cli-{}.{}" - ), - TARGET, PKG_EXTENSION - ), - ] { - let bytes = download(&url); - let unpack_path = if !args.docker { - base_path.join("bin") - } else { - PathBuf::from("/usr/local/bin") - }; - - #[cfg(not(target_env = "msvc"))] - if let Err(err) = tar::Archive::new(flate2::bufread::GzDecoder::new(Cursor::new(bytes))) - .unpack(unpack_path) - { - eprintln!("āŒ Failed to unpack {}: {}", url, err); - return Ok(()); - } - - #[cfg(target_env = "msvc")] - if let Err(err) = zip_extract::extract(Cursor::new(bytes), &unpack_path, true) { - eprintln!("āŒ Failed to unpack {}: {}", url, err); - return Ok(()); - } - } - } - - // Obtain domain name - 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(); - - // Obtain TLS configuration - let is_acme = Confirm::new() - .with_prompt(&format!("Do you want the TLS certificates for {hostname} to be obtained automatically from Let's Encrypt using ACME?")) - .interact() - .unwrap(); - - let (cert_path, pk_path) = { - let base_path = base_path.join("etc").join("certs").join(&hostname); - - // Create directories - fs::create_dir_all(&base_path)?; - let cert_path = base_path.join("fullchain.pem"); - let pk_path = base_path.join("privkey.pem"); - - // Build self-signed cert - let CertifiedKey { cert, key_pair } = - generate_simple_self_signed(vec![hostname.to_string()]).unwrap_or_else(|err| { - panic!("Failed to generate self-signed certificate for {hostname}: {err}",) - }); - std::fs::write(&cert_path, cert.pem())?; - std::fs::write(&pk_path, key_pair.serialize_pem())?; - - ( - cert_path.to_str().unwrap().to_string(), - pk_path.to_str().unwrap().to_string(), - ) - }; - - // Generate DKIM key and instructions - let dkim_instructions = generate_dkim(&base_path, &domain, &hostname)?; - - // Update config file - if args.docker { - sed( - cfg_path.join("common").join("server.toml"), - &[ - ("[server.run-as]", "#[server.run-as]"), - ("user = \"stalwart-mail\"", "#user = \"stalwart-mail\""), - ("group = \"stalwart-mail\"", "#group = \"stalwart-mail\""), - ], - ); - sed( - cfg_path.join("smtp").join("listener.toml"), - &[("127.0.0.1:8080", "[::]:8080")], - ); - } - sed( - cfg_path.join("config.toml"), - &[ - ("__BASE_PATH__", base_path.to_str().unwrap()), - ("__DOMAIN__", &domain), - ("__HOST__", &hostname), - ], - ); - if is_acme { - sed( - cfg_path.join("common").join("tls.toml"), - &[ - ("certificate = \"default\"", "#certificate = \"default\""), - ("#acme =", "acme ="), - ("__CERT_PATH__", &cert_path), - ("__PK_PATH__", &pk_path), - ], - ); - } else { - sed( - cfg_path.join("common").join("tls.toml"), - &[("__CERT_PATH__", &cert_path), ("__PK_PATH__", &pk_path)], - ); - } - - // Write service file - if !args.docker { - // Change permissions - #[cfg(not(target_env = "msvc"))] - { - let mut cmd = std::process::Command::new("chown"); - cmd.arg("-R") - .arg(format!("{}:{}", ACCOUNT_NAME, ACCOUNT_NAME)) - .arg(&base_path); - if let Err(err) = cmd.status() { - eprintln!("Warning: Failed to set permissions: {}", err); - } - let mut cmd = std::process::Command::new("chmod"); - cmd.arg("-R") - .arg("770") - .arg(&format!("{}/etc", base_path.display())) - .arg(&format!("{}/data", base_path.display())) - .arg(&format!("{}/logs", base_path.display())); - if let Err(err) = cmd.status() { - eprintln!("Warning: Failed to set permissions: {}", err); - } - } - - #[cfg(target_os = "linux")] - { - let service_file = format!( - "/etc/systemd/system/stalwart-{}.service", - component.binary_name() - ); - let service_name = format!("stalwart-{}", component.binary_name()); - match fs::write( - &service_file, - SERVICE - .replace("__PATH__", base_path.to_str().unwrap()) - .replace("__NAME__", component.binary_name()) - .replace("__TITLE__", component.name()), - ) { - Ok(_) => { - if let Err(err) = std::process::Command::new("/bin/systemctl") - .arg("enable") - .arg(service_file) - .status() - .and_then(|_| { - std::process::Command::new("/bin/systemctl") - .arg("restart") - .arg(&service_name) - .status() - }) - { - eprintln!("Warning: Failed to start service: {}", err); - } - } - Err(err) => { - eprintln!("Warning: Failed to write service file: {}", err); - } - } - } - #[cfg(target_os = "macos")] - { - let service_file = format!( - "/Library/LaunchDaemons/stalwart.{}.plist", - component.binary_name() - ); - let service_name = format!("system/stalwart.{}", component.binary_name()); - match fs::write( - &service_file, - SERVICE - .replace("__PATH__", base_path.to_str().unwrap()) - .replace("__NAME__", component.binary_name()) - .replace("__TITLE__", component.name()), - ) { - Ok(_) => { - if let Err(err) = std::process::Command::new("launchctl") - .arg("load") - .arg(service_file) - .status() - .and_then(|_| { - std::process::Command::new("launchctl") - .arg("enable") - .arg(&service_name) - .status() - }) - .and_then(|_| { - std::process::Command::new("launchctl") - .arg("start") - .arg(&service_name) - .status() - }) - { - eprintln!("Warning: Failed to start service: {}", err); - } - } - Err(err) => { - eprintln!("Warning: Failed to write service file: {}", err); - } - } - } - } - - if is_acme { - eprintln!( - "\nšŸ›”ļø Ensure that port 443 (HTTPS) on {hostname} is open and accessible from the internet to successfully obtain your ACME TLS certificate.", - ); - } else { - eprintln!( - "\nšŸ›”ļø Self-signed certificates have been generated under {}, you'll need to replace them with your own certificates to enable TLS.", base_path.join("etc").join("certs").join(&hostname).display() - ); - } - if is_internal { - eprintln!( - "\nšŸ”‘ The administrator account is 'admin' and the password can be found in the log files at {}/logs.", base_path.display() - ); - } - - eprintln!("\nāœ… {dkim_instructions}\nšŸŽ‰ Installation completed! Please consider sponsoring Stalwart at https://liberapay.com/stalwartlabs\n"); - - Ok(()) -} - -fn sed(path: impl AsRef, replacements: &[(&str, impl AsRef)]) { - let path = path.as_ref(); - match fs::read_to_string(path) { - Ok(mut contents) => { - for (from, to) in replacements { - contents = contents.replace(from, to.as_ref()); - } - if let Err(err) = fs::write(path, contents) { - eprintln!( - "āŒ Failed to write configuration file {}: {}", - path.display(), - err - ); - exit(1); - } - } - Err(err) => { - eprintln!( - "āŒ Failed to read configuration file {}: {}", - path.display(), - err - ); - exit(1); - } - } -} - -fn download(url: &str) -> Vec { - match reqwest::blocking::Client::builder() - .connect_timeout(Duration::from_secs(60)) - .timeout(Duration::from_secs(60)) - .build() - .unwrap_or_default() - .get(url) - .send() - .and_then(|r| { - if r.status().is_success() { - r.bytes().map(Ok) - } else { - Ok(Err(r)) - } - }) { - Ok(Ok(bytes)) => bytes.to_vec(), - Ok(Err(response)) => { - eprintln!( - "āŒ Failed to download {}, make sure your platform is supported: {}", - url, - response.status() - ); - exit(1); - } - - Err(err) => { - eprintln!("āŒ Failed to download {}: {}", url, err); - exit(1); - } - } -} - -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()) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to read input: {}", err), - ) - })? - { - 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()) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to read input: {}", err), - ) - }) -} - -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(()) - } -} - -#[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 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::{ffi::CString, process::Command}; - let c_str = CString::new("stalwart-mail").unwrap(); - let pw = libc::getpwnam(c_str.as_ptr()); - let gr = libc::getgrnam(c_str.as_ptr()); - - 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(c_str.as_ptr()); - let gr = libc::getgrnam(c_str.as_ptr()); - (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 Store { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::RocksDB, - 1 => Self::FoundationDB, - 2 => Self::SQLite, - 3 => Self::PostgreSQL, - 4 => Self::MySQL, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - Store::RocksDB => 0, - Store::FoundationDB => 1, - Store::SQLite => 2, - Store::PostgreSQL => 3, - Store::MySQL => 4, - } - } -} - -impl SelectItem for Directory { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::Internal, - 1 => Self::Ldap, - 2 => Self::PostgreSQL, - 3 => Self::MySQL, - 4 => Self::SQLite, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - Directory::Internal => 0, - Directory::Ldap => 1, - Directory::PostgreSQL => 2, - Directory::MySQL => 3, - Directory::SQLite => 4, - } - } -} - -impl SelectItem for SmtpDirectory { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::PostgreSQL, - 1 => Self::MySQL, - 2 => Self::SQLite, - 3 => Self::Ldap, - 4 => Self::Lmtp, - 5 => Self::Imap, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - SmtpDirectory::PostgreSQL => 0, - SmtpDirectory::MySQL => 1, - SmtpDirectory::SQLite => 2, - SmtpDirectory::Ldap => 3, - SmtpDirectory::Lmtp => 4, - SmtpDirectory::Imap => 5, - } - } -} - -impl SelectItem for Blob { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::Internal, - 1 => Self::Filesystem, - 2 => Self::S3, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - Blob::Internal => 0, - Blob::Filesystem => 1, - Blob::S3 => 2, - } - } -} - -impl SelectItem for SpamDb { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::Internal, - 1 => Self::Redis, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - SpamDb::Internal => 0, - SpamDb::Redis => 1, - } - } -} - -impl SelectItem for Fts { - fn from_index(index: usize) -> Self { - match index { - 0 => Self::Internal, - 1 => Self::ElasticSearch, - _ => unreachable!(), - } - } - - fn to_index(&self) -> usize { - match self { - Fts::Internal => 0, - Fts::ElasticSearch => 1, - } - } -} - -impl Component { - fn default_base_path(&self) -> &'static str { - #[cfg(not(target_env = "msvc"))] - match self { - Self::AllInOne => "/opt/stalwart-mail", - Self::Jmap => "/opt/stalwart-jmap", - Self::Imap => "/opt/stalwart-imap", - Self::Smtp => "/opt/stalwart-smtp", - } - #[cfg(target_env = "msvc")] - match self { - Self::AllInOne => "C:\\Program Files\\Stalwart Mail", - Self::Jmap => "C:\\Program Files\\Stalwart JMAP", - Self::Imap => "C:\\Program Files\\Stalwart IMAP", - Self::Smtp => "C:\\Program Files\\Stalwart SMTP", - } - } - - fn binary_name(&self) -> &'static str { - match self { - Self::AllInOne => "mail", - Self::Jmap => "jmap", - Self::Imap => "imap", - Self::Smtp => "smtp", - } - } - - fn name(&self) -> &'static str { - match self { - Self::AllInOne => "Mail", - Self::Jmap => "JMAP", - Self::Imap => "IMAP", - Self::Smtp => "SMTP", - } - } -} - -impl Display for Store { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::RocksDB => write!(f, "RocksDB"), - Self::FoundationDB => write!(f, "FoundationDB"), - Self::SQLite => write!(f, "SQLite"), - Self::PostgreSQL => write!(f, "PostgreSQL"), - Self::MySQL => write!(f, "MySQL"), - } - } -} - -impl Store { - pub fn id(&self) -> &'static str { - match self { - Self::RocksDB => "rocksdb", - Self::FoundationDB => "foundationdb", - Self::SQLite => "sqlite", - Self::PostgreSQL => "postgresql", - Self::MySQL => "mysql", - } - } -} - -impl Directory { - pub fn id(&self) -> &'static str { - match self { - Directory::Internal => "internal", - Directory::Ldap => "ldap", - Directory::PostgreSQL | Directory::MySQL | Directory::SQLite => "sql", - } - } - - pub fn sql_store_id(&self) -> Option<&'static str> { - match self { - Directory::PostgreSQL => Some("postgresql"), - Directory::MySQL => Some("mysql"), - Directory::SQLite => Some("sqlite"), - Directory::Internal | Directory::Ldap => None, - } - } -} - -impl SmtpDirectory { - pub fn id(&self) -> &'static str { - match self { - SmtpDirectory::Ldap => "ldap", - SmtpDirectory::Lmtp => "lmtp", - SmtpDirectory::Imap => "imap", - SmtpDirectory::PostgreSQL | SmtpDirectory::MySQL | SmtpDirectory::SQLite => "sql", - } - } - - pub fn sql_store_id(&self) -> Option<&'static str> { - match self { - SmtpDirectory::PostgreSQL => Some("postgresql"), - SmtpDirectory::MySQL => Some("mysql"), - SmtpDirectory::SQLite => Some("sqlite"), - SmtpDirectory::Ldap | SmtpDirectory::Lmtp | SmtpDirectory::Imap => None, - } - } -} - -impl Blob { - pub fn id(&self) -> Option<&'static str> { - match self { - Self::Internal => None, - Self::Filesystem => "fs".into(), - Self::S3 => "s3".into(), - } - } -} - -impl Fts { - pub fn id(&self) -> Option<&'static str> { - match self { - Self::Internal => None, - Self::ElasticSearch => "elasticsearch".into(), - } - } -} - -impl SpamDb { - pub fn id(&self) -> Option<&'static str> { - match self { - Self::Internal => None, - Self::Redis => "redis".into(), - } - } -} diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 51647350..3f36e0f7 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -53,6 +53,7 @@ rasn-pkix = "0.10" rsa = "0.9.2" async-trait = "0.1.68" lz4_flex = { version = "0.11", default-features = false } +rev_lines = "0.3.0" [dev-dependencies] ece = "2.2" diff --git a/crates/jmap/src/api/management/dkim.rs b/crates/jmap/src/api/management/dkim.rs index 95683344..1b1680d3 100644 --- a/crates/jmap/src/api/management/dkim.rs +++ b/crates/jmap/src/api/management/dkim.rs @@ -221,9 +221,13 @@ impl JMAP { format!("signature.{id}.canonicalization"), "relaxed/relaxed".to_string(), ), + (format!("signature.{id}.headers.0"), "From".to_string()), + (format!("signature.{id}.headers.1"), "To".to_string()), + (format!("signature.{id}.headers.2"), "Date".to_string()), + (format!("signature.{id}.headers.3"), "Subject".to_string()), ( - format!("signature.{id}.headers"), - "['From', 'To', 'Date', 'Subject', 'Message-ID']".to_string(), + format!("signature.{id}.headers.4"), + "Message-ID".to_string(), ), (format!("signature.{id}.report"), "false".to_string()), ]) diff --git a/crates/jmap/src/api/management/log.rs b/crates/jmap/src/api/management/log.rs new file mode 100644 index 00000000..3b99af87 --- /dev/null +++ b/crates/jmap/src/api/management/log.rs @@ -0,0 +1,135 @@ +use std::{ + fs::{self, File}, + io, + path::Path, +}; + +use rev_lines::RevLines; +use serde::Serialize; +use serde_json::json; +use tokio::sync::oneshot; +use utils::url_params::UrlParams; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + JMAP, +}; + +use super::ManagementApiError; + +#[derive(Serialize)] +struct LogEntry { + timestamp: String, + level: String, + message: String, +} + +impl JMAP { + pub async fn handle_view_logs(&self, req: &HttpRequest) -> HttpResponse { + // Obtain log file path + let path = match self.core.storage.config.get("tracer.log.path").await { + Ok(Some(path)) => path, + Ok(None) => { + return ManagementApiError::Unsupported { + details: "Tracer log path not configured".into(), + } + .into_http_response() + } + Err(err) => return err.into_http_response(), + }; + + let params = UrlParams::new(req.uri().query()); + let filter = params.get("filter").unwrap_or_default().to_string(); + let page: usize = params.parse("page").unwrap_or(0); + let limit: usize = params.parse("limit").unwrap_or(100); + let offset = page.saturating_sub(1) * limit; + + // TODO: Use worker pool + let (tx, rx) = oneshot::channel(); + tokio::task::spawn_blocking(move || { + let _ = tx.send(read_log_files(path, &filter, offset, limit)); + }); + + match rx.await { + Ok(result) => match result { + Ok((total, items)) => JsonResponse::new(json!({ + "data": { + "items": items, + "total": total, + }, + })) + .into_http_response(), + Err(err) => err.into_http_response(), + }, + Err(_) => { + tracing::warn!(context = "view_logs", event = "error", "Thread join error"); + ManagementApiError::Other { + details: "Thread join error".into(), + } + .into_http_response() + } + } + } +} + +fn read_log_files( + path: impl AsRef, + filter: &str, + mut offset: usize, + limit: usize, +) -> io::Result<(usize, Vec)> { + let mut logs = fs::read_dir(path)?.collect::, _>>()?; + let mut total = 0; + + // Sort the entries by file name in reverse order. + logs.sort_by_key(|b| std::cmp::Reverse(b.file_name())); + + // Iterate and print the file names. + let mut entries = Vec::with_capacity(limit); + let mut logs = logs.into_iter(); + while let Some(log) = logs.next() { + if log.file_type()?.is_file() { + let mut rev_lines = RevLines::new(File::open(log.path())?); + + while let Some(line) = rev_lines.next() { + let line = line.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if filter.is_empty() || line.contains(filter) { + total += 1; + if offset == 0 { + if let Some(entry) = LogEntry::from_line(&line) { + entries.push(entry); + if entries.len() == limit { + if rev_lines.next().is_some() || logs.next().is_some() { + total += limit; + } + + return Ok((total, entries)); + } + } + } else { + offset -= 1; + } + } + } + } + } + + Ok((total, entries)) +} + +impl LogEntry { + fn from_line(line: &str) -> Option { + let (timestamp, rest) = line.split_once(' ')?; + let timestamp = timestamp + .rsplit_once('.') + .filter(|(_, z)| z.ends_with('Z')) + .map_or_else(|| timestamp.to_string(), |(t, _)| format!("{t}Z")); + let (level, message) = rest.trim().split_once(' ')?; + let message = message.split_once(": ").map_or(message, |(_, v)| v); + Some(Self { + timestamp, + level: level.to_string(), + message: message.to_string(), + }) + } +} diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index 5e92c3a3..0e9c4b35 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -23,6 +23,7 @@ pub mod dkim; pub mod domain; +pub mod log; pub mod principal; pub mod queue; pub mod reload; @@ -76,15 +77,24 @@ impl JMAP { let is_superuser = access_token.is_super_user(); match path.first().copied().unwrap_or_default() { + "queue" if is_superuser => self.handle_manage_queue(req, path).await, + "settings" if is_superuser => self.handle_manage_settings(req, path, body).await, + "reports" if is_superuser => self.handle_manage_reports(req, path).await, "principal" if is_superuser => self.handle_manage_principal(req, path, body).await, "domain" if is_superuser => self.handle_manage_domain(req, path).await, "store" if is_superuser => self.handle_manage_store(req, path).await, "reload" if is_superuser => self.handle_manage_reload(req, path).await, - "settings" if is_superuser => self.handle_manage_settings(req, path, body).await, - "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, + "logs" if is_superuser && req.method() == Method::GET => { + self.handle_view_logs(req).await + } + "restart" if is_superuser && req.method() == Method::GET => { + ManagementApiError::Unsupported { + details: "Restart is not yet supported".into(), + } + .into_http_response() + } "oauth" => self.handle_oauth_api_request(access_token, body).await, "crypto" => match *req.method() { Method::POST => self.handle_crypto_post(access_token, body).await, diff --git a/crates/jmap/src/services/housekeeper.rs b/crates/jmap/src/services/housekeeper.rs index 00e591d0..ad4fd383 100644 --- a/crates/jmap/src/services/housekeeper.rs +++ b/crates/jmap/src/services/housekeeper.rs @@ -52,13 +52,18 @@ struct Action { event: ActionClass, } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Debug)] enum ActionClass { Session, Store(usize), Acme(String), } +#[derive(Default)] +struct Queue { + heap: BinaryHeap, +} + pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { tokio::spawn(async move { tracing::debug!("Housekeeper task started."); @@ -71,29 +76,29 @@ pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { tokio::spawn(async move { jmap.fts_index_queued().await; }); - let mut heap = BinaryHeap::new(); + let mut queue = Queue::default(); - // Add all purge events to heap + // Add all events to queue let core_ = core.core.load(); - heap.push(Action { - due: Instant::now() + core_.jmap.session_purge_frequency.time_to_next(), - event: ActionClass::Session, - }); + queue.schedule( + Instant::now() + core_.jmap.session_purge_frequency.time_to_next(), + ActionClass::Session, + ); for (idx, schedule) in core_.storage.purge_schedules.iter().enumerate() { - heap.push(Action { - due: Instant::now() + schedule.cron.time_to_next(), - event: ActionClass::Store(idx), - }); + queue.schedule( + Instant::now() + schedule.cron.time_to_next(), + ActionClass::Store(idx), + ); } // Add all ACME renewals to heap for provider in core_.tls.acme_providers.values() { match core_.init_acme(provider).await { Ok(renew_at) => { - heap.push(Action { - due: Instant::now() + renew_at, - event: ActionClass::Acme(provider.id.clone()), - }); + queue.schedule( + Instant::now() + renew_at, + ActionClass::Acme(provider.id.clone()), + ); } Err(err) => { tracing::error!( @@ -106,21 +111,13 @@ pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { } loop { - let time_to_next = heap - .peek() - .map(|e| e.due.saturating_duration_since(Instant::now())) - .unwrap_or(LONG_SLUMBER); - - match tokio::time::timeout(time_to_next, rx.recv()).await { + match tokio::time::timeout(queue.wake_up_time(), rx.recv()).await { Ok(Some(event)) => match event { Event::AcmeReschedule { provider_id, renew_at, } => { - heap.push(Action { - due: renew_at, - event: ActionClass::Acme(provider_id), - }); + queue.schedule(renew_at, ActionClass::Acme(provider_id)); } Event::IndexStart => { if !index_busy { @@ -159,11 +156,7 @@ pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { } Err(_) => { let core_ = core.core.load(); - while let Some(event) = heap.peek() { - if event.due > Instant::now() { - break; - } - let event = heap.pop().unwrap(); + while let Some(event) = queue.pop() { match event.event { ActionClass::Acme(provider_id) => { let inner = core.jmap_inner.clone(); @@ -216,20 +209,20 @@ pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { tracing::debug!("Purging session cache."); inner.purge(); }); - heap.push(Action { - due: Instant::now() + queue.schedule( + Instant::now() + core_.jmap.session_purge_frequency.time_to_next(), - event: ActionClass::Session, - }); + ActionClass::Session, + ); } ActionClass::Store(idx) => { if let Some(schedule) = core_.storage.purge_schedules.get(idx).cloned() { - heap.push(Action { - due: Instant::now() + schedule.cron.time_to_next(), - event: ActionClass::Store(idx), - }); + queue.schedule( + Instant::now() + schedule.cron.time_to_next(), + ActionClass::Store(idx), + ); tokio::spawn(async move { let (class, result) = match schedule.store { PurgeStore::Data(store) => { @@ -268,6 +261,28 @@ pub fn spawn_housekeeper(core: JmapInstance, mut rx: mpsc::Receiver) { }); } +impl Queue { + pub fn schedule(&mut self, due: Instant, event: ActionClass) { + tracing::debug!(due_in = due.saturating_duration_since(Instant::now()).as_secs(), event = ?event, "Scheduling housekeeper event."); + self.heap.push(Action { due, event }); + } + + pub fn wake_up_time(&self) -> Duration { + self.heap + .peek() + .map(|e| e.due.saturating_duration_since(Instant::now())) + .unwrap_or(LONG_SLUMBER) + } + + pub fn pop(&mut self) -> Option { + if self.heap.peek()?.due <= Instant::now() { + self.heap.pop() + } else { + None + } + } +} + impl Ord for Action { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.due.cmp(&other.due).reverse() diff --git a/crates/main/src/main.rs b/crates/main/src/main.rs index 6325ced0..a52bfd0f 100644 --- a/crates/main/src/main.rs +++ b/crates/main/src/main.rs @@ -48,10 +48,6 @@ async fn main() -> std::io::Result<()> { let core = init.core; // Init servers - tracing::info!( - "Starting Stalwart Mail Server v{}...", - env!("CARGO_PKG_VERSION") - ); let (delivery_tx, delivery_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); let smtp = SMTP::init(&mut config, core.clone(), delivery_tx).await; let jmap = JMAP::init(&mut config, delivery_rx, core.clone(), smtp.inner.clone()).await; diff --git a/crates/store/src/backend/memory/mod.rs b/crates/store/src/backend/memory/mod.rs index ccd43d09..56decdc5 100644 --- a/crates/store/src/backend/memory/mod.rs +++ b/crates/store/src/backend/memory/mod.rs @@ -47,95 +47,91 @@ impl Stores { let mut lookups = AHashMap::new(); let mut errors = Vec::new(); - for (key, value) in &config.keys { - if let Some(key) = key.strip_prefix("lookup.") { - if let Some((id, key)) = key - .split_once('.') - .filter(|(id, key)| !id.is_empty() && !key.is_empty()) - { - // Detect if the key is a glob pattern - let mut last_ch = '\0'; - let mut has_escape = false; - let mut is_glob = false; - for ch in key.chars() { - match ch { - '\\' => { - has_escape = true; - } - '*' | '?' if last_ch != '\\' => { - is_glob = true; - } - _ => {} + for (key, value) in config.iterate_prefix("lookup") { + if let Some((id, key)) = key + .split_once('.') + .filter(|(id, key)| !id.is_empty() && !key.is_empty()) + { + // Detect if the key is a glob pattern + let mut last_ch = '\0'; + let mut has_escape = false; + let mut is_glob = false; + for ch in key.chars() { + match ch { + '\\' => { + has_escape = true; } - - last_ch = ch; + '*' | '?' if last_ch != '\\' => { + is_glob = true; + } + _ => {} } - // Detect value type - let value = if !value.is_empty() { - let mut has_integers = false; - let mut has_floats = false; - let mut has_others = false; + last_ch = ch; + } - for (pos, ch) in value.as_bytes().iter().enumerate() { - match ch { - b'.' if !has_floats && has_integers => { - has_floats = true; - } - b'0'..=b'9' => { - has_integers = true; - } - b'-' if pos == 0 && value.len() > 1 => {} - _ => { - has_others = true; - } + // Detect value type + let value = if !value.is_empty() { + let mut has_integers = false; + let mut has_floats = false; + let mut has_others = false; + + for (pos, ch) in value.as_bytes().iter().enumerate() { + match ch { + b'.' if !has_floats && has_integers => { + has_floats = true; + } + b'0'..=b'9' => { + has_integers = true; + } + b'-' if pos == 0 && value.len() > 1 => {} + _ => { + has_others = true; } } + } - if has_others { - if value == "true" { - Value::Integer(1.into()) - } else if value == "false" { - Value::Integer(0.into()) - } else { - Value::Text(value.to_string().into()) - } - } else if has_floats { - value - .parse() - .map(Value::Float) - .unwrap_or_else(|_| Value::Text(value.to_string().into())) + if has_others { + if value == "true" { + Value::Integer(1.into()) + } else if value == "false" { + Value::Integer(0.into()) } else { - value - .parse() - .map(Value::Integer) - .unwrap_or_else(|_| Value::Text(value.to_string().into())) + Value::Text(value.to_string().into()) } + } else if has_floats { + value + .parse() + .map(Value::Float) + .unwrap_or_else(|_| Value::Text(value.to_string().into())) } else { - Value::Text("".into()) - }; - - // Add entry - let store = lookups - .entry(id.to_string()) - .or_insert_with(MemoryStore::default); - if is_glob { - store.globs.push((GlobPattern::compile(key, false), value)); - } else { - store.entries.insert( - if has_escape { - key.replace('\\', "") - } else { - key.to_string() - }, - value, - ); + value + .parse() + .map(Value::Integer) + .unwrap_or_else(|_| Value::Text(value.to_string().into())) } } else { - errors.push(key.to_string()); + Value::Text("".into()) + }; + + // Add entry + let store = lookups + .entry(id.to_string()) + .or_insert_with(MemoryStore::default); + if is_glob { + store.globs.push((GlobPattern::compile(key, false), value)); + } else { + store.entries.insert( + if has_escape { + key.replace('\\', "") + } else { + key.to_string() + }, + value, + ); } - } else if !lookups.is_empty() { - break; + } else { + errors.push(key.to_string()); } } diff --git a/crates/store/src/config.rs b/crates/store/src/config.rs index e2a10b59..cef9511d 100644 --- a/crates/store/src/config.rs +++ b/crates/store/src/config.rs @@ -23,7 +23,7 @@ use std::sync::Arc; -use utils::config::{cron::SimpleCron, Config}; +use utils::config::{cron::SimpleCron, utils::ParseValue, Config}; use crate::{ backend::fs::FsStore, @@ -278,41 +278,46 @@ impl Stores { .and_then(|store_id| self.stores.get(store_id)) { let store_id = config.value("storage.data").unwrap().to_string(); - if let Some(cron) = - config.property::(("store", store_id.as_str(), "purge.frequency")) - { - self.purge_schedules.push(PurgeSchedule { - cron, - store_id, - store: PurgeStore::Data(store.clone()), - }); - } + self.purge_schedules.push(PurgeSchedule { + cron: config + .property_or_default::( + ("store", store_id.as_str(), "purge.frequency"), + "0 3 *", + ) + .unwrap_or_else(|| SimpleCron::parse_value("0 3 *").unwrap()), + store_id, + store: PurgeStore::Data(store.clone()), + }); if let Some(blob_store) = config .value("storage.blob") .and_then(|blob_store_id| self.blob_stores.get(blob_store_id)) { let store_id = config.value("storage.blob").unwrap().to_string(); - if let Some(cron) = - config.property::(("store", store_id.as_str(), "purge.frequency")) - { - self.purge_schedules.push(PurgeSchedule { - cron, - store_id, - store: PurgeStore::Blobs { - store: store.clone(), - blob_store: blob_store.clone(), - }, - }); - } + self.purge_schedules.push(PurgeSchedule { + cron: config + .property_or_default::( + ("store", store_id.as_str(), "purge.frequency"), + "0 4 *", + ) + .unwrap_or_else(|| SimpleCron::parse_value("0 4 *").unwrap()), + store_id, + store: PurgeStore::Blobs { + store: store.clone(), + blob_store: blob_store.clone(), + }, + }); } } for (store_id, store) in &self.lookup_stores { - if let Some(cron) = - config.property::(("store", store_id.as_str(), "purge.frequency")) - { + if matches!(store, LookupStore::Store(_)) { self.purge_schedules.push(PurgeSchedule { - cron, + cron: config + .property_or_default::( + ("store", store_id.as_str(), "purge.frequency"), + "0 5 *", + ) + .unwrap_or_else(|| SimpleCron::parse_value("0 5 *").unwrap()), store_id: store_id.clone(), store: PurgeStore::Lookup(store.clone()), }); diff --git a/crates/utils/src/config/mod.rs b/crates/utils/src/config/mod.rs index a28501cd..b8b2e55c 100644 --- a/crates/utils/src/config/mod.rs +++ b/crates/utils/src/config/mod.rs @@ -31,12 +31,15 @@ use std::{collections::BTreeMap, time::Duration}; use ahash::AHashMap; use serde::Serialize; -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Default, Serialize)] pub struct Config { #[serde(skip)] pub keys: BTreeMap, pub warnings: AHashMap, pub errors: AHashMap, + #[cfg(debug_assertions)] + #[serde(skip)] + pub keys_read: parking_lot::Mutex>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -44,6 +47,7 @@ pub struct Config { pub enum ConfigWarning { Missing, AppliedDefault { default: String }, + Unread { value: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -199,7 +203,10 @@ impl Config { } } - pub fn log_warnings(&self, use_stderr: bool) { + pub fn log_warnings(&mut self, use_stderr: bool) { + #[cfg(debug_assertions)] + self.warn_unread_keys(); + for (key, warn) in &self.warnings { let message = match warn { ConfigWarning::AppliedDefault { default } => { @@ -208,6 +215,9 @@ impl Config { ConfigWarning::Missing => { format!("WARNING: Missing setting {key:?}") } + ConfigWarning::Unread { value } => { + format!("WARNING: Unused setting {key:?} with value {value:?}") + } }; if !use_stderr { tracing::debug!("{}", message); @@ -218,6 +228,26 @@ impl Config { } } +impl Clone for Config { + fn clone(&self) -> Self { + Self { + keys: self.keys.clone(), + warnings: self.warnings.clone(), + errors: self.errors.clone(), + #[cfg(debug_assertions)] + keys_read: Default::default(), + } + } +} + +impl PartialEq for Config { + fn eq(&self, other: &Self) -> bool { + self.keys == other.keys && self.warnings == other.warnings && self.errors == other.errors + } +} + +impl Eq for Config {} + impl From<(String, String)> for ConfigKey { fn from((key, value): (String, String)) -> Self { Self { key, value } diff --git a/crates/utils/src/config/utils.rs b/crates/utils/src/config/utils.rs index e0f5d1dd..12cbce80 100644 --- a/crates/utils/src/config/utils.rs +++ b/crates/utils/src/config/utils.rs @@ -39,6 +39,10 @@ use super::{Config, ConfigError, ConfigWarning, Rate}; impl Config { pub fn property(&mut self, key: impl AsKey) -> Option { let key = key.as_key(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(key.clone()); + if let Some(value) = self.keys.get(&key) { match T::parse_value(value) { Ok(value) => Some(value), @@ -58,6 +62,10 @@ impl Config { default: &str, ) -> Option { let key = key.as_key(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(key.clone()); + let value = match self.keys.get(&key) { Some(value) => value.as_str(), None => default, @@ -94,6 +102,10 @@ impl Config { pub fn property_require(&mut self, key: impl AsKey) -> Option { let key = key.as_key(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(key.clone()); + if let Some(value) = self.keys.get(&key) { match T::parse_value(value) { Ok(value) => Some(value), @@ -137,31 +149,22 @@ impl Config { pub fn set_values<'x, 'y: 'x>(&'y self, prefix: impl AsKey) -> impl Iterator + 'x { let prefix = prefix.as_prefix(); + #[cfg(debug_assertions)] + self.keys_read.lock().insert(prefix.clone()); + self.keys .keys() .filter_map(move |key| key.strip_prefix(&prefix)) } - pub fn set_values_or_else( - &self, - prefix: impl AsKey, - default: impl AsKey, - ) -> impl Iterator { - let mut prefix = prefix.as_prefix(); - - self.set_values(if self.keys.keys().any(|k| k.starts_with(&prefix)) { - prefix.truncate(prefix.len() - 1); - prefix - } else { - default.as_key() - }) - } - pub fn properties(&mut self, prefix: impl AsKey) -> Vec<(String, T)> { let full_prefix = prefix.as_key(); let prefix = prefix.as_prefix(); let mut results = Vec::new(); + #[cfg(debug_assertions)] + self.keys_read.lock().insert(prefix.clone()); + for (key, value) in &self.keys { if key.starts_with(&prefix) || key == &full_prefix { match T::parse_value(value) { @@ -180,7 +183,12 @@ impl Config { } pub fn value(&self, key: impl AsKey) -> Option<&str> { - self.keys.get(&key.as_key()).map(|s| s.as_str()) + let key = key.as_key(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(key.clone()); + + self.keys.get(&key).map(|s| s.as_str()) } pub fn contains_key(&self, key: impl AsKey) -> bool { @@ -189,6 +197,10 @@ impl Config { pub fn value_require(&mut self, key: impl AsKey) -> Option<&str> { let key = key.as_key(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(key.clone()); + if let Some(value) = self.keys.get(&key) { Some(value.as_str()) } else { @@ -214,8 +226,16 @@ impl Config { } pub fn value_or_else(&self, key: impl AsKey, or_else: impl AsKey) -> Option<&str> { + let key = key.as_key(); + + #[cfg(debug_assertions)] + { + self.keys_read.lock().insert(key.clone()); + self.keys_read.lock().insert(or_else.clone().as_key()); + } + self.keys - .get(&key.as_key()) + .get(&key) .or_else(|| self.keys.get(&or_else.as_key())) .map(|s| s.as_str()) } @@ -224,6 +244,9 @@ impl Config { let full_prefix = prefix.as_key(); let prefix = prefix.as_prefix(); + #[cfg(debug_assertions)] + self.keys_read.lock().insert(prefix.clone()); + self.keys.iter().filter_map(move |(key, value)| { if key.starts_with(&prefix) || key == &full_prefix { (key.as_str(), value.as_str()).into() @@ -233,18 +256,35 @@ impl Config { }) } + pub fn iterate_prefix(&self, prefix: impl AsKey) -> impl Iterator { + let prefix = prefix.as_prefix(); + + #[cfg(debug_assertions)] + self.keys_read.lock().insert(prefix.clone()); + + self.keys + .iter() + .filter_map(move |(key, value)| Some((key.strip_prefix(&prefix)?, value.as_str()))) + } + pub fn values_or_else( &self, prefix: impl AsKey, - default: impl AsKey, + or_else: impl AsKey, ) -> impl Iterator { let mut prefix = prefix.as_prefix(); + #[cfg(debug_assertions)] + { + self.keys_read.lock().insert(prefix.clone()); + self.keys_read.lock().insert(or_else.clone().as_prefix()); + } + self.values(if self.keys.keys().any(|k| k.starts_with(&prefix)) { prefix.truncate(prefix.len() - 1); prefix } else { - default.as_key() + or_else.as_key() }) } @@ -253,10 +293,6 @@ impl Config { self.keys.keys().any(|k| k.starts_with(&prefix)) } - pub fn take_value(&mut self, key: &str) -> Option { - self.keys.remove(key) - } - pub fn new_parse_error(&mut self, key: impl AsKey, details: impl Into) { self.errors.insert( key.as_key(), @@ -278,6 +314,24 @@ impl Config { pub fn new_missing_property(&mut self, key: impl AsKey) { self.warnings.insert(key.as_key(), ConfigWarning::Missing); } + + #[cfg(debug_assertions)] + pub fn warn_unread_keys(&mut self) { + let mut keys = self.keys.clone(); + + for key in self.keys_read.lock().iter() { + if let Some(base_key) = key.strip_suffix('.') { + keys.remove(base_key); + keys.retain(|k, _| !k.starts_with(key)); + } else { + keys.remove(key); + } + } + + for (key, value) in keys { + self.warnings.insert(key, ConfigWarning::Unread { value }); + } + } } pub trait ParseValue: Sized { diff --git a/install_new.sh b/install_new.sh new file mode 100644 index 00000000..f14e1007 --- /dev/null +++ b/install_new.sh @@ -0,0 +1,734 @@ +#!/usr/bin/env sh +# shellcheck shell=dash + +# Stalwart Mail Server install script -- based on the rustup installation script. + +set -e +set -u + +readonly BASE_URL="https://github.com/stalwartlabs/mail-server/releases/latest/download" + +main() { + downloader --check + need_cmd uname + need_cmd mktemp + need_cmd chmod + need_cmd mkdir + need_cmd rm + need_cmd rmdir + need_cmd tar + + # Make sure we are running as root + if [ "$(id -u)" -ne 0 ] ; then + err "āŒ Install failed: This program needs to run as root." + fi + + # Detect OS + local _os="unknown" + local _uname="$(uname)" + _account="stalwart-mail" + if [ "${_uname}" = "Linux" ]; then + _os="linux" + elif [ "${_uname}" = "Darwin" ]; then + _os="macos" + _account="_stalwart-mail" + fi + + # Read arguments + local _dir="/opt/stalwart-mail" + + # Default component setting + local _component="stalwart-mail" + + # Loop through the arguments + for arg in "$@"; do + case "$arg" in + --fdb) + _component="stalwart-mail-foundationdb" + ;; + *) + if [ -n "$arg" ]; then + _dir=$arg + fi + ;; + esac + done + + # Detect platform architecture + get_architecture || return 1 + local _arch="$RETVAL" + assert_nz "$_arch" "arch" + + # Create directories + ensure mkdir -p "$_dir" "$_dir/bin" "$_dir/etc" "$_dir/logs" + + # Download latest binary + say "ā³ Downloading ${_component} for ${_arch}..." + local _file="${_dir}/bin/stalwart-mail.tar.gz" + local _url="${BASE_URL}/${_component}-${_arch}.tar.gz" + ensure mkdir -p "$_dir" + ensure downloader "$_url" "$_file" "$_arch" + ensure tar zxvf "$_file" -C "$_dir/bin" + ignore chmod +x "$_dir/bin/stalwart-mail" + ignore rm "$_file" + + # Create system account + if ! id -u ${_account} > /dev/null 2>&1; then + say "šŸ–„ļø Creating '${_account}' account..." + if [ "${_os}" = "macos" ]; then + local _last_uid="$(dscacheutil -q user | grep uid | awk '{print $2}' | sort -n | tail -n 1)" + local _last_gid="$(dscacheutil -q group | grep gid | awk '{print $2}' | sort -n | tail -n 1)" + local _uid="$((_last_uid+1))" + local _gid="$((_last_gid+1))" + + ensure dscl /Local/Default -create Groups/_stalwart-mail + ensure dscl /Local/Default -create Groups/_stalwart-mail Password \* + ensure dscl /Local/Default -create Groups/_stalwart-mail PrimaryGroupID $_gid + ensure dscl /Local/Default -create Groups/_stalwart-mail RealName "Stalwart Mail service" + ensure dscl /Local/Default -create Groups/_stalwart-mail RecordName _stalwart-mail stalwart-mail + + ensure dscl /Local/Default -create Users/_stalwart-mail + ensure dscl /Local/Default -create Users/_stalwart-mail NFSHomeDirectory /Users/_stalwart-mail + ensure dscl /Local/Default -create Users/_stalwart-mail Password \* + ensure dscl /Local/Default -create Users/_stalwart-mail PrimaryGroupID $_gid + ensure dscl /Local/Default -create Users/_stalwart-mail RealName "Stalwart Mail service" + ensure dscl /Local/Default -create Users/_stalwart-mail RecordName _stalwart-mail stalwart-mail + ensure dscl /Local/Default -create Users/_stalwart-mail UniqueID $_uid + ensure dscl /Local/Default -create Users/_stalwart-mail UserShell /bin/bash + + ensure dscl /Local/Default -delete /Users/_stalwart-mail AuthenticationAuthority + ensure dscl /Local/Default -delete /Users/_stalwart-mail PasswordPolicyOptions + else + ensure useradd ${_account} -s /sbin/nologin -M + fi + fi + + # Run init + ignore $_dir/bin/stalwart-mail --init "$_dir" + + # Set permissions + say "šŸ” Setting permissions..." + ensure chown -R ${_account}:${_account} "$_dir" + ensure chmod -R 755 "$_dir" + ensure chmod 700 "$_dir/etc/config.toml" + + # Create service file + say "šŸš€ Starting service..." + if [ "${_os}" = "linux" ]; then + printf "\n[server.run-as]\nuser = \"stalwart-mail\"\ngroup = \"stalwart-mail\"\n" >> "$_dir/etc/config.toml" + create_service_linux "$_dir" + elif [ "${_os}" = "macos" ]; then + create_service_macos "$_dir" + fi + + # Installation complete + local _host=$(hostname) + say "šŸŽ‰ Installation complete! Continue the setup at http://$_host:8080/login" + + return 0 +} + +# Functions to create service files +create_service_linux() { + local _dir="$1" + cat < /etc/systemd/system/stalwart-mail.service +[Unit] +Description=Stalwart Mail Server Server +Conflicts=postfix.service sendmail.service exim4.service +ConditionPathExists=__PATH__/etc/config.toml +After=network-online.target + +[Service] +Type=simple +LimitNOFILE=65536 +KillMode=process +KillSignal=SIGINT +Restart=on-failure +RestartSec=5 +ExecStart=__PATH__/bin/stalwart-mail --config=__PATH__/etc/config.toml +PermissionsStartOnly=true +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=stalwart-mail + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable stalwart-mail.service + systemctl restart stalwart-mail.service +} + +create_service_macos() { + local _dir="$1" + cat < /Library/LaunchAgents/stalwart.mail.plist + + + + + Label + stalwart.mail + ServiceDescription + Stalwart Mail Server + ProgramArguments + + __PATH__/bin/stalwart-mail + --config=__PATH__/etc/config.toml + + RunAtLoad + + KeepAlive + + + +EOF + launchctl load /Library/LaunchAgents/stalwart.mail.plist + launchctl enable system/stalwart.mail + launchctl start system/stalwart.mail +} + + +get_architecture() { + local _ostype _cputype _bitness _arch _clibtype + _ostype="$(uname -s)" + _cputype="$(uname -m)" + _clibtype="gnu" + + if [ "$_ostype" = Linux ]; then + if [ "$(uname -o)" = Android ]; then + _ostype=Android + fi + if ldd --version 2>&1 | grep -q 'musl'; then + _clibtype="musl" + fi + fi + + if [ "$_ostype" = Darwin ] && [ "$_cputype" = i386 ]; then + # Darwin `uname -m` lies + if sysctl hw.optional.x86_64 | grep -q ': 1'; then + _cputype=x86_64 + fi + fi + + if [ "$_ostype" = SunOS ]; then + # Both Solaris and illumos presently announce as "SunOS" in "uname -s" + # so use "uname -o" to disambiguate. We use the full path to the + # system uname in case the user has coreutils uname first in PATH, + # which has historically sometimes printed the wrong value here. + if [ "$(/usr/bin/uname -o)" = illumos ]; then + _ostype=illumos + fi + + # illumos systems have multi-arch userlands, and "uname -m" reports the + # machine hardware name; e.g., "i86pc" on both 32- and 64-bit x86 + # systems. Check for the native (widest) instruction set on the + # running kernel: + if [ "$_cputype" = i86pc ]; then + _cputype="$(isainfo -n)" + fi + fi + + case "$_ostype" in + + Android) + _ostype=linux-android + ;; + + Linux) + check_proc + _ostype=unknown-linux-$_clibtype + _bitness=$(get_bitness) + ;; + + FreeBSD) + _ostype=unknown-freebsd + ;; + + NetBSD) + _ostype=unknown-netbsd + ;; + + DragonFly) + _ostype=unknown-dragonfly + ;; + + Darwin) + _ostype=apple-darwin + ;; + + illumos) + _ostype=unknown-illumos + ;; + + MINGW* | MSYS* | CYGWIN* | Windows_NT) + _ostype=pc-windows-gnu + ;; + + *) + err "unrecognized OS type: $_ostype" + ;; + + esac + + case "$_cputype" in + + i386 | i486 | i686 | i786 | x86) + _cputype=i686 + ;; + + xscale | arm) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + fi + ;; + + armv6l) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + armv7l | armv8l) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + aarch64 | arm64) + _cputype=aarch64 + ;; + + x86_64 | x86-64 | x64 | amd64) + _cputype=x86_64 + ;; + + mips) + _cputype=$(get_endianness mips '' el) + ;; + + mips64) + if [ "$_bitness" -eq 64 ]; then + # only n64 ABI is supported for now + _ostype="${_ostype}abi64" + _cputype=$(get_endianness mips64 '' el) + fi + ;; + + ppc) + _cputype=powerpc + ;; + + ppc64) + _cputype=powerpc64 + ;; + + ppc64le) + _cputype=powerpc64le + ;; + + s390x) + _cputype=s390x + ;; + riscv64) + _cputype=riscv64gc + ;; + *) + err "unknown CPU type: $_cputype" + + esac + + # Detect 64-bit linux with 32-bit userland + if [ "${_ostype}" = unknown-linux-gnu ] && [ "${_bitness}" -eq 32 ]; then + case $_cputype in + x86_64) + if [ -n "${RUSTUP_CPUTYPE:-}" ]; then + _cputype="$RUSTUP_CPUTYPE" + else { + # 32-bit executable for amd64 = x32 + if is_host_amd64_elf; then { + echo "This host is running an x32 userland; as it stands, x32 support is poor," 1>&2 + echo "and there isn't a native toolchain -- you will have to install" 1>&2 + echo "multiarch compatibility with i686 and/or amd64, then select one" 1>&2 + echo "by re-running this script with the RUSTUP_CPUTYPE environment variable" 1>&2 + echo "set to i686 or x86_64, respectively." 1>&2 + echo 1>&2 + echo "You will be able to add an x32 target after installation by running" 1>&2 + echo " rustup target add x86_64-unknown-linux-gnux32" 1>&2 + exit 1 + }; else + _cputype=i686 + fi + }; fi + ;; + mips64) + _cputype=$(get_endianness mips '' el) + ;; + powerpc64) + _cputype=powerpc + ;; + aarch64) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + riscv64gc) + err "riscv64 with 32-bit userland unsupported" + ;; + esac + fi + + # Detect armv7 but without the CPU features Rust needs in that build, + # and fall back to arm. + # See https://github.com/rust-lang/rustup.rs/issues/587. + if [ "$_ostype" = "unknown-linux-gnueabihf" ] && [ "$_cputype" = armv7 ]; then + if ensure grep '^Features' /proc/cpuinfo | grep -q -v neon; then + # At least one processor does not have NEON. + _cputype=arm + fi + fi + + _arch="${_cputype}-${_ostype}" + + RETVAL="$_arch" +} + +check_proc() { + # Check for /proc by looking for the /proc/self/exe link + # This is only run on Linux + if ! test -L /proc/self/exe ; then + err "fatal: Unable to find /proc/self/exe. Is /proc mounted? Installation cannot proceed without /proc." + fi +} + +get_bitness() { + need_cmd head + # Architecture detection without dependencies beyond coreutils. + # ELF files start out "\x7fELF", and the following byte is + # 0x01 for 32-bit and + # 0x02 for 64-bit. + # The printf builtin on some shells like dash only supports octal + # escape sequences, so we use those. + local _current_exe_head + _current_exe_head=$(head -c 5 /proc/self/exe ) + if [ "$_current_exe_head" = "$(printf '\177ELF\001')" ]; then + echo 32 + elif [ "$_current_exe_head" = "$(printf '\177ELF\002')" ]; then + echo 64 + else + err "unknown platform bitness" + fi +} + +is_host_amd64_elf() { + need_cmd head + need_cmd tail + # ELF e_machine detection without dependencies beyond coreutils. + # Two-byte field at offset 0x12 indicates the CPU, + # but we're interested in it being 0x3E to indicate amd64, or not that. + local _current_exe_machine + _current_exe_machine=$(head -c 19 /proc/self/exe | tail -c 1) + [ "$_current_exe_machine" = "$(printf '\076')" ] +} + +get_endianness() { + local cputype=$1 + local suffix_eb=$2 + local suffix_el=$3 + + # detect endianness without od/hexdump, like get_bitness() does. + need_cmd head + need_cmd tail + + local _current_exe_endianness + _current_exe_endianness="$(head -c 6 /proc/self/exe | tail -c 1)" + if [ "$_current_exe_endianness" = "$(printf '\001')" ]; then + echo "${cputype}${suffix_el}" + elif [ "$_current_exe_endianness" = "$(printf '\002')" ]; then + echo "${cputype}${suffix_eb}" + else + err "unknown platform endianness" + fi +} + +say() { + printf '%s\n' "$1" +} + +err() { + say "$1" >&2 + exit 1 +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +assert_nz() { + if [ -z "$1" ]; then err "assert_nz $2"; fi +} + +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing +# command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +# This wraps curl or wget. Try curl first, if not installed, +# use wget instead. +downloader() { + local _dld + local _ciphersuites + local _err + local _status + local _retry + if check_cmd curl; then + _dld=curl + elif check_cmd wget; then + _dld=wget + else + _dld='curl or wget' # to be used in error message of need_cmd + fi + + if [ "$1" = --check ]; then + need_cmd "$_dld" + elif [ "$_dld" = curl ]; then + check_curl_for_retry_support + _retry="$RETVAL" + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" curl --proto --tlsv1.2; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q 404; then + err "āŒ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + elif [ "$_dld" = wget ]; then + if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then + echo "Warning: using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + get_ciphersuites_for_wget + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" wget --https-only --secure-protocol; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _status=$? + fi + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q ' 404 Not Found'; then + err "āŒ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + else + err "Unknown downloader" # should not reach here + fi +} + +# Check if curl supports the --retry flag, then pass it to the curl invocation. +check_curl_for_retry_support() { + local _retry_supported="" + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--retry"; then + _retry_supported="--retry 3" + fi + + RETVAL="$_retry_supported" + +} + +check_help_for() { + local _arch + local _cmd + local _arg + _arch="$1" + shift + _cmd="$1" + shift + + local _category + if "$_cmd" --help | grep -q 'For all options use the manual or "--help all".'; then + _category="all" + else + _category="" + fi + + case "$_arch" in + + *darwin*) + if check_cmd sw_vers; then + case $(sw_vers -productVersion) in + 10.*) + # If we're running on macOS, older than 10.13, then we always + # fail to find these options to force fallback + if [ "$(sw_vers -productVersion | cut -d. -f2)" -lt 13 ]; then + # Older than 10.13 + echo "Warning: Detected macOS platform older than 10.13" + return 1 + fi + ;; + 11.*) + # We assume Big Sur will be OK for now + ;; + *) + # Unknown product version, warn and continue + echo "Warning: Detected unknown macOS major version: $(sw_vers -productVersion)" + echo "Warning TLS capabilities detection may fail" + ;; + esac + fi + ;; + + esac + + for _arg in "$@"; do + if ! "$_cmd" --help $_category | grep -q -- "$_arg"; then + return 1 + fi + done + + true # not strictly needed +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these curl backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_curl() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _openssl_syntax="no" + local _gnutls_syntax="no" + local _backend_supported="yes" + if curl -V | grep -q ' OpenSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' LibreSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' BoringSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' GnuTLS/'; then + _gnutls_syntax="yes" + else + _backend_supported="no" + fi + + local _args_supported="no" + if [ "$_backend_supported" = "yes" ]; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--tlsv1.2" "--ciphers" "--proto"; then + _args_supported="yes" + fi + fi + + local _cs="" + if [ "$_args_supported" = "yes" ]; then + if [ "$_openssl_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "openssl") + elif [ "$_gnutls_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these wget backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_wget() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _cs="" + if wget -V | grep -q '\-DHAVE_LIBSSL'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "openssl") + fi + elif wget -V | grep -q '\-DHAVE_LIBGNUTLS'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return strong TLS 1.2-1.3 cipher suites in OpenSSL or GnuTLS syntax. TLS 1.2 +# excludes non-ECDHE and non-AEAD cipher suites. DHE is excluded due to bad +# DH params often found on servers (see RFC 7919). Sequence matches or is +# similar to Firefox 68 ESR with weak cipher suites disabled via about:config. +# $1 must be openssl or gnutls. +get_strong_ciphersuites_for() { + if [ "$1" = "openssl" ]; then + # OpenSSL is forgiving of unknown values, no problems with TLS 1.3 values on versions that don't support it yet. + echo "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384" + elif [ "$1" = "gnutls" ]; then + # GnuTLS isn't forgiving of unknown values, so this may require a GnuTLS version that supports TLS 1.3 even if wget doesn't. + # Begin with SECURE128 (and higher) then remove/add to build cipher suites. Produces same 9 cipher suites as OpenSSL but in slightly different order. + echo "SECURE128:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-DTLS-ALL:-CIPHER-ALL:-MAC-ALL:-KX-ALL:+AEAD:+ECDHE-ECDSA:+ECDHE-RSA:+AES-128-GCM:+CHACHA20-POLY1305:+AES-256-GCM" + fi +} + +# This is just for indicating that commands' results are being +# intentionally ignored. Usually, because it's being executed +# as part of error handling. +ignore() { + "$@" +} + +main "$@" || exit 1 diff --git a/resources/docker/configure.sh b/resources/docker/download.sh similarity index 92% rename from resources/docker/configure.sh rename to resources/docker/download.sh index a7395db7..d34533be 100644 --- a/resources/docker/configure.sh +++ b/resources/docker/download.sh @@ -1,13 +1,12 @@ #!/usr/bin/env sh # shellcheck shell=dash -# Stalwart Mail install script -- based on the rustup installation script. +# Stalwart Mail Server install script -- based on the rustup installation script. set -e set -u readonly BASE_URL="https://github.com/stalwartlabs/mail-server/releases/latest/download" -readonly BIN_DIR="/usr/local/bin" main() { downloader --check @@ -35,37 +34,23 @@ main() { _account="_stalwart-mail" fi - # Start configuration mode - if [ "$#" -eq 1 ] && [ "$1" = "--download" ] ; then - # Detect platform architecture - get_architecture || return 1 - local _arch="$RETVAL" - assert_nz "$_arch" "arch" + # Default component setting + local _component="stalwart-mail" + local _dir="/usr/local/bin" - # Download binaries - say "ā³ Downloading Stalwart binary for ${_arch}..." - local _file="${BIN_DIR}/stalwart-install.tar.gz" - local _url="https://github.com/stalwartlabs/__R__/releases/latest/download/stalwart-__N__-${_arch}.tar.gz" - ensure downloader "$_url" "$_file" "$_arch" - ensure tar zxvf "$_file" -C "$BIN_DIR" - ignore rm "$_file" + # Detect platform architecture + get_architecture || return 1 + local _arch="$RETVAL" + assert_nz "$_arch" "arch" - say "ā³ Downloading configure tool for ${_arch}..." - local _file="${BIN_DIR}/stalwart-install.tar.gz" - local _url="${BASE_URL}/stalwart-install-${_arch}.tar.gz" - ensure downloader "$_url" "$_file" "$_arch" - ensure tar zxvf "$_file" -C "$BIN_DIR" - ignore rm "$_file" - - say "ā³ Downloading CLI tool for ${_arch}..." - local _file="${BIN_DIR}/stalwart-cli.tar.gz" - local _url="${BASE_URL}/stalwart-cli-${_arch}.tar.gz" - ensure downloader "$_url" "$_file" "$_arch" - ensure tar zxvf "$_file" -C "$BIN_DIR" - ignore rm "$_file" - else - ignore $BIN_DIR/stalwart-install -c __C__ -p /opt/stalwart-mail -d - fi + # Download latest binary + say "ā³ Downloading ${_component} for ${_arch}..." + local _file="${_dir}/stalwart-mail.tar.gz" + local _url="${BASE_URL}/${_component}-${_arch}.tar.gz" + ensure downloader "$_url" "$_file" "$_arch" + ensure tar zxvf "$_file" -C "$_dir" + ignore chmod +x "$_dir/stalwart-mail" + ignore rm "$_file" return 0 } @@ -343,7 +328,7 @@ get_endianness() { } say() { - printf 'stalwart-mail: %s\n' "$1" + printf '%s\n' "$1" } err() { diff --git a/resources/docker/entrypoint.sh b/resources/docker/entrypoint.sh index d962583e..9b8cb390 100644 --- a/resources/docker/entrypoint.sh +++ b/resources/docker/entrypoint.sh @@ -1,10 +1,10 @@ #!/usr/bin/env sh # shellcheck shell=dash -# If the configuration file does not exist wait until it does. -while [ ! -f /opt/stalwart-mail/etc/config.toml ] || grep -q "__CERT_PATH__" /opt/stalwart-mail/etc/common/tls.toml; do - sleep 1 -done +# If the configuration file does not exist initialize it. +if [ ! -f /opt/stalwart-mail/etc/config.toml ]; then + /usr/local/bin/stalwart-mail --init /opt/stalwart-mail +fi # If the configuration file exists, start the server. -exec /usr/local/bin/__B__ --config /opt/stalwart-mail/etc/config.toml +exec /usr/local/bin/stalwart-mail --config /opt/stalwart-mail/etc/config.toml diff --git a/resources/systemd/stalwart-mail.service b/resources/systemd/stalwart-mail.service index 7d38c632..ef13e425 100644 --- a/resources/systemd/stalwart-mail.service +++ b/resources/systemd/stalwart-mail.service @@ -1,5 +1,5 @@ [Unit] -Description=Stalwart __TITLE__ Server +Description=Stalwart Mail Server Server Conflicts=postfix.service sendmail.service exim4.service ConditionPathExists=__PATH__/etc/config.toml After=network-online.target @@ -11,11 +11,11 @@ KillMode=process KillSignal=SIGINT Restart=on-failure RestartSec=5 -ExecStart=__PATH__/bin/stalwart-__NAME__ --config=__PATH__/etc/config.toml +ExecStart=__PATH__/bin/stalwart-mail --config=__PATH__/etc/config.toml PermissionsStartOnly=true StandardOutput=syslog StandardError=syslog -SyslogIdentifier=stalwart-__NAME__ +SyslogIdentifier=stalwart-mail [Install] WantedBy=multi-user.target diff --git a/resources/systemd/stalwart.mail.plist b/resources/systemd/stalwart.mail.plist index ac0e86bd..dba8eda9 100644 --- a/resources/systemd/stalwart.mail.plist +++ b/resources/systemd/stalwart.mail.plist @@ -4,12 +4,12 @@ Label - stalwart.__NAME__ + stalwart.mail ServiceDescription - Stalwart __TITLE__ Server + Stalwart Mail Server ProgramArguments - __PATH__/bin/stalwart-__NAME__ + __PATH__/bin/stalwart-mail --config=__PATH__/etc/config.toml RunAtLoad diff --git a/tests/resources/docker/docker-compose-pebble.yaml b/tests/resources/docker/docker-compose-pebble.yaml index 88e8c810..990b5d26 100644 --- a/tests/resources/docker/docker-compose-pebble.yaml +++ b/tests/resources/docker/docker-compose-pebble.yaml @@ -1,4 +1,8 @@ +# docker-compose -f docker-compose-pebble.yaml up # curl --request POST --data '{"ip":"192.168.5.2"}' http://localhost:8055/set-default-ipv4 +# HTTPS port should be 5001 +# Directory https://localhost:14000/dir + version: '3' services: pebble: diff --git a/tests/resources/scripts/create_test_env.sh b/tests/resources/scripts/create_test_env.sh index e044d3bd..434f1880 100644 --- a/tests/resources/scripts/create_test_env.sh +++ b/tests/resources/scripts/create_test_env.sh @@ -9,6 +9,7 @@ rm -rf $BASE_DIR # Create admin user 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 +printf "[server.http]\npermissive-cors = true\n" >> $BASE_DIR/etc/config.toml +printf "[tracer.stdout]\ntype = 'stdout'\nlevel = 'trace'\nansi = true\nenable = true\n" >> $BASE_DIR/etc/config.toml +sed -i '' 's/secret =/secret = "secret"\n#secret =/g' $BASE_DIR/etc/config.toml #cargo run -p mail-server --no-default-features --features "$FEATURES" -- --config=$BASE_DIR/etc/config.toml