diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2d29d94 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: "Test" +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '42 7 * * *' +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Nix + uses: cachix/install-nix-action@v30 + - name: Execute unit tests + run: nix flake check diff --git a/flake.lock b/flake.lock index 9769102..598bc62 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,20 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1748047550, + "narHash": "sha256-t0qLLqb4C1rdtiY8IFRH5KIapTY/n3Lqt57AmxEv9mk=", + "owner": "ipetkov", + "repo": "crane", + "rev": "b718a78696060df6280196a6f992d04c87a16aef", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -23,11 +38,11 @@ ] }, "locked": { - "lastModified": 1730504689, - "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "506278e768c2a08bec68eb62932193e341f55c90", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", "type": "github" }, "original": { @@ -36,10 +51,32 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1747372754, + "narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ - "pre-commit-hooks-nix", + "git-hooks-nix", "nixpkgs" ] }, @@ -59,64 +96,47 @@ }, "nixpkgs": { "locked": { - "lastModified": 1731755305, - "narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=", + "lastModified": 1748162331, + "narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-24.11", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1720386169, - "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "194846768975b7ad2c4988bdb82572c00222c0d7", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "pre-commit-hooks-nix": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1732021966, - "narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "root": { "inputs": { + "crane": "crane", "flake-parts": "flake-parts", + "git-hooks-nix": "git-hooks-nix", "nixpkgs": "nixpkgs", - "pre-commit-hooks-nix": "pre-commit-hooks-nix" + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1748243702, + "narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index fc7359a..082f348 100644 --- a/flake.nix +++ b/flake.nix @@ -2,11 +2,14 @@ description = "An opinionated Nixos Mailsystem"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; - pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix"; - pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + crane.url = "github:ipetkov/crane"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { @@ -21,7 +24,9 @@ "aarch64-linux" ]; imports = [ - inputs.pre-commit-hooks-nix.flakeModule + flake-parts.flakeModules.easyOverlay + inputs.treefmt-nix.flakeModule + inputs.git-hooks-nix.flakeModule ]; perSystem = { @@ -31,10 +36,33 @@ pkgs, system, ... - }: { - devShells.default = pkgs.mkShell { + }: let + craneLib = inputs.crane.mkLib pkgs; + pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default; + in { + checks = let + tests = ["internal" "basic" "aliases" "rspamd"]; + genTest = testName: { + "name" = testName; + "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; + }; + in + pkgs.lib.listToAttrs (map genTest tests); + + packages = rec { + default = mailnix; + mailnix = craneLib.buildPackage { + src = craneLib.cleanCargoSource ./pkgs/mailnix; + }; + }; + overlayAttrs = { + mailnix = config.packages.mailnix; + }; + + devShells.default = craneLib.devShell { packages = with pkgs; [ - alejandra + self'.formatter.outPath # Add all formatters to environment + mailnix ]; shellHook = '' ${config.pre-commit.installationScript} @@ -42,8 +70,25 @@ }; pre-commit.settings.hooks = { - alejandra.enable = true; + treefmt.enable = true; }; + + treefmt = { + programs = { + actionlint.enable = true; + alejandra.enable = true; + rustfmt.enable = true; + }; + settings.global.excludes = [ + ".envrc" + "*.sieve" + ]; + }; + }; + + flake.nixosModules = rec { + default = mailsystem; + mailsystem = import ./mailsystem; }; }; } diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 6d9df16..48dcdcb 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -1,7 +1,40 @@ -{config, ...}: let +{ + config, + pkgs, + ... +}: let cfg = config.mailsystem; in rec { - sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; - sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; - sslCertService = ["acme-finished-${cfg.fqdn}.target"]; + certificateDirectory = "/var/certs"; + sslCertPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem" + else "${certificateDirectory}/cert-${cfg.fqdn}.pem"; + + sslKeyPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem" + else "${certificateDirectory}/key-${cfg.fqdn}.pem"; + + sslCertService = + if cfg.certificateScheme == "acme" + then ["acme-finished-${cfg.fqdn}.target"] + else ["mailsystem-selfsigned-certificate.service"]; + + mailnixCmd = let + mailnixCfgFile = pkgs.writeText "mailnix-public.json" (builtins.toJSON { + inherit (cfg) accounts domains; + aliases = cfg.virtualAliases; + }); + extraCfgFile = + if (cfg.extraSettingsFile != null) + then cfg.extraSettingsFile + else ""; + in "${pkgs.mailnix}/bin/mailnix ${extraCfgFile} ${mailnixCfgFile}"; + + dovecotDynamicStateDir = "/var/lib/dovecot"; + dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; + + rspamdProxySocket = "/run/rspamd-proxy.sock"; + rspamdControllerSocket = "/run/rspamd-controller.sock"; } diff --git a/mailsystem/default.nix b/mailsystem/default.nix index abc5b73..8b6b7bb 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -1,4 +1,10 @@ -{lib, ...}: { +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { options.mailsystem = { enable = lib.mkEnableOption "nixos-mailsystem"; @@ -14,6 +20,32 @@ description = "Fully qualified domain name of the mail server."; }; + reverseFqdn = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + defaultText = lib.literalMD "{option}`mailsystem.fqdn`"; + example = "server.example.com"; + description = '' + Fully qualified domain name used by the server to identify + with other servers. + + This needs to be set to the same value of the server's IP reverse DNS. + ''; + }; + + domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = ["example.com"]; + default = []; + description = "List of domains to be served by the mail server"; + }; + + messageSizeLimit = lib.mkOption { + type = lib.types.int; + default = 64 * 1024 * 1024; + description = "Maximum accepted mail size"; + }; + vmailUID = lib.mkOption { type = lib.types.int; default = 5000; @@ -37,10 +69,178 @@ default = "/var/vmail"; description = "Storage location for all mail."; }; + + accounts = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({name, ...}: { + options = { + name = lib.mkOption { + type = lib.types.str; + example = "user1@example.com"; + description = "Username"; + }; + + hashedPasswordFile = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + example = "/run/secrets/user1-passwordhash"; + description = '' + A file containing the user's hashed password. Use `mkpasswd` as follows + + ``` + nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + ``` + ''; + }; + + isSystemUser = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + System users are not allowed to change their password and are + cannot receive any mails (-> send-only). Mails sent to such an + account will be rejected. + ''; + }; + + rejectMessage = lib.mkOption { + type = lib.types.str; + default = "This account cannot receive emails."; + description = '' + The message that will be returned to the sender when an email is + sent to a system account. + ''; + }; + }; + + config.name = lib.mkDefault name; + })); + example = { + user1 = { + hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; + }; + user2 = { + hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/"; + }; + }; + description = "All available accounts for the mailsystem."; + default = {}; + }; + + virtualAliases = lib.mkOption { + type = let + isAccount = value: builtins.elem value (builtins.attrNames cfg.accounts); + isDomain = value: !(lib.hasInfix "@" value) && (builtins.elem value cfg.domains); + account = lib.mkOptionType { + name = "Mail Account"; + check = isAccount; + }; + accountOrDomain = lib.mkOptionType { + name = "Mail Account or Domain"; + check = value: (isAccount value) || (isDomain value); + }; + in + with lib.types; attrsOf (either (nonEmptyListOf account) accountOrDomain); + example = { + "info@example.com" = "user1@example.com"; + "postmaster@example.com" = "user1@example.com"; + "abuse@example.com" = "user1@example.com"; + "multi@example.com" = ["user1@example.com" "user2@example.com"]; + "aliasdomain.com" = "domain.com"; + }; + description = '' + Virtual account and domain aliases. A virtual alias means, that all mail directed + at a given target are forwarded to the specified other destinations, too. + + For account aliases, this means that, e.g., `"user1@example.com"` receives all mail + sent to `"info@example.com"`. In addition, `"user1@example.com"` is also able to + impersonate `"info@example.com"` when sending mails. It is also possible to create + an alias for multiple accounts. In this example, all mails for `"multi@example.com"` + will be forwarded to both + `"user1@example.com"` and `"user2@example.com"`. + + For domain aliases, this means that all mails directed at an aliased domain, e.g., + `"aliasdomain.com"` are forwarded to `"domain.com"` This also entails, that any + account or alias of `"domain.com"` receives mails directed at `"aliasdomain.com"`. + However, if `"user@domain.com"` shall be able to send mails using + `"user@aliasdomain.com"`, an explicit alias needs to be configured. + ''; + default = {}; + }; + + dkimSettings = lib.mkOption { + type = with lib.types; + attrsOf (listOf (submodule { + options = { + selector = lib.mkOption { + type = lib.types.str; + example = "mail"; + description = "DKIM Selector"; + }; + keyFile = lib.mkOption { + type = lib.types.path; + example = "/run/secrets/dkim/example.com.mail.key"; + description = '' + Path to DKIM private-key-file. A public-private-pair can be generated as follows: + + ``` + nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -t ed25519 -d example.com + nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -b 2048 -d example.com + ``` + ''; + }; + }; + })); + example = { + "example.com" = [ + { + selector = "mail"; + keyFile = "/run/secrets/dkim/example.com.mail.key"; + } + ]; + }; + description = '' + Per-domain DKIM configuration. + This option allows to optionally set one or more DKIM private keys + and their respective selectors for each domain individually. + ''; + default = {}; + }; + + extraSettingsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + description = '' + YAML file to merge into the mailsystem configuration at runtime. + This can be used to store secrets, and, more importantly, keep your email + addresses out of the hands of spammers. This `extraSettingsFile` currently + supports `domains`, `accounts` and `virtualAliases` which can be defined in + the same manner as they can be via nix. + ''; + default = null; + }; + + certificateScheme = lib.mkOption { + type = lib.types.enum ["acme" "selfsigned"]; + default = "acme"; + description = '' + The scheme to use for managing TLS certificates: + + 1. `acme`: The server retrieves letsencrypt certificates via NixOS's acme module using nginx. + 2. `selfsigned`: The server creates self-signed certificates on the fly (intended for testing). + ''; + internal = true; + visible = false; + }; }; imports = [ + ./dovecot.nix + ./kresd.nix ./nginx.nix + ./postfix.nix + ./redis.nix + ./roundcube.nix + ./rspamd.nix + ./selfsigned.nix ./user.nix ]; } diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix new file mode 100644 index 0000000..877c3e0 --- /dev/null +++ b/mailsystem/dovecot.nix @@ -0,0 +1,281 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config pkgs;}); let + cfg = config.mailsystem; + postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; + + runtimeStateDir = "/run/dovecot2"; + + staticPasswdFile = "${runtimeStateDir}/passwd"; + initialPasswdFile = "${runtimeStateDir}/initial-passwd"; + userdbFile = "${runtimeStateDir}/userdb"; + # dovecotDynamicStateDir and dovecotDynamicPasswdFile are defined in common.nix + + systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts; + normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts; + + genUserdbEntry = user: value: "${user}:::::::"; + genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::"; + + genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + if (! test -d "${runtimeStateDir}"); then + mkdir "${runtimeStateDir}" + chmod 755 "${runtimeStateDir}" + fi + + if (! test -d "${dovecotDynamicStateDir}"); then + mkdir "${dovecotDynamicStateDir}" + chmod 755 "${dovecotDynamicStateDir}" + fi + + # Ensure passwd files are not world-readable at any time + umask 077 + + # Prepare static passwd-file for system users + ${mailnixCmd} generate-static-passdb > "${staticPasswdFile}" + + # Prepare/Update passwd-file for dynamic users + ${mailnixCmd} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}" + + ${lib.optionalString cfg.roundcube.enable '' + # Ensure roundcube has access to dynamic passwd file + ${pkgs.acl.bin}/bin/setfacl -m "u:${config.services.phpfpm.pools.roundcube.user}:rw" "${dovecotDynamicPasswdFile}" + ''} + + # Prepare userdb-file + ${mailnixCmd} generate-userdb > "${userdbFile}" + ''; + + genMaildirScript = pkgs.writeScript "generate-maildir" '' + #!${pkgs.stdenv.shell} + + # Create mail directory and set permissions accordingly. + umask 007 + mkdir -p ${cfg.mailDirectory} + chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory} + chmod 02770 ${cfg.mailDirectory} + ''; + + junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") dovecot2Cfg.mailboxes); + junkMailboxNumber = builtins.length junkMailboxes; + # The assertion guarantees that there is exactly one Junk mailbox. + junkMailboxName = + if junkMailboxNumber == 1 + then builtins.elemAt junkMailboxes 0 + else ""; +in { + options.mailsystem.dovecot.dhparamSize = lib.mkOption { + type = lib.types.int; + default = 2048; + description = "The bit size for the prime that is used during a Diffie-Hellman key exchange by dovecot."; + }; + + config = lib.mkIf cfg.enable { + assertions = + [ + { + assertion = junkMailboxNumber == 1; + message = "mailnix requires exactly one dovecot mailbox with the 'special use' flag to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; + } + ] + ++ lib.mapAttrsToList ( + user: value: { + assertion = value.hashedPasswordFile != null; + message = "A file containing the hashed password for user ${user} needs to be set."; + } + ) + cfg.accounts; + + environment.systemPackages = [ + # sieves + managesieve + pkgs.dovecot_pigeonhole + ]; + + # For compatibility with python imaplib + environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules"; + + services.dovecot2 = { + enable = true; + enableImap = true; + enablePAM = false; + # TODO: enable quota and setup quota warnings + #enableQuota = true; + mailUser = cfg.vmailUserName; + mailGroup = cfg.vmailGroupName; + mailLocation = "maildir:~/Maildir"; + + sslServerCert = sslCertPath; + sslServerKey = sslKeyPath; + + enableLmtp = true; + + # enable managesieve + protocols = ["sieve"]; + + pluginSettings = { + sieve = "file:~/sieve;active=~/.dovecot.sieve"; + }; + + sieve = { + extensions = [ + "fileinto" + "mailbox" + ]; + + scripts.after = builtins.toFile "spam.sieve" '' + require "fileinto"; + require "mailbox"; + + if header :is "X-Spam" "Yes" { + fileinto :create "${junkMailboxName}"; + stop; + } + ''; + + pipeBins = map lib.getExe [ + (pkgs.writeShellScriptBin "learn-ham.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_ham") + (pkgs.writeShellScriptBin "learn-spam.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_spam") + ]; + }; + + imapsieve.mailbox = [ + { + name = junkMailboxName; + causes = ["COPY" "APPEND"]; + before = ./dovecot/report-spam.sieve; + } + { + name = "*"; + from = junkMailboxName; + causes = ["COPY"]; + before = ./dovecot/report-ham.sieve; + } + ]; + + # TODO: move configuration to default.nix? + mailboxes = { + Drafts = { + auto = "subscribe"; + specialUse = "Drafts"; + }; + Junk = { + auto = "subscribe"; + specialUse = "Junk"; + }; + Trash = { + auto = "subscribe"; + specialUse = "Trash"; + }; + Sent = { + auto = "subscribe"; + specialUse = "Sent"; + }; + }; + + extraConfig = '' + service imap-login { + inet_listener imap { + # Using starttls for encryption shifts the responsibility for + # encrypted communication over to the client in the face of MITM. + # However, it has shown that clients are often not enforcing it. + port = 0 # clients should use starttls instaed + } + inet_listener imaps { + port = 993 + ssl = yes + } + } + + protocol imap { + mail_max_userip_connections = 100 + mail_plugins = $mail_plugins imap_sieve + } + + mail_access_groups = ${cfg.vmailGroupName} + ssl = required + ssl_min_protocol = TLSv1.2 + ssl_prefer_server_ciphers = yes + + service lmtp { + unix_listener dovecot-lmtp { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0600 + } + } + + recipient_delimiter = + + lmtp_save_to_detail_mailbox = no + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + # Passwords stored among two passwd-files: One for users allowed to + # change their password and one for any other user (mostly system + # users) with immutable passwords. + passdb { + driver = passwd-file + args = ${dovecotDynamicPasswdFile} + } + passdb { + driver = passwd-file + args = ${staticPasswdFile} + } + + userdb { + driver = passwd-file + args = ${userdbFile} + default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%{domain}/%{username} + } + + service auth { + unix_listener auth { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0660 + } + } + + auth_mechanisms = plain login + + namespace inbox { + separator = / + inbox = yes + } + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + ''; + }; + + security.dhparams.params.dovecot2.bits = cfg.dovecot.dhparamSize; + + systemd.services.dovecot2 = { + preStart = '' + ${genAuthDbsScript} + ${genMaildirScript} + ''; + wants = sslCertService; + after = sslCertService; + }; + systemd.services.postfix.restartTriggers = [genAuthDbsScript]; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 993 # imaps + 4190 #managesieve + ]; + }; + }; +} diff --git a/mailsystem/dovecot/report-ham.sieve b/mailsystem/dovecot/report-ham.sieve new file mode 100644 index 0000000..6217a90 --- /dev/null +++ b/mailsystem/dovecot/report-ham.sieve @@ -0,0 +1,15 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +if string "${mailbox}" "Trash" { + stop; +} + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-ham.sh" [ "${username}" ]; diff --git a/mailsystem/dovecot/report-spam.sieve b/mailsystem/dovecot/report-spam.sieve new file mode 100644 index 0000000..9d4c74b --- /dev/null +++ b/mailsystem/dovecot/report-spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-spam.sh" [ "${username}" ]; diff --git a/mailsystem/kresd.nix b/mailsystem/kresd.nix new file mode 100644 index 0000000..448b644 --- /dev/null +++ b/mailsystem/kresd.nix @@ -0,0 +1,11 @@ +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { + config = lib.mkIf cfg.enable { + services.kresd.enable = true; + }; +} diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 2bb294b..919bf7c 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -3,18 +3,31 @@ pkgs, lib, ... -}: let +}: +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; in { - config = lib.mkIf cfg.enable { - services.nginx = { - enable = true; - virtualHosts."${cfg.fqdn}" = { - forceSSL = true; - enableACME = true; + config = + lib.mkIf cfg.enable { + services.nginx = { + enable = true; + virtualHosts."${cfg.fqdn}" = + { + forceSSL = true; + enableACME = cfg.certificateScheme == "acme"; + } + // lib.optionalAttrs (cfg.certificateScheme == "selfsigned") { + sslCertificate = sslCertPath; + sslCertificateKey = sslKeyPath; + }; }; - }; - networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; - }; + networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; + } + // lib.mkIf (cfg.enable && cfg.certificateScheme == "acme") { + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" + "dovecot2.service" + ]; + }; } diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix new file mode 100644 index 0000000..3e31cb6 --- /dev/null +++ b/mailsystem/postfix.nix @@ -0,0 +1,205 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config pkgs;}); let + cfg = config.mailsystem; + + mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; + + runtimeDir = "/run/postfix"; + aliases_file = "${runtimeDir}/virtual_aliases"; + virtual_domains_file = "${runtimeDir}/virtual_domains"; + denied_recipients_file = "${runtimeDir}/denied_recipients"; + + genPostmapsScript = pkgs.writeScript "generate-postfix-postmaps" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + if (! test -d "${runtimeDir}"); then + mkdir "${runtimeDir}" + chmod 755 "${runtimeDir}" + fi + + ${mailnixCmd} "generate-aliases" > "${aliases_file}" + ${mailnixCmd} "generate-domains" > "${virtual_domains_file}" + ${mailnixCmd} "generate-denied-recipients" > "${denied_recipients_file}" + ''; + + submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. + + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + + # Replace the user's submitted hostname with the server's FQDN to hide the + # user's host/network. + /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> + ''; + + tls_protocols = "TLSv1.3, TLSv1.2, !TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; +in { + config = lib.mkIf cfg.enable { + assertions = let + isDomain = value: !lib.hasInfix "@" value; + aliasedDomains = builtins.filter isDomain (builtins.attrNames cfg.virtualAliases); + in + map (domain: { + assertion = builtins.elem domain cfg.domains; + message = "The domain to be aliased (${domain}) must be managed by the mailserver."; + }) + aliasedDomains; + + services.postfix = { + enable = true; + hostname = "${cfg.reverseFqdn}"; + networksStyle = "host"; + + sslCert = sslCertPath; + sslKey = sslKeyPath; + + enableSubmissions = true; + + mapFiles."virtual_aliases" = aliases_file; + mapFiles."denied_recipients" = denied_recipients_file; + + submissionsOptions = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_local_domain = "$myhostname"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + smtpd_sender_login_maps = mappedFile "virtual_aliases"; + smtpd_sender_restrictions = "reject_sender_login_mismatch"; + smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; + + config = { + mydestination = ""; + recipient_delimiter = "+"; + smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + disable_vrfy_command = true; + message_size_limit = toString cfg.messageSizeLimit; + + virtual_uid_maps = "static:${toString cfg.vmailUID}"; + virtual_gid_maps = "static:${toString cfg.vmailUID}"; + virtual_mailbox_base = cfg.mailDirectory; + virtual_mailbox_domains = virtual_domains_file; + virtual_alias_maps = [ + (mappedFile "virtual_aliases") + ]; + virtual_mailbox_maps = [ + (mappedFile "virtual_aliases") + ]; + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients + lmtp_destination_recipient_limit = "1"; + + # sasl with dovecot (enforce authentication via dovecot) + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_auth_enable = true; + smtpd_relay_restrictions = [ + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unauth_destination" + ]; + smtpd_recipient_restrictions = [ + "check_recipient_access ${mappedFile "denied_recipients"}" + ]; + + # TLS settings, inspired by https://github.com/jeaye/nix-files + # Submission by mail clients is handled in submissionOptions + smtpd_tls_security_level = "may"; + + # Disable obsolete protocols + smtpd_tls_protocols = tls_protocols; + smtp_tls_protocols = tls_protocols; + smtpd_tls_mandatory_protocols = tls_protocols; + smtp_tls_mandatory_protocols = tls_protocols; + + smtp_tls_ciphers = "high"; + smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; + smtpd_tls_mandatory_ciphers = "high"; + + # Disable deprecated ciphers + smtpd_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtpd_tls_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_exclude_ciphers = tls_exclude_ciphers; + + tls_preempt_cipherlist = true; + + # Allowing AUTH on a non-encrypted connection poses a security risk + smtpd_tls_auth_only = true; + # Log only a summary message on TLS handshake completion + smtpd_tls_loglevel = "1"; + + # Configure a non-blocking source of randomness + tls_random_source = "dev:/dev/urandom"; + + smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + # Also use milter for outgoing mails (for e.g., dkim) + non_smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + milter_protocol = "6"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + + # Fix for https://www.postfix.org/smtp-smuggling.html + smtpd_forbid_bare_newline = "yes"; + smtpd_forbid_bare_newline_exclusions = "$mynetworks"; + }; + + masterConfig = { + "lmtp" = { + # Add headers when delivering, see http://www.postfix.org/smtp.8.html + # D => Delivered-To, O => X-Original-To, R => Return-Path + args = ["flags=O"]; + }; + "submission-header-cleanup" = { + type = "unix"; + private = false; + chroot = false; + maxproc = 0; + command = "cleanup"; + args = ["-o" "header_checks=pcre:${submission_header_cleanup_rules}"]; + }; + }; + }; + + systemd.services.postfix-setup = { + preStart = '' + ${genPostmapsScript} + ''; + }; + systemd.services.postfix = { + wants = sslCertService; + after = + ["dovecot2.service" "rspamd.service"] + ++ sslCertService; + requires = ["dovecot2.service" "rspamd.service"]; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 25 # smtp + 465 # submissions + ]; + }; + }; +} diff --git a/mailsystem/redis.nix b/mailsystem/redis.nix new file mode 100644 index 0000000..658093d --- /dev/null +++ b/mailsystem/redis.nix @@ -0,0 +1,27 @@ +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.mailsystem; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; +in { + config = lib.mkIf cfg.enable { + services.redis.servers.rspamd = { + enable = true; + # Don't accept connections via tcp + port = 0; + unixSocketPerm = 600; + }; + + # TODO: Run commands as service user instead of as root? + systemd.services.redis-rspamd.serviceConfig.ExecStartPost = + "+" + + pkgs.writeShellScript "redis-rspamd-postStart" '' + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:x" "${builtins.dirOf redisCfg.unixSocket}" + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:rw" "${redisCfg.unixSocket}" + ''; + }; +} diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix new file mode 100644 index 0000000..4c11c51 --- /dev/null +++ b/mailsystem/roundcube.nix @@ -0,0 +1,57 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config pkgs;}); let + cfg = config.mailsystem; + roundcubeCfg = config.mailsystem.roundcube; +in { + options.mailsystem.roundcube = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable roundcube in order to provide a webmail interface"; + }; + hostName = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + description = "FQDN to be used by roundcube. Defaults to {option}`mailsystem.fqdn`."; + }; + passwordHashingAlgorithm = lib.mkOption { + type = lib.types.str; + default = "BLF-CRYPT"; + description = "Password hashing algorithm to be used with `doveadm pw`"; + }; + }; + + config = lib.mkIf (cfg.enable && roundcubeCfg.enable) { + services.roundcube = { + enable = true; + hostName = roundcubeCfg.hostName; + plugins = ["managesieve" "password"]; + extraConfig = '' + // Use implicitly encrypted communications for imap and imap (implicit tls) + $config['imap_host'] = "ssl://${cfg.fqdn}"; + $config['smtp_host'] = "ssl://${cfg.fqdn}"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + + $config['managesieve_host'] = "localhost"; + + $config['password_driver'] = "dovecot_passwdfile"; + $config['password_confirm_current'] = true; + $config['password_minimum_length'] = 8; + $config['password_algorithm'] = "dovecot"; + // Enables saving the new password even if it machtes the old password. Useful + // for upgrading the stored passwords after the encryption scheme has changed. + $config['password_force_save'] = true; + $config['password_dovecot_passwdfile_path'] = "${dovecotDynamicPasswdFile}"; + $config['password_dovecotpw'] = "${pkgs.dovecot}/bin/doveadm pw"; + $config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}"; + $config['password_dovecotpw_with_method'] = true; + ''; + }; + }; +} diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix new file mode 100644 index 0000000..851693a --- /dev/null +++ b/mailsystem/rspamd.nix @@ -0,0 +1,181 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config pkgs;}); let + cfg = config.mailsystem; + dovecot2Cfg = config.services.dovecot2; + nginxCfg = config.services.nginx; + postfixCfg = config.services.postfix; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; + + genSystemdSocketCfg = name: socketPath: additionalUsers: { + description = "rspamd ${name} worker socket"; + listenStreams = [socketPath]; + requiredBy = ["rspamd.service"]; + socketConfig = { + Service = "rspamd.service"; + SocketUser = rspamdCfg.user; + SocketMode = 0600; + ExecStartPost = + lib.mkIf (additionalUsers != []) + (pkgs.writeShellScript "set-systemd-socket-permissions" + (lib.concatMapStringsSep "\n" (user: ''${pkgs.acl.bin}/bin/setfacl -m "u:${user}:rw" "${socketPath}"'') + additionalUsers)); + }; + }; +in { + options.mailsystem.rspamd.webUi = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`"; + }; + + basicAuthFile = lib.mkOption { + type = lib.types.str; + description = "Path to basic auth file (entries can be generated using htpasswd)"; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = + [ + { + assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null; + message = "Setting basicAuthFile is required if rspamd's web interface is enabled"; + } + ] + ++ lib.mapAttrsToList ( + domain: dkimList: { + assertion = builtins.elem domain cfg.domains; + message = "Domain ${domain} as per `config.mailsystem.dkimSettings` needs to be managed by the mailserver."; + } + ) + cfg.dkimSettings + ++ lib.mapAttrsToList ( + domain: dkimList: { + assertion = dkimList != []; + message = "Entry ${domain} as per `config.mailsystem.dkimSettings` must not be an empty list."; + } + ) + cfg.dkimSettings; + + services.rspamd = { + enable = true; + overrides = { + "classifier-bayes.conf" = { + text = '' + autolearn { + spam_threshold = 6.0 # When to learn spam (score >= threshold) + ham_threshold = -2.0 # When to learn ham (score <= threshold) + } + ''; + }; + "dkim_signing.conf" = let + genDkimSelectorList = entry: '' + { + path: "${entry.keyFile}"; + selector: "${entry.selector}"; + } + ''; + genDkimDomainCfg = domain: domainSettings: '' + ${domain} { + selectors [ + ${lib.concatStringsSep "\n" (map genDkimSelectorList domainSettings)} + ] + } + ''; + in { + text = + '' + sign_authenticated = true; + use_esld = true; + use_domain = "header"; + check_pubkey = true; + allow_username_mismatch = true; + allow_hdrfrom_mismatch = true; + allow_hdrfrom_mismatch_sign_networks = true; + + '' + + lib.optionalString (cfg.dkimSettings != {}) '' + domain { + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genDkimDomainCfg cfg.dkimSettings)} + } + ''; + }; + "milter_headers.conf" = { + text = '' + # Add headers related to spam-detection + extended_spam_headers = true; + ''; + }; + "redis.conf" = { + text = '' + servers = "${redisCfg.unixSocket}"; + ''; + }; + "worker-controller.inc" = lib.mkIf cfg.rspamd.webUi.enable { + text = '' + secure_ip = "0.0.0.0/0"; + secure_ip = "::/0"; + ''; + }; + }; + + workers = { + rspamd_proxy = { + bindSockets = ["systemd:rspamd-proxy.socket"]; + count = 1; # Do not spawn too many processes of this type + extraConfig = '' + milter = yes; # Enable milter mode + timeout = 120s; # Needed for Milter usually + + upstream "local" { + default = yes; # Self-scan upstreams are always default + self_scan = yes; # Enable self-scan + } + ''; + }; + + controller = { + count = 1; + bindSockets = ["systemd:rspamd-controller.socket"]; + extraConfig = '' + static_dir = "''${WWWDIR}"; # Serve the web UI static assets + ''; + }; + }; + }; + + systemd.sockets = { + rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket [postfixCfg.user]; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ([dovecot2Cfg.mailUser] ++ lib.optional cfg.rspamd.webUi.enable nginxCfg.user); + }; + + systemd.services.rspamd = { + requires = ["redis-rspamd.service"]; + after = ["redis-rspamd.service"]; + }; + + services.nginx = lib.mkIf cfg.rspamd.webUi.enable { + enable = true; + virtualHosts."${cfg.fqdn}" = { + forceSSL = true; + locations."/rspamd" = { + proxyPass = "http://unix:${rspamdControllerSocket}:/"; + basicAuthFile = cfg.rspamd.webUi.basicAuthFile; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; + sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; + }; + }; + }; +} diff --git a/mailsystem/selfsigned.nix b/mailsystem/selfsigned.nix new file mode 100644 index 0000000..1a27318 --- /dev/null +++ b/mailsystem/selfsigned.nix @@ -0,0 +1,33 @@ +{ + config, + pkgs, + lib, + ... +}: +with (import ./common.nix {inherit config pkgs;}); let + cfg = config.mailsystem; +in { + config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") { + systemd.services.mailsystem-selfsigned-certificate = { + after = ["local-fs.target"]; + script = '' + # Create certificates if they do not exist yet + dir="${certificateDirectory}" + fqdn="${cfg.fqdn}" + [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") + key="${sslKeyPath}" + cert="${sslCertPath}" + + if [[ ! -f $key || ! -f $cert ]]; then + mkdir -p "$dir" + (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 4096) && + "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" -days 3650 -out "$cert" + fi + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + }; + }; + }; +} diff --git a/pkgs/mailnix/Cargo.lock b/pkgs/mailnix/Cargo.lock new file mode 100644 index 0000000..b916c85 --- /dev/null +++ b/pkgs/mailnix/Cargo.lock @@ -0,0 +1,806 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.3", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "mailnix" +version = "0.1.0" +dependencies = [ + "clap", + "regex", + "serde", + "serde_with", + "serde_yml", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/pkgs/mailnix/Cargo.toml b/pkgs/mailnix/Cargo.toml new file mode 100644 index 0000000..accee63 --- /dev/null +++ b/pkgs/mailnix/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mailnix" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.28", features = ["derive"] } +regex = "1.11.1" +serde = { version = "1.0.217", features = ["derive", "rc"] } +serde_with = "3.12.0" +serde_yml = "0.0.12" diff --git a/pkgs/mailnix/src/cli.rs b/pkgs/mailnix/src/cli.rs new file mode 100644 index 0000000..ab995fa --- /dev/null +++ b/pkgs/mailnix/src/cli.rs @@ -0,0 +1,22 @@ +use clap::{Parser, Subcommand}; + +#[derive(Subcommand, Debug)] +pub enum Commands { + Check, + GenerateUserdb, + GenerateStaticPassdb, + UpdateDynamicPassdb { path: String }, + GenerateAliases, + GenerateDeniedRecipients, + GenerateDomains, +} + +#[derive(Parser, Debug)] +#[command(author, version, about)] +pub struct Cli { + pub config_path: String, + pub additional_config_path: Option, + + #[command(subcommand)] + pub command: Commands, +} diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs new file mode 100644 index 0000000..b3dc274 --- /dev/null +++ b/pkgs/mailnix/src/config.rs @@ -0,0 +1,226 @@ +use serde::Deserialize; +use serde_with::formats::PreferMany; +use serde_with::{MapPreventDuplicates, OneOrMany, serde_as}; + +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fs::{self, File}; +use std::io::BufReader; +use std::path::Path; + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub domains: Vec, + + #[serde(default)] + #[serde_as(deserialize_as = "MapPreventDuplicates<_, _>")] + pub accounts: HashMap, + + #[serde(default)] + #[serde_as(deserialize_as = "MapPreventDuplicates<_, OneOrMany<_, PreferMany>>")] + pub aliases: HashMap>, +} + +fn default_reject_message() -> String { + "This account cannot receive emails.".to_string() +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AccountConfig { + #[serde(rename(deserialize = "hashedPassword"))] + #[serde(skip)] + pub hashed_password: String, + + #[serde(rename(deserialize = "hashedPassword"))] + hashed_password_: Option, + + #[serde(rename(deserialize = "hashedPasswordFile"))] + hashed_password_file_: Option, + + #[serde(default, rename(deserialize = "isSystemUser"))] + pub is_system_user: bool, + + #[serde( + default = "default_reject_message", + rename(deserialize = "rejectMessage") + )] + pub reject_message: String, +} + +fn sanitize_vec(vec: &mut [String]) -> Result<(), Box> { + let mut seen = HashSet::new(); + for item in vec.iter_mut() { + *item = item.to_lowercase(); + if !seen.insert(item.clone()) { + return Err(format!("Duplicate entry ({item}) detected, aborting...").into()); + } + } + Ok(()) +} + +fn sanitize_map_keys(map: &mut HashMap) -> Result<(), Box> { + let mut new_map = HashMap::::new(); + let mut seen = HashSet::new(); + for (k, v) in map.iter() { + let new_k = k.to_lowercase(); + new_map.insert(new_k.clone(), v.clone()); + if !seen.insert(new_k.clone()) { + return Err(format!("Duplicate entry ({new_k}) detected, aborting...").into()); + } + } + *map = new_map; + Ok(()) +} + +fn is_domain(address: &str) -> bool { + !address.contains("@") +} + +fn parse_address(address: &str) -> Result<(String, String), Box> { + let address_pattern = Regex::new(r"^(?P.*)@(?P.*)$").unwrap(); + let caps = address_pattern + .captures(address) + .ok_or("Mail address regex does not match for \"{address}\"") + .unwrap(); + Ok((caps["local_part"].to_string(), caps["domain"].to_string())) +} + +impl Config { + pub fn load>(path: P) -> Result> { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut cfg: Config = serde_yml::from_reader(reader)?; + cfg.sanitize()?; + for (name, acc) in cfg.accounts.iter_mut() { + match ( + acc.hashed_password_.clone(), + acc.hashed_password_file_.clone(), + ) { + (Some(_hash), Some(_file)) => { + return Err("Account {name} has both HashedPassword and HashedPasswordFile set, aborting...".into()); + } + (Some(hash), None) => { + acc.hashed_password = hash; + } + (None, Some(file)) => { + let hash = fs::read_to_string(file.clone()).unwrap_or_else(|err| { + panic!("Account ({name}): Reading {file} failed: {err:?}") + }); + acc.hashed_password = hash.trim().to_string(); + } + (None, None) => { + return Err("Account {name} is missing HashedPassword or HashedPasswordFile, aborting...".into()); + } + } + } + Ok(cfg) + } + + fn sanitize(&mut self) -> Result<(), Box> { + // standardize capitalization, ... + sanitize_vec(&mut self.domains).unwrap_or_else(|err| panic!("domain: {err:?}")); + sanitize_map_keys(&mut self.accounts).unwrap_or_else(|err| panic!("accounts: {err:?}")); + sanitize_map_keys(&mut self.aliases).unwrap_or_else(|err| panic!("aliases: {err:?}")); + for (from, dests) in self.aliases.iter_mut() { + sanitize_vec(&mut *dests).unwrap_or_else(|err| panic!("aliases ({from}): {err:?}")); + } + Ok(()) + } + + fn is_valid_destination(&self, address: &String) -> bool { + if self.accounts.contains_key(address) { + return true; + } + + // There is no explicit account matching 'address'. However, 'address' may still be + // implicitly valid due to the existence of a catchall-alias or the domain being aliased. + let (local_part, domain) = parse_address(address).unwrap(); + if let Some(dests) = self.aliases.get(&domain) { + if dests.iter().filter(|dest| !is_domain(dest)).count() > 0 { + // At least one explicit catchall-address exists! + return true; + } + // At this point, we need to (recursively) iterate over domain-aliases to check whether + // there is an exact match (or a catchall-alias for the aliased domain). + for dest in dests.iter().filter(|addr| is_domain(addr)) { + let aliased_addr = format!("{local_part}@{dest}"); + // FIXME: This current implementation may not terminate if the configured aliases + // contain a loop! + if self.is_valid_destination(&aliased_addr) { + return true; + } + } + } + // alias destination is not valid + false + } + + pub fn check(&self) -> Result<(), Box> { + // check whether all account domains exist + for name in self.accounts.keys() { + let (_, domain) = parse_address(name).unwrap(); + if !self.domains.contains(&domain) { + panic!("Domain of account \"{name}\" does not exist"); + } + } + + // check whether aliases have corresponding accounts/domains + for (from, dests) in self.aliases.iter() { + if is_domain(from) && !self.domains.contains(from) { + panic!("Aliased from-domain \"{from}\" does not exist"); + } + for dest in dests.iter() { + if is_domain(dest) && !self.domains.contains(dest) { + panic!("Aliased dest-domain \"{dest}\" does not exist"); + } else if !is_domain(dest) && !self.is_valid_destination(dest) { + panic!("Aliased dest-account \"{dest}\" does not exist"); + } + } + } + Ok(()) + } + + pub fn merge>(&mut self, path: P) -> Result<(), Box> { + let mut other = Config::load(path).unwrap(); + + for domain in self.domains.iter() { + if other.domains.contains(domain) { + return Err( + "domains: Duplicate entry ({domain}) during merge detected, aborting..." + .to_string() + .into(), + ); + } + } + self.domains.append(&mut other.domains); + + for name in self.accounts.keys() { + if other.accounts.contains_key(name) { + return Err( + "accounts: Duplicate entry ({name}) during merge detected, aborting..." + .to_string() + .into(), + ); + } + } + self.accounts.extend(other.accounts); + + for alias in self.aliases.keys() { + if other.aliases.contains_key(alias) { + return Err( + "aliases: Duplicate entry ({alias}) during merge detected, aborting..." + .to_string() + .into(), + ); + } + } + self.aliases.extend(other.aliases); + + Ok(()) + } +} diff --git a/pkgs/mailnix/src/dovecot.rs b/pkgs/mailnix/src/dovecot.rs new file mode 100644 index 0000000..341afa7 --- /dev/null +++ b/pkgs/mailnix/src/dovecot.rs @@ -0,0 +1,67 @@ +use regex::Regex; +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::path::Path; + +use crate::config::{AccountConfig, Config}; + +pub fn generate_userdb(cfg: Config) { + for name in cfg.accounts.into_keys() { + println!("{}:::::::", name); + } +} + +pub fn generate_static_passdb(cfg: Config) { + let system_accounts = cfg + .accounts + .into_iter() + .filter(|(_, acc)| acc.is_system_user); + for (name, acc) in system_accounts { + println!("{}:{}::::::", name, acc.hashed_password); + } +} + +fn try_load_passdb>(path: P) -> Result, Box> { + let mut curr_dynamic_users = HashMap::new(); + + if path.as_ref().exists() { + let re = Regex::new(r"^(?P.*):(?P.*)::::::$").unwrap(); + + let curr_passdb = fs::read_to_string(path.as_ref()).unwrap(); + for line in curr_passdb.lines() { + let caps = re + .captures(line) + .unwrap_or_else(|| panic!("Regex does not match line: {line}")); + curr_dynamic_users.insert( + caps["name"].to_string(), + caps["hashed_password"].to_string(), + ); + } + eprintln!("current passdb entries: {curr_dynamic_users:#?}"); + } + Ok(curr_dynamic_users) +} + +pub fn update_dynamic_passdb>(cfg: Config, path: P) -> Result<(), Box> { + // create hashmap of all accounts with their initial passdb-lines + let mut accounts: HashMap = cfg + .accounts + .into_iter() + .filter(|(_, acc)| !acc.is_system_user) + .collect(); + eprintln!("settings: {:#?}", accounts); + + // load current passdb and update account password hashes + let curr_dynamic_users = try_load_passdb(path)?; + for (name, hashed_password) in curr_dynamic_users.into_iter() { + accounts.entry(name).and_modify(|e| { + e.hashed_password = hashed_password; + }); + } + + for (name, acc) in accounts.into_iter() { + println!("{}:{}::::::", name, acc.hashed_password); + } + Ok(()) +} diff --git a/pkgs/mailnix/src/main.rs b/pkgs/mailnix/src/main.rs new file mode 100644 index 0000000..86847ab --- /dev/null +++ b/pkgs/mailnix/src/main.rs @@ -0,0 +1,33 @@ +mod cli; +mod config; +mod dovecot; +mod postfix; + +use crate::{ + cli::{Cli, Commands}, + config::Config, +}; + +use clap::Parser; + +fn main() { + let args = Cli::parse(); + let mut cfg = Config::load(args.config_path).unwrap(); + if let Some(additional_cfg_path) = args.additional_config_path { + cfg.merge(additional_cfg_path).unwrap(); + } + let cfg = cfg; + cfg.check().unwrap(); + + match &args.command { + Commands::Check => println!("Check: {:#?}", cfg), + Commands::GenerateUserdb => dovecot::generate_userdb(cfg), + Commands::GenerateStaticPassdb => dovecot::generate_static_passdb(cfg), + Commands::UpdateDynamicPassdb { path } => { + dovecot::update_dynamic_passdb(cfg, path).unwrap() + } + Commands::GenerateAliases => postfix::generate_aliases(cfg), + Commands::GenerateDeniedRecipients => postfix::generate_denied_recipients(cfg), + Commands::GenerateDomains => postfix::generate_domains(cfg), + } +} diff --git a/pkgs/mailnix/src/postfix.rs b/pkgs/mailnix/src/postfix.rs new file mode 100644 index 0000000..d7b456b --- /dev/null +++ b/pkgs/mailnix/src/postfix.rs @@ -0,0 +1,32 @@ +use crate::config::Config; + +fn prefix_domain(value: String) -> String { + if !value.contains("@") { + return format!("@{value}"); + } + value +} + +pub fn generate_aliases(cfg: Config) { + for name in cfg.accounts.into_keys() { + println!("{} {}", name, name); + } + for (src, dests) in cfg.aliases.into_iter() { + let dests: Vec<_> = dests.into_iter().map(prefix_domain).collect(); + println!("{} {}", prefix_domain(src), dests.join(", ")); + } +} + +pub fn generate_denied_recipients(cfg: Config) { + let system_accounts = cfg + .accounts + .into_iter() + .filter(|(_, acc)| acc.is_system_user); + for (name, acc) in system_accounts { + println!("{} REJECT {}", name, acc.reject_message); + } +} + +pub fn generate_domains(cfg: Config) { + println!("{}", cfg.domains.into_iter().collect::>().join("\n")); +} diff --git a/tests/aliases.nix b/tests/aliases.nix new file mode 100644 index 0000000..4db867d --- /dev/null +++ b/tests/aliases.nix @@ -0,0 +1,212 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + lib = pkgs.lib; + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2;"; + }; + "alias" = { + address = "user3@example.com"; + password = "secret-password3"; + }; + "multi-alias1" = { + address = "multi-alias1@example.com"; + password = "secret-password4;"; + }; + "multi-alias2" = { + address = "multi-alias2@example.com"; + password = "secret-password5;"; + }; + "catchall" = { + address = "catchall@example.com"; + password = "secret-password6;"; + }; + "otherdomain" = { + address = "otherdomain@example.com"; + password = "secret-password7;"; + }; + }; +in + pkgs.nixosTest { + name = "aliases"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com" "aliased.com" "otherdomain.com"]; + accounts = mkAccounts accounts; + virtualAliases = { + # domain aliases + "aliased.com" = "example.com"; + # account aliases + "alias@example.com" = accounts."alias".address; + "multi-alias@example.com" = lib.map (x: accounts.${x}.address) ["multi-alias1" "multi-alias2"]; + "example.com" = accounts."catchall".address; + "user@otherdomain.com" = accounts."otherdomain".address; + }; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("send mail from aliased address"): + client.succeed("${sendMail "alias" "alias@example.com" accounts."normal".address '' + Subject: Testmail1 + + Hello User1, + this is a mail dispatched using an alias instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address"): + client.succeed("${sendMail "normal" "" "alias@example.com" '' + Subject: Testmail2 + + Hello alias-User, + this mail should reach you on your aliased address alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "alias"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 1 when no new mail is available + # (as alias is the more specific address, catchall shouldn't receive the mail) + client.fail("${recvMail "catchall"} >&2") + + with subtest("receive mail on all accounts with same alias"): + client.succeed("${sendMail "normal" "" "multi-alias@example.com" '' + Subject: Testmail3 + + Hello multi-alias-Users, + this mail should reach you on your aliased address multi-alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias1"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias2"} >&2") + + with subtest("send mail to catchAll-alias"): + # send email to non-existent account + client.succeed("${sendMail "normal" "" "somerandomaddress@example.com" '' + Subject: Catchall-Test + + Hello Catchall-User, + this is mail is directed at an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("send mail from catchAll-account with a non-existing account behind"): + # send email from non-existent account + client.succeed("${sendMail "catchall" "somerandomaddress@example.com" accounts."normal2".address '' + Subject: Catchall-Test2 + + Hello User2, + this is mail is sent from an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("catchAll-account cannot send mail from an address with an existing account behind"): + # send email to non-existent account + client.fail("${sendMail "catchall" accounts."normal".address accounts."normal2".address '' + Subject: Catchall-Test3 + + Hello User2, + this is mail should not be possible to be sent as it is dispatched by a catchall-account using an address with a user-account behind it. + ''}") + + with subtest("send mail from aliased address of other domain"): + client.succeed("${sendMail "otherdomain" "user@otherdomain.com" accounts."normal".address '' + Subject: Dispatch from other domain + + Hello User1, + this is a mail dispatched using an alias to a different domain instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address of other domain"): + client.succeed("${sendMail "normal" "" "user@otherdomain.com" '' + Subject: Reception from other domain + + Hello otherdomain-User, + this mail should reach you on your aliased address user@otherdomain.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "otherdomain"} >&2") + + with subtest("receiving mail on aliased domain using normal account"): + client.succeed("${sendMail "normal" "" "user2@aliased.com" '' + Subject: aliasedDomain with normal account + + Hello User2, + this is mail is sent to you by using your address @example.org. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("receiving mail on aliased domain using catchall-account"): + client.succeed("${sendMail "normal" "" "somerandomaddress@aliased.com" '' + Subject: aliasedDomain using catchall-account + + Hello Catchall-User, + this is mail is sent to you by using an address without any user-account behind it for neither @example.com nor @aliased.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("sending mail from aliased domain fails"): + client.fail("${sendMail "normal" "user1@aliased.com" accounts."normal2".address '' + Subject: aliasedDomain + + Hello User2, + this mail should not be dispatched to you as I'm using "my" address @aliased.com. + ''}") + ''; + } diff --git a/tests/basic.nix b/tests/basic.nix new file mode 100644 index 0000000..cdee2ad --- /dev/null +++ b/tests/basic.nix @@ -0,0 +1,109 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2"; + }; + "system" = { + address = "system@example.com"; + password = "secret-password3"; + isSystemUser = true; + }; + }; +in + pkgs.nixosTest { + name = "basic"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = mkAccounts accounts; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + cfg = nodes.server.mailsystem; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("imap works and retrieves no new mails"): + # fetchmail returns EXIT_CODE 1 when no new mail is available + client.succeed("${recvMail "normal"} || [ $? -eq 1 ] >&2") + + with subtest("send succeeds for normal user"): + client.succeed("${sendMail "normal" "" accounts."normal2".address '' + Message-ID: <123456asdf@host.local.network> + Subject: Testmail1 + + Hello User2, + this is some text! + ''}") + # give the mail server some time to process the mail + server.wait_until_fails('${pendingPostqueue}') + + with subtest("mail can be retrieved via imap"): + client.succeed("${recvMail "normal2"} >&2") + + with subtest("mail header contains no sensitive information"): + client.fail("grep '${clientAddr}' $HOME/mail/*") + client.succeed("grep '^Message-ID:.*@${cfg.fqdn}>$' $HOME/mail/*") + + with subtest("mail header contains correct fqdn in received from"): + client.succeed("grep 'Received: from ${cfg.fqdn}' $HOME/mail/*") + + with subtest("user cannot forge from-address"): + client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."normal2".address '' + Subject: I actually do not own this from-address + + Hello User2, + I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. + ''}") + + with subtest("send succeeds for system user"): + client.succeed("${sendMail "system" "" accounts."normal".address '' + Subject: Testmail2 + + Hello User1, + this is some text! + ''}") + # give the mail server some time to process the mail + server.wait_until_fails('${pendingPostqueue}') + + with subtest("mail can be retrieved via imap"): + client.succeed("${recvMail "normal"} >&2") + + with subtest("mail sent to system-account is rejected"): + client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."system".address '' + Subject: Mail to system-account + + Hello System user, + this mail should never reach you as it should be rejected by postfix. + ''}") + + with subtest("server issues no warnings nor errors"): + ${checkLogs "server"} + ''; + } diff --git a/tests/common/client.nix b/tests/common/client.nix new file mode 100644 index 0000000..82dd41b --- /dev/null +++ b/tests/common/client.nix @@ -0,0 +1,17 @@ +{pkgs, ...}: { + config = { + # added to the environment for manual verification via interactiveDriver if necessary + environment.systemPackages = with pkgs; [fetchmail msmtp procmail openssl]; + systemd.tmpfiles.settings."10-mailtest" = let + dirPerms = { + user = "root"; + mode = "0700"; + }; + in { + "/root/mail".d = dirPerms; + "/root/.procmailrc"."L+".argument = "${pkgs.writeText ".procmailrc" '' + DEFAULT=$HOME/mail + ''}"; + }; + }; +} diff --git a/tests/common/lib.nix b/tests/common/lib.nix new file mode 100644 index 0000000..a2b1a15 --- /dev/null +++ b/tests/common/lib.nix @@ -0,0 +1,77 @@ +{pkgs, ...}: let + lib = pkgs.lib; +in rec { + waitForRspamd = node: let + inherit (import ../../mailsystem/common.nix {inherit (node) config pkgs;}) rspamdProxySocket; + in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]"; + + mkHashedPasswordFile = password: + pkgs.runCommand "mk-password-hash-${password}" { + buildInputs = [pkgs.mkpasswd]; + inherit password; + } '' + echo "$password" | mkpasswd -sm bcrypt > $out + ''; + + mkAccounts = accounts: + lib.concatMapAttrs (_: account: { + ${account.address} = + { + hashedPasswordFile = "${mkHashedPasswordFile account.password}"; + } + // builtins.removeAttrs account ["address" "password"]; + }) + accounts; + + mkSendMail = smtpSettings: accounts: accountName: fromAddr: recipient: body: let + account = accounts.${accountName}; + senderAddr = + if fromAddr == "" + then account.address + else fromAddr; + msmtprc = pkgs.writeText "msmtprc" '' + account default + auth on + tls on + tls_starttls off + tls_certcheck off + host ${smtpSettings.address} + port ${toString smtpSettings.port} + from ${senderAddr} + user ${account.address} + password ${account.password} + ''; + mail = pkgs.writeText "mail-${account.address}-${recipient}" '' + From: <${account.address}> + To: <${recipient}> + ${body} + ''; + in "${pkgs.msmtp}/bin/msmtp -C ${msmtprc} ${recipient} < ${mail} >&2"; + + pendingPostqueue = "[ \"$(postqueue -p)\" != \"Mail queue is empty\" ]"; + cleanupMail = "rm $HOME/mail/*"; + + # mkRecvMail requires procmail to be setup correctly. This is ensured by + # importing ./server.nix + mkRecvMail = imapAddr: accounts: accountName: let + mkFetchmailRcScript = imapAddr: account: + pkgs.writeScript "mk-fetchmailrc-${account.address}" '' + umask 077 + readonly out=$(mktemp) + cat < "$out" + poll ${imapAddr} with proto IMAP + user '${account.address}' there with password '${account.password}' is 'root' here + mda procmail + EOF + echo $out + ''; + fetchmailrc = mkFetchmailRcScript imapAddr accounts.${accountName}; + in "${pkgs.fetchmail}/bin/fetchmail -f $(${fetchmailrc}) --ssl --nosslcertck -v"; + + checkLogs = node: '' + ${node}.fail("journalctl -u postfix | grep -i error >&2") + ${node}.fail("journalctl -u postfix | grep -i warning >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i error >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i warning >&2") + ''; +} diff --git a/tests/common/server.nix b/tests/common/server.nix new file mode 100644 index 0000000..561d1bb --- /dev/null +++ b/tests/common/server.nix @@ -0,0 +1,26 @@ +{lib, ...}: { + imports = [./../../mailsystem]; + config = { + virtualisation.memorySize = 1024; + mailsystem = { + enable = true; + certificateScheme = "selfsigned"; + }; + + # Prevent tests deadlocking on DNS requests which will never succeed. + services.rspamd.locals."options.inc".text = '' + dns { + nameservers = ["127.0.0.1"]; + timeout = 0.0s; + retransmits = 0; + } + ''; + + # Enable more verbose logging (required for, e.g., sieve testing) + services.dovecot2.extraConfig = lib.mkAfter '' + mail_debug = yes + auth_debug = yes + verbose_ssl = yes + ''; + }; +} diff --git a/tests/internal.nix b/tests/internal.nix new file mode 100644 index 0000000..8fccbfb --- /dev/null +++ b/tests/internal.nix @@ -0,0 +1,50 @@ +{pkgs, ...}: +pkgs.nixosTest { + name = "internal"; + nodes.machine = {...}: { + imports = [./common/server.nix]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = {}; + vmailUserName = "vmail"; + vmailGroupName = "vmail"; + vmailUID = 5000; + }; + }; + testScript = {nodes, ...}: let + pkgs = nodes.machine.nixpkgs.pkgs; + in '' + machine.start() + machine.wait_for_unit("multi-user.target") + + with subtest("imap is only available via port 993 and is encrypted"): + machine.wait_for_closed_port(143) + machine.wait_for_open_port(993) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'" + ) + + with subtest("smtp is only available via port 465 and is encrypted"): + machine.wait_for_closed_port(587) + machine.wait_for_open_port(465) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:465 | grep 'New, TLS'" + ) + + with subtest("`postfix check` succeeds"): + machine.succeed( + "${pkgs.postfix}/bin/postfix check" + ) + + with subtest("vmail uid is set correctly"): + machine.succeed( + "[ $(getent passwd vmail | cut -d: -f3) -eq 5000 ]" + ) + + with subtest("vmail gid is set correctly"): + machine.succeed( + "[ $(getent group vmail | cut -d: -f3) -eq 5000 ]" + ) + ''; +} diff --git a/tests/rspamd.nix b/tests/rspamd.nix new file mode 100644 index 0000000..eb71ed6 --- /dev/null +++ b/tests/rspamd.nix @@ -0,0 +1,178 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + lib = pkgs.lib; + accounts = { + "normal" = { + address = "user@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user@example.org"; + password = "secret-password2;"; + }; + }; + genDkimSecret = domain: name: type: + pkgs.runCommand "mk-dkim-secrets-${domain}-${selector}" { + buildInputs = [pkgs.rspamd]; + inherit domain name type; + } '' + rspamadm dkim_keygen -d $domain -s $name -t $type ${lib.optionalString (type == "rsa") "-b 2048"} -k $out + ''; + + mkDkimSettings = domains: selectors: + lib.listToAttrs ( + map (domain: + lib.nameValuePair domain (map (entry: { + selector = entry.name; + keyFile = genDkimSecret domain entry.name entry.type; + }) + selectors)) + domains + ); +in + pkgs.nixosTest { + name = "rspamd"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com" "example.org"]; + accounts = mkAccounts accounts; + dkimSettings = mkDkimSettings ["example.com" "example.org"] [ + { + name = "elliptic"; + type = "ed25519"; + } + { + name = "selector"; + type = "rsa"; + } + ]; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + cfg = nodes.server.mailsystem; + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + test-mark-spam = accountName: + pkgs.writeScript "imap-mark-spam" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverAddr}') as imap: + imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}') + imap.select() + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'Junk') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + test-mark-ham = accountName: + pkgs.writeScript "imap-mark-ham" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverAddr}') as imap: + imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}') + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'INBOX') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('INBOX') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("rspamd configuration is valid"): + server.succeed("${pkgs.rspamd}/bin/rspamadm configtest >&2") + + with subtest("rspamd rejects spam"): + client.fail("${sendMail "normal" "" accounts."normal2".address '' + Subject: GTUBE-Test + + Hello User2, + this is a mail containing a GTUBE pattern that should result in the rejection of this mail. + + XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X + ''}") + + with subtest("imap sieve junk trainer"): + client.succeed("${sendMail "normal" "" accounts."normal2".address '' + Subject: Testmail + + Hello User2, + this is a testmail. + ''}") + server.wait_until_fails('${pendingPostqueue}') + + client.succeed("${test-mark-spam "normal2"} >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-spam.sh >&2") + server.fail("journalctl -u dovecot2 | grep -i learn-spam.sh | grep -i error >&2") + + client.succeed("${test-mark-ham "normal2"} >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-ham.sh >&2") + server.fail("journalctl -u dovecot2 | grep -i learn-ham.sh | grep -i error >&2") + + with subtest("dkim signing"): + client.succeed("${sendMail "normal2" "" accounts."normal".address '' + Subject: Testmail + + Hello User1, + this is also a testmail. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + client.succeed("cat ~/mail/* >&2") + # make sure the mail has all configured dkim signatures + client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 0).selector} ~/mail/*") + client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 1).selector} ~/mail/*") + ''; + }