diff --git a/default.nix b/default.nix index 571abf7..60ba271 100644 --- a/default.nix +++ b/default.nix @@ -32,7 +32,6 @@ let mkRemovedOptionModule mkRenamedOptionModule types - warn ; cfg = config.mailserver; @@ -131,20 +130,6 @@ in description = "The domains that this mail server serves."; }; - certificateDomains = mkOption { - type = types.listOf types.str; - example = [ - "imap.example.com" - "pop3.example.com" - ]; - default = [ ]; - description = '' - ({option}`mailserver.certificateScheme` == `acme-nginx`) - - Secondary domains and subdomains for which it is necessary to generate a certificate. - ''; - }; - messageSizeLimit = mkOption { type = types.int; example = 52428800; @@ -788,91 +773,43 @@ in }; }; - certificateScheme = - let - schemes = [ - "manual" - "selfsigned" - "acme-nginx" - "acme" - ]; - translate = - i: - warn - "Setting mailserver.certificateScheme by number is deprecated, please use names instead: 'mailserver.certificateScheme = ${builtins.toString i}' can be replaced by 'mailserver.certificateScheme = \"${ - (builtins.elemAt schemes (i - 1)) - }\"'." - (builtins.elemAt schemes (i - 1)); - in - mkOption { - type = - with types; - coercedTo (enum [ - 1 - 2 - 3 - ]) translate (enum schemes); - default = "selfsigned"; + x509 = { + useACMEHost = mkOption { + type = with types; nullOr str; + default = null; + example = literalExpression "config.mailserver.fqdn"; description = '' - The scheme to use for managing TLS certificates: + Common name used in the relevant `security.acme.certs` configuration. - 1. `manual`: you specify locations via {option}`mailserver.certificateFile` and - {option}`mailserver.keyFile` and manually copy certificates there. - 2. `selfsigned`: you let the server create new (self-signed) certificates on the fly. - 3. `acme-nginx`: you let the server request certificates from [Let's Encrypt](https://letsencrypt.org) - via NixOS' ACME module. By default, this will set up a stripped-down Nginx server for - {option}`mailserver.fqdn` and open port 80. For this to work, the FQDN must be properly - configured to point to your server (see the [setup guide](setup-guide.rst) for more information). - 4. `acme`: you already have an ACME certificate set up (for example, you're already running a TLS-enabled - Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded - when the certificate is renewed. + Mutually exclusive with {option}`mailserver.x509.certificateFile` and {option}`mailserver.x509.privateKeyFile`. ''; }; - certificateFile = mkOption { - type = types.path; - example = "/root/mail-server.crt"; - description = '' - ({option}`mailserver.certificateScheme` == `manual`) + certificateFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/var/keys/certs/fullchain.pem"; + description = '' + Path to the signed X509 certificate including intermediate certificates. - Location of the certificate. - ''; - }; + This is commonly referred to as {file}`fullchain.pem`. - keyFile = mkOption { - type = types.path; - example = "/root/mail-server.key"; - description = '' - ({option}`mailserver.certificateScheme` == `manual`) + Mutually exclusive with {option}`mailserver.x509.useACMEHost`. + ''; + }; - Location of the key file. - ''; - }; + privateKeyFile = mkOption { + type = with types; nullOr str; + default = null; + example = "/var/keys/certs/privkey.pem"; + description = '' + Path to the X509 private key. - certificateDirectory = mkOption { - type = types.path; - default = "/var/certs"; - description = '' - ({option}`mailserver.certificateScheme` == `selfsigned`) + This is commonly referred to as {file}`privkey.pem`. - This is the folder where the self-signed certificate will be created. The name is - hardcoded to "cert-DOMAIN.pem" and "key-DOMAIN.pem" and the - certificate is valid for 10 years. - ''; - }; - - acmeCertificateName = mkOption { - type = types.str; - default = cfg.fqdn; - defaultText = literalExpression "config.mailserver.fqdn"; - example = "example.com"; - description = '' - ({option}`mailserver.certificateScheme` == `acme`) - - When the `acme` `certificateScheme` is selected, you can use this option - to override the default certificate name. This is useful if you've - generated a wildcard certificate, for example. - ''; + Mutually exclusive with {option}`mailserver.x509.useACMEHost`. + ''; + }; }; enableImap = mkOption { @@ -1502,7 +1439,6 @@ in ./mail-server/dovecot.nix ./mail-server/postfix.nix ./mail-server/rspamd.nix - ./mail-server/nginx.nix ./mail-server/kresd.nix (mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] '' SPF checking has been migrated to Rspamd, which makes this config redundant. Please look into the rspamd config to migrate your settings. @@ -1531,5 +1467,16 @@ in (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "fromName" ] '' The name in the `FROM` field for DMARC report now uses the `mailserver.systemName`. '') + + (mkRemovedOptionModule [ "mailserver" "certificateDomains" ] '' + Configure `security.acme.certs.''${config.mailserver.fqdn}.extraDomains` instead. + '') + (mkRemovedOptionModule [ "mailserver" "certificateScheme" ] "") + (mkRemovedOptionModule [ "mailserver" "certificateDirectory" ] '' + Automatic creation of self-signed certificates is no longer supported. + '') + (mkRenamedOptionModule [ "mailserver" "acmeCertificateName" ] [ "mailserver" "x509" "useACMEHost" ]) + (mkRenamedOptionModule [ "mailserver" "certificateFile" ] [ "mailserver" "x509" "certificateFile" ]) + (mkRenamedOptionModule [ "mailserver" "keyFile" ] [ "mailserver" "x509" "privateKeyFile" ]) ]; } diff --git a/docs/release-notes.rst b/docs/release-notes.rst index b41110d..36d2499 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -1,6 +1,16 @@ Release Notes ============= +NixOS 26.05 +----------- + +- Certificate handling was simplified. We recommend setting + :option:`mailserver.x509.useACMEHost` to a ``security.acme.certs`` + configuration. If that does not fit your requirements, configure certificate + and private key using :option:`mailserver.x509.certificateFile` and + :option:`mailserver.x509.privateKeyFile` instead. Support for automatic + creation of self-signed certificates has been removed. + NixOS 25.11 ----------- diff --git a/docs/setup-example.nix b/docs/setup-example.nix index d73d49f..8a4134a 100644 --- a/docs/setup-example.nix +++ b/docs/setup-example.nix @@ -1,3 +1,7 @@ +{ + config, + ... +}: { imports = [ (builtins.fetchTarball { @@ -9,12 +13,24 @@ }) ]; + security.acme = { + acceptTerms = true; + defaults.email = "security@example.com"; + certs.${config.mailserver.fqdn} = { + # Further setup required, check the manual: + # https://nixos.org/manual/nixos/stable/#module-security-acme + }; + }; + mailserver = { enable = true; stateVersion = 3; fqdn = "mail.example.com"; domains = [ "example.com" ]; + # reference an existing ACME configuration + x509.useACMEHost = config.mailserver.fqdn; + # A list of all login accounts. To create the password hashes, use # nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' loginAccounts = { @@ -26,11 +42,5 @@ # ... }; }; - - # Use Let's Encrypt certificates. Note that this needs to set up a stripped - # down nginx and opens port 80. - certificateScheme = "acme-nginx"; }; - security.acme.acceptTerms = true; - security.acme.defaults.email = "security@example.com"; } diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix index 1885af4..a5f5fe7 100644 --- a/mail-server/assertions.nix +++ b/mail-server/assertions.nix @@ -38,6 +38,20 @@ in assertion = config.mailserver.stateVersion != null; message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at."; } + { + assertion = + config.mailserver.x509.useACMEHost != null + -> config.mailserver.x509.certificateFile == null && config.mailserver.x509.privateKeyFile == null; + message = "Configuring an ACME certificate (`mailserver.x509.useACMEHost`) is not possible while also passing an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`)."; + } + { + assertion = + config.mailserver.x509.useACMEHost != null + || ( + config.mailserver.x509.certificateFile != null && config.mailserver.x509.privateKeyFile != null + ); + message = "Configure either an ACME certificate (`mailserver.x509.useACMEHost`) or pass an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`)."; + } ] ++ lib.optionals config.mailserver.ldap.enable [ { @@ -75,11 +89,5 @@ in ''; } ] - ++ lib.optionals (config.mailserver.certificateScheme != "acme") [ - { - assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn; - message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName"; - } - ] ); } diff --git a/mail-server/common.nix b/mail-server/common.nix index f060908..04ab71d 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -24,28 +24,20 @@ let cfg = config.mailserver; in -{ - # cert :: PATH - certificatePath = - if cfg.certificateScheme == "manual" then - cfg.certificateFile - else if cfg.certificateScheme == "selfsigned" then - "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" - else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then - "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" - else - throw "unknown certificate scheme"; +rec { + withACME = cfg.x509.useACMEHost != null; - # key :: PATH - keyPath = - if cfg.certificateScheme == "manual" then - cfg.keyFile - else if cfg.certificateScheme == "selfsigned" then - "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" - else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then - "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem" + x509CertificateFile = + if withACME then + "${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem" else - throw "unknown certificate scheme"; + cfg.x509.certificateFile; + + x509PrivateKeyFile = + if withACME then + "${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/key.pem" + else + cfg.x509.privateKeyFile; passwordFiles = let diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index e1d927a..6248351 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -196,6 +196,12 @@ in multiple languages are present in the configuration. ''; + security.acme.certs = lib.mkIf withACME { + ${cfg.x509.useACMEHost} = { + reloadServices = [ "dovecot.service" ]; + }; + }; + # for sieve-test. Shelling it in on demand usually doesnt' work, as it reads # the global config and tries to open shared libraries configured in there, # which are usually not compatible. @@ -216,8 +222,8 @@ in mailGroup = cfg.vmailGroupName; mailUser = cfg.vmailUserName; mailLocation = dovecotMaildir; - sslServerCert = certificatePath; - sslServerKey = keyPath; + sslServerCert = x509CertificateFile; + sslServerKey = x509PrivateKeyFile; enableDHE = lib.mkDefault false; enableLmtp = true; mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ @@ -455,6 +461,10 @@ in ${genPasswdScript} '' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); + reloadTriggers = lib.mkIf (!withACME) [ + x509CertificateFile + x509PrivateKeyFile + ]; }; systemd.services.postfix.restartTriggers = [ diff --git a/mail-server/environment.nix b/mail-server/environment.nix index 462cb05..86a81df 100644 --- a/mail-server/environment.nix +++ b/mail-server/environment.nix @@ -26,14 +26,11 @@ let in { config = lib.mkIf cfg.enable { - environment.systemPackages = - with pkgs; - [ - dovecot - openssh - postfix - rspamd - ] - ++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]); + environment.systemPackages = with pkgs; [ + dovecot + openssh + postfix + rspamd + ]; }; } diff --git a/mail-server/networking.nix b/mail-server/networking.nix index a79aa37..2fc00b8 100644 --- a/mail-server/networking.nix +++ b/mail-server/networking.nix @@ -32,8 +32,7 @@ in ++ lib.optional cfg.enableImapSsl 993 ++ lib.optional cfg.enablePop3 110 ++ lib.optional cfg.enablePop3Ssl 995 - ++ lib.optional cfg.enableManageSieve 4190 - ++ lib.optional (cfg.certificateScheme == "acme-nginx") 80; + ++ lib.optional cfg.enableManageSieve 4190; }; }; } diff --git a/mail-server/nginx.nix b/mail-server/nginx.nix deleted file mode 100644 index ab1c28d..0000000 --- a/mail-server/nginx.nix +++ /dev/null @@ -1,59 +0,0 @@ -# nixos-mailserver: a simple mail server -# Copyright (C) 2016-2018 Robin Raymond -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see - -{ - config, - options, - pkgs, - lib, - ... -}: - -with (import ./common.nix { - inherit - config - options - lib - pkgs - ; -}); - -let - cfg = config.mailserver; -in -{ - config = - lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) - { - services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { - enable = true; - virtualHosts."${cfg.fqdn}" = { - serverName = cfg.fqdn; - serverAliases = cfg.certificateDomains; - forceSSL = true; - enableACME = true; - }; - }; - - security.acme.certs."${cfg.acmeCertificateName}" = { - extraDomainNames = lib.mkIf (cfg.certificateScheme == "acme") cfg.certificateDomains; - reloadServices = [ - "postfix.service" - "dovecot.service" - ]; - }; - }; -} diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 2d0c56a..d6827ae 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -279,6 +279,17 @@ in }; }; + security.acme.certs = lib.mkIf withACME { + ${cfg.x509.useACMEHost} = { + reloadServices = [ "postfix.service" ]; + }; + }; + + systemd.services.postfix.reloadTriggers = lib.mkIf (!withACME) [ + x509CertificateFile + x509PrivateKeyFile + ]; + systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { preStart = '' ${appendPwdInVirtualMailboxMap} @@ -364,8 +375,8 @@ in # The X509 private key followed by the corresponding certificate smtpd_tls_chain_files = [ - "${keyPath}" - "${certificatePath}" + "${x509PrivateKeyFile}" + "${x509CertificateFile}" ]; # TLS for incoming mail is optional diff --git a/mail-server/rspamd.nix b/mail-server/rspamd.nix index ee48b28..b04aac0 100644 --- a/mail-server/rspamd.nix +++ b/mail-server/rspamd.nix @@ -40,6 +40,7 @@ let pkgs.writeShellScript "dkim-keygen-${domain}" '' if [ ! -f "${privateKey}" ] then + export PATH=${lib.makeBinPath [ pkgs.openssl ]} ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \ --domain "${domain}" \ --selector "${cfg.dkimSelector}" \ diff --git a/mail-server/systemd.nix b/mail-server/systemd.nix index d50408a..208b8de 100644 --- a/mail-server/systemd.nix +++ b/mail-server/systemd.nix @@ -33,47 +33,16 @@ with (import ./common.nix { let cfg = config.mailserver; - certificatesDeps = - if cfg.certificateScheme == "manual" then - [ ] - else if cfg.certificateScheme == "selfsigned" then - [ "mailserver-selfsigned-certificate.service" ] - else - [ "acme-finished-${cfg.fqdn}.target" ]; - + certificateDeps = lib.optionals withACME [ + "acme-order-renew-${cfg.x509.useACMEHost}.service" + ]; in { config = lib.mkIf cfg.enable { - # Create self signed certificate - systemd.services.mailserver-selfsigned-certificate = - lib.mkIf (cfg.certificateScheme == "selfsigned") - { - after = [ "local-fs.target" ]; - script = '' - # Create certificates if they do not exist yet - dir="${cfg.certificateDirectory}" - fqdn="${cfg.fqdn}" - [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") - key="$dir/key-${cfg.fqdn}.pem"; - cert="$dir/cert-${cfg.fqdn}.pem"; - if [[ ! -f $key || ! -f $cert ]]; then - mkdir -p "${cfg.certificateDirectory}" - (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && - "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ - -days 3650 -out "$cert" - fi - ''; - serviceConfig = { - Type = "oneshot"; - PrivateTmp = true; - }; - }; - - # Create maildir folder before dovecot startup systemd.services.dovecot = { - wants = certificatesDeps; - after = certificatesDeps; + wants = certificateDeps; + after = certificateDeps; preStart = let directories = lib.strings.escapeShellArgs ( @@ -93,12 +62,12 @@ in # Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work systemd.services.postfix = { - wants = certificatesDeps; + wants = certificateDeps; after = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service" - ++ certificatesDeps; + ++ certificateDeps; requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service"; }; }; diff --git a/scripts/generate-options.py b/scripts/generate-options.py index ffc1168..1fe948b 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -26,7 +26,7 @@ options = json.load(f) groups = [ "mailserver.loginAccounts", - "mailserver.certificate", + "mailserver.x509", "mailserver.dkim", "mailserver.srs", "mailserver.dmarcReporting", diff --git a/tests/external.nix b/tests/external.nix index 0f47acb..bdbb546 100644 --- a/tests/external.nix +++ b/tests/external.nix @@ -26,7 +26,10 @@ ./lib/config.nix ]; - environment.systemPackages = with pkgs; [ netcat ]; + environment.systemPackages = with pkgs; [ + netcat + openssl + ]; virtualisation.memorySize = 1024; diff --git a/tests/internal.nix b/tests/internal.nix index 29d0880..a23c152 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -113,6 +113,7 @@ in '' machine.start() machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("dovecot.service") # Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205 with subtest("mail forwarded can are locally kept"): diff --git a/tests/lib/cert.pem b/tests/lib/cert.pem new file mode 100644 index 0000000..ad6cd5c --- /dev/null +++ b/tests/lib/cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBizCCATGgAwIBAgIUN4ncJfMVIQSSurMkdE73x4aefTMwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQdGVzdC5sb2NhbGRvbWFpbjAeFw0yNTEwMTgyMTQ4MTNaFw0z +NTEwMTYyMTQ4MTNaMBsxGTAXBgNVBAMMEHRlc3QubG9jYWxkb21haW4wWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAARCJUj4j7eC/7Xso3REUscqHlWPvW9zvl5I6TIy +zEXFsWxM0QxMuNW4oXE56UiCyJklcpk0JfQUGat+kKQqSUJyo1MwUTAdBgNVHQ4E +FgQUW3CnmBf3n/Y30vfj3ERsIQnXu9QwHwYDVR0jBBgwFoAUW3CnmBf3n/Y30vfj +3ERsIQnXu9QwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAhwAi +K4xdr8KxD5xRvvzShheh48i8X7NtBIQ3bd01Jx4CIG/kYTDK5nDZri7UYOMsgz2l +iWss56p2dGWTL7LrBHgM +-----END CERTIFICATE----- diff --git a/tests/lib/config.nix b/tests/lib/config.nix index 7a8a0b0..b4466b6 100644 --- a/tests/lib/config.nix +++ b/tests/lib/config.nix @@ -10,6 +10,17 @@ # Keep testing submission with explicit TLS mailserver.enableSubmission = true; + # Certificate created for testing purposes from RFC9500 private key + # https://datatracker.ietf.org/doc/rfc9500/ + # openssl req -x509 -new -key key.pem \ + # -subj "/CN=test.localdomain" \ + # -sha256 -days 3650 \ + # -out cert.pem + mailserver.x509 = { + certificateFile = "${./cert.pem}"; + privateKeyFile = "${./key.pem}"; + }; + # Enable second CPU core virtualisation.cores = lib.mkDefault 2; diff --git a/tests/lib/key.pem b/tests/lib/key.pem new file mode 100644 index 0000000..e9bbb11 --- /dev/null +++ b/tests/lib/key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49 +AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV +uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg== +-----END EC PRIVATE KEY----- diff --git a/tests/multiple.nix b/tests/multiple.nix index d222b82..94d2527 100644 --- a/tests/multiple.nix +++ b/tests/multiple.nix @@ -93,8 +93,9 @@ in testScript = '' start_all() - domain1.wait_for_unit("multi-user.target") - domain2.wait_for_unit("multi-user.target") + for domain in [domain1, domain2]: + domain.wait_for_unit("multi-user.target") + domain.wait_for_unit("dovecot.service") # TODO put this blocking into the systemd units? domain1.wait_until_succeeds(