From af7c7b42c137b65a6d12de63544cad400de636f5 Mon Sep 17 00:00:00 2001 From: Joachim Schiele Date: Fri, 14 Jul 2017 16:55:53 +0200 Subject: postfix: complete remake of postfix service (#27276) --- nixos/modules/services/mail/postfix.nix | 592 ++++++++++++++++++++++++-------- 1 file changed, 443 insertions(+), 149 deletions(-) (limited to 'nixos/modules/services') diff --git a/nixos/modules/services/mail/postfix.nix b/nixos/modules/services/mail/postfix.nix index caaa87b94d61..845c6acc7feb 100644 --- a/nixos/modules/services/mail/postfix.nix +++ b/nixos/modules/services/mail/postfix.nix @@ -9,7 +9,8 @@ let group = cfg.group; setgidGroup = cfg.setgidGroup; - haveAliases = cfg.postmasterAlias != "" || cfg.rootAlias != "" || cfg.extraAliases != ""; + haveAliases = cfg.postmasterAlias != "" || cfg.rootAlias != "" + || cfg.extraAliases != ""; haveTransport = cfg.transport != ""; haveVirtual = cfg.virtual != ""; @@ -25,149 +26,275 @@ let clientRestrictions = concatStringsSep ", " (clientAccess ++ dnsBl); - mainCf = - '' - compatibility_level = 9999 - - mail_owner = ${user} - default_privs = nobody - - # NixOS specific locations - data_directory = /var/lib/postfix/data - queue_directory = /var/lib/postfix/queue - - # Default location of everything in package - meta_directory = ${pkgs.postfix}/etc/postfix - command_directory = ${pkgs.postfix}/bin - sample_directory = /etc/postfix - newaliases_path = ${pkgs.postfix}/bin/newaliases - mailq_path = ${pkgs.postfix}/bin/mailq - readme_directory = no - sendmail_path = ${pkgs.postfix}/bin/sendmail - daemon_directory = ${pkgs.postfix}/libexec/postfix - manpage_directory = ${pkgs.postfix}/share/man - html_directory = ${pkgs.postfix}/share/postfix/doc/html - shlib_directory = no + mainCf = let + escape = replaceStrings ["$"] ["$$"]; + mkList = items: "\n " + concatMapStringsSep "\n " escape items; + mkVal = value: + if isList value then mkList value + else " " + (if value == true then "yes" + else if value == false then "no" + else toString value); + mkEntry = name: value: "${escape name} =${mkVal value}"; + in + concatStringsSep "\n" (mapAttrsToList mkEntry (recursiveUpdate defaultConf cfg.config)) + + "\n" + cfg.extraConfig; + + defaultConf = { + compatibility_level = "9999"; + mail_owner = user; + default_privs = "nobody"; + + # NixOS specific locations + data_directory = "/var/lib/postfix/data"; + queue_directory = "/var/lib/postfix/queue"; + + # Default location of everything in package + meta_directory = "${pkgs.postfix}/etc/postfix"; + command_directory = "${pkgs.postfix}/bin"; + sample_directory = "/etc/postfix"; + newaliases_path = "${pkgs.postfix}/bin/newaliases"; + mailq_path = "${pkgs.postfix}/bin/mailq"; + readme_directory = false; + sendmail_path = "${pkgs.postfix}/bin/sendmail"; + daemon_directory = "${pkgs.postfix}/libexec/postfix"; + manpage_directory = "${pkgs.postfix}/share/man"; + html_directory = "${pkgs.postfix}/share/postfix/doc/html"; + shlib_directory = false; + relayhost = if cfg.lookupMX || cfg.relayHost == "" + then cfg.relayHost + else "[${cfg.relayHost}]"; + mail_spool_directory = "/var/spool/mail/"; + setgid_group = setgidGroup; + } + // optionalAttrs config.networking.enableIPv6 { inet_protocols = "all"; } + // optionalAttrs (cfg.networks != null) { mynetworks = cfg.networks; } + // optionalAttrs (cfg.networksStyle != "") { mynetworks_style = cfg.networksStyle; } + // optionalAttrs (cfg.hostname != "") { myhostname = cfg.hostname; } + // optionalAttrs (cfg.domain != "") { mydomain = cfg.domain; } + // optionalAttrs (cfg.origin != "") { myorigin = cfg.origin; } + // optionalAttrs (cfg.destination != null) { mydestination = cfg.destination; } + // optionalAttrs (cfg.relayDomains != null) { relay_domains = cfg.relayDomains; } + // optionalAttrs (cfg.recipientDelimiter != "") { recipient_delimiter = cfg.recipientDelimiter; } + // optionalAttrs haveAliases { alias_maps = "${cfg.aliasMapType}:/etc/postfix/aliases"; } + // optionalAttrs haveTransport { transport_maps = "hash:/etc/postfx/transport"; } + // optionalAttrs haveVirtual { virtual_alias_maps = "${cfg.virtualMapType}:/etc/postfix/virtual"; } + // optionalAttrs (cfg.dnsBlacklists != []) { smtpd_client_restrictions = clientRestrictions; } + // optionalAttrs cfg.enableHeaderChecks { header_checks = "regexp:/etc/postfix/header_checks"; } + // optionalAttrs (cfg.sslCert != "") { + smtp_tls_CAfile = cfg.sslCACert; + smtp_tls_cert_file = cfg.sslCert; + smtp_tls_key_file = cfg.sslKey; + + smtp_use_tls = true; + + smtpd_tls_CAfile = cfg.sslCACert; + smtpd_tls_cert_file = cfg.sslCert; + smtpd_tls_key_file = cfg.sslKey; + + smtpd_use_tls = true; + }; - '' - + optionalString config.networking.enableIPv6 '' - inet_protocols = all - '' - + (if cfg.networks != null then - '' - mynetworks = ${concatStringsSep ", " cfg.networks} - '' - else if cfg.networksStyle != "" then - '' - mynetworks_style = ${cfg.networksStyle} - '' - else - "") - + optionalString (cfg.hostname != "") '' - myhostname = ${cfg.hostname} - '' - + optionalString (cfg.domain != "") '' - mydomain = ${cfg.domain} - '' - + optionalString (cfg.origin != "") '' - myorigin = ${cfg.origin} - '' - + optionalString (cfg.destination != null) '' - mydestination = ${concatStringsSep ", " cfg.destination} - '' - + optionalString (cfg.relayDomains != null) '' - relay_domains = ${concatStringsSep ", " cfg.relayDomains} - '' - + '' - relayhost = ${if cfg.lookupMX || cfg.relayHost == "" then - cfg.relayHost - else - "[" + cfg.relayHost + "]"} + masterCfOptions = { options, config, name, ... }: { + options = { + name = mkOption { + type = types.str; + default = name; + example = "smtp"; + description = '' + The name of the service to run. Defaults to the attribute set key. + ''; + }; - mail_spool_directory = /var/spool/mail/ + type = mkOption { + type = types.enum [ "inet" "unix" "fifo" "pass" ]; + default = "unix"; + example = "inet"; + description = "The type of the service"; + }; - setgid_group = ${setgidGroup} - '' - + optionalString (cfg.sslCert != "") '' + private = mkOption { + type = types.bool; + example = false; + description = '' + Whether the service's sockets and storage directory is restricted to + be only available via the mail system. If null is + given it uses the postfix default true. + ''; + }; - smtp_tls_CAfile = ${cfg.sslCACert} - smtp_tls_cert_file = ${cfg.sslCert} - smtp_tls_key_file = ${cfg.sslKey} + privileged = mkOption { + type = types.bool; + example = true; + description = ""; + }; - smtp_use_tls = yes + chroot = mkOption { + type = types.bool; + example = true; + description = '' + Whether the service is chrooted to have only access to the + and the closure of + store paths specified by the option. + ''; + }; - smtpd_tls_CAfile = ${cfg.sslCACert} - smtpd_tls_cert_file = ${cfg.sslCert} - smtpd_tls_key_file = ${cfg.sslKey} + wakeup = mkOption { + type = types.int; + example = 60; + description = '' + Automatically wake up the service after the specified number of + seconds. If 0 is given, never wake the service + up. + ''; + }; - smtpd_use_tls = yes - '' - + optionalString (cfg.recipientDelimiter != "") '' - recipient_delimiter = ${cfg.recipientDelimiter} - '' - + optionalString haveAliases '' - alias_maps = hash:/etc/postfix/aliases - '' - + optionalString haveTransport '' - transport_maps = hash:/etc/postfix/transport - '' - + optionalString haveVirtual '' - virtual_alias_maps = hash:/etc/postfix/virtual - '' - + optionalString (cfg.dnsBlacklists != []) '' - smtpd_client_restrictions = ${clientRestrictions} - '' - + cfg.extraConfig; - - masterCf = '' - # ========================================================================== - # service type private unpriv chroot wakeup maxproc command + args - # (yes) (yes) (no) (never) (100) - # ========================================================================== - smtp inet n - n - - smtpd - '' + optionalString cfg.enableSubmission '' - submission inet n - n - - smtpd - ${concatStringsSep "\n " (mapAttrsToList (x: y: "-o " + x + "=" + y) cfg.submissionOptions)} - '' - + '' - pickup unix n - n 60 1 pickup - cleanup unix n - n - 0 cleanup - qmgr unix n - n 300 1 qmgr - tlsmgr unix - - n 1000? 1 tlsmgr - rewrite unix - - n - - trivial-rewrite - bounce unix - - n - 0 bounce - defer unix - - n - 0 bounce - trace unix - - n - 0 bounce - verify unix - - n - 1 verify - flush unix n - n 1000? 0 flush - proxymap unix - - n - - proxymap - proxywrite unix - - n - 1 proxymap - '' - + optionalString cfg.enableSmtp '' - smtp unix - - n - - smtp - relay unix - - n - - smtp - -o smtp_fallback_relay= - # -o smtp_helo_timeout=5 -o smtp_connect_timeout=5 - '' - + '' - showq unix n - n - - showq - error unix - - n - - error - retry unix - - n - - error - discard unix - - n - - discard - local unix - n n - - local - virtual unix - n n - - virtual - lmtp unix - - n - - lmtp - anvil unix - - n - 1 anvil - scache unix - - n - 1 scache - ${cfg.extraMasterConf} - ''; - - aliases = + wakeupUnusedComponent = mkOption { + type = types.bool; + example = false; + description = '' + If set to false the component will only be woken + up if it is used. This is equivalent to postfix' notion of adding a + question mark behind the wakeup time in + master.cf + ''; + }; + + maxproc = mkOption { + type = types.int; + example = 1; + description = '' + The maximum number of processes to spawn for this service. If the + value is 0 it doesn't have any limit. If + null is given it uses the postfix default of + 100. + ''; + }; + + command = mkOption { + type = types.str; + default = name; + example = "smtpd"; + description = '' + A program name specifying a Postfix service/daemon process. + By default it's the attribute . + ''; + }; + + args = mkOption { + type = types.listOf types.str; + default = []; + example = [ "-o" "smtp_helo_timeout=5" ]; + description = '' + Arguments to pass to the . There is no shell + processing involved and shell syntax is passed verbatim to the + process. + ''; + }; + + rawEntry = mkOption { + type = types.listOf types.str; + default = []; + internal = true; + description = '' + The raw configuration line for the master.cf. + ''; + }; + }; + + config.rawEntry = let + mkBool = bool: if bool then "y" else "n"; + mkArg = arg: "${optionalString (hasPrefix "-" arg) "\n "}${arg}"; + + maybeOption = fun: option: + if options.${option}.isDefined then fun config.${option} else "-"; + + # This is special, because we have two options for this value. + wakeup = let + wakeupDefined = options.wakeup.isDefined; + wakeupUCDefined = options.wakeupUnusedComponent.isDefined; + finalValue = toString config.wakeup + + optionalString (!config.wakeupUnusedComponent) "?"; + in if wakeupDefined && wakeupUCDefined then finalValue else "-"; + + in [ + config.name + config.type + (maybeOption mkBool "private") + (maybeOption (b: mkBool (!b)) "privileged") + (maybeOption mkBool "chroot") + wakeup + (maybeOption toString "maxproc") + (config.command + " " + concatMapStringsSep " " mkArg config.args) + ]; + }; + + masterCfContent = let + + labels = [ + "# service" "type" "private" "unpriv" "chroot" "wakeup" "maxproc" + "command + args" + ]; + + labelDefaults = [ + "# " "" "(yes)" "(yes)" "(no)" "(never)" "(100)" "" "" + ]; + + masterCf = mapAttrsToList (const (getAttr "rawEntry")) cfg.masterConfig; + + # A list of the maximum width of the columns across all lines and labels + maxWidths = let + foldLine = line: acc: let + columnLengths = map stringLength line; + in zipListsWith max acc columnLengths; + # We need to handle the last column specially here, because it's + # open-ended (command + args). + lines = [ labels labelDefaults ] ++ (map (l: init l ++ [""]) masterCf); + in fold foldLine (genList (const 0) (length labels)) lines; + + # Pad a string with spaces from the right (opposite of fixedWidthString). + pad = width: str: let + padWidth = width - stringLength str; + padding = concatStrings (genList (const " ") padWidth); + in str + optionalString (padWidth > 0) padding; + + # It's + 2 here, because that's the amount of spacing between columns. + fullWidth = fold (width: acc: acc + width + 2) 0 maxWidths; + + formatLine = line: concatStringsSep " " (zipListsWith pad maxWidths line); + + formattedLabels = let + sep = "# " + concatStrings (genList (const "=") (fullWidth + 5)); + lines = [ sep (formatLine labels) (formatLine labelDefaults) sep ]; + in concatStringsSep "\n" lines; + + in formattedLabels + "\n" + concatMapStringsSep "\n" formatLine masterCf + "\n"; + + headerCheckOptions = { ... }: + { + options = { + pattern = mkOption { + type = types.str; + default = "/^.*/"; + example = "/^X-Mailer:/"; + description = "A regexp pattern matching the header"; + }; + action = mkOption { + type = types.str; + default = "DUNNO"; + example = "BCC mail@example.com"; + description = "The action to be executed when the pattern is matched"; + }; + }; + }; + + headerChecks = concatStringsSep "\n" (map (x: "${x.pattern} ${x.action}") cfg.headerChecks) + cfg.extraHeaderChecks; + + aliases = let seperator = if cfg.aliasMapType == "hash" then ":" else ""; in optionalString (cfg.postmasterAlias != "") '' - postmaster: ${cfg.postmasterAlias} + postmaster${seperator} ${cfg.postmasterAlias} '' + optionalString (cfg.rootAlias != "") '' - root: ${cfg.rootAlias} + root${seperator} ${cfg.rootAlias} '' + cfg.extraAliases ; @@ -176,8 +303,9 @@ let virtualFile = pkgs.writeText "postfix-virtual" cfg.virtual; checkClientAccessFile = pkgs.writeText "postfix-check-client-access" cfg.dnsBlacklistOverrides; mainCfFile = pkgs.writeText "postfix-main.cf" mainCf; - masterCfFile = pkgs.writeText "postfix-master.cf" masterCf; + masterCfFile = pkgs.writeText "postfix-master.cf" masterCfContent; transportFile = pkgs.writeText "postfix-transport" cfg.transport; + headerChecksFile = pkgs.writeText "postfix-header-checks" headerChecks; in @@ -199,27 +327,29 @@ in default = true; description = "Whether to enable smtp in master.cf."; }; - + enableSubmission = mkOption { type = types.bool; default = false; - description = "Whether to enable smtp submission"; + description = "Whether to enable smtp submission."; }; submissionOptions = mkOption { type = types.attrs; - default = { "smtpd_tls_security_level" = "encrypt"; - "smtpd_sasl_auth_enable" = "yes"; - "smtpd_client_restrictions" = "permit_sasl_authenticated,reject"; - "milter_macro_daemon_name" = "ORIGINATING"; - }; + default = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; + example = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + milter_macro_daemon_name = "ORIGINATING"; + }; description = "Options for the submission config in master.cf"; - example = { "smtpd_tls_security_level" = "encrypt"; - "smtpd_sasl_auth_enable" = "yes"; - "smtpd_sasl_type" = "dovecot"; - "smtpd_client_restrictions" = "permit_sasl_authenticated,reject"; - "milter_macro_daemon_name" = "ORIGINATING"; - }; }; setSendmail = mkOption { @@ -352,6 +482,25 @@ in "; }; + aliasMapType = mkOption { + type = with types; enum [ "hash" "regexp" "pcre" ]; + default = "hash"; + example = "regexp"; + description = "The format the alias map should have. Use regexp if you want to use regular expressions."; + }; + + config = mkOption { + type = with types; attrsOf (either bool (either str (listOf str))); + default = defaultConf; + description = '' + The main.cf configuration file as key value set. + ''; + example = { + mail_owner = "postfix"; + smtp_use_tls = true; + }; + }; + extraConfig = mkOption { type = types.lines; default = ""; @@ -395,6 +544,14 @@ in "; }; + virtualMapType = mkOption { + type = types.enum ["hash" "regexp" "pcre"]; + default = "hash"; + description = '' + What type of virtual alias map file to use. Use "regexp" for regular expressions. + ''; + }; + transport = mkOption { default = ""; description = " @@ -413,6 +570,22 @@ in description = "contents of check_client_access for overriding dnsBlacklists"; }; + masterConfig = mkOption { + type = types.attrsOf (types.submodule masterCfOptions); + default = {}; + example = + { submission = { + type = "inet"; + args = [ "-o" "smtpd_tls_security_level=encrypt" ]; + }; + }; + description = '' + An attribute set of service options, which correspond to the service + definitions usually done within the Postfix + master.cf file. + ''; + }; + extraMasterConf = mkOption { type = types.lines; default = ""; @@ -420,6 +593,27 @@ in description = "Extra lines to append to the generated master.cf file."; }; + enableHeaderChecks = mkOption { + type = types.bool; + default = false; + example = true; + description = "Whether to enable postfix header checks"; + }; + + headerChecks = mkOption { + type = types.listOf (types.submodule headerCheckOptions); + default = []; + example = [ { pattern = "/^X-Spam-Flag:/"; action = "REDIRECT spam@example.com"; } ]; + description = "Postfix header checks."; + }; + + extraHeaderChecks = mkOption { + type = types.lines; + default = ""; + example = "/^X-Spam-Flag:/ REDIRECT spam@example.com"; + description = "Extra lines to /etc/postfix/header_checks file."; + }; + aliasFiles = mkOption { type = types.attrsOf types.path; default = {}; @@ -530,6 +724,101 @@ in ${pkgs.postfix}/bin/postfix set-permissions config_directory=/var/lib/postfix/conf ''; }; + + services.postfix.masterConfig = { + smtp_inet = { + name = "smtp"; + type = "inet"; + private = false; + command = "smtpd"; + }; + pickup = { + private = false; + wakeup = 60; + maxproc = 1; + }; + cleanup = { + private = false; + maxproc = 0; + }; + qmgr = { + private = false; + wakeup = 300; + maxproc = 1; + }; + tlsmgr = { + wakeup = 1000; + wakeupUnusedComponent = false; + maxproc = 1; + }; + rewrite = { + command = "trivial-rewrite"; + }; + bounce = { + maxproc = 0; + }; + defer = { + maxproc = 0; + command = "bounce"; + }; + trace = { + maxproc = 0; + command = "bounce"; + }; + verify = { + maxproc = 1; + }; + flush = { + private = false; + wakeup = 1000; + wakeupUnusedComponent = false; + maxproc = 0; + }; + proxymap = { + command = "proxymap"; + }; + proxywrite = { + maxproc = 1; + command = "proxymap"; + }; + showq = { + private = false; + }; + error = {}; + retry = { + command = "error"; + }; + discard = {}; + local = { + privileged = true; + }; + virtual = { + privileged = true; + }; + lmtp = { + }; + anvil = { + maxproc = 1; + }; + scache = { + maxproc = 1; + }; + } // optionalAttrs cfg.enableSubmission { + submission = { + type = "inet"; + private = false; + command = "smtpd"; + args = let + mkKeyVal = opt: val: [ "-o" (opt + "=" + val) ]; + in concatLists (mapAttrsToList mkKeyVal cfg.submissionOptions); + }; + } // optionalAttrs cfg.enableSmtp { + smtp = {}; + relay = { + command = "smtp"; + args = [ "-o" "smtp_fallback_relay=" ]; + }; + }; } (mkIf haveAliases { @@ -541,9 +830,14 @@ in (mkIf haveVirtual { services.postfix.mapFiles."virtual" = virtualFile; }) + (mkIf cfg.enableHeaderChecks { + services.postfix.mapFiles."header_checks" = headerChecksFile; + }) (mkIf (cfg.dnsBlacklists != []) { services.postfix.mapFiles."client_access" = checkClientAccessFile; }) + (mkIf (cfg.extraConfig != "") { + warnings = [ "The services.postfix.extraConfig option was deprecated. Please use services.postfix.config instead." ]; + }) ]); - } -- cgit 1.4.1