aboutsummaryrefslogtreecommitdiffstats
path: root/modules/by-name/st/stalwart-mail/module.nix
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/by-name/st/stalwart-mail/module.nix428
1 files changed, 428 insertions, 0 deletions
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..f7e44d8
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/module.nix
@@ -0,0 +1,428 @@
+{
+ lib,
+ config,
+ pkgs,
+ vhackPackages,
+ ...
+}: let
+ cfg = config.vhack.stalwart-mail;
+ topCfg = config.services.stalwart-mail;
+
+ configFormat = pkgs.formats.toml {};
+ configFile = configFormat.generate "stalwart-mail.toml" topCfg.settings;
+in {
+ imports = [
+ ./settings.nix
+ ];
+
+ options.vhack.stalwart-mail = {
+ enable = lib.mkEnableOption "starwart-mail";
+
+ package = lib.mkPackageOption pkgs "stalwart-mail" {};
+
+ admin = lib.mkOption {
+ description = ''
+ Email address to advertise as administrator. This is the address, where dkim, spv
+ etc. refusal reports are sent to.
+
+ The format should be: `mailto:<name>@<domain>`
+ '';
+ type = lib.types.str;
+ example = "mailto:dmarc+rua@example.com";
+ default = "";
+ };
+
+ 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 {
+ default = null;
+ type = lib.types.nullOr (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 = let
+ prefix = pre: lib.types.strMatching "^${lib.strings.escapeRegex pre}.*";
+ in
+ lib.types.oneOf [
+ (prefix "$argon2")
+ (prefix "$pbkdf2")
+ (prefix "$scrypt")
+ (prefix "$2") # bcrypt
+ (prefix "$6$") # sha-512
+ (prefix "$5$") # sha-256
+ (prefix "$sha1")
+ (prefix "$1") # md5
+ (prefix "_") # BSDi crypt
+ (prefix "{SHA}") # base64 sha
+ (prefix "{SSHA}") # base64 salted sha
+
+ # unix crypt
+ (prefix "{CRYPT}")
+ (prefix "{crypt}")
+
+ # Plain text
+ (prefix "{PLAIN}")
+ (prefix "{plain}")
+ (prefix "{CLEAR}")
+ (prefix "{clear}")
+ ];
+ description = ''
+ Sets the password for the user account.
+ Passwords can be stored hashed or in plain text (not recommended).
+ See <https://stalw.art/docs/auth/authentication/password/> 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 = {
+ verificationMode = lib.mkOption {
+ type = lib.types.enum ["relaxed" "strict"];
+ description = ''
+ Whether to allow invalid signatures/checks or not.
+ '';
+ default = "relaxed";
+ };
+
+ dkimKeys = lib.mkOption {
+ type = lib.types.attrsOf (lib.types.submodule {
+ options = {
+ dkimPublicKey = lib.mkOption {
+ type = lib.types.str;
+ description = ''
+ The base 64 encoded representation of the public dkim key.
+ '';
+ };
+ dkimPrivateKeyPath = lib.mkOption {
+ type = lib.types.path;
+ description = ''
+ The path to the dkim private key agenix file.
+ Generate it via the `./gen_key` script:
+ '';
+ };
+ keyAlgorithm = lib.mkOption {
+ type = lib.types.enum ["ed25519-sha256" "rsa-sha-256" "rsa-sha-1"];
+ description = "The algorithm of the used key";
+ };
+ };
+ });
+ description = ''
+ Which key to use for which domain. The attr keys are the domains
+ '';
+ default = {};
+ };
+ };
+ });
+ description = ''
+ Security options. This should only be set to `null` when testing.
+ '';
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.admin != "";
+ message = "You need to specify an admin address.";
+ }
+ ];
+
+ vhack.nginx.enable = true;
+ 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 configuration.
+ # However, this decision could obviously be reversed in the future. <2025-02-08>
+ enable = false;
+ inherit (cfg) package;
+ };
+
+ nginx.virtualHosts."${cfg.fqdn}" = {
+ locations."/" = {
+ proxyPass = "http://${builtins.elemAt config.services.stalwart-mail.settings.server.listener.http.bind 0}";
+ recommendedProxySettings = true;
+ };
+
+ useACMEHost = "${cfg.fqdn}";
+ forceSSL = true;
+ };
+
+ 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";
+ };
+ };
+ };
+ };
+ };
+ security.acme.certs = {
+ "${cfg.fqdn}" = {
+ domain = cfg.fqdn;
+ group = "stalwart-mail-certificates";
+ };
+ };
+
+ age.secrets = let
+ keys =
+ lib.mapAttrs' (
+ keyDomain: keyConfig:
+ lib.nameValuePair "stalwartMail${keyDomain}"
+ {
+ file = keyConfig.dkimPrivateKeyPath;
+ mode = "600";
+ owner = "stalwart-mail";
+ group = "stalwart-mail";
+ }
+ )
+ cfg.security.dkimKeys;
+ in
+ lib.mkIf (cfg.security != null) keys;
+
+ 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-stalwart-mail";
+ mode = "0770";
+ }
+ ];
+
+ # 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 = {
+ gid = config.vhack.constants.ids.gids.stalwart-mail;
+ };
+ stalwart-mail-certificates = {
+ gid = config.vhack.constants.ids.gids.stalwart-mail-certificates;
+ };
+ redis-stalwart-mail = {
+ gid = config.vhack.constants.ids.gids.redis-stalwart-mail;
+ };
+ };
+ users = {
+ nginx = {
+ extraGroups = ["stalwart-mail-certificates"];
+ };
+ stalwart-mail = {
+ isSystemUser = true;
+ group = "stalwart-mail";
+ uid = config.vhack.constants.ids.uids.stalwart-mail;
+ extraGroups = ["stalwart-mail-certificates"];
+ };
+ redis-stalwart-mail = {
+ group = "redis-stalwart-mail";
+ uid = config.vhack.constants.ids.uids.redis-stalwart-mail;
+ };
+ };
+ };
+
+ systemd.tmpfiles.rules = [
+ "d '${cfg.dataDirectory}' - stalwart-mail stalwart-mail - -"
+ ];
+
+ systemd = {
+ services.stalwart-mail = {
+ wantedBy = ["multi-user.target"];
+ requires =
+ [
+ "redis-stalwart-mail.service"
+ "network-online.target"
+ ]
+ ++ (lib.optional (cfg.security != null) "acme-${cfg.fqdn}.service");
+ after = [
+ "local-fs.target"
+ "network.target"
+ "network-online.target"
+ "redis-stalwart-mail.service"
+ "acme-${cfg.fqdn}.service"
+ ];
+ conflicts = [
+ "postfix.service"
+ "sendmail.service"
+ "exim4.service"
+ ];
+ description = "Stalwart Mail Server";
+
+ environment = {
+ SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
+ NIX_SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
+ };
+
+ 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 = pkgs.writers.writeDash "start-stalwart-mail" ''
+ ${lib.getExe cfg.package} --config="$CACHE_DIRECTORY/mutable_config_file.toml"
+ '';
+
+ Restart = "no";
+
+ KillMode = "process";
+ KillSignal = "SIGINT";
+
+ Type = "simple";
+ LimitNOFILE = 65536;
+
+ StandardOutput = "journal";
+ StandardError = "journal";
+
+ ReadWritePaths = [
+ cfg.dataDirectory
+ ];
+ CacheDirectory = "stalwart-mail";
+ StateDirectory = "stalwart-mail";
+
+ User = "stalwart-mail";
+ Group = "stalwart-mail";
+
+ SyslogIdentifier = "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";
+ };
+ };
+ };
+
+ # 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;
+ };
+ };
+}