nixpkgs/nixos/modules/services/mail/postfix.nix
Peter Simons 59bacaca3d nixos: add 'localRecipients' config option for Postfix
The new option services.postfix.localRecipients allows
configuring the postfix option 'local_recipient_maps'. When
set to a list of user names (or patterns), that map
effectively replaces the lookup in the system's user
database that's used by default to determine which local
users are valid.

This option is useful to explicitly set local users that are
allowed to receive e-mail from the outside world. For local
injection i.e. via the 'sendmail' command this option has no
effect.
2019-07-15 17:36:20 +02:00

899 lines
28 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.postfix;
user = cfg.user;
group = cfg.group;
setgidGroup = cfg.setgidGroup;
haveAliases = cfg.postmasterAlias != "" || cfg.rootAlias != ""
|| cfg.extraAliases != "";
haveTransport = cfg.transport != "";
haveVirtual = cfg.virtual != "";
haveLocalRecipients = cfg.localRecipients != null;
clientAccess =
optional (cfg.dnsBlacklistOverrides != "")
"check_client_access hash:/etc/postfix/client_access";
dnsBl =
optionals (cfg.dnsBlacklists != [])
(map (s: "reject_rbl_client " + s) cfg.dnsBlacklists);
clientRestrictions = concatStringsSep ", " (clientAccess ++ dnsBl);
mainCf = let
escape = replaceStrings ["$"] ["$$"];
mkList = items: "\n " + concatStringsSep ",\n " 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 cfg.config)
+ "\n" + cfg.extraConfig;
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.
'';
};
type = mkOption {
type = types.enum [ "inet" "unix" "fifo" "pass" ];
default = "unix";
example = "inet";
description = "The type of the service";
};
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 <literal>null</literal> is
given it uses the postfix default <literal>true</literal>.
'';
};
privileged = mkOption {
type = types.bool;
example = true;
description = "";
};
chroot = mkOption {
type = types.bool;
example = true;
description = ''
Whether the service is chrooted to have only access to the
<option>services.postfix.queueDir</option> and the closure of
store paths specified by the <option>program</option> option.
'';
};
wakeup = mkOption {
type = types.int;
example = 60;
description = ''
Automatically wake up the service after the specified number of
seconds. If <literal>0</literal> is given, never wake the service
up.
'';
};
wakeupUnusedComponent = mkOption {
type = types.bool;
example = false;
description = ''
If set to <literal>false</literal> 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
<filename>master.cf</filename>
'';
};
maxproc = mkOption {
type = types.int;
example = 1;
description = ''
The maximum number of processes to spawn for this service. If the
value is <literal>0</literal> it doesn't have any limit. If
<literal>null</literal> is given it uses the postfix default of
<literal>100</literal>.
'';
};
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 <option>name</option>.
'';
};
args = mkOption {
type = types.listOf types.str;
default = [];
example = [ "-o" "smtp_helo_timeout=5" ];
description = ''
Arguments to pass to the <option>command</option>. 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 <filename>master.cf</filename>.
'';
};
};
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 (wakeupUCDefined && !config.wakeupUnusedComponent) "?";
in if wakeupDefined 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" + cfg.extraMasterConf;
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${seperator} ${cfg.postmasterAlias}
''
+ optionalString (cfg.rootAlias != "") ''
root${seperator} ${cfg.rootAlias}
''
+ cfg.extraAliases
;
aliasesFile = pkgs.writeText "postfix-aliases" aliases;
virtualFile = pkgs.writeText "postfix-virtual" cfg.virtual;
localRecipientMapFile = pkgs.writeText "postfix-local-recipient-map" (concatMapStrings (x: x + " ACCEPT\n") cfg.localRecipients);
checkClientAccessFile = pkgs.writeText "postfix-check-client-access" cfg.dnsBlacklistOverrides;
mainCfFile = pkgs.writeText "postfix-main.cf" mainCf;
masterCfFile = pkgs.writeText "postfix-master.cf" masterCfContent;
transportFile = pkgs.writeText "postfix-transport" cfg.transport;
headerChecksFile = pkgs.writeText "postfix-header-checks" headerChecks;
in
{
###### interface
options = {
services.postfix = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to run the Postfix mail server.";
};
enableSmtp = mkOption {
default = true;
description = "Whether to enable smtp in master.cf.";
};
enableSubmission = mkOption {
type = types.bool;
default = false;
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";
};
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";
};
setSendmail = mkOption {
type = types.bool;
default = true;
description = "Whether to set the system sendmail to postfix's.";
};
user = mkOption {
type = types.str;
default = "postfix";
description = "What to call the Postfix user (must be used only for postfix).";
};
group = mkOption {
type = types.str;
default = "postfix";
description = "What to call the Postfix group (must be used only for postfix).";
};
setgidGroup = mkOption {
type = types.str;
default = "postdrop";
description = "
How to call postfix setgid group (for postdrop). Should
be uniquely used group.
";
};
networks = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = ["192.168.0.1/24"];
description = "
Net masks for trusted - allowed to relay mail to third parties -
hosts. Leave empty to use mynetworks_style configuration or use
default (localhost-only).
";
};
networksStyle = mkOption {
type = types.str;
default = "";
description = "
Name of standard way of trusted network specification to use,
leave blank if you specify it explicitly or if you want to use
default (localhost-only).
";
};
hostname = mkOption {
type = types.str;
default = "";
description ="
Hostname to use. Leave blank to use just the hostname of machine.
It should be FQDN.
";
};
domain = mkOption {
type = types.str;
default = "";
description ="
Domain to use. Leave blank to use hostname minus first component.
";
};
origin = mkOption {
type = types.str;
default = "";
description ="
Origin to use in outgoing e-mail. Leave blank to use hostname.
";
};
destination = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = ["localhost"];
description = "
Full (!) list of domains we deliver locally. Leave blank for
acceptable Postfix default.
";
};
relayDomains = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = ["localdomain"];
description = "
List of domains we agree to relay to. Default is empty.
";
};
relayHost = mkOption {
type = types.str;
default = "";
description = "
Mail relay for outbound mail.
";
};
relayPort = mkOption {
type = types.int;
default = 25;
description = "
SMTP port for relay mail relay.
";
};
lookupMX = mkOption {
type = types.bool;
default = false;
description = "
Whether relay specified is just domain whose MX must be used.
";
};
postmasterAlias = mkOption {
type = types.str;
default = "root";
description = "
Who should receive postmaster e-mail. Multiple values can be added by
separating values with comma.
";
};
rootAlias = mkOption {
type = types.str;
default = "";
description = "
Who should receive root e-mail. Blank for no redirection.
Multiple values can be added by separating values with comma.
";
};
extraAliases = mkOption {
type = types.lines;
default = "";
description = "
Additional entries to put verbatim into aliases file, cf. man-page aliases(8).
";
};
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)));
description = ''
The main.cf configuration file as key value set.
'';
example = {
mail_owner = "postfix";
smtp_use_tls = true;
};
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "
Extra lines to be added verbatim to the main.cf configuration file.
";
};
sslCert = mkOption {
type = types.str;
default = "";
description = "SSL certificate to use.";
};
sslCACert = mkOption {
type = types.str;
default = "";
description = "SSL certificate of CA.";
};
sslKey = mkOption {
type = types.str;
default = "";
description = "SSL key to use.";
};
recipientDelimiter = mkOption {
type = types.str;
default = "";
example = "+";
description = "
Delimiter for address extension: so mail to user+test can be handled by ~user/.forward+test
";
};
virtual = mkOption {
type = types.lines;
default = "";
description = "
Entries for the virtual alias map, cf. man-page virtual(8).
";
};
virtualMapType = mkOption {
type = types.enum ["hash" "regexp" "pcre"];
default = "hash";
description = ''
What type of virtual alias map file to use. Use <literal>"regexp"</literal> for regular expressions.
'';
};
localRecipients = mkOption {
type = with types; nullOr (listOf string);
default = null;
description = ''
List of accepted local users. Specify a bare username, an
<literal>"@domain.tld"</literal> wild-card, or a complete
<literal>"user@domain.tld"</literal> address. If set, these names end
up in the local recipient map -- see the local(8) man-page -- and
effectively replace the system user database lookup that's otherwise
used by default.
'';
};
transport = mkOption {
default = "";
description = "
Entries for the transport map, cf. man-page transport(8).
";
};
dnsBlacklists = mkOption {
default = [];
type = with types; listOf string;
description = "dns blacklist servers to use with smtpd_client_restrictions";
};
dnsBlacklistOverrides = mkOption {
default = "";
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
<filename>master.cf</filename> file.
'';
};
extraMasterConf = mkOption {
type = types.lines;
default = "";
example = "submission inet n - n - - smtpd";
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 = {};
description = "Aliases' tables to be compiled and placed into /var/lib/postfix/conf.";
};
mapFiles = mkOption {
type = types.attrsOf types.path;
default = {};
description = "Maps to be compiled and placed into /var/lib/postfix/conf.";
};
useSrs = mkOption {
type = types.bool;
default = false;
description = "Whether to enable sender rewriting scheme";
};
};
};
###### implementation
config = mkIf config.services.postfix.enable (mkMerge [
{
environment = {
etc = singleton
{ source = "/var/lib/postfix/conf";
target = "postfix";
};
# This makes it comfortable to run 'postqueue/postdrop' for example.
systemPackages = [ pkgs.postfix ];
};
services.pfix-srsd.enable = config.services.postfix.useSrs;
services.mail.sendmailSetuidWrapper = mkIf config.services.postfix.setSendmail {
program = "sendmail";
source = "${pkgs.postfix}/bin/sendmail";
group = setgidGroup;
setuid = false;
setgid = true;
};
security.wrappers.postqueue = {
program = "postqueue";
source = "${pkgs.postfix}/bin/postqueue";
group = setgidGroup;
setuid = false;
setgid = true;
};
security.wrappers.postdrop = {
program = "postdrop";
source = "${pkgs.postfix}/bin/postdrop";
group = setgidGroup;
setuid = false;
setgid = true;
};
users.users = optional (user == "postfix")
{ name = "postfix";
description = "Postfix mail server user";
uid = config.ids.uids.postfix;
group = group;
};
users.groups =
optional (group == "postfix")
{ name = group;
gid = config.ids.gids.postfix;
}
++ optional (setgidGroup == "postdrop")
{ name = setgidGroup;
gid = config.ids.gids.postdrop;
};
systemd.services.postfix =
{ description = "Postfix mail server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = [ pkgs.postfix ];
serviceConfig = {
Type = "forking";
Restart = "always";
PIDFile = "/var/lib/postfix/queue/pid/master.pid";
ExecStart = "${pkgs.postfix}/bin/postfix start";
ExecStop = "${pkgs.postfix}/bin/postfix stop";
ExecReload = "${pkgs.postfix}/bin/postfix reload";
};
preStart = ''
# Backwards compatibility
if [ ! -d /var/lib/postfix ] && [ -d /var/postfix ]; then
mkdir -p /var/lib
mv /var/postfix /var/lib/postfix
fi
# All permissions set according ${pkgs.postfix}/etc/postfix/postfix-files script
mkdir -p /var/lib/postfix /var/lib/postfix/queue/{pid,public,maildrop}
chmod 0755 /var/lib/postfix
chown root:root /var/lib/postfix
rm -rf /var/lib/postfix/conf
mkdir -p /var/lib/postfix/conf
chmod 0755 /var/lib/postfix/conf
ln -sf ${pkgs.postfix}/etc/postfix/postfix-files /var/lib/postfix/conf/postfix-files
ln -sf ${mainCfFile} /var/lib/postfix/conf/main.cf
ln -sf ${masterCfFile} /var/lib/postfix/conf/master.cf
${concatStringsSep "\n" (mapAttrsToList (to: from: ''
ln -sf ${from} /var/lib/postfix/conf/${to}
${pkgs.postfix}/bin/postalias /var/lib/postfix/conf/${to}
'') cfg.aliasFiles)}
${concatStringsSep "\n" (mapAttrsToList (to: from: ''
ln -sf ${from} /var/lib/postfix/conf/${to}
${pkgs.postfix}/bin/postmap /var/lib/postfix/conf/${to}
'') cfg.mapFiles)}
mkdir -p /var/spool/mail
chown root:root /var/spool/mail
chmod a+rwxt /var/spool/mail
ln -sf /var/spool/mail /var/
#Finally delegate to postfix checking remain directories in /var/lib/postfix and set permissions on them
${pkgs.postfix}/bin/postfix set-permissions config_directory=/var/lib/postfix/conf
'';
};
services.postfix.config = (mapAttrs (_: v: mkDefault v) {
compatibility_level = "9999";
mail_owner = cfg.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;
mail_spool_directory = "/var/spool/mail/";
setgid_group = cfg.setgidGroup;
})
// optionalAttrs (cfg.relayHost != "") { relayhost = if cfg.lookupMX
then "${cfg.relayHost}:${toString cfg.relayPort}"
else "[${cfg.relayHost}]:${toString cfg.relayPort}"; }
// optionalAttrs config.networking.enableIPv6 { inet_protocols = mkDefault "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/postfix/transport" ]; }
// optionalAttrs haveVirtual { virtual_alias_maps = [ "${cfg.virtualMapType}:/etc/postfix/virtual" ]; }
// optionalAttrs haveLocalRecipients { local_recipient_maps = [ "hash:/etc/postfix/local_recipients" ] ++ optional haveAliases "$alias_maps"; }
// optionalAttrs (cfg.dnsBlacklists != []) { smtpd_client_restrictions = clientRestrictions; }
// optionalAttrs cfg.useSrs {
sender_canonical_maps = [ "tcp:127.0.0.1:10001" ];
sender_canonical_classes = [ "envelope_sender" ];
recipient_canonical_maps = [ "tcp:127.0.0.1:10002" ];
recipient_canonical_classes = [ "envelope_recipient" ];
}
// 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;
};
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 {
services.postfix.aliasFiles."aliases" = aliasesFile;
})
(mkIf haveTransport {
services.postfix.mapFiles."transport" = transportFile;
})
(mkIf haveVirtual {
services.postfix.mapFiles."virtual" = virtualFile;
})
(mkIf haveLocalRecipients {
services.postfix.mapFiles."local_recipients" = localRecipientMapFile;
})
(mkIf cfg.enableHeaderChecks {
services.postfix.mapFiles."header_checks" = headerChecksFile;
})
(mkIf (cfg.dnsBlacklists != []) {
services.postfix.mapFiles."client_access" = checkClientAccessFile;
})
]);
}