about summary refs log blame commit diff stats
path: root/modules/by-name/st/stalwart-mail/module.nix
blob: 4595c21418ada41d41accf3731e8c426a1e84cb1 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12


         
                





                                                                          






                                                
                                                                        









































































































































































































































































                                                                                                                   
{
  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 vhackPackages "stalwart-mail-free" {};

    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 <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 = {
          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;
      };
  };
}