From d58364619f74a3f7ddca93da76d6a92797a2fe94 Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 4 Aug 2024 16:34:22 +0200 Subject: [PATCH] feat: fix DNS entry listing in stalwart via patch, increase concurrent imap limit, allow aliasing existing mailboxes --- hosts/envoy/a.patch | 27 +++++ hosts/envoy/stalwart-mail.nix | 158 ++++++++++++++++++------------ pkgs/default.nix | 1 + pkgs/stal.nix | 149 ++++++++++++++++++++++++++++ users/myuser/secrets/user.nix.age | Bin 5039 -> 4618 bytes 5 files changed, 274 insertions(+), 61 deletions(-) create mode 100644 hosts/envoy/a.patch create mode 100644 pkgs/stal.nix diff --git a/hosts/envoy/a.patch b/hosts/envoy/a.patch new file mode 100644 index 0000000..895ce9e --- /dev/null +++ b/hosts/envoy/a.patch @@ -0,0 +1,27 @@ +diff --git a/crates/jmap/src/api/management/domain.rs b/crates/jmap/src/api/management/domain.rs +index e3890df5..7083aaf6 100644 +--- a/crates/jmap/src/api/management/domain.rs ++++ b/crates/jmap/src/api/management/domain.rs +@@ -123,6 +123,8 @@ impl JMAP { + } + + async fn build_dns_records(&self, domain_name: &str) -> trc::Result> { ++ let signature_config = self.core.storage.config.build_config("signature").await?; ++ + // Obtain server name + let server_name = self + .core +@@ -143,7 +145,11 @@ impl JMAP { + } + _ => (), + } +- keys.insert(key, value); ++ let val = signature_config.keys ++ .get(&format!("signature.{key}")) ++ .cloned() ++ .unwrap_or(value.clone()); ++ keys.insert(key, val); + } + + // Add MX and CNAME records + diff --git a/hosts/envoy/stalwart-mail.nix b/hosts/envoy/stalwart-mail.nix index 4400b48..0003911 100644 --- a/hosts/envoy/stalwart-mail.nix +++ b/hosts/envoy/stalwart-mail.nix @@ -50,6 +50,14 @@ in { services.stalwart-mail = { enable = true; + package = pkgs.stalwart-mail.overrideAttrs (old: { + patches = + old.patches + ++ [ + ./a.patch + ]; + doCheck = false; + }); settings = let case = field: check: value: data: { "if" = field; @@ -139,68 +147,63 @@ in { members = ""; # "SELECT name FROM emails WHERE address = ?1"; recipients = toSingleLineSql '' - -- It is important that we return only one value here, but these three UNIONed + -- It is important that we return only one value here, but in theory three UNIONed -- queries are guaranteed to be distinct. This is because a mailbox address -- and alias address can never be the same, their cross-table uniqueness is guaranteed on insert. -- The catch-all union can also only return something if @domain.tld is given as a parameter, -- which is invalid for aliases and mailboxes. - - -- Select the primary mailbox address if it matches and - -- all related parts are active - SELECT m.address AS name - FROM mailboxes AS m - JOIN domains AS d ON m.domain = d.domain - JOIN users AS u ON m.owner = u.username - WHERE m.address = ?1 - AND m.active = true - AND d.active = true - AND u.active = true - -- Then select the target of a matching alias - -- but make sure that all related parts are active. - UNION - SELECT a.target AS name - FROM aliases AS a - JOIN domains AS d ON a.domain = d.domain - JOIN ( - -- To check whether the owner is active we need to make a subquery - -- because the owner could be a user or mailbox - SELECT username - FROM users - WHERE active = true - UNION - SELECT m.address AS username - FROM mailboxes AS m - JOIN users AS u ON m.owner = u.username - WHERE m.active = true - AND u.active = true - ) AS u ON a.owner = u.username - WHERE a.address = ?1 - AND a.active = true - AND d.active = true - -- Finally, select any catch_all address that would catch this. - -- Again make sure everything is active. - UNION - SELECT d.catch_all AS name - FROM domains AS d - JOIN mailboxes AS m ON d.catch_all = m.address - JOIN users AS u ON m.owner = u.username - WHERE ?1 = ('@' || d.domain) - AND d.active = true - AND m.active = true - AND u.active = true - - -- This alternative catch-all query would expand catch-alls directly, but would - -- also require sorting the resulting table by precedence and LIMIT 1 - -- to always return just one result. - -- UNION - -- SELECT d.catch_all AS name - -- FROM domains AS d - -- JOIN mailboxes AS m ON d.catch_all = m.address - -- JOIN users AS u ON m.owner = u.username - -- WHERE ?1 LIKE ('%@' || d.domain) - -- AND d.active = true - -- AND m.active = true - -- AND u.active = true + -- + -- Nonetheless, it may be beneficial to allow an alias to override an existing mailbox, + -- so we can have send-only mailboxes which have their incoming mail redirected somewhere else. + -- Therefore, we make sure to order the query by (aliases -> mailboxes -> catch all) and only return the + -- highest priority one. + SELECT name FROM ( + -- Select the target of a matching alias (if any) + -- but make sure that all related parts are active. + SELECT a.target AS name, 1 AS rowOrder + FROM aliases AS a + JOIN domains AS d ON a.domain = d.domain + JOIN ( + -- To check whether the owner is active we need to make a subquery + -- because the owner could be a user or mailbox + SELECT username + FROM users + WHERE active = true + UNION + SELECT m.address AS username + FROM mailboxes AS m + JOIN users AS u ON m.owner = u.username + WHERE m.active = true + AND u.active = true + ) AS u ON a.owner = u.username + WHERE a.address = ?1 + AND a.active = true + AND d.active = true + -- Select the primary mailbox address if it matches and + -- all related parts are active. + UNION + SELECT m.address AS name, 2 AS rowOrder + FROM mailboxes AS m + JOIN domains AS d ON m.domain = d.domain + JOIN users AS u ON m.owner = u.username + WHERE m.address = ?1 + AND m.active = true + AND d.active = true + AND u.active = true + -- Finally, select any catch_all address that would catch this. + -- Again make sure everything is active. + UNION + SELECT d.catch_all, 3 AS rowOrder AS name + FROM domains AS d + JOIN mailboxes AS m ON d.catch_all = m.address + JOIN users AS u ON m.owner = u.username + WHERE ?1 = ('@' || d.domain) + AND d.active = true + AND m.active = true + AND u.active = true + ORDER BY rowOrder, name ASC + LIMIT 1 + ) ''; # "SELECT address FROM emails WHERE name = ?1 AND type != 'list' ORDER BY type DESC, address ASC"; emails = toSingleLineSql '' @@ -414,8 +417,8 @@ in { idle = "30m"; }; rate-limit = { - requests = "2000/1m"; - concurrent = 4; + requests = "20000/1m"; + concurrent = 32; }; }; @@ -543,7 +546,7 @@ in { lib.forEach (builtins.attrNames globals.mail.domains) (domain: '' if [[ ! -e /var/lib/stalwart-mail/dkim/rsa-${domain}.key ]]; then echo "Generating DKIM key for ${domain} (rsa)" - ${lib.getExe pkgs.openssl} genrsa -out /var/lib/stalwart-mail/dkim/rsa-${domain}.key 2048 + ${lib.getExe pkgs.openssl} genrsa -traditional -out /var/lib/stalwart-mail/dkim/rsa-${domain}.key 2048 fi if [[ ! -e /var/lib/stalwart-mail/dkim/ed25519-${domain}.key ]]; then echo "Generating DKIM key for ${domain} (ed25519)" @@ -563,4 +566,37 @@ in { RestartSec = "60"; # Retry every minute }; }; + + # systemd.services.stalwart-backup = { + # description = "Stalwart and idmail backup"; + # serviceConfig = { + # ExecStart = "${config.services.paperless.package}/bin/paperless-ngx document_exporter -na -nt -f -d ${stalwartBackupDir}"; + # ReadWritePaths = [ + # dataDir + # config.services.idmail.dataDir + # stalwartBackupDir + # ]; + # Restart = "no"; + # Type = "oneshot"; + # }; + # inherit (cfg) environment; + # requiredBy = ["restic-backups-storage-box-dusk.service"]; + # before = ["restic-backups-storage-box-dusk.service"]; + # }; + # + # # Needed so we don't run out of tmpfs space for large backups. + # # Technically this could be cleared each boot but whatever. + # environment.persistence."/state".directories = [ + # { + # directory = stalwartBackupDir; + # user = "stalwart-mail"; + # group = "stalwart-mail"; + # mode = "0700"; + # } + # ]; + # + # backups.storageBoxes.dusk = { + # subuser = "stalwart"; + # paths = [stalwartBackupDir]; + # }; } diff --git a/pkgs/default.nix b/pkgs/default.nix index b5962bf..0956680 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -4,6 +4,7 @@ _inputs: [ (_final: prev: { deploy = prev.callPackage ./deploy.nix {}; git-fuzzy = prev.callPackage ./git-fuzzy {}; + stalwart-mail = prev.callPackage ./stal.nix {}; kanidm = prev.kanidm.overrideAttrs (old: let provisionSrc = prev.fetchFromGitHub { owner = "oddlama"; diff --git a/pkgs/stal.nix b/pkgs/stal.nix new file mode 100644 index 0000000..b792664 --- /dev/null +++ b/pkgs/stal.nix @@ -0,0 +1,149 @@ +{ + lib, + rustPlatform, + fetchFromGitHub, + fetchpatch, + pkg-config, + protobuf, + bzip2, + openssl, + sqlite, + foundationdb, + zstd, + stdenv, + darwin, + nix-update-script, + nixosTests, + rocksdb_8_11, +}: let + # Stalwart depends on rocksdb crate: + # https://github.com/stalwartlabs/mail-server/blob/v0.8.0/crates/store/Cargo.toml#L10 + # which expects a specific rocksdb versions: + # https://github.com/rust-rocksdb/rust-rocksdb/blob/v0.22.0/librocksdb-sys/Cargo.toml#L3 + # See upstream issue for rocksdb 9.X support + # https://github.com/stalwartlabs/mail-server/issues/407 + rocksdb = rocksdb_8_11; + version = "0.9.0"; +in + rustPlatform.buildRustPackage { + pname = "stalwart-mail"; + inherit version; + + src = fetchFromGitHub { + owner = "stalwartlabs"; + repo = "mail-server"; + # XXX: We need to use a revisoin two commits after v0.9.0, which includes fixes for test cases. + # Can be reverted to "v${version}" next release. + rev = "2a12e251f2591b7785d7a921364f125d2e9c1e6e"; + hash = "sha256-qoU09tLpOlsy5lKv2GdCV23bd70hnNZ0r/O5APGVDyw="; + fetchSubmodules = true; + }; + + cargoHash = "sha256-rGCu3J+hTxiIENDIQM/jPz1wUNJr0ouoa1IkwWKfOWM="; + + patches = [ + # Remove "PermissionsStartOnly" from systemd service files, + # which is deprecated and conflicts with our module's ExecPreStart. + # Upstream PR: https://github.com/stalwartlabs/mail-server/pull/528 + (fetchpatch { + url = "https://github.com/stalwartlabs/mail-server/pull/528/commits/6e292b3d7994441e58e367b87967c9a277bce490.patch"; + hash = "sha256-j/Li4bYNE7IppxG3FGfljra70/rHyhRvDgOkZOlhMHY="; + }) + ]; + + nativeBuildInputs = [ + pkg-config + protobuf + rustPlatform.bindgenHook + ]; + + buildInputs = + [ + bzip2 + openssl + sqlite + foundationdb + zstd + ] + ++ lib.optionals stdenv.isDarwin [ + darwin.apple_sdk.frameworks.CoreFoundation + darwin.apple_sdk.frameworks.Security + darwin.apple_sdk.frameworks.SystemConfiguration + ]; + + env = { + OPENSSL_NO_VENDOR = true; + ZSTD_SYS_USE_PKG_CONFIG = true; + ROCKSDB_INCLUDE_DIR = "${rocksdb}/include"; + ROCKSDB_LIB_DIR = "${rocksdb}/lib"; + }; + + postInstall = '' + mkdir -p $out/etc/stalwart + cp resources/config/spamfilter.toml $out/etc/stalwart/spamfilter.toml + cp -r resources/config/spamfilter $out/etc/stalwart/ + + mkdir -p $out/lib/systemd/system + + substitute resources/systemd/stalwart-mail.service $out/lib/systemd/system/stalwart-mail.service \ + --replace "__PATH__" "$out" + ''; + + checkFlags = [ + # Require running mysql, postgresql daemon + "--skip=directory::imap::imap_directory" + "--skip=directory::internal::internal_directory" + "--skip=directory::ldap::ldap_directory" + "--skip=directory::sql::sql_directory" + "--skip=store::blob::blob_tests" + "--skip=store::lookup::lookup_tests" + # thread 'directory::smtp::lmtp_directory' panicked at tests/src/store/mod.rs:122:44: + # called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" } + "--skip=directory::smtp::lmtp_directory" + # thread 'imap::imap_tests' panicked at tests/src/imap/mod.rs:436:14: + # Missing store type. Try running `STORE= cargo test`: NotPresent + "--skip=imap::imap_tests" + # thread 'jmap::jmap_tests' panicked at tests/src/jmap/mod.rs:303:14: + # Missing store type. Try running `STORE= cargo test`: NotPresent + "--skip=jmap::jmap_tests" + # Failed to read system DNS config: io error: No such file or directory (os error 2) + "--skip=smtp::inbound::data::data" + # Expected "X-My-Header: true" but got Received: from foobar.net (unknown [10.0.0.123]) + "--skip=smtp::inbound::scripts::sieve_scripts" + # panicked at tests/src/smtp/outbound/smtp.rs:173:5: + "--skip=smtp::outbound::smtp::smtp_delivery" + # thread 'smtp::queue::retry::queue_retry' panicked at tests/src/smtp/queue/retry.rs:119:5: + # assertion `left == right` failed + # left: [1, 2, 2] + # right: [1, 2, 3] + "--skip=smtp::queue::retry::queue_retry" + # Missing store type. Try running `STORE= cargo test`: NotPresent + "--skip=store::store_tests" + # thread 'config::parser::tests::toml_parse' panicked at crates/utils/src/config/parser.rs:463:58: + # called `Result::unwrap()` on an `Err` value: "Expected ['\\n'] but found '!' in value at line 70." + "--skip=config::parser::tests::toml_parse" + # error[E0432]: unresolved import `r2d2_sqlite` + # use of undeclared crate or module `r2d2_sqlite` + "--skip=backend::sqlite::pool::SqliteConnectionManager::with_init" + # thread 'smtp::reporting::analyze::report_analyze' panicked at tests/src/smtp/reporting/analyze.rs:88:5: + # assertion `left == right` failed + # left: 0 + # right: 12 + "--skip=smtp::reporting::analyze::report_analyze" + ]; + + doCheck = !(stdenv.isLinux && stdenv.isAarch64); + + passthru = { + update-script = nix-update-script {}; + tests.stalwart-mail = nixosTests.stalwart-mail; + }; + + meta = with lib; { + description = "Secure & Modern All-in-One Mail Server (IMAP, JMAP, SMTP)"; + homepage = "https://github.com/stalwartlabs/mail-server"; + changelog = "https://github.com/stalwartlabs/mail-server/blob/${version}/CHANGELOG"; + license = licenses.agpl3Only; + maintainers = with maintainers; [happysalada onny oddlama]; + }; + } diff --git a/users/myuser/secrets/user.nix.age b/users/myuser/secrets/user.nix.age index c0100054c7496f1e7f0f38d4f62e4ca868483a90..f31706e78a3c8679df10aa151e70c7bb933a36f0 100644 GIT binary patch literal 4618 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 Fwn4EDbE*IU