From f7ff6a320e216da1b52e335599e42a86106cf908 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Sun, 9 Feb 2025 16:24:51 +0100 Subject: module/stalwart-mail: Init initial version --- modules/by-name/st/stalwart-mail/module.nix | 334 ++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 modules/by-name/st/stalwart-mail/module.nix (limited to 'modules/by-name/st/stalwart-mail/module.nix') diff --git a/modules/by-name/st/stalwart-mail/module.nix b/modules/by-name/st/stalwart-mail/module.nix new file mode 100644 index 0000000..e5a681e --- /dev/null +++ b/modules/by-name/st/stalwart-mail/module.nix @@ -0,0 +1,334 @@ +{ + lib, + config, + pkgs, + pkgsUnstable, + nixLib, + ... +}: let + cfg = config.vhack.stalwart-mail; + topCfg = config.services.stalwart-mail; + + configFormat = pkgs.formats.toml {}; + configFile = configFormat.generate "stalwart-mail.toml" topCfg.settings; + + spamfilter = pkgs.callPackage ./spam-filter.nix {}; + + stalwart-mail = pkgsUnstable.stalwart-mail.overrideAttrs (final: prev: { + passthru = nixLib.warnMerge (prev.passthru or {}) { + inherit spamfilter; + } "stalwart-mail passthru"; + + checkFlags = + (prev.checkFlags or []) + ++ [ + # This started to fail? + # TODO(@bpeetz): Find out why. <2025-02-08> + "--skip=smtp::outbound::lmtp::lmtp_delivery" + ]; + + # `stalwart-mail` does enable their `enterprise` feature per default. + # We want a AGPL only build (i.e., without unfree dependencies), therefore disable the + # `enterprise` feature here. + # We cannot use the `buildFeatures` attribute because it does not actually change the + # correct features. As such we simply patch the correct `Cargo.toml` file. + patches = + (prev.patches or []) + ++ [ + ./patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch + ./patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch + ]; + + # Check that the enterprise feature is really disabled. + postCheck = + (prev.postCheck or "") + + + # bash + '' + if grep "enterprise" ./target/*/release/stalwart-mail.d; then + echo "ERROR: Proprietary 'enterprise' feature active." + exit 1 + fi + ''; + }); +in { + imports = [ + ./settings.nix + ]; + + options.vhack.stalwart-mail = { + enable = lib.mkEnableOption "starwart-mail"; + + # package = lib.mkPackageOption pkgsUnstable "stalwart-mail" {pkgsText = "pkgsUnstable";}; + package = lib.mkOption { + description = "The stalwart-mail package to use"; + type = lib.types.package; + default = stalwart-mail; + }; + + fqdn = lib.mkOption { + type = lib.types.str; + example = "mail.foss-syndicate.org"; + description = '' + The fully qualified domain name for this mail server. + ''; + }; + + principals = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Specifies the username of the account"; + }; + + class = lib.mkOption { + type = lib.types.enum ["individual" "admin"]; + description = "Specifies the account type"; + }; + + description = lib.mkOption { + type = lib.types.str; + description = "Provides a description or full name for the user"; + default = ""; + }; + + secret = lib.mkOption { + type = lib.types.str; + description = '' + Sets the password for the user account. + Passwords can be stored hashed or in plain text (not recommended). + See for a description + of password encoding. + ''; + }; + email = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = '' + A list of email addresses associated with the user. + The first address in the list is considered the primary address. + ''; + }; + }; + }); + }; + + dataDirectory = lib.mkOption { + description = '' + The directory in which to store all storage things. + ''; + default = "/var/lib/stalwart-mail"; + type = lib.types.path; + readOnly = true; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to open TCP firewall ports, which are specified in + {option}`services.stalwart-mail.settings.listener` on all interfaces. + ''; + }; + + security = lib.mkOption { + type = lib.types.nullOr (lib.types.submodule { + options = { + dkimPrivateKeyPath = lib.mkOption { + type = lib.types.path; + description = '' + The path to the dkim private key agenix file. + ''; + }; + }; + }); + description = '' + Security options. This should only be set to `null` when testing. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + services.stalwart-mail = { + # NOTE(@bpeetz): We do not use the nixos service, as it comes with too much + # bothersome default configuration and not really any useful config. + # However, this decision could obviously be reverse in the future. <2025-02-08> + enable = false; + inherit (cfg) package; + # dataDir = cfg.dataDirectory; + }; + + security.acme.certs = { + "${cfg.fqdn}" = { + domain = cfg.fqdn; + group = "stalwart-mail"; + }; + }; + + age.secrets = lib.mkIf (cfg.security != null) { + stalwartMailDkim = { + file = cfg.security.dkimPrivateKeyPath; + mode = "600"; + owner = "stalwart-mail"; + group = "stalwart-mail"; + }; + }; + + vhack.persist.directories = [ + { + directory = "${cfg.dataDirectory}"; + user = "stalwart-mail"; + group = "stalwart-mail"; + mode = "0700"; + } + { + directory = "${config.services.redis.servers."stalwart-mail".settings.dir}"; + user = "stalwart-mail"; + group = "redis"; + mode = "0770"; + } + ]; + + services.redis = { + servers = { + "stalwart-mail" = { + enable = true; + + user = "stalwart-mail"; + + # Disable TCP listening. (We have a UNIX socket) + port = 0; + bind = null; + + settings = { + protected-mode = true; + enable-protected-configs = false; + enable-debug-command = false; + enable-module-command = false; + + supervised = "systemd"; + stop-writes-on-bgsave-error = true; + sanitize-dump-payload = "clients"; + }; + }; + }; + }; + + # This service stores a potentially large amount of data. + # Running it as a dynamic user would force chown to be run every time the + # service is restarted on a potentially large number of files. + # That would cause unnecessary and unwanted delays. + users = { + groups.stalwart-mail = {}; + users.stalwart-mail = { + isSystemUser = true; + group = "stalwart-mail"; + }; + }; + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDirectory}' - stalwart-mail stalwart-mail - -" + ]; + + systemd = { + packages = [cfg.package]; + services.stalwart-mail = { + wantedBy = ["multi-user.target"]; + after = [ + "local-fs.target" + "network.target" + ]; + + preStart = let + esa = lib.strings.escapeShellArg; + mkTmpFile = path: "[ -d ${esa path} ] || mkdir --parents ${esa path}"; + + # Create the directories for stalwart + storageDirectories = lib.lists.filter (v: v != null) (lib.attrsets.mapAttrsToList (_: {path ? null, ...}: + if (path != null) + then mkTmpFile path + else null) + topCfg.settings.store); + in + '' + # Stalwart actually wants to store _data_ (e.g., blocked ips) in it's own config file. + # Thus we need to make it writable. + cat ${esa configFile} >$CACHE_DIRECTORY/mutable_config_file.toml + '' + + (builtins.concatStringsSep "\n" storageDirectories); + + serviceConfig = { + ExecStart = [ + "" + "${cfg.package}/bin/stalwart-mail --config=$CACHE_DIRECTORY/mutable_config_file.toml" + ]; + + StandardOutput = "journal"; + StandardError = "journal"; + + ReadWritePaths = [ + cfg.dataDirectory + ]; + CacheDirectory = "stalwart-mail"; + StateDirectory = "stalwart-mail"; + + # Bind standard privileged ports + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; + CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; + + # Hardening + DeviceAllow = [""]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateDevices = true; + PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE + ProcSubset = "pid"; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ]; + UMask = "0077"; + }; + unitConfig.ConditionPathExists = [ + "" + "${configFile}" + ]; + }; + }; + + # Make admin commands available in the shell + environment.systemPackages = [cfg.package]; + + networking.firewall = let + parsePorts = listeners: let + parseAddresses = listeners: lib.flatten (lib.mapAttrsToList (name: value: value.bind) listeners); + splitAddress = addr: lib.splitString ":" addr; + extractPort = addr: lib.toInt (builtins.foldl' (a: b: b) "" (splitAddress addr)); + in + builtins.map extractPort (parseAddresses listeners); + in + lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" topCfg.settings.server)) + { + allowedTCPPorts = parsePorts topCfg.settings.server.listener; + }; + }; +} -- cgit 1.4.1