uboot: (firmwareOdroidC2/C4) don't invoke patch tool, use patches = [] instead

https://github.com/NixOS/nixpkgs/blob/master/pkgs/stdenv/generic/setup.sh#L948
this can do it nicely.

Signed-off-by: Anton Arapov <anton@deadbeef.mx>
This commit is contained in:
Anton Arapov 2021-04-03 12:58:10 +02:00 committed by Alan Daniels
commit 56de2bcd43
30691 changed files with 3076956 additions and 0 deletions

View file

@ -0,0 +1,181 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.clamsmtp;
clamdSocket = "/run/clamav/clamd.ctl"; # See services/security/clamav.nix
in
{
##### interface
options = {
services.clamsmtp = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable clamsmtp.";
};
instances = mkOption {
description = "Instances of clamsmtp to run.";
type = types.listOf (types.submodule { options = {
action = mkOption {
type = types.enum [ "bounce" "drop" "pass" ];
default = "drop";
description =
''
Action to take when a virus is detected.
Note that viruses often spoof sender addresses, so bouncing is
in most cases not a good idea.
'';
};
header = mkOption {
type = types.str;
default = "";
example = "X-Virus-Scanned: ClamAV using ClamSMTP";
description =
''
A header to add to scanned messages. See clamsmtpd.conf(5) for
more details. Empty means no header.
'';
};
keepAlives = mkOption {
type = types.int;
default = 0;
description =
''
Number of seconds to wait between each NOOP sent to the sending
server. 0 to disable.
This is meant for slow servers where the sending MTA times out
waiting for clamd to scan the file.
'';
};
listen = mkOption {
type = types.str;
example = "127.0.0.1:10025";
description =
''
Address to wait for incoming SMTP connections on. See
clamsmtpd.conf(5) for more details.
'';
};
quarantine = mkOption {
type = types.bool;
default = false;
description =
''
Whether to quarantine files that contain viruses by leaving them
in the temporary directory.
'';
};
maxConnections = mkOption {
type = types.int;
default = 64;
description = "Maximum number of connections to accept at once.";
};
outAddress = mkOption {
type = types.str;
description =
''
Address of the SMTP server to send email to once it has been
scanned.
'';
};
tempDirectory = mkOption {
type = types.str;
default = "/tmp";
description =
''
Temporary directory that needs to be accessible to both clamd
and clamsmtpd.
'';
};
timeout = mkOption {
type = types.int;
default = 180;
description = "Time-out for network connections.";
};
transparentProxy = mkOption {
type = types.bool;
default = false;
description = "Enable clamsmtp's transparent proxy support.";
};
virusAction = mkOption {
type = with types; nullOr path;
default = null;
description =
''
Command to run when a virus is found. Please see VIRUS ACTION in
clamsmtpd(8) for a discussion of this option and its safe use.
'';
};
xClient = mkOption {
type = types.bool;
default = false;
description =
''
Send the XCLIENT command to the receiving server, for forwarding
client addresses and connection information if the receiving
server supports this feature.
'';
};
};});
};
};
};
##### implementation
config = let
configfile = conf: pkgs.writeText "clamsmtpd.conf"
''
Action: ${conf.action}
ClamAddress: ${clamdSocket}
Header: ${conf.header}
KeepAlives: ${toString conf.keepAlives}
Listen: ${conf.listen}
Quarantine: ${if conf.quarantine then "on" else "off"}
MaxConnections: ${toString conf.maxConnections}
OutAddress: ${conf.outAddress}
TempDirectory: ${conf.tempDirectory}
TimeOut: ${toString conf.timeout}
TransparentProxy: ${if conf.transparentProxy then "on" else "off"}
User: clamav
${optionalString (conf.virusAction != null) "VirusAction: ${conf.virusAction}"}
XClient: ${if conf.xClient then "on" else "off"}
'';
in
mkIf cfg.enable {
assertions = [
{ assertion = config.services.clamav.daemon.enable;
message = "clamsmtp requires clamav to be enabled";
}
];
systemd.services = listToAttrs (imap1 (i: conf:
nameValuePair "clamsmtp-${toString i}" {
description = "ClamSMTP instance ${toString i}";
wantedBy = [ "multi-user.target" ];
script = "exec ${pkgs.clamsmtp}/bin/clamsmtpd -f ${configfile conf}";
after = [ "clamav-daemon.service" ];
requires = [ "clamav-daemon.service" ];
serviceConfig.Type = "forking";
serviceConfig.PrivateTmp = "yes";
unitConfig.JoinsNamespaceOf = "clamav-daemon.service";
}
) cfg.instances);
};
meta.maintainers = with lib.maintainers; [ ekleog ];
}

View file

@ -0,0 +1,99 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.davmail;
configType = with types;
oneOf [ (attrsOf configType) str int bool ] // {
description = "davmail config type (str, int, bool or attribute set thereof)";
};
toStr = val: if isBool val then boolToString val else toString val;
linesForAttrs = attrs: concatMap (name: let value = attrs.${name}; in
if isAttrs value
then map (line: name + "." + line) (linesForAttrs value)
else [ "${name}=${toStr value}" ]
) (attrNames attrs);
configFile = pkgs.writeText "davmail.properties" (concatStringsSep "\n" (linesForAttrs cfg.config));
in
{
options.services.davmail = {
enable = mkEnableOption "davmail, an MS Exchange gateway";
url = mkOption {
type = types.str;
description = "Outlook Web Access URL to access the exchange server, i.e. the base webmail URL.";
example = "https://outlook.office365.com/EWS/Exchange.asmx";
};
config = mkOption {
type = configType;
default = {};
description = ''
Davmail configuration. Refer to
<link xlink:href="http://davmail.sourceforge.net/serversetup.html"/>
and <link xlink:href="http://davmail.sourceforge.net/advanced.html"/>
for details on supported values.
'';
example = literalExpression ''
{
davmail.allowRemote = true;
davmail.imapPort = 55555;
davmail.bindAddress = "10.0.1.2";
davmail.smtpSaveInSent = true;
davmail.folderSizeLimit = 10;
davmail.caldavAutoSchedule = false;
log4j.logger.rootLogger = "DEBUG";
}
'';
};
};
config = mkIf cfg.enable {
services.davmail.config = {
davmail = mapAttrs (name: mkDefault) {
server = true;
disableUpdateCheck = true;
logFilePath = "/var/log/davmail/davmail.log";
logFileSize = "1MB";
mode = "auto";
url = cfg.url;
caldavPort = 1080;
imapPort = 1143;
ldapPort = 1389;
popPort = 1110;
smtpPort = 1025;
};
log4j = {
logger.davmail = mkDefault "WARN";
logger.httpclient.wire = mkDefault "WARN";
logger.org.apache.commons.httpclient = mkDefault "WARN";
rootLogger = mkDefault "WARN";
};
};
systemd.services.davmail = {
description = "DavMail POP/IMAP/SMTP Exchange Gateway";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.davmail}/bin/davmail ${configFile}";
Restart = "on-failure";
DynamicUser = "yes";
LogsDirectory = "davmail";
};
};
environment.systemPackages = [ pkgs.davmail ];
};
}

View file

@ -0,0 +1,120 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.dkimproxy-out;
keydir = "/var/lib/dkimproxy-out";
privkey = "${keydir}/private.key";
pubkey = "${keydir}/public.key";
in
{
##### interface
options = {
services.dkimproxy-out = {
enable = mkOption {
type = types.bool;
default = false;
description =
''
Whether to enable dkimproxy_out.
Note that a key will be auto-generated, and can be found in
${keydir}.
'';
};
listen = mkOption {
type = types.str;
example = "127.0.0.1:10027";
description = "Address:port DKIMproxy should listen on.";
};
relay = mkOption {
type = types.str;
example = "127.0.0.1:10028";
description = "Address:port DKIMproxy should forward mail to.";
};
domains = mkOption {
type = with types; listOf str;
example = [ "example.org" "example.com" ];
description = "List of domains DKIMproxy can sign for.";
};
selector = mkOption {
type = types.str;
example = "selector1";
description =
''
The selector to use for DKIM key identification.
For example, if 'selector1' is used here, then for each domain
'example.org' given in `domain`, 'selector1._domainkey.example.org'
should contain the TXT record indicating the public key is the one
in ${pubkey}: "v=DKIM1; t=s; p=[THE PUBLIC KEY]".
'';
};
keySize = mkOption {
type = types.int;
default = 2048;
description =
''
Size of the RSA key to use to sign outgoing emails. Note that the
maximum mandatorily verified as per RFC6376 is 2048.
'';
};
# TODO: allow signature for other schemes than dkim(c=relaxed/relaxed)?
# This being the scheme used by gmail, maybe nothing more is needed for
# reasonable use.
};
};
##### implementation
config = let
configfile = pkgs.writeText "dkimproxy_out.conf"
''
listen ${cfg.listen}
relay ${cfg.relay}
domain ${concatStringsSep "," cfg.domains}
selector ${cfg.selector}
signature dkim(c=relaxed/relaxed)
keyfile ${privkey}
'';
in
mkIf cfg.enable {
users.groups.dkimproxy-out = {};
users.users.dkimproxy-out = {
description = "DKIMproxy_out daemon";
group = "dkimproxy-out";
isSystemUser = true;
};
systemd.services.dkimproxy-out = {
description = "DKIMproxy_out";
wantedBy = [ "multi-user.target" ];
preStart = ''
if [ ! -d "${keydir}" ]; then
mkdir -p "${keydir}"
chmod 0700 "${keydir}"
${pkgs.openssl}/bin/openssl genrsa -out "${privkey}" ${toString cfg.keySize}
${pkgs.openssl}/bin/openssl rsa -in "${privkey}" -pubout -out "${pubkey}"
chown -R dkimproxy-out:dkimproxy-out "${keydir}"
fi
'';
script = ''
exec ${pkgs.dkimproxy}/bin/dkimproxy.out --conf_file=${configfile}
'';
serviceConfig = {
User = "dkimproxy-out";
PermissionsStartOnly = true;
};
};
};
meta.maintainers = with lib.maintainers; [ ekleog ];
}

View file

@ -0,0 +1,462 @@
{ options, config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.dovecot2;
dovecotPkg = pkgs.dovecot;
baseDir = "/run/dovecot2";
stateDir = "/var/lib/dovecot";
dovecotConf = concatStrings [
''
base_dir = ${baseDir}
protocols = ${concatStringsSep " " cfg.protocols}
sendmail_path = /run/wrappers/bin/sendmail
# defining mail_plugins must be done before the first protocol {} filter because of https://doc.dovecot.org/configuration_manual/config_file/config_file_syntax/#variable-expansion
mail_plugins = $mail_plugins ${concatStringsSep " " cfg.mailPlugins.globally.enable}
''
(
concatStringsSep "\n" (
mapAttrsToList (
protocol: plugins: ''
protocol ${protocol} {
mail_plugins = $mail_plugins ${concatStringsSep " " plugins.enable}
}
''
) cfg.mailPlugins.perProtocol
)
)
(
if cfg.sslServerCert == null then ''
ssl = no
disable_plaintext_auth = no
'' else ''
ssl_cert = <${cfg.sslServerCert}
ssl_key = <${cfg.sslServerKey}
${optionalString (cfg.sslCACert != null) ("ssl_ca = <" + cfg.sslCACert)}
${optionalString cfg.enableDHE ''ssl_dh = <${config.security.dhparams.params.dovecot2.path}''}
disable_plaintext_auth = yes
''
)
''
default_internal_user = ${cfg.user}
default_internal_group = ${cfg.group}
${optionalString (cfg.mailUser != null) "mail_uid = ${cfg.mailUser}"}
${optionalString (cfg.mailGroup != null) "mail_gid = ${cfg.mailGroup}"}
mail_location = ${cfg.mailLocation}
maildir_copy_with_hardlinks = yes
pop3_uidl_format = %08Xv%08Xu
auth_mechanisms = plain login
service auth {
user = root
}
''
(
optionalString cfg.enablePAM ''
userdb {
driver = passwd
}
passdb {
driver = pam
args = ${optionalString cfg.showPAMFailure "failure_show_msg=yes"} dovecot2
}
''
)
(
optionalString (cfg.sieveScripts != {}) ''
plugin {
${concatStringsSep "\n" (mapAttrsToList (to: from: "sieve_${to} = ${stateDir}/sieve/${to}") cfg.sieveScripts)}
}
''
)
(
optionalString (cfg.mailboxes != {}) ''
namespace inbox {
inbox=yes
${concatStringsSep "\n" (map mailboxConfig (attrValues cfg.mailboxes))}
}
''
)
(
optionalString cfg.enableQuota ''
service quota-status {
executable = ${dovecotPkg}/libexec/dovecot/quota-status -p postfix
inet_listener {
port = ${cfg.quotaPort}
}
client_limit = 1
}
plugin {
quota_rule = *:storage=${cfg.quotaGlobalPerUser}
quota = count:User quota # per virtual mail user quota
quota_status_success = DUNNO
quota_status_nouser = DUNNO
quota_status_overquota = "552 5.2.2 Mailbox is full"
quota_grace = 10%%
quota_vsizes = yes
}
''
)
cfg.extraConfig
];
modulesDir = pkgs.symlinkJoin {
name = "dovecot-modules";
paths = map (pkg: "${pkg}/lib/dovecot") ([ dovecotPkg ] ++ map (module: module.override { dovecot = dovecotPkg; }) cfg.modules);
};
mailboxConfig = mailbox: ''
mailbox "${mailbox.name}" {
auto = ${toString mailbox.auto}
'' + optionalString (mailbox.autoexpunge != null) ''
autoexpunge = ${mailbox.autoexpunge}
'' + optionalString (mailbox.specialUse != null) ''
special_use = \${toString mailbox.specialUse}
'' + "}";
mailboxes = { name, ... }: {
options = {
name = mkOption {
type = types.strMatching ''[^"]+'';
example = "Spam";
default = name;
readOnly = true;
description = "The name of the mailbox.";
};
auto = mkOption {
type = types.enum [ "no" "create" "subscribe" ];
default = "no";
example = "subscribe";
description = "Whether to automatically create or create and subscribe to the mailbox or not.";
};
specialUse = mkOption {
type = types.nullOr (types.enum [ "All" "Archive" "Drafts" "Flagged" "Junk" "Sent" "Trash" ]);
default = null;
example = "Junk";
description = "Null if no special use flag is set. Other than that every use flag mentioned in the RFC is valid.";
};
autoexpunge = mkOption {
type = types.nullOr types.str;
default = null;
example = "60d";
description = ''
To automatically remove all email from the mailbox which is older than the
specified time.
'';
};
};
};
in
{
imports = [
(mkRemovedOptionModule [ "services" "dovecot2" "package" ] "")
];
options.services.dovecot2 = {
enable = mkEnableOption "the dovecot 2.x POP3/IMAP server";
enablePop3 = mkEnableOption "starting the POP3 listener (when Dovecot is enabled).";
enableImap = mkEnableOption "starting the IMAP listener (when Dovecot is enabled)." // { default = true; };
enableLmtp = mkEnableOption "starting the LMTP listener (when Dovecot is enabled).";
protocols = mkOption {
type = types.listOf types.str;
default = [];
description = "Additional listeners to start when Dovecot is enabled.";
};
user = mkOption {
type = types.str;
default = "dovecot2";
description = "Dovecot user name.";
};
group = mkOption {
type = types.str;
default = "dovecot2";
description = "Dovecot group name.";
};
extraConfig = mkOption {
type = types.lines;
default = "";
example = "mail_debug = yes";
description = "Additional entries to put verbatim into Dovecot's config file.";
};
mailPlugins =
let
plugins = hint: types.submodule {
options = {
enable = mkOption {
type = types.listOf types.str;
default = [];
description = "mail plugins to enable as a list of strings to append to the ${hint} <literal>$mail_plugins</literal> configuration variable";
};
};
};
in
mkOption {
type = with types; submodule {
options = {
globally = mkOption {
description = "Additional entries to add to the mail_plugins variable for all protocols";
type = plugins "top-level";
example = { enable = [ "virtual" ]; };
default = { enable = []; };
};
perProtocol = mkOption {
description = "Additional entries to add to the mail_plugins variable, per protocol";
type = attrsOf (plugins "corresponding per-protocol");
default = {};
example = { imap = [ "imap_acl" ]; };
};
};
};
description = "Additional entries to add to the mail_plugins variable, globally and per protocol";
example = {
globally.enable = [ "acl" ];
perProtocol.imap.enable = [ "imap_acl" ];
};
default = { globally.enable = []; perProtocol = {}; };
};
configFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Config file used for the whole dovecot configuration.";
apply = v: if v != null then v else pkgs.writeText "dovecot.conf" dovecotConf;
};
mailLocation = mkOption {
type = types.str;
default = "maildir:/var/spool/mail/%u"; /* Same as inbox, as postfix */
example = "maildir:~/mail:INBOX=/var/spool/mail/%u";
description = ''
Location that dovecot will use for mail folders. Dovecot mail_location option.
'';
};
mailUser = mkOption {
type = types.nullOr types.str;
default = null;
description = "Default user to store mail for virtual users.";
};
mailGroup = mkOption {
type = types.nullOr types.str;
default = null;
description = "Default group to store mail for virtual users.";
};
createMailUser = mkEnableOption ''automatically creating the user
given in <option>services.dovecot.user</option> and the group
given in <option>services.dovecot.group</option>.'' // { default = true; };
modules = mkOption {
type = types.listOf types.package;
default = [];
example = literalExpression "[ pkgs.dovecot_pigeonhole ]";
description = ''
Symlinks the contents of lib/dovecot of every given package into
/etc/dovecot/modules. This will make the given modules available
if a dovecot package with the module_dir patch applied is being used.
'';
};
sslCACert = mkOption {
type = types.nullOr types.str;
default = null;
description = "Path to the server's CA certificate key.";
};
sslServerCert = mkOption {
type = types.nullOr types.str;
default = null;
description = "Path to the server's public key.";
};
sslServerKey = mkOption {
type = types.nullOr types.str;
default = null;
description = "Path to the server's private key.";
};
enablePAM = mkEnableOption "creating a own Dovecot PAM service and configure PAM user logins." // { default = true; };
enableDHE = mkEnableOption "enable ssl_dh and generation of primes for the key exchange." // { default = true; };
sieveScripts = mkOption {
type = types.attrsOf types.path;
default = {};
description = "Sieve scripts to be executed. Key is a sequence, e.g. 'before2', 'after' etc.";
};
showPAMFailure = mkEnableOption "showing the PAM failure message on authentication error (useful for OTPW).";
mailboxes = mkOption {
type = with types; coercedTo
(listOf unspecified)
(list: listToAttrs (map (entry: { name = entry.name; value = removeAttrs entry ["name"]; }) list))
(attrsOf (submodule mailboxes));
default = {};
example = literalExpression ''
{
Spam = { specialUse = "Junk"; auto = "create"; };
}
'';
description = "Configure mailboxes and auto create or subscribe them.";
};
enableQuota = mkEnableOption "the dovecot quota service.";
quotaPort = mkOption {
type = types.str;
default = "12340";
description = ''
The Port the dovecot quota service binds to.
If using postfix, add check_policy_service inet:localhost:12340 to your smtpd_recipient_restrictions in your postfix config.
'';
};
quotaGlobalPerUser = mkOption {
type = types.str;
default = "100G";
example = "10G";
description = "Quota limit for the user in bytes. Supports suffixes b, k, M, G, T and %.";
};
};
config = mkIf cfg.enable {
security.pam.services.dovecot2 = mkIf cfg.enablePAM {};
security.dhparams = mkIf (cfg.sslServerCert != null && cfg.enableDHE) {
enable = true;
params.dovecot2 = {};
};
services.dovecot2.protocols =
optional cfg.enableImap "imap"
++ optional cfg.enablePop3 "pop3"
++ optional cfg.enableLmtp "lmtp";
services.dovecot2.mailPlugins = mkIf cfg.enableQuota {
globally.enable = [ "quota" ];
perProtocol.imap.enable = [ "imap_quota" ];
};
users.users = {
dovenull =
{
uid = config.ids.uids.dovenull2;
description = "Dovecot user for untrusted logins";
group = "dovenull";
};
} // optionalAttrs (cfg.user == "dovecot2") {
dovecot2 =
{
uid = config.ids.uids.dovecot2;
description = "Dovecot user";
group = cfg.group;
};
} // optionalAttrs (cfg.createMailUser && cfg.mailUser != null) {
${cfg.mailUser} =
{ description = "Virtual Mail User"; isSystemUser = true; } // optionalAttrs (cfg.mailGroup != null)
{ group = cfg.mailGroup; };
};
users.groups = {
dovenull.gid = config.ids.gids.dovenull2;
} // optionalAttrs (cfg.group == "dovecot2") {
dovecot2.gid = config.ids.gids.dovecot2;
} // optionalAttrs (cfg.createMailUser && cfg.mailGroup != null) {
${cfg.mailGroup} = {};
};
environment.etc."dovecot/modules".source = modulesDir;
environment.etc."dovecot/dovecot.conf".source = cfg.configFile;
systemd.services.dovecot2 = {
description = "Dovecot IMAP/POP3 server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ cfg.configFile modulesDir ];
startLimitIntervalSec = 60; # 1 min
serviceConfig = {
Type = "notify";
ExecStart = "${dovecotPkg}/sbin/dovecot -F";
ExecReload = "${dovecotPkg}/sbin/doveadm reload";
Restart = "on-failure";
RestartSec = "1s";
RuntimeDirectory = [ "dovecot2" ];
};
# When copying sieve scripts preserve the original time stamp
# (should be 0) so that the compiled sieve script is newer than
# the source file and Dovecot won't try to compile it.
preStart = ''
rm -rf ${stateDir}/sieve
'' + optionalString (cfg.sieveScripts != {}) ''
mkdir -p ${stateDir}/sieve
${concatStringsSep "\n" (
mapAttrsToList (
to: from: ''
if [ -d '${from}' ]; then
mkdir '${stateDir}/sieve/${to}'
cp -p "${from}/"*.sieve '${stateDir}/sieve/${to}'
else
cp -p '${from}' '${stateDir}/sieve/${to}'
fi
${pkgs.dovecot_pigeonhole}/bin/sievec '${stateDir}/sieve/${to}'
''
) cfg.sieveScripts
)}
chown -R '${cfg.mailUser}:${cfg.mailGroup}' '${stateDir}/sieve'
'';
};
environment.systemPackages = [ dovecotPkg ];
warnings = mkIf (any isList options.services.dovecot2.mailboxes.definitions) [
"Declaring `services.dovecot2.mailboxes' as a list is deprecated and will break eval in 21.05! See the release notes for more info for migration."
];
assertions = [
{
assertion = (cfg.sslServerCert == null) == (cfg.sslServerKey == null)
&& (cfg.sslCACert != null -> !(cfg.sslServerCert == null || cfg.sslServerKey == null));
message = "dovecot needs both sslServerCert and sslServerKey defined for working crypto";
}
{
assertion = cfg.showPAMFailure -> cfg.enablePAM;
message = "dovecot is configured with showPAMFailure while enablePAM is disabled";
}
{
assertion = cfg.sieveScripts != {} -> (cfg.mailUser != null && cfg.mailGroup != null);
message = "dovecot requires mailUser and mailGroup to be set when sieveScripts is set";
}
];
};
}

View file

@ -0,0 +1,150 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.dspam;
dspam = pkgs.dspam;
defaultSock = "/run/dspam/dspam.sock";
cfgfile = pkgs.writeText "dspam.conf" ''
Home /var/lib/dspam
StorageDriver ${dspam}/lib/dspam/lib${cfg.storageDriver}_drv.so
Trust root
Trust ${cfg.user}
SystemLog on
UserLog on
${optionalString (cfg.domainSocket != null) ''
ServerDomainSocketPath "${cfg.domainSocket}"
ClientHost "${cfg.domainSocket}"
''}
${cfg.extraConfig}
'';
in {
###### interface
options = {
services.dspam = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the dspam spam filter.";
};
user = mkOption {
type = types.str;
default = "dspam";
description = "User for the dspam daemon.";
};
group = mkOption {
type = types.str;
default = "dspam";
description = "Group for the dspam daemon.";
};
storageDriver = mkOption {
type = types.str;
default = "hash";
description = "Storage driver backend to use for dspam.";
};
domainSocket = mkOption {
type = types.nullOr types.path;
default = defaultSock;
description = "Path to local domain socket which is used for communication with the daemon. Set to null to disable UNIX socket.";
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Additional dspam configuration.";
};
maintenanceInterval = mkOption {
type = types.nullOr types.str;
default = null;
description = "If set, maintenance script will be run at specified (in systemd.timer format) interval";
};
};
};
###### implementation
config = mkIf cfg.enable (mkMerge [
{
users.users = optionalAttrs (cfg.user == "dspam") {
dspam = {
group = cfg.group;
uid = config.ids.uids.dspam;
};
};
users.groups = optionalAttrs (cfg.group == "dspam") {
dspam.gid = config.ids.gids.dspam;
};
environment.systemPackages = [ dspam ];
environment.etc."dspam/dspam.conf".source = cfgfile;
systemd.services.dspam = {
description = "dspam spam filtering daemon";
wantedBy = [ "multi-user.target" ];
after = [ "postgresql.service" ];
restartTriggers = [ cfgfile ];
serviceConfig = {
ExecStart = "${dspam}/bin/dspam --daemon --nofork";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = optional (cfg.domainSocket == defaultSock) "dspam";
RuntimeDirectoryMode = optional (cfg.domainSocket == defaultSock) "0750";
StateDirectory = "dspam";
StateDirectoryMode = "0750";
LogsDirectory = "dspam";
LogsDirectoryMode = "0750";
# DSPAM segfaults on just about every error
Restart = "on-abort";
RestartSec = "1s";
};
};
}
(mkIf (cfg.maintenanceInterval != null) {
systemd.timers.dspam-maintenance = {
description = "Timer for dspam maintenance script";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.maintenanceInterval;
Unit = "dspam-maintenance.service";
};
};
systemd.services.dspam-maintenance = {
description = "dspam maintenance script";
restartTriggers = [ cfgfile ];
serviceConfig = {
ExecStart = "${dspam}/bin/dspam_maintenance --verbose";
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
};
};
})
]);
}

View file

@ -0,0 +1,132 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) literalExpression mkIf mkOption singleton types;
inherit (pkgs) coreutils;
cfg = config.services.exim;
in
{
###### interface
options = {
services.exim = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the Exim mail transfer agent.";
};
config = mkOption {
type = types.lines;
default = "";
description = ''
Verbatim Exim configuration. This should not contain exim_user,
exim_group, exim_path, or spool_directory.
'';
};
user = mkOption {
type = types.str;
default = "exim";
description = ''
User to use when no root privileges are required.
In particular, this applies when receiving messages and when doing
remote deliveries. (Local deliveries run as various non-root users,
typically as the owner of a local mailbox.) Specifying this value
as root is not supported.
'';
};
group = mkOption {
type = types.str;
default = "exim";
description = ''
Group to use when no root privileges are required.
'';
};
spoolDir = mkOption {
type = types.path;
default = "/var/spool/exim";
description = ''
Location of the spool directory of exim.
'';
};
package = mkOption {
type = types.package;
default = pkgs.exim;
defaultText = literalExpression "pkgs.exim";
description = ''
The Exim derivation to use.
This can be used to enable features such as LDAP or PAM support.
'';
};
queueRunnerInterval = mkOption {
type = types.str;
default = "5m";
description = ''
How often to spawn a new queue runner.
'';
};
};
};
###### implementation
config = mkIf cfg.enable {
environment = {
etc."exim.conf".text = ''
exim_user = ${cfg.user}
exim_group = ${cfg.group}
exim_path = /run/wrappers/bin/exim
spool_directory = ${cfg.spoolDir}
${cfg.config}
'';
systemPackages = [ cfg.package ];
};
users.users.${cfg.user} = {
description = "Exim mail transfer agent user";
uid = config.ids.uids.exim;
group = cfg.group;
};
users.groups.${cfg.group} = {
gid = config.ids.gids.exim;
};
security.wrappers.exim =
{ setuid = true;
owner = "root";
group = "root";
source = "${cfg.package}/bin/exim";
};
systemd.services.exim = {
description = "Exim Mail Daemon";
wantedBy = [ "multi-user.target" ];
restartTriggers = [ config.environment.etc."exim.conf".source ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/exim -bdf -q${cfg.queueRunnerInterval}";
ExecReload = "${coreutils}/bin/kill -HUP $MAINPID";
};
preStart = ''
if ! test -d ${cfg.spoolDir}; then
${coreutils}/bin/mkdir -p ${cfg.spoolDir}
${coreutils}/bin/chown ${cfg.user}:${cfg.group} ${cfg.spoolDir}
fi
'';
};
};
}

View file

@ -0,0 +1,273 @@
{ config, lib, pkgs, ... }:
with lib;
let
name = "maddy";
cfg = config.services.maddy;
defaultConfig = ''
# Minimal configuration with TLS disabled, adapted from upstream example
# configuration here https://github.com/foxcpp/maddy/blob/master/maddy.conf
# Do not use this in production!
tls off
auth.pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
storage.imapsql local_mailboxes {
driver sqlite3
dsn imapsql.db
}
table.chain local_rewrites {
optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
optional_step static {
entry postmaster postmaster@$(primary_domain)
}
optional_step file /etc/maddy/aliases
}
msgpipeline local_routing {
destination postmaster $(local_domains) {
modify {
replace_rcpt &local_rewrites
}
deliver_to &local_mailboxes
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
smtp tcp://0.0.0.0:25 {
limits {
all rate 20 1s
all concurrency 10
}
dmarc yes
check {
require_mx_record
dkim
spf
}
source $(local_domains) {
reject 501 5.1.8 "Use Submission for outgoing SMTP"
}
default_source {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
}
submission tcp://0.0.0.0:587 {
limits {
all rate 50 1s
}
auth &local_authdb
source $(local_domains) {
check {
authorize_sender {
prepare_email &local_rewrites
user_to_email identity
}
}
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
modify {
dkim $(primary_domain) $(local_domains) default
}
deliver_to &remote_queue
}
}
default_source {
reject 501 5.1.8 "Non-local sender domain"
}
}
target.remote outbound_delivery {
limits {
destination rate 20 1s
destination concurrency 10
}
mx_auth {
dane
mtasts {
cache fs
fs_dir mtasts_cache/
}
local_policy {
min_tls_level encrypted
min_mx_level none
}
}
}
target.queue remote_queue {
target &outbound_delivery
autogenerated_msg_domain $(primary_domain)
bounce {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
}
}
}
imap tcp://0.0.0.0:143 {
auth &local_authdb
storage &local_mailboxes
}
'';
in {
options = {
services.maddy = {
enable = mkEnableOption "Maddy, a free an open source mail server";
user = mkOption {
default = "maddy";
type = with types; uniq string;
description = ''
User account under which maddy runs.
<note><para>
If left as the default value this user will automatically be created
on system activation, otherwise the sysadmin is responsible for
ensuring the user exists before the maddy service starts.
</para></note>
'';
};
group = mkOption {
default = "maddy";
type = with types; uniq string;
description = ''
Group account under which maddy runs.
<note><para>
If left as the default value this group will automatically be created
on system activation, otherwise the sysadmin is responsible for
ensuring the group exists before the maddy service starts.
</para></note>
'';
};
hostname = mkOption {
default = "localhost";
type = with types; uniq string;
example = ''example.com'';
description = ''
Hostname to use. It should be FQDN.
'';
};
primaryDomain = mkOption {
default = "localhost";
type = with types; uniq string;
example = ''mail.example.com'';
description = ''
Primary MX domain to use. It should be FQDN.
'';
};
localDomains = mkOption {
type = with types; listOf str;
default = ["$(primary_domain)"];
example = [
"$(primary_domain)"
"example.com"
"other.example.com"
];
description = ''
Define list of allowed domains.
'';
};
config = mkOption {
type = with types; nullOr lines;
default = defaultConfig;
description = ''
Server configuration, see
<link xlink:href="https://maddy.email">https://maddy.email</link> for
more information. The default configuration of this module will setup
minimal maddy instance for mail transfer without TLS encryption.
<note><para>
This should not be used in a production environment.
</para></note>
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open the configured incoming and outgoing mail server ports.
'';
};
};
};
config = mkIf cfg.enable {
systemd = {
packages = [ pkgs.maddy ];
services.maddy = {
serviceConfig = {
User = cfg.user;
Group = cfg.group;
StateDirectory = [ "maddy" ];
};
restartTriggers = [ config.environment.etc."maddy/maddy.conf".source ];
wantedBy = [ "multi-user.target" ];
};
};
environment.etc."maddy/maddy.conf" = {
text = ''
$(hostname) = ${cfg.hostname}
$(primary_domain) = ${cfg.primaryDomain}
$(local_domains) = ${toString cfg.localDomains}
hostname ${cfg.hostname}
${cfg.config}
'';
};
users.users = optionalAttrs (cfg.user == name) {
${name} = {
isSystemUser = true;
group = cfg.group;
description = "Maddy mail transfer agent user";
};
};
users.groups = optionalAttrs (cfg.group == name) {
${cfg.group} = { };
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ 25 143 587 ];
};
environment.systemPackages = [
pkgs.maddy
];
};
}

View file

@ -0,0 +1,34 @@
{ config, options, lib, ... }:
with lib;
{
###### interface
options = {
services.mail = {
sendmailSetuidWrapper = mkOption {
type = types.nullOr options.security.wrappers.type.nestedTypes.elemType;
default = null;
internal = true;
description = ''
Configuration for the sendmail setuid wapper.
'';
};
};
};
###### implementation
config = mkIf (config.services.mail.sendmailSetuidWrapper != null) {
security.wrappers.sendmail = config.services.mail.sendmailSetuidWrapper;
};
}

View file

@ -0,0 +1,68 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.mailcatcher;
inherit (lib) mkEnableOption mkIf mkOption types optionalString;
in
{
# interface
options = {
services.mailcatcher = {
enable = mkEnableOption "MailCatcher";
http.ip = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The ip address of the http server.";
};
http.port = mkOption {
type = types.port;
default = 1080;
description = "The port address of the http server.";
};
http.path = mkOption {
type = with types; nullOr str;
default = null;
description = "Prefix to all HTTP paths.";
example = "/mailcatcher";
};
smtp.ip = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The ip address of the smtp server.";
};
smtp.port = mkOption {
type = types.port;
default = 1025;
description = "The port address of the smtp server.";
};
};
};
# implementation
config = mkIf cfg.enable {
environment.systemPackages = [ pkgs.mailcatcher ];
systemd.services.mailcatcher = {
description = "MailCatcher Service";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
Restart = "always";
ExecStart = "${pkgs.mailcatcher}/bin/mailcatcher --foreground --no-quit --http-ip ${cfg.http.ip} --http-port ${toString cfg.http.port} --smtp-ip ${cfg.smtp.ip} --smtp-port ${toString cfg.smtp.port}" + optionalString (cfg.http.path != null) " --http-path ${cfg.http.path}";
AmbientCapabilities = optionalString (cfg.http.port < 1024 || cfg.smtp.port < 1024) "cap_net_bind_service";
};
};
};
}

View file

@ -0,0 +1,82 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.mailhog;
args = lib.concatStringsSep " " (
[
"-api-bind-addr :${toString cfg.apiPort}"
"-smtp-bind-addr :${toString cfg.smtpPort}"
"-ui-bind-addr :${toString cfg.uiPort}"
"-storage ${cfg.storage}"
] ++ lib.optional (cfg.storage == "maildir")
"-maildir-path $STATE_DIRECTORY"
++ cfg.extraArgs
);
in
{
###### interface
imports = [
(mkRemovedOptionModule [ "services" "mailhog" "user" ] "")
];
options = {
services.mailhog = {
enable = mkEnableOption "MailHog";
storage = mkOption {
type = types.enum [ "maildir" "memory" ];
default = "memory";
description = "Store mails on disk or in memory.";
};
apiPort = mkOption {
type = types.port;
default = 8025;
description = "Port on which the API endpoint will listen.";
};
smtpPort = mkOption {
type = types.port;
default = 1025;
description = "Port on which the SMTP endpoint will listen.";
};
uiPort = mkOption {
type = types.port;
default = 8025;
description = "Port on which the HTTP UI will listen.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [];
description = "List of additional arguments to pass to the MailHog process.";
};
};
};
###### implementation
config = mkIf cfg.enable {
systemd.services.mailhog = {
description = "MailHog - Web and API based SMTP testing";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "exec";
ExecStart = "${pkgs.mailhog}/bin/MailHog ${args}";
DynamicUser = true;
Restart = "on-failure";
StateDirectory = "mailhog";
};
};
};
}

View file

@ -0,0 +1,473 @@
{ config, pkgs, lib, ... }: # mailman.nix
with lib;
let
cfg = config.services.mailman;
inherit (pkgs.mailmanPackages.buildEnvs { withHyperkitty = cfg.hyperkitty.enable; })
mailmanEnv webEnv;
withPostgresql = config.services.postgresql.enable;
# This deliberately doesn't use recursiveUpdate so users can
# override the defaults.
webSettings = {
DEFAULT_FROM_EMAIL = cfg.siteOwner;
SERVER_EMAIL = cfg.siteOwner;
ALLOWED_HOSTS = [ "localhost" "127.0.0.1" ] ++ cfg.webHosts;
COMPRESS_OFFLINE = true;
STATIC_ROOT = "/var/lib/mailman-web-static";
MEDIA_ROOT = "/var/lib/mailman-web/media";
LOGGING = {
version = 1;
disable_existing_loggers = true;
handlers.console.class = "logging.StreamHandler";
loggers.django = {
handlers = [ "console" ];
level = "INFO";
};
};
HAYSTACK_CONNECTIONS.default = {
ENGINE = "haystack.backends.whoosh_backend.WhooshEngine";
PATH = "/var/lib/mailman-web/fulltext-index";
};
} // cfg.webSettings;
webSettingsJSON = pkgs.writeText "settings.json" (builtins.toJSON webSettings);
# TODO: Should this be RFC42-ised so that users can set additional options without modifying the module?
postfixMtaConfig = pkgs.writeText "mailman-postfix.cfg" ''
[postfix]
postmap_command: ${pkgs.postfix}/bin/postmap
transport_file_type: hash
'';
mailmanCfg = lib.generators.toINI {} cfg.settings;
mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" ''
[general]
# This is your HyperKitty installation, preferably on the localhost. This
# address will be used by Mailman to forward incoming emails to HyperKitty
# for archiving. It does not need to be publicly available, in fact it's
# better if it is not.
base_url: ${cfg.hyperkitty.baseUrl}
# Shared API key, must be the identical to the value in HyperKitty's
# settings.
api_key: @API_KEY@
'';
in {
###### interface
imports = [
(mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ]
[ "services" "mailman" "hyperkitty" "baseUrl" ])
(mkRemovedOptionModule [ "services" "mailman" "hyperkittyApiKey" ] ''
The Hyperkitty API key is now generated on first run, and not
stored in the world-readable Nix store. To continue using
Hyperkitty, you must set services.mailman.hyperkitty.enable = true.
'')
(mkRemovedOptionModule [ "services" "mailman" "package" ] ''
Didn't have an effect for several years.
'')
];
options = {
services.mailman = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
};
enablePostfix = mkOption {
type = types.bool;
default = true;
example = false;
description = ''
Enable Postfix integration. Requires an active Postfix installation.
If you want to use another MTA, set this option to false and configure
settings in services.mailman.settings.mta.
Refer to the Mailman manual for more info.
'';
};
siteOwner = mkOption {
type = types.str;
example = "postmaster@example.org";
description = ''
Certain messages that must be delivered to a human, but which can't
be delivered to a list owner (e.g. a bounce from a list owner), will
be sent to this address. It should point to a human.
'';
};
webHosts = mkOption {
type = types.listOf types.str;
default = [];
description = ''
The list of hostnames and/or IP addresses from which the Mailman Web
UI will accept requests. By default, "localhost" and "127.0.0.1" are
enabled. All additional names under which your web server accepts
requests for the UI must be listed here or incoming requests will be
rejected.
'';
};
webUser = mkOption {
type = types.str;
default = "mailman-web";
description = ''
User to run mailman-web as
'';
};
webSettings = mkOption {
type = types.attrs;
default = {};
description = ''
Overrides for the default mailman-web Django settings.
'';
};
serve = {
enable = mkEnableOption "Automatic nginx and uwsgi setup for mailman-web";
};
extraPythonPackages = mkOption {
description = "Packages to add to the python environment used by mailman and mailman-web";
type = types.listOf types.package;
default = [];
};
settings = mkOption {
description = "Settings for mailman.cfg";
type = types.attrsOf (types.attrsOf types.str);
default = {};
};
hyperkitty = {
enable = mkEnableOption "the Hyperkitty archiver for Mailman";
baseUrl = mkOption {
type = types.str;
default = "http://localhost:18507/archives/";
description = ''
Where can Mailman connect to Hyperkitty's internal API, preferably on
localhost?
'';
};
};
};
};
###### implementation
config = mkIf cfg.enable {
services.mailman.settings = {
mailman.site_owner = lib.mkDefault cfg.siteOwner;
mailman.layout = "fhs";
"paths.fhs" = {
bin_dir = "${pkgs.mailmanPackages.mailman}/bin";
var_dir = "/var/lib/mailman";
queue_dir = "$var_dir/queue";
template_dir = "$var_dir/templates";
log_dir = "/var/log/mailman";
lock_dir = "$var_dir/lock";
etc_dir = "/etc";
pid_file = "/run/mailman/master.pid";
};
mta.configuration = lib.mkDefault (if cfg.enablePostfix then "${postfixMtaConfig}" else throw "When Mailman Postfix integration is disabled, set `services.mailman.settings.mta.configuration` to the path of the config file required to integrate with your MTA.");
"archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
class = "mailman_hyperkitty.Archiver";
enable = "yes";
configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
};
} // (let
loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"];
loggerSectionNames = map (n: "logging.${n}") loggerNames;
in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; })
);
assertions = let
inherit (config.services) postfix;
requirePostfixHash = optionPath: dataFile:
with lib;
let
expected = "hash:/var/lib/mailman/data/${dataFile}";
value = attrByPath optionPath [] postfix;
in
{ assertion = postfix.enable -> isList value && elem expected value;
message = ''
services.postfix.${concatStringsSep "." optionPath} must contain
"${expected}".
See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
'';
};
in [
{ assertion = cfg.webHosts != [];
message = ''
services.mailman.serve.enable requires there to be at least one entry
in services.mailman.webHosts.
'';
}
] ++ (lib.optionals cfg.enablePostfix [
{ assertion = postfix.enable;
message = ''
Mailman's default NixOS configuration requires Postfix to be enabled.
If you want to use another MTA, set services.mailman.enablePostfix
to false and configure settings in services.mailman.settings.mta.
Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
for more info.
'';
}
(requirePostfixHash [ "relayDomains" ] "postfix_domains")
(requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
(requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
]);
users.users.mailman = {
description = "GNU Mailman";
isSystemUser = true;
group = "mailman";
};
users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
description = "GNU Mailman web interface";
isSystemUser = true;
group = "mailman";
};
users.groups.mailman = {};
environment.etc."mailman.cfg".text = mailmanCfg;
environment.etc."mailman3/settings.py".text = ''
import os
# Required by mailman_web.settings, but will be overridden when
# settings_local.json is loaded.
os.environ["SECRET_KEY"] = ""
from mailman_web.settings.base import *
from mailman_web.settings.mailman import *
import json
with open('${webSettingsJSON}') as f:
globals().update(json.load(f))
with open('/var/lib/mailman-web/settings_local.json') as f:
globals().update(json.load(f))
'';
services.nginx = mkIf (cfg.serve.enable && cfg.webHosts != []) {
enable = mkDefault true;
virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
locations = {
"/".extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
"/static/".alias = webSettings.STATIC_ROOT + "/";
};
});
};
environment.systemPackages = [ (pkgs.buildEnv {
name = "mailman-tools";
# We don't want to pollute the system PATH with a python
# interpreter etc. so let's pick only the stuff we actually
# want from {web,mailman}Env
pathsToLink = ["/bin"];
paths = [ mailmanEnv webEnv ];
# Only mailman-related stuff is installed, the rest is removed
# in `postBuild`.
ignoreCollisions = true;
postBuild = ''
find $out/bin/ -mindepth 1 -not -name "mailman*" -delete
'';
}) ];
services.postfix = lib.mkIf cfg.enablePostfix {
recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP
config = {
owner_request_special = "no"; # Mailman handles -owner addresses on its own
};
};
systemd.sockets.mailman-uwsgi = lib.mkIf cfg.serve.enable {
wantedBy = ["sockets.target"];
before = ["nginx.service"];
socketConfig.ListenStream = "/run/mailman-web.socket";
};
systemd.services = {
mailman = {
description = "GNU Mailman Master Process";
before = lib.optional cfg.enablePostfix "postfix.service";
after = [ "network.target" ]
++ lib.optional cfg.enablePostfix "postfix-setup.service"
++ lib.optional withPostgresql "postgresql.service";
restartTriggers = [ config.environment.etc."mailman.cfg".source ];
requires = optional withPostgresql "postgresql.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${mailmanEnv}/bin/mailman start";
ExecStop = "${mailmanEnv}/bin/mailman stop";
User = "mailman";
Group = "mailman";
Type = "forking";
RuntimeDirectory = "mailman";
LogsDirectory = "mailman";
PIDFile = "/run/mailman/master.pid";
};
};
mailman-settings = {
description = "Generate settings files (including secrets) for Mailman";
before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
path = with pkgs; [ jq ];
after = optional withPostgresql "postgresql.service";
requires = optional withPostgresql "postgresql.service";
serviceConfig.Type = "oneshot";
script = ''
mailmanDir=/var/lib/mailman
mailmanWebDir=/var/lib/mailman-web
mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
mailmanWebCfg=$mailmanWebDir/settings_local.json
install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static
install -m 0770 -o mailman -g mailman -d $mailmanDir
install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir
if [ ! -e $mailmanWebCfg ]; then
hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
mailmanWebCfgTmp=$(mktemp)
jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
--arg archiver_key "$hyperkittyApiKey" \
--arg secret_key "$secretKey" \
>"$mailmanWebCfgTmp"
chown root:mailman "$mailmanWebCfgTmp"
chmod 440 "$mailmanWebCfgTmp"
mv -n "$mailmanWebCfgTmp" "$mailmanWebCfg"
fi
hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")"
mailmanCfgTmp=$(mktemp)
sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp"
chown mailman:mailman "$mailmanCfgTmp"
mv "$mailmanCfgTmp" "$mailmanCfg"
'';
};
mailman-web-setup = {
description = "Prepare mailman-web files and database";
before = [ "mailman-uwsgi.service" ];
requiredBy = [ "mailman-uwsgi.service" ];
restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
script = ''
[[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete
${webEnv}/bin/mailman-web migrate
${webEnv}/bin/mailman-web collectstatic
${webEnv}/bin/mailman-web compress
'';
serviceConfig = {
User = cfg.webUser;
Group = "mailman";
Type = "oneshot";
WorkingDirectory = "/var/lib/mailman-web";
};
};
mailman-uwsgi = mkIf cfg.serve.enable (let
uwsgiConfig.uwsgi = {
type = "normal";
plugins = ["python3"];
home = webEnv;
module = "mailman_web.wsgi";
http = "127.0.0.1:18507";
};
uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
in {
wantedBy = ["multi-user.target"];
after = optional withPostgresql "postgresql.service";
requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"]
++ optional withPostgresql "postgresql.service";
restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
serviceConfig = {
# Since the mailman-web settings.py obstinately creates a logs
# dir in the cwd, change to the (writable) runtime directory before
# starting uwsgi.
ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; }}/bin/uwsgi --json ${uwsgiConfigFile}";
User = cfg.webUser;
Group = "mailman";
RuntimeDirectory = "mailman-uwsgi";
};
});
mailman-daily = {
description = "Trigger daily Mailman events";
startAt = "daily";
restartTriggers = [ config.environment.etc."mailman.cfg".source ];
serviceConfig = {
ExecStart = "${mailmanEnv}/bin/mailman digests --send";
User = "mailman";
Group = "mailman";
};
};
hyperkitty = lib.mkIf cfg.hyperkitty.enable {
description = "GNU Hyperkitty QCluster Process";
after = [ "network.target" ];
restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
wantedBy = [ "mailman.service" "multi-user.target" ];
serviceConfig = {
ExecStart = "${webEnv}/bin/mailman-web qcluster";
User = cfg.webUser;
Group = "mailman";
WorkingDirectory = "/var/lib/mailman-web";
};
};
} // flip lib.mapAttrs' {
"minutely" = "minutely";
"quarter_hourly" = "*:00/15";
"hourly" = "hourly";
"daily" = "daily";
"weekly" = "weekly";
"yearly" = "yearly";
} (name: startAt:
lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
description = "Trigger ${name} Hyperkitty events";
inherit startAt;
restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
serviceConfig = {
ExecStart = "${webEnv}/bin/mailman-web runjobs ${name}";
User = cfg.webUser;
Group = "mailman";
WorkingDirectory = "/var/lib/mailman-web";
};
}));
};
meta = {
maintainers = with lib.maintainers; [ lheckemann qyliss ma27 ];
doc = ./mailman.xml;
};
}

View file

@ -0,0 +1,94 @@
<chapter xmlns="http://docbook.org/ns/docbook"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xi="http://www.w3.org/2001/XInclude"
version="5.0"
xml:id="module-services-mailman">
<title>Mailman</title>
<para>
<link xlink:href="https://www.list.org">Mailman</link> is free
software for managing electronic mail discussion and e-newsletter
lists. Mailman and its web interface can be configured using the
corresponding NixOS module. Note that this service is best used with
an existing, securely configured Postfix setup, as it does not automatically configure this.
</para>
<section xml:id="module-services-mailman-basic-usage">
<title>Basic usage with Postfix</title>
<para>
For a basic configuration with Postfix as the MTA, the following settings are suggested:
<programlisting>{ config, ... }: {
services.postfix = {
enable = true;
relayDomains = ["hash:/var/lib/mailman/data/postfix_domains"];
sslCert = config.security.acme.certs."lists.example.org".directory + "/full.pem";
sslKey = config.security.acme.certs."lists.example.org".directory + "/key.pem";
config = {
transport_maps = ["hash:/var/lib/mailman/data/postfix_lmtp"];
local_recipient_maps = ["hash:/var/lib/mailman/data/postfix_lmtp"];
};
};
services.mailman = {
<link linkend="opt-services.mailman.enable">enable</link> = true;
<link linkend="opt-services.mailman.serve.enable">serve.enable</link> = true;
<link linkend="opt-services.mailman.hyperkitty.enable">hyperkitty.enable</link> = true;
<link linkend="opt-services.mailman.webHosts">webHosts</link> = ["lists.example.org"];
<link linkend="opt-services.mailman.siteOwner">siteOwner</link> = "mailman@example.org";
};
<link linkend="opt-services.nginx.virtualHosts._name_.enableACME">services.nginx.virtualHosts."lists.example.org".enableACME</link> = true;
<link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 25 80 443 ];
}</programlisting>
</para>
<para>
DNS records will also be required:
<itemizedlist>
<listitem><para><literal>AAAA</literal> and <literal>A</literal> records pointing to the host in question, in order for browsers to be able to discover the address of the web server;</para></listitem>
<listitem><para>An <literal>MX</literal> record pointing to a domain name at which the host is reachable, in order for other mail servers to be able to deliver emails to the mailing lists it hosts.</para></listitem>
</itemizedlist>
</para>
<para>
After this has been done and appropriate DNS records have been
set up, the Postorius mailing list manager and the Hyperkitty
archive browser will be available at
https://lists.example.org/. Note that this setup is not
sufficient to deliver emails to most email providers nor to
avoid spam -- a number of additional measures for authenticating
incoming and outgoing mails, such as SPF, DMARC and DKIM are
necessary, but outside the scope of the Mailman module.
</para>
</section>
<section xml:id="module-services-mailman-other-mtas">
<title>Using with other MTAs</title>
<para>
Mailman also supports other MTA, though with a little bit more configuration. For example, to use Mailman with Exim, you can use the following settings:
<programlisting>{ config, ... }: {
services = {
mailman = {
enable = true;
siteOwner = "mailman@example.org";
<link linkend="opt-services.mailman.enablePostfix">enablePostfix</link> = false;
settings.mta = {
incoming = "mailman.mta.exim4.LMTP";
outgoing = "mailman.mta.deliver.deliver";
lmtp_host = "localhost";
lmtp_port = "8024";
smtp_host = "localhost";
smtp_port = "25";
configuration = "python:mailman.config.exim4";
};
};
exim = {
enable = true;
# You can configure Exim in a separate file to reduce configuration.nix clutter
config = builtins.readFile ./exim.conf;
};
};
}</programlisting>
</para>
<para>
The exim config needs some special additions to work with Mailman. Currently
NixOS can't manage Exim config with such granularity. Please refer to
<link xlink:href="https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html">Mailman documentation</link>
for more info on configuring Mailman for working with Exim.
</para>
</section>
</chapter>

View file

@ -0,0 +1,171 @@
{ config, lib, pkgs, ... }:
with lib;
let
concatMapLines = f: l: lib.concatStringsSep "\n" (map f l);
cfg = config.services.mlmmj;
stateDir = "/var/lib/mlmmj";
spoolDir = "/var/spool/mlmmj";
listDir = domain: list: "${spoolDir}/${domain}/${list}";
listCtl = domain: list: "${listDir domain list}/control";
transport = domain: list: "${domain}--${list}@local.list.mlmmj mlmmj:${domain}/${list}";
virtual = domain: list: "${list}@${domain} ${domain}--${list}@local.list.mlmmj";
alias = domain: list: "${list}: \"|${pkgs.mlmmj}/bin/mlmmj-receive -L ${listDir domain list}/\"";
subjectPrefix = list: "[${list}]";
listAddress = domain: list: "${list}@${domain}";
customHeaders = domain: list: [
"List-Id: ${list}"
"Reply-To: ${list}@${domain}"
"List-Post: <mailto:${list}@${domain}>"
"List-Help: <mailto:${list}+help@${domain}>"
"List-Subscribe: <mailto:${list}+subscribe@${domain}>"
"List-Unsubscribe: <mailto:${list}+unsubscribe@${domain}>"
];
footer = domain: list: "To unsubscribe send a mail to ${list}+unsubscribe@${domain}";
createList = d: l:
let ctlDir = listCtl d l; in
''
for DIR in incoming queue queue/discarded archive text subconf unsubconf \
bounce control moderation subscribers.d digesters.d requeue \
nomailsubs.d
do
mkdir -p '${listDir d l}'/"$DIR"
done
${pkgs.coreutils}/bin/mkdir -p ${ctlDir}
echo ${listAddress d l} > '${ctlDir}/listaddress'
[ ! -e ${ctlDir}/customheaders ] && \
echo "${lib.concatStringsSep "\n" (customHeaders d l)}" > '${ctlDir}/customheaders'
[ ! -e ${ctlDir}/footer ] && \
echo ${footer d l} > '${ctlDir}/footer'
[ ! -e ${ctlDir}/prefix ] && \
echo ${subjectPrefix l} > '${ctlDir}/prefix'
'';
in
{
###### interface
options = {
services.mlmmj = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable mlmmj";
};
user = mkOption {
type = types.str;
default = "mlmmj";
description = "mailinglist local user";
};
group = mkOption {
type = types.str;
default = "mlmmj";
description = "mailinglist local group";
};
listDomain = mkOption {
type = types.str;
default = "localhost";
description = "Set the mailing list domain";
};
mailLists = mkOption {
type = types.listOf types.str;
default = [];
description = "The collection of hosted maillists";
};
maintInterval = mkOption {
type = types.str;
default = "20min";
description = ''
Time interval between mlmmj-maintd runs, see
<citerefentry><refentrytitle>systemd.time</refentrytitle>
<manvolnum>7</manvolnum></citerefentry> for format information.
'';
};
};
};
###### implementation
config = mkIf cfg.enable {
users.users.${cfg.user} = {
description = "mlmmj user";
home = stateDir;
createHome = true;
uid = config.ids.uids.mlmmj;
group = cfg.group;
useDefaultShell = true;
};
users.groups.${cfg.group} = {
gid = config.ids.gids.mlmmj;
};
services.postfix = {
enable = true;
recipientDelimiter= "+";
masterConfig.mlmmj = {
type = "unix";
private = true;
privileged = true;
chroot = false;
wakeup = 0;
command = "pipe";
args = [
"flags=ORhu"
"user=mlmmj"
"argv=${pkgs.mlmmj}/bin/mlmmj-receive"
"-F"
"-L"
"${spoolDir}/$nexthop"
];
};
extraAliases = concatMapLines (alias cfg.listDomain) cfg.mailLists;
extraConfig = "propagate_unmatched_extensions = virtual";
virtual = concatMapLines (virtual cfg.listDomain) cfg.mailLists;
transport = concatMapLines (transport cfg.listDomain) cfg.mailLists;
};
environment.systemPackages = [ pkgs.mlmmj ];
system.activationScripts.mlmmj = ''
${pkgs.coreutils}/bin/mkdir -p ${stateDir} ${spoolDir}/${cfg.listDomain}
${pkgs.coreutils}/bin/chown -R ${cfg.user}:${cfg.group} ${spoolDir}
${concatMapLines (createList cfg.listDomain) cfg.mailLists}
${pkgs.postfix}/bin/postmap /etc/postfix/virtual
${pkgs.postfix}/bin/postmap /etc/postfix/transport
'';
systemd.services.mlmmj-maintd = {
description = "mlmmj maintenance daemon";
serviceConfig = {
User = cfg.user;
Group = cfg.group;
ExecStart = "${pkgs.mlmmj}/bin/mlmmj-maintd -F -d ${spoolDir}/${cfg.listDomain}";
};
};
systemd.timers.mlmmj-maintd = {
description = "mlmmj maintenance timer";
timerConfig.OnUnitActiveSec = cfg.maintInterval;
wantedBy = [ "timers.target" ];
};
};
}

View file

@ -0,0 +1,244 @@
{ config, lib, pkgs, ... }:
with lib;
{
options = {
services.nullmailer = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable nullmailer daemon.";
};
user = mkOption {
type = types.str;
default = "nullmailer";
description = ''
User to use to run nullmailer-send.
'';
};
group = mkOption {
type = types.str;
default = "nullmailer";
description = ''
Group to use to run nullmailer-send.
'';
};
setSendmail = mkOption {
type = types.bool;
default = true;
description = "Whether to set the system sendmail to nullmailer's.";
};
remotesFile = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Path to the <code>remotes</code> control file. This file contains a
list of remote servers to which to send each message.
See <code>man 8 nullmailer-send</code> for syntax and available
options.
'';
};
config = {
adminaddr = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If set, all recipients to users at either "localhost" (the literal string)
or the canonical host name (from the me control attribute) are remapped to this address.
This is provided to allow local daemons to be able to send email to
"somebody@localhost" and have it go somewhere sensible instead of being bounced
by your relay host. To send to multiple addresses,
put them all on one line separated by a comma.
'';
};
allmailfrom = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If set, content will override the envelope sender on all messages.
'';
};
defaultdomain = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The content of this attribute is appended to any host name that
does not contain a period (except localhost), including defaulthost
and idhost. Defaults to the value of the me attribute, if it exists,
otherwise the literal name defauldomain.
'';
};
defaulthost = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The content of this attribute is appended to any address that
is missing a host name. Defaults to the value of the me control
attribute, if it exists, otherwise the literal name defaulthost.
'';
};
doublebounceto = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If the original sender was empty (the original message was a
delivery status or disposition notification), the double bounce
is sent to the address in this attribute.
'';
};
helohost = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Sets the environment variable $HELOHOST which is used by the
SMTP protocol module to set the parameter given to the HELO command.
Defaults to the value of the me configuration attribute.
'';
};
idhost = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The content of this attribute is used when building the message-id
string for the message. Defaults to the canonicalized value of defaulthost.
'';
};
maxpause = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The maximum time to pause between successive queue runs, in seconds.
Defaults to 24 hours (86400).
'';
};
me = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The fully-qualifiled host name of the computer running nullmailer.
Defaults to the literal name me.
'';
};
pausetime = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The minimum time to pause between successive queue runs when there
are messages in the queue, in seconds. Defaults to 1 minute (60).
Each time this timeout is reached, the timeout is doubled to a
maximum of maxpause. After new messages are injected, the timeout
is reset. If this is set to 0, nullmailer-send will exit
immediately after going through the queue once (one-shot mode).
'';
};
remotes = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
A list of remote servers to which to send each message. Each line
contains a remote host name or address followed by an optional
protocol string, separated by white space.
See <code>man 8 nullmailer-send</code> for syntax and available
options.
WARNING: This is stored world-readable in the nix store. If you need
to specify any secret credentials here, consider using the
<code>remotesFile</code> option instead.
'';
};
sendtimeout = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The time to wait for a remote module listed above to complete sending
a message before killing it and trying again, in seconds.
Defaults to 1 hour (3600). If this is set to 0, nullmailer-send
will wait forever for messages to complete sending.
'';
};
};
};
};
config = let
cfg = config.services.nullmailer;
in mkIf cfg.enable {
assertions = [
{ assertion = cfg.config.remotes == null || cfg.remotesFile == null;
message = "Only one of `remotesFile` or `config.remotes` may be used at a time.";
}
];
environment = {
systemPackages = [ pkgs.nullmailer ];
etc = let
validAttrs = filterAttrs (name: value: value != null) cfg.config;
in
(foldl' (as: name: as // { "nullmailer/${name}".text = validAttrs.${name}; }) {} (attrNames validAttrs))
// optionalAttrs (cfg.remotesFile != null) { "nullmailer/remotes".source = cfg.remotesFile; };
};
users = {
users.${cfg.user} = {
description = "Nullmailer relay-only mta user";
group = cfg.group;
isSystemUser = true;
};
groups.${cfg.group} = { };
};
systemd.tmpfiles.rules = [
"d /var/spool/nullmailer - ${cfg.user} - - -"
];
systemd.services.nullmailer = {
description = "nullmailer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart = ''
mkdir -p /var/spool/nullmailer/{queue,tmp,failed}
rm -f /var/spool/nullmailer/trigger && mkfifo -m 660 /var/spool/nullmailer/trigger
'';
serviceConfig = {
User = cfg.user;
Group = cfg.group;
ExecStart = "${pkgs.nullmailer}/bin/nullmailer-send";
Restart = "always";
};
};
services.mail.sendmailSetuidWrapper = mkIf cfg.setSendmail {
program = "sendmail";
source = "${pkgs.nullmailer}/bin/sendmail";
owner = cfg.user;
group = cfg.group;
setuid = true;
setgid = true;
};
};
}

View file

@ -0,0 +1,72 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.offlineimap;
in {
options.services.offlineimap = {
enable = mkEnableOption "OfflineIMAP, a software to dispose your mailbox(es) as a local Maildir(s)";
install = mkOption {
type = types.bool;
default = false;
description = ''
Whether to install a user service for Offlineimap. Once
the service is started, emails will be fetched automatically.
The service must be manually started for each user with
"systemctl --user start offlineimap" or globally through
<varname>services.offlineimap.enable</varname>.
'';
};
package = mkOption {
type = types.package;
default = pkgs.offlineimap;
defaultText = literalExpression "pkgs.offlineimap";
description = "Offlineimap derivation to use.";
};
path = mkOption {
type = types.listOf types.path;
default = [];
example = literalExpression "[ pkgs.pass pkgs.bash pkgs.notmuch ]";
description = "List of derivations to put in Offlineimap's path.";
};
onCalendar = mkOption {
type = types.str;
default = "*:0/3"; # every 3 minutes
description = "How often is offlineimap started. Default is '*:0/3' meaning every 3 minutes. See systemd.time(7) for more information about the format.";
};
timeoutStartSec = mkOption {
type = types.str;
default = "120sec"; # Kill if still alive after 2 minutes
description = "How long waiting for offlineimap before killing it. Default is '120sec' meaning every 2 minutes. See systemd.time(7) for more information about the format.";
};
};
config = mkIf (cfg.enable || cfg.install) {
systemd.user.services.offlineimap = {
description = "Offlineimap: a software to dispose your mailbox(es) as a local Maildir(s)";
serviceConfig = {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/offlineimap -u syslog -o -1";
TimeoutStartSec = cfg.timeoutStartSec;
};
path = cfg.path;
};
environment.systemPackages = [ cfg.package ];
systemd.user.timers.offlineimap = {
description = "offlineimap timer";
timerConfig = {
Unit = "offlineimap.service";
OnCalendar = cfg.onCalendar;
# start immediately after computer is started:
Persistent = "true";
};
} // optionalAttrs cfg.enable { wantedBy = [ "default.target" ]; };
};
}

View file

@ -0,0 +1,167 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.opendkim;
defaultSock = "local:/run/opendkim/opendkim.sock";
keyFile = "${cfg.keyPath}/${cfg.selector}.private";
args = [ "-f" "-l"
"-p" cfg.socket
"-d" cfg.domains
"-k" keyFile
"-s" cfg.selector
] ++ optionals (cfg.configFile != null) [ "-x" cfg.configFile ];
in {
imports = [
(mkRenamedOptionModule [ "services" "opendkim" "keyFile" ] [ "services" "opendkim" "keyPath" ])
];
###### interface
options = {
services.opendkim = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the OpenDKIM sender authentication system.";
};
socket = mkOption {
type = types.str;
default = defaultSock;
description = "Socket which is used for communication with OpenDKIM.";
};
user = mkOption {
type = types.str;
default = "opendkim";
description = "User for the daemon.";
};
group = mkOption {
type = types.str;
default = "opendkim";
description = "Group for the daemon.";
};
domains = mkOption {
type = types.str;
default = "csl:${config.networking.hostName}";
defaultText = literalExpression ''"csl:''${config.networking.hostName}"'';
example = "csl:example.com,mydomain.net";
description = ''
Local domains set (see <literal>opendkim(8)</literal> for more information on datasets).
Messages from them are signed, not verified.
'';
};
keyPath = mkOption {
type = types.path;
description = ''
The path that opendkim should put its generated private keys into.
The DNS settings will be found in this directory with the name selector.txt.
'';
default = "/var/lib/opendkim/keys";
};
selector = mkOption {
type = types.str;
description = "Selector to use when signing.";
};
configFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Additional opendkim configuration.";
};
};
};
###### implementation
config = mkIf cfg.enable {
users.users = optionalAttrs (cfg.user == "opendkim") {
opendkim = {
group = cfg.group;
uid = config.ids.uids.opendkim;
};
};
users.groups = optionalAttrs (cfg.group == "opendkim") {
opendkim.gid = config.ids.gids.opendkim;
};
environment.systemPackages = [ pkgs.opendkim ];
systemd.tmpfiles.rules = [
"d '${cfg.keyPath}' - ${cfg.user} ${cfg.group} - -"
];
systemd.services.opendkim = {
description = "OpenDKIM signing and verification daemon";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
preStart = ''
cd "${cfg.keyPath}"
if ! test -f ${cfg.selector}.private; then
${pkgs.opendkim}/bin/opendkim-genkey -s ${cfg.selector} -d all-domains-generic-key
echo "Generated OpenDKIM key! Please update your DNS settings:\n"
echo "-------------------------------------------------------------"
cat ${cfg.selector}.txt
echo "-------------------------------------------------------------"
fi
'';
serviceConfig = {
ExecStart = "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = optional (cfg.socket == defaultSock) "opendkim";
StateDirectory = "opendkim";
StateDirectoryMode = "0700";
ReadWritePaths = [ cfg.keyPath ];
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6 AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged @resources" ];
UMask = "0077";
};
};
};
}

View file

@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.opensmtpd;
conf = pkgs.writeText "smtpd.conf" cfg.serverConfiguration;
args = concatStringsSep " " cfg.extraServerArgs;
sendmail = pkgs.runCommand "opensmtpd-sendmail" { preferLocalBuild = true; } ''
mkdir -p $out/bin
ln -s ${cfg.package}/sbin/smtpctl $out/bin/sendmail
'';
in {
###### interface
imports = [
(mkRenamedOptionModule [ "services" "opensmtpd" "addSendmailToSystemPath" ] [ "services" "opensmtpd" "setSendmail" ])
];
options = {
services.opensmtpd = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the OpenSMTPD server.";
};
package = mkOption {
type = types.package;
default = pkgs.opensmtpd;
defaultText = literalExpression "pkgs.opensmtpd";
description = "The OpenSMTPD package to use.";
};
setSendmail = mkOption {
type = types.bool;
default = true;
description = "Whether to set the system sendmail to OpenSMTPD's.";
};
extraServerArgs = mkOption {
type = types.listOf types.str;
default = [];
example = [ "-v" "-P mta" ];
description = ''
Extra command line arguments provided when the smtpd process
is started.
'';
};
serverConfiguration = mkOption {
type = types.lines;
example = ''
listen on lo
accept for any deliver to lmtp localhost:24
'';
description = ''
The contents of the smtpd.conf configuration file. See the
OpenSMTPD documentation for syntax information.
'';
};
procPackages = mkOption {
type = types.listOf types.package;
default = [];
description = ''
Packages to search for filters, tables, queues, and schedulers.
Add OpenSMTPD-extras here if you want to use the filters, etc. from
that package.
'';
};
};
};
###### implementation
config = mkIf cfg.enable rec {
users.groups = {
smtpd.gid = config.ids.gids.smtpd;
smtpq.gid = config.ids.gids.smtpq;
};
users.users = {
smtpd = {
description = "OpenSMTPD process user";
uid = config.ids.uids.smtpd;
group = "smtpd";
};
smtpq = {
description = "OpenSMTPD queue user";
uid = config.ids.uids.smtpq;
group = "smtpq";
};
};
security.wrappers.smtpctl = {
owner = "root";
group = "smtpq";
setuid = false;
setgid = true;
source = "${cfg.package}/bin/smtpctl";
};
services.mail.sendmailSetuidWrapper = mkIf cfg.setSendmail
(security.wrappers.smtpctl // { program = "sendmail"; });
systemd.tmpfiles.rules = [
"d /var/spool/smtpd 711 root - - -"
"d /var/spool/smtpd/offline 770 root smtpq - -"
"d /var/spool/smtpd/purge 700 smtpq root - -"
];
systemd.services.opensmtpd = let
procEnv = pkgs.buildEnv {
name = "opensmtpd-procs";
paths = [ cfg.package ] ++ cfg.procPackages;
pathsToLink = [ "/libexec/opensmtpd" ];
};
in {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig.ExecStart = "${cfg.package}/sbin/smtpd -d -f ${conf} ${args}";
environment.OPENSMTPD_PROC_PATH = "${procEnv}/libexec/opensmtpd";
};
};
}

View file

@ -0,0 +1,56 @@
{ config, lib, pkgs, ... }:
with lib;
{
###### interface
options = {
services.pfix-srsd = {
enable = mkOption {
default = false;
type = types.bool;
description = "Whether to run the postfix sender rewriting scheme daemon.";
};
domain = mkOption {
description = "The domain for which to enable srs";
type = types.str;
example = "example.com";
};
secretsFile = mkOption {
description = ''
The secret data used to encode the SRS address.
to generate, use a command like:
<literal>for n in $(seq 5); do dd if=/dev/urandom count=1 bs=1024 status=none | sha256sum | sed 's/ -$//' | sed 's/^/ /'; done</literal>
'';
type = types.path;
default = "/var/lib/pfix-srsd/secrets";
};
};
};
###### implementation
config = mkIf config.services.pfix-srsd.enable {
environment = {
systemPackages = [ pkgs.pfixtools ];
};
systemd.services.pfix-srsd = {
description = "Postfix sender rewriting scheme daemon";
before = [ "postfix.service" ];
#note that we use requires rather than wants because postfix
#is unable to process (almost) all mail without srsd
requiredBy = [ "postfix.service" ];
serviceConfig = {
Type = "forking";
PIDFile = "/run/pfix-srsd.pid";
ExecStart = "${pkgs.pfixtools}/bin/pfix-srsd -p /run/pfix-srsd.pid -I ${config.services.pfix-srsd.domain} ${config.services.pfix-srsd.secretsFile}";
};
};
};
}

View file

@ -0,0 +1,994 @@
{ 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 != "";
haveCanonical = cfg.canonical != "";
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" "unix-dgram" "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 foldr 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 = foldr (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;
canonicalFile = pkgs.writeText "postfix-canonical" cfg.canonical;
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 {
type = types.bool;
default = true;
description = "Whether to enable smtp in master.cf.";
};
enableSubmission = mkOption {
type = types.bool;
default = false;
description = "Whether to enable smtp submission.";
};
enableSubmissions = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable smtp submission via smtps.
According to RFC 8314 this should be preferred
over STARTTLS for submission of messages by end user clients.
'';
};
submissionOptions = mkOption {
type = with types; attrsOf str;
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";
};
submissionsOptions = mkOption {
type = with types; attrsOf str;
default = {
smtpd_sasl_auth_enable = "yes";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
milter_macro_daemon_name = "ORIGINATING";
};
example = {
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 via smtps in master.cf.
smtpd_tls_security_level will be set to encrypt, if it is missing
or has one of the values "may" or "none".
smtpd_tls_wrappermode with value "yes" will be added automatically.
'';
};
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 (oneOf [ bool str (listOf str) ]);
description = ''
The main.cf configuration file as key value set.
'';
example = {
mail_owner = "postfix";
smtp_tls_security_level = "may";
};
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "
Extra lines to be added verbatim to the main.cf configuration file.
";
};
tlsTrustedAuthorities = mkOption {
type = types.str;
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
defaultText = literalExpression ''"''${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"'';
description = ''
File containing trusted certification authorities (CA) to verify certificates of mailservers contacted for mail delivery. This basically sets smtp_tls_CAfile and enables opportunistic tls. Defaults to NixOS trusted certification authorities.
'';
};
sslCert = mkOption {
type = types.str;
default = "";
description = "SSL certificate to use.";
};
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
";
};
canonical = mkOption {
type = types.lines;
default = "";
description = ''
Entries for the <citerefentry><refentrytitle>canonical</refentrytitle>
<manvolnum>5</manvolnum></citerefentry> table.
'';
};
virtual = mkOption {
type = types.lines;
default = "";
description = "
Entries for the virtual alias map, cf. man-page virtual(5).
";
};
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 str);
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 = "";
type = types.lines;
description = "
Entries for the transport map, cf. man-page transport(8).
";
};
dnsBlacklists = mkOption {
default = [];
type = with types; listOf str;
description = "dns blacklist servers to use with smtpd_client_restrictions";
};
dnsBlacklistOverrides = mkOption {
default = "";
type = types.lines;
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.postfix.source = "/var/lib/postfix/conf";
# 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";
owner = "root";
group = setgidGroup;
setuid = false;
setgid = true;
};
security.wrappers.mailq = {
program = "mailq";
source = "${pkgs.postfix}/bin/mailq";
owner = "root";
group = setgidGroup;
setuid = false;
setgid = true;
};
security.wrappers.postqueue = {
program = "postqueue";
source = "${pkgs.postfix}/bin/postqueue";
owner = "root";
group = setgidGroup;
setuid = false;
setgid = true;
};
security.wrappers.postdrop = {
program = "postdrop";
source = "${pkgs.postfix}/bin/postdrop";
owner = "root";
group = setgidGroup;
setuid = false;
setgid = true;
};
users.users = optionalAttrs (user == "postfix")
{ postfix = {
description = "Postfix mail server user";
uid = config.ids.uids.postfix;
group = group;
};
};
users.groups =
optionalAttrs (group == "postfix")
{ ${group}.gid = config.ids.gids.postfix;
}
// optionalAttrs (setgidGroup == "postdrop")
{ ${setgidGroup}.gid = config.ids.gids.postdrop;
};
systemd.services.postfix-setup =
{ description = "Setup for Postfix mail server";
serviceConfig.RemainAfterExit = true;
serviceConfig.Type = "oneshot";
script = ''
# 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
'';
};
systemd.services.postfix =
{ description = "Postfix mail server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postfix-setup.service" ];
requires = [ "postfix-setup.service" ];
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";
};
};
services.postfix.config = (mapAttrs (_: v: mkDefault v) {
compatibility_level = pkgs.postfix.version;
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.tlsTrustedAuthorities != "") {
smtp_tls_CAfile = cfg.tlsTrustedAuthorities;
smtp_tls_security_level = mkDefault "may";
}
// optionalAttrs (cfg.sslCert != "") {
smtp_tls_cert_file = cfg.sslCert;
smtp_tls_key_file = cfg.sslKey;
smtp_tls_security_level = mkDefault "may";
smtpd_tls_cert_file = cfg.sslCert;
smtpd_tls_key_file = cfg.sslKey;
smtpd_tls_security_level = "may";
};
services.postfix.masterConfig = {
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_inet = {
name = "smtp";
type = "inet";
private = false;
command = "smtpd";
};
smtp = {};
relay = {
command = "smtp";
args = [ "-o" "smtp_fallback_relay=" ];
};
} // optionalAttrs cfg.enableSubmissions {
submissions = {
type = "inet";
private = false;
command = "smtpd";
args = let
mkKeyVal = opt: val: [ "-o" (opt + "=" + val) ];
adjustSmtpTlsSecurityLevel = !(cfg.submissionsOptions ? smtpd_tls_security_level) ||
cfg.submissionsOptions.smtpd_tls_security_level == "none" ||
cfg.submissionsOptions.smtpd_tls_security_level == "may";
submissionsOptions = cfg.submissionsOptions // {
smtpd_tls_wrappermode = "yes";
} // optionalAttrs adjustSmtpTlsSecurityLevel {
smtpd_tls_security_level = "encrypt";
};
in concatLists (mapAttrsToList mkKeyVal submissionsOptions);
};
};
}
(mkIf haveAliases {
services.postfix.aliasFiles.aliases = aliasesFile;
})
(mkIf haveCanonical {
services.postfix.mapFiles.canonical = canonicalFile;
})
(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;
})
]);
imports = [
(mkRemovedOptionModule [ "services" "postfix" "sslCACert" ]
"services.postfix.sslCACert was replaced by services.postfix.tlsTrustedAuthorities. In case you intend that your server should validate requested client certificates use services.postfix.extraConfig.")
(mkChangedOptionModule [ "services" "postfix" "useDane" ]
[ "services" "postfix" "config" "smtp_tls_security_level" ]
(config: mkIf config.services.postfix.useDane "dane"))
];
}

View file

@ -0,0 +1,199 @@
{ lib, config, pkgs, ... }:
with lib;
let
cfg = config.services.postfixadmin;
fpm = config.services.phpfpm.pools.postfixadmin;
localDB = cfg.database.host == "localhost";
user = if localDB then cfg.database.username else "nginx";
in
{
options.services.postfixadmin = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable postfixadmin.
Also enables nginx virtual host management.
Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.&lt;name&gt;</literal>.
See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.
'';
};
hostName = mkOption {
type = types.str;
example = "postfixadmin.example.com";
description = "Hostname to use for the nginx vhost";
};
adminEmail = mkOption {
type = types.str;
example = "postmaster@example.com";
description = ''
Defines the Site Admin's email address.
This will be used to send emails from to create mailboxes and
from Send Email / Broadcast message pages.
'';
};
setupPasswordFile = mkOption {
type = types.path;
description = ''
Password file for the admin.
Generate with <literal>php -r "echo password_hash('some password here', PASSWORD_DEFAULT);"</literal>
'';
};
database = {
username = mkOption {
type = types.str;
default = "postfixadmin";
description = ''
Username for the postgresql connection.
If <literal>database.host</literal> is set to <literal>localhost</literal>, a unix user and group of the same name will be created as well.
'';
};
host = mkOption {
type = types.str;
default = "localhost";
description = ''
Host of the postgresql server. If this is not set to
<literal>localhost</literal>, you have to create the
postgresql user and database yourself, with appropriate
permissions.
'';
};
passwordFile = mkOption {
type = types.path;
description = "Password file for the postgresql connection. Must be readable by user <literal>nginx</literal>.";
};
dbname = mkOption {
type = types.str;
default = "postfixadmin";
description = "Name of the postgresql database";
};
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Extra configuration for the postfixadmin instance, see postfixadmin's config.inc.php for available options.";
};
};
config = mkIf cfg.enable {
environment.etc."postfixadmin/config.local.php".text = ''
<?php
$CONF['setup_password'] = file_get_contents('${cfg.setupPasswordFile}');
$CONF['database_type'] = 'pgsql';
$CONF['database_host'] = ${if localDB then "null" else "'${cfg.database.host}'"};
${optionalString localDB "$CONF['database_user'] = '${cfg.database.username}';"}
$CONF['database_password'] = ${if localDB then "'dummy'" else "file_get_contents('${cfg.database.passwordFile}')"};
$CONF['database_name'] = '${cfg.database.dbname}';
$CONF['configured'] = true;
${cfg.extraConfig}
'';
systemd.tmpfiles.rules = [ "d /var/cache/postfixadmin/templates_c 700 ${user} ${user}" ];
services.nginx = {
enable = true;
virtualHosts = {
${cfg.hostName} = {
forceSSL = mkDefault true;
enableACME = mkDefault true;
locations."/" = {
root = "${pkgs.postfixadmin}/public";
index = "index.php";
extraConfig = ''
location ~* \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${fpm.socket};
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${pkgs.nginx}/conf/fastcgi.conf;
}
'';
};
};
};
};
services.postgresql = mkIf localDB {
enable = true;
ensureUsers = [ {
name = cfg.database.username;
} ];
};
# The postgresql module doesn't currently support concepts like
# objects owners and extensions; for now we tack on what's needed
# here.
systemd.services.postfixadmin-postgres = let pgsql = config.services.postgresql; in mkIf localDB {
after = [ "postgresql.service" ];
bindsTo = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
path = [
pgsql.package
pkgs.util-linux
];
script = ''
set -eu
PSQL() {
psql --port=${toString pgsql.port} "$@"
}
PSQL -tAc "SELECT 1 FROM pg_database WHERE datname = '${cfg.database.dbname}'" | grep -q 1 || PSQL -tAc 'CREATE DATABASE "${cfg.database.dbname}" OWNER "${cfg.database.username}"'
current_owner=$(PSQL -tAc "SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_catalog.pg_database WHERE datname = '${cfg.database.dbname}'")
if [[ "$current_owner" != "${cfg.database.username}" ]]; then
PSQL -tAc 'ALTER DATABASE "${cfg.database.dbname}" OWNER TO "${cfg.database.username}"'
if [[ -e "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}" ]]; then
echo "Reassigning ownership of database ${cfg.database.dbname} to user ${cfg.database.username} failed on last boot. Failing..."
exit 1
fi
touch "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}"
PSQL "${cfg.database.dbname}" -tAc "REASSIGN OWNED BY \"$current_owner\" TO \"${cfg.database.username}\""
rm "${config.services.postgresql.dataDir}/.reassigning_${cfg.database.dbname}"
fi
'';
serviceConfig = {
User = pgsql.superUser;
Type = "oneshot";
RemainAfterExit = true;
};
};
users.users.${user} = mkIf localDB {
group = user;
isSystemUser = true;
createHome = false;
};
users.groups.${user} = mkIf localDB {};
services.phpfpm.pools.postfixadmin = {
user = user;
phpPackage = pkgs.php74;
phpOptions = ''
error_log = 'stderr'
log_errors = on
'';
settings = mapAttrs (name: mkDefault) {
"listen.owner" = "nginx";
"listen.group" = "nginx";
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"catch_workers_output" = true;
};
};
};
}

View file

@ -0,0 +1,205 @@
{ config, lib, pkgs, ... }:
with lib; let
cfg = config.services.postgrey;
natural = with types; addCheck int (x: x >= 0);
natural' = with types; addCheck int (x: x > 0);
socket = with types; addCheck (either (submodule unixSocket) (submodule inetSocket)) (x: x ? path || x ? port);
inetSocket = with types; {
options = {
addr = mkOption {
type = nullOr str;
default = null;
example = "127.0.0.1";
description = "The address to bind to. Localhost if null";
};
port = mkOption {
type = natural';
default = 10030;
description = "Tcp port to bind to";
};
};
};
unixSocket = with types; {
options = {
path = mkOption {
type = path;
default = "/run/postgrey.sock";
description = "Path of the unix socket";
};
mode = mkOption {
type = str;
default = "0777";
description = "Mode of the unix socket";
};
};
};
in {
imports = [
(mkMergedOptionModule [ [ "services" "postgrey" "inetAddr" ] [ "services" "postgrey" "inetPort" ] ] [ "services" "postgrey" "socket" ] (config: let
value = p: getAttrFromPath p config;
inetAddr = [ "services" "postgrey" "inetAddr" ];
inetPort = [ "services" "postgrey" "inetPort" ];
in
if value inetAddr == null
then { path = "/run/postgrey.sock"; }
else { addr = value inetAddr; port = value inetPort; }
))
];
options = {
services.postgrey = with types; {
enable = mkOption {
type = bool;
default = false;
description = "Whether to run the Postgrey daemon";
};
socket = mkOption {
type = socket;
default = {
path = "/run/postgrey.sock";
mode = "0777";
};
example = {
addr = "127.0.0.1";
port = 10030;
};
description = "Socket to bind to";
};
greylistText = mkOption {
type = str;
default = "Greylisted for %%s seconds";
description = "Response status text for greylisted messages; use %%s for seconds left until greylisting is over and %%r for mail domain of recipient";
};
greylistAction = mkOption {
type = str;
default = "DEFER_IF_PERMIT";
description = "Response status for greylisted messages (see access(5))";
};
greylistHeader = mkOption {
type = str;
default = "X-Greylist: delayed %%t seconds by postgrey-%%v at %%h; %%d";
description = "Prepend header to greylisted mails; use %%t for seconds delayed due to greylisting, %%v for the version of postgrey, %%d for the date, and %%h for the host";
};
delay = mkOption {
type = natural;
default = 300;
description = "Greylist for N seconds";
};
maxAge = mkOption {
type = natural;
default = 35;
description = "Delete entries from whitelist if they haven't been seen for N days";
};
retryWindow = mkOption {
type = either str natural;
default = 2;
example = "12h";
description = "Allow N days for the first retry. Use string with appended 'h' to specify time in hours";
};
lookupBySubnet = mkOption {
type = bool;
default = true;
description = "Strip the last N bits from IP addresses, determined by IPv4CIDR and IPv6CIDR";
};
IPv4CIDR = mkOption {
type = natural;
default = 24;
description = "Strip N bits from IPv4 addresses if lookupBySubnet is true";
};
IPv6CIDR = mkOption {
type = natural;
default = 64;
description = "Strip N bits from IPv6 addresses if lookupBySubnet is true";
};
privacy = mkOption {
type = bool;
default = true;
description = "Store data using one-way hash functions (SHA1)";
};
autoWhitelist = mkOption {
type = nullOr natural';
default = 5;
description = "Whitelist clients after successful delivery of N messages";
};
whitelistClients = mkOption {
type = listOf path;
default = [];
description = "Client address whitelist files (see postgrey(8))";
};
whitelistRecipients = mkOption {
type = listOf path;
default = [];
description = "Recipient address whitelist files (see postgrey(8))";
};
};
};
config = mkIf cfg.enable {
environment.systemPackages = [ pkgs.postgrey ];
users = {
users = {
postgrey = {
description = "Postgrey Daemon";
uid = config.ids.uids.postgrey;
group = "postgrey";
};
};
groups = {
postgrey = {
gid = config.ids.gids.postgrey;
};
};
};
systemd.services.postgrey = let
bind-flag = if cfg.socket ? path then
"--unix=${cfg.socket.path} --socketmode=${cfg.socket.mode}"
else
''--inet=${optionalString (cfg.socket.addr != null) (cfg.socket.addr + ":")}${toString cfg.socket.port}'';
in {
description = "Postfix Greylisting Service";
wantedBy = [ "multi-user.target" ];
before = [ "postfix.service" ];
preStart = ''
mkdir -p /var/postgrey
chown postgrey:postgrey /var/postgrey
chmod 0770 /var/postgrey
'';
serviceConfig = {
Type = "simple";
ExecStart = ''${pkgs.postgrey}/bin/postgrey \
${bind-flag} \
--group=postgrey --user=postgrey \
--dbdir=/var/postgrey \
--delay=${toString cfg.delay} \
--max-age=${toString cfg.maxAge} \
--retry-window=${toString cfg.retryWindow} \
${if cfg.lookupBySubnet then "--lookup-by-subnet" else "--lookup-by-host"} \
--ipv4cidr=${toString cfg.IPv4CIDR} --ipv6cidr=${toString cfg.IPv6CIDR} \
${optionalString cfg.privacy "--privacy"} \
--auto-whitelist-clients=${toString (if cfg.autoWhitelist == null then 0 else cfg.autoWhitelist)} \
--greylist-action=${cfg.greylistAction} \
--greylist-text="${cfg.greylistText}" \
--x-greylist-header="${cfg.greylistHeader}" \
${concatMapStringsSep " " (x: "--whitelist-clients=" + x) cfg.whitelistClients} \
${concatMapStringsSep " " (x: "--whitelist-recipients=" + x) cfg.whitelistRecipients}
'';
Restart = "always";
RestartSec = 5;
TimeoutSec = 10;
};
};
};
}

View file

@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.postsrsd;
in {
###### interface
options = {
services.postsrsd = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the postsrsd SRS server for Postfix.";
};
secretsFile = mkOption {
type = types.path;
default = "/var/lib/postsrsd/postsrsd.secret";
description = "Secret keys used for signing and verification";
};
domain = mkOption {
type = types.str;
description = "Domain name for rewrite";
};
separator = mkOption {
type = types.enum ["-" "=" "+"];
default = "=";
description = "First separator character in generated addresses";
};
# bindAddress = mkOption { # uncomment once 1.5 is released
# type = types.str;
# default = "127.0.0.1";
# description = "Socket listen address";
# };
forwardPort = mkOption {
type = types.int;
default = 10001;
description = "Port for the forward SRS lookup";
};
reversePort = mkOption {
type = types.int;
default = 10002;
description = "Port for the reverse SRS lookup";
};
timeout = mkOption {
type = types.int;
default = 1800;
description = "Timeout for idle client connections in seconds";
};
excludeDomains = mkOption {
type = types.listOf types.str;
default = [];
description = "Origin domains to exclude from rewriting in addition to primary domain";
};
user = mkOption {
type = types.str;
default = "postsrsd";
description = "User for the daemon";
};
group = mkOption {
type = types.str;
default = "postsrsd";
description = "Group for the daemon";
};
};
};
###### implementation
config = mkIf cfg.enable {
services.postsrsd.domain = mkDefault config.networking.hostName;
users.users = optionalAttrs (cfg.user == "postsrsd") {
postsrsd = {
group = cfg.group;
uid = config.ids.uids.postsrsd;
};
};
users.groups = optionalAttrs (cfg.group == "postsrsd") {
postsrsd.gid = config.ids.gids.postsrsd;
};
systemd.services.postsrsd = {
description = "PostSRSd SRS rewriting server";
after = [ "network.target" ];
before = [ "postfix.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.coreutils ];
serviceConfig = {
ExecStart = ''${pkgs.postsrsd}/sbin/postsrsd "-s${cfg.secretsFile}" "-d${cfg.domain}" -a${cfg.separator} -f${toString cfg.forwardPort} -r${toString cfg.reversePort} -t${toString cfg.timeout} "-X${concatStringsSep "," cfg.excludeDomains}"'';
User = cfg.user;
Group = cfg.group;
PermissionsStartOnly = true;
};
preStart = ''
if [ ! -e "${cfg.secretsFile}" ]; then
echo "WARNING: secrets file not found, autogenerating!"
DIR="$(dirname "${cfg.secretsFile}")"
if [ ! -d "$DIR" ]; then
mkdir -p -m750 "$DIR"
chown "${cfg.user}:${cfg.group}" "$DIR"
fi
dd if=/dev/random bs=18 count=1 | base64 > "${cfg.secretsFile}"
chmod 600 "${cfg.secretsFile}"
fi
chown "${cfg.user}:${cfg.group}" "${cfg.secretsFile}"
'';
};
};
}

View file

@ -0,0 +1,579 @@
{ lib, pkgs, config, ... }:
with lib;
let
cfg = config.services.public-inbox;
stateDir = "/var/lib/public-inbox";
manref = name: vol: "<citerefentry><refentrytitle>${name}</refentrytitle><manvolnum>${toString vol}</manvolnum></citerefentry>";
gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0;
useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
cfg.settings.publicinboxwatch.spamcheck == "spamc";
publicInboxDaemonOptions = proto: defaultPort: {
args = mkOption {
type = with types; listOf str;
default = [];
description = "Command-line arguments to pass to ${manref "public-inbox-${proto}d" 1}.";
};
port = mkOption {
type = with types; nullOr (either str port);
default = defaultPort;
description = ''
Listening port.
Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not.
Set to null and use <code>systemd.sockets.public-inbox-${proto}d.listenStreams</code>
if you need a more advanced listening.
'';
};
cert = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/fullchain.pem";
description = "Path to TLS certificate to use for connections to ${manref "public-inbox-${proto}d" 1}.";
};
key = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/key.pem";
description = "Path to TLS key to use for connections to ${manref "public-inbox-${proto}d" 1}.";
};
};
serviceConfig = srv:
let proto = removeSuffix "d" srv;
needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
in {
serviceConfig = {
# Enable JIT-compiled C (via Inline::C)
Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
# NonBlocking is REQUIRED to avoid a race condition
# if running simultaneous services.
NonBlocking = true;
#LimitNOFILE = 30000;
User = config.users.users."public-inbox".name;
Group = config.users.groups."public-inbox".name;
RuntimeDirectory = [
"public-inbox-${srv}/perl-inline"
];
RuntimeDirectoryMode = "700";
# This is for BindPaths= and BindReadOnlyPaths=
# to allow traversal of directories they create inside RootDirectory=
UMask = "0066";
StateDirectory = ["public-inbox"];
StateDirectoryMode = "0750";
WorkingDirectory = stateDir;
BindReadOnlyPaths = [
"/etc"
"/run/systemd"
"${config.i18n.glibcLocales}"
] ++
mapAttrsToList (name: inbox: inbox.description) cfg.inboxes ++
# Without confinement the whole Nix store
# is made available to the service
optionals (!config.systemd.services."public-inbox-${srv}".confinement.enable) [
"${pkgs.dash}/bin/dash:/bin/sh"
builtins.storeDir
];
# The following options are only for optimizing:
# systemd-analyze security public-inbox-'*'
AmbientCapabilities = "";
CapabilityBoundingSet = "";
# ProtectClock= adds DeviceAllow=char-rtc r
DeviceAllow = "";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateNetwork = mkDefault (!needNetwork);
ProcSubset = "pid";
ProtectClock = true;
ProtectHome = mkDefault true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectProc = "invisible";
#ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_UNIX" ] ++
optionals needNetwork [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallFilter = [
"@system-service"
"~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources"
# Not removing @setuid and @privileged because Inline::C needs them.
# Not removing @timer because git upload-pack needs it.
];
SystemCallArchitectures = "native";
# The following options are redundant when confinement is enabled
RootDirectory = "/var/empty";
TemporaryFileSystem = "/";
PrivateMounts = true;
MountAPIVFS = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectControlGroups = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
};
confinement = {
# Until we agree upon doing it directly here in NixOS
# https://github.com/NixOS/nixpkgs/pull/104457#issuecomment-1115768447
# let the user choose to enable the confinement with:
# systemd.services.public-inbox-httpd.confinement.enable = true;
# systemd.services.public-inbox-imapd.confinement.enable = true;
# systemd.services.public-inbox-init.confinement.enable = true;
# systemd.services.public-inbox-nntpd.confinement.enable = true;
#enable = true;
mode = "full-apivfs";
# Inline::C needs a /bin/sh, and dash is enough
binSh = "${pkgs.dash}/bin/dash";
packages = [
pkgs.iana-etc
(getLib pkgs.nss)
pkgs.tzdata
];
};
};
in
{
options.services.public-inbox = {
enable = mkEnableOption "the public-inbox mail archiver";
package = mkOption {
type = types.package;
default = pkgs.public-inbox;
defaultText = literalExpression "pkgs.public-inbox";
description = "public-inbox package to use.";
};
path = mkOption {
type = with types; listOf package;
default = [];
example = literalExpression "with pkgs; [ spamassassin ]";
description = ''
Additional packages to place in the path of public-inbox-mda,
public-inbox-watch, etc.
'';
};
inboxes = mkOption {
description = ''
Inboxes to configure, where attribute names are inbox names.
'';
default = {};
type = types.attrsOf (types.submodule ({name, ...}: {
freeformType = types.attrsOf iniAtom;
options.inboxdir = mkOption {
type = types.str;
default = "${stateDir}/inboxes/${name}";
description = "The absolute path to the directory which hosts the public-inbox.";
};
options.address = mkOption {
type = with types; listOf str;
example = "example-discuss@example.org";
description = "The email addresses of the public-inbox.";
};
options.url = mkOption {
type = with types; nullOr str;
default = null;
example = "https://example.org/lists/example-discuss";
description = "URL where this inbox can be accessed over HTTP.";
};
options.description = mkOption {
type = types.str;
example = "user/dev discussion of public-inbox itself";
description = "User-visible description for the repository.";
apply = pkgs.writeText "public-inbox-description-${name}";
};
options.newsgroup = mkOption {
type = with types; nullOr str;
default = null;
description = "NNTP group name for the inbox.";
};
options.watch = mkOption {
type = with types; listOf str;
default = [];
description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail.";
example = [ "maildir:/path/to/test.example.com.git" ];
};
options.watchheader = mkOption {
type = with types; nullOr str;
default = null;
example = "List-Id:<test@example.com>";
description = ''
If specified, ${manref "public-inbox-watch" 1} will only process
mail containing a matching header.
'';
};
options.coderepo = mkOption {
type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
description = "list of coderepo names";
};
default = [];
description = "Nicknames of a 'coderepo' section associated with the inbox.";
};
}));
};
imap = {
enable = mkEnableOption "the public-inbox IMAP server";
} // publicInboxDaemonOptions "imap" 993;
http = {
enable = mkEnableOption "the public-inbox HTTP server";
mounts = mkOption {
type = with types; listOf str;
default = [ "/" ];
example = [ "/lists/archives" ];
description = ''
Root paths or URLs that public-inbox will be served on.
If domain parts are present, only requests to those
domains will be accepted.
'';
};
args = (publicInboxDaemonOptions "http" 80).args;
port = mkOption {
type = with types; nullOr (either str port);
default = 80;
example = "/run/public-inbox-httpd.sock";
description = ''
Listening port or systemd's ListenStream= entry
to be used as a reverse proxy, eg. in nginx:
<code>locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";</code>
Set to null and use <code>systemd.sockets.public-inbox-httpd.listenStreams</code>
if you need a more advanced listening.
'';
};
};
mda = {
enable = mkEnableOption "the public-inbox Mail Delivery Agent";
args = mkOption {
type = with types; listOf str;
default = [];
description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}.";
};
};
postfix.enable = mkEnableOption "the integration into Postfix";
nntp = {
enable = mkEnableOption "the public-inbox NNTP server";
} // publicInboxDaemonOptions "nntp" 563;
spamAssassinRules = mkOption {
type = with types; nullOr path;
default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
description = "SpamAssassin configuration specific to public-inbox.";
};
settings = mkOption {
description = ''
Settings for the <link xlink:href="https://public-inbox.org/public-inbox-config.html">public-inbox config file</link>.
'';
default = {};
type = types.submodule {
freeformType = gitIni.type;
options.publicinbox = mkOption {
default = {};
description = "public inboxes";
type = types.submodule {
freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom);
options.css = mkOption {
type = with types; listOf str;
default = [];
description = "The local path name of a CSS file for the PSGI web interface.";
};
options.nntpserver = mkOption {
type = with types; listOf str;
default = [];
example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
description = "NNTP URLs to this public-inbox instance";
};
options.wwwlisting = mkOption {
type = with types; enum [ "all" "404" "match=domain" ];
default = "404";
description = ''
Controls which lists (if any) are listed for when the root
public-inbox URL is accessed over HTTP.
'';
};
};
};
options.publicinboxmda.spamcheck = mkOption {
type = with types; enum [ "spamc" "none" ];
default = "none";
description = ''
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
using SpamAssassin.
'';
};
options.publicinboxwatch.spamcheck = mkOption {
type = with types; enum [ "spamc" "none" ];
default = "none";
description = ''
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
using SpamAssassin.
'';
};
options.publicinboxwatch.watchspam = mkOption {
type = with types; nullOr str;
default = null;
example = "maildir:/path/to/spam";
description = ''
If set, mail in this maildir will be trained as spam and
deleted from all watched inboxes
'';
};
options.coderepo = mkOption {
default = {};
description = "code repositories";
type = types.attrsOf (types.submodule {
freeformType = types.attrsOf iniAtom;
options.cgitUrl = mkOption {
type = types.str;
description = "URL of a cgit instance";
};
options.dir = mkOption {
type = types.str;
description = "Path to a git repository";
};
});
};
};
};
openFirewall = mkEnableOption "opening the firewall when using a port option";
};
config = mkIf cfg.enable {
assertions = [
{ assertion = config.services.spamassassin.enable || !useSpamAssassin;
message = ''
public-inbox is configured to use SpamAssassin, but
services.spamassassin.enable is false. If you don't need
spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
`services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
'';
}
{ assertion = cfg.path != [] || !useSpamAssassin;
message = ''
public-inbox is configured to use SpamAssassin, but there is
no spamc executable in services.public-inbox.path. If you
don't need spam checking, set
`services.public-inbox.settings.publicinboxmda.spamcheck' and
`services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
'';
}
];
services.public-inbox.settings =
filterAttrsRecursive (n: v: v != null) {
publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
};
users = {
users.public-inbox = {
home = stateDir;
group = "public-inbox";
isSystemUser = true;
};
groups.public-inbox = {};
};
networking.firewall = mkIf cfg.openFirewall
{ allowedTCPPorts = mkMerge
(map (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ]))
["imap" "http" "nntp"]);
};
services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
# Not sure limiting to 1 is necessary, but better safe than sorry.
config.public-inbox_destination_recipient_limit = "1";
# Register the addresses as existing
virtual =
concatStringsSep "\n" (mapAttrsToList (_: inbox:
concatMapStringsSep "\n" (address:
"${address} ${address}"
) inbox.address
) cfg.inboxes);
# Deliver the addresses with the public-inbox transport
transport =
concatStringsSep "\n" (mapAttrsToList (_: inbox:
concatMapStringsSep "\n" (address:
"${address} public-inbox:${address}"
) inbox.address
) cfg.inboxes);
# The public-inbox transport
masterConfig.public-inbox = {
type = "unix";
privileged = true; # Required for user=
command = "pipe";
args = [
"flags=X" # Report as a final delivery
"user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
# Specifying a nexthop when using the transport
# (eg. test public-inbox:test) allows to
# receive mails with an extension (eg. test+foo).
"argv=${pkgs.writeShellScript "public-inbox-transport" ''
export HOME="${stateDir}"
export ORIGINAL_RECIPIENT="''${2:-1}"
export PATH="${makeBinPath cfg.path}:$PATH"
exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
''} \${original_recipient} \${nexthop}"
];
};
};
systemd.sockets = mkMerge (map (proto:
mkIf (cfg.${proto}.enable && cfg.${proto}.port != null)
{ "public-inbox-${proto}d" = {
listenStreams = [ (toString cfg.${proto}.port) ];
wantedBy = [ "sockets.target" ];
};
}
) [ "imap" "http" "nntp" ]);
systemd.services = mkMerge [
(mkIf cfg.imap.enable
{ public-inbox-imapd = mkMerge [(serviceConfig "imapd") {
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = {
ExecStart = escapeShellArgs (
[ "${cfg.package}/bin/public-inbox-imapd" ] ++
cfg.imap.args ++
optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
);
};
}];
})
(mkIf cfg.http.enable
{ public-inbox-httpd = mkMerge [(serviceConfig "httpd") {
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = {
ExecStart = escapeShellArgs (
[ "${cfg.package}/bin/public-inbox-httpd" ] ++
cfg.http.args ++
# See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
# for upstream's example.
[ (pkgs.writeText "public-inbox.psgi" ''
#!${cfg.package.fullperl} -w
use strict;
use warnings;
use Plack::Builder;
use PublicInbox::WWW;
my $www = PublicInbox::WWW->new;
$www->preload;
builder {
# If reached through a reverse proxy,
# make it transparent by resetting some HTTP headers
# used by public-inbox to generate URIs.
enable 'ReverseProxy';
# No need to send a response body if it's an HTTP HEAD requests.
enable 'Head';
# Route according to configured domains and root paths.
${concatMapStrings (path: ''
mount q(${path}) => sub { $www->call(@_); };
'') cfg.http.mounts}
}
'') ]
);
};
}];
})
(mkIf cfg.nntp.enable
{ public-inbox-nntpd = mkMerge [(serviceConfig "nntpd") {
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = {
ExecStart = escapeShellArgs (
[ "${cfg.package}/bin/public-inbox-nntpd" ] ++
cfg.nntp.args ++
optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
);
};
}];
})
(mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
|| cfg.settings.publicinboxwatch.watchspam != null)
{ public-inbox-watch = mkMerge [(serviceConfig "watch") {
inherit (cfg) path;
wants = [ "public-inbox-init.service" ];
requires = [ "public-inbox-init.service" ] ++
optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/public-inbox-watch";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
};
}];
})
({ public-inbox-init = let
PI_CONFIG = gitIni.generate "public-inbox.ini"
(filterAttrsRecursive (n: v: v != null) cfg.settings);
in mkMerge [(serviceConfig "init") {
wantedBy = [ "multi-user.target" ];
restartIfChanged = true;
restartTriggers = [ PI_CONFIG ];
script = ''
set -ux
install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
'' + optionalString useSpamAssassin ''
install -m 0700 -o spamd -d ${stateDir}/.spamassassin
${optionalString (cfg.spamAssassinRules != null) ''
ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
''}
'' + concatStrings (mapAttrsToList (name: inbox: ''
if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
# public-inbox-init creates an inbox and adds it to a config file.
# It tries to atomically write the config file by creating
# another file in the same directory, and renaming it.
# This has the sad consequence that we can't use
# /dev/null, or it would try to create a file in /dev.
conf_dir="$(mktemp -d)"
PI_CONFIG=$conf_dir/conf \
${cfg.package}/bin/public-inbox-init -V2 \
${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
rm -rf $conf_dir
fi
ln -sf ${inbox.description} \
${stateDir}/inboxes/${escapeShellArg name}/description
export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
if test -d "$GIT_DIR"; then
# Config is inherited by each epoch repository,
# so just needs to be set for all.git.
${pkgs.git}/bin/git config core.sharedRepository 0640
fi
'') cfg.inboxes
) + ''
shopt -s nullglob
for inbox in ${stateDir}/inboxes/*/; do
# This should be idempotent, but only do it for new
# inboxes anyway because it's only needed once, and could
# be slow for large pre-existing inboxes.
ls -1 "$inbox" | grep -q '^xap' ||
${cfg.package}/bin/public-inbox-index "$inbox"
done
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = [
"public-inbox/.public-inbox"
"public-inbox/.public-inbox/emergency"
"public-inbox/inboxes"
];
};
}];
})
];
environment.systemPackages = with pkgs; [ cfg.package ];
};
meta.maintainers = with lib.maintainers; [ julm qyliss ];
}

View file

@ -0,0 +1,249 @@
{ lib, config, pkgs, ... }:
with lib;
let
cfg = config.services.roundcube;
fpm = config.services.phpfpm.pools.roundcube;
localDB = cfg.database.host == "localhost";
user = cfg.database.username;
phpWithPspell = pkgs.php80.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled);
in
{
options.services.roundcube = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable roundcube.
Also enables nginx virtual host management.
Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.&lt;name&gt;</literal>.
See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.
'';
};
hostName = mkOption {
type = types.str;
example = "webmail.example.com";
description = "Hostname to use for the nginx vhost";
};
package = mkOption {
type = types.package;
default = pkgs.roundcube;
defaultText = literalExpression "pkgs.roundcube";
example = literalExpression ''
roundcube.withPlugins (plugins: [ plugins.persistent_login ])
'';
description = ''
The package which contains roundcube's sources. Can be overriden to create
an environment which contains roundcube and third-party plugins.
'';
};
database = {
username = mkOption {
type = types.str;
default = "roundcube";
description = ''
Username for the postgresql connection.
If <literal>database.host</literal> is set to <literal>localhost</literal>, a unix user and group of the same name will be created as well.
'';
};
host = mkOption {
type = types.str;
default = "localhost";
description = ''
Host of the postgresql server. If this is not set to
<literal>localhost</literal>, you have to create the
postgresql user and database yourself, with appropriate
permissions.
'';
};
password = mkOption {
type = types.str;
description = "Password for the postgresql connection. Do not use: the password will be stored world readable in the store; use <literal>passwordFile</literal> instead.";
default = "";
};
passwordFile = mkOption {
type = types.str;
description = "Password file for the postgresql connection. Must be readable by user <literal>nginx</literal>. Ignored if <literal>database.host</literal> is set to <literal>localhost</literal>, as peer authentication will be used.";
};
dbname = mkOption {
type = types.str;
default = "roundcube";
description = "Name of the postgresql database";
};
};
plugins = mkOption {
type = types.listOf types.str;
default = [];
description = ''
List of roundcube plugins to enable. Currently, only those directly shipped with Roundcube are supported.
'';
};
dicts = mkOption {
type = types.listOf types.package;
default = [];
example = literalExpression "with pkgs.aspellDicts; [ en fr de ]";
description = ''
List of aspell dictionnaries for spell checking. If empty, spell checking is disabled.
'';
};
maxAttachmentSize = mkOption {
type = types.int;
default = 18;
description = ''
The maximum attachment size in MB.
Note: Since roundcube only uses 70% of max upload values configured in php
30% is added automatically to <xref linkend="opt-services.roundcube.maxAttachmentSize"/>.
'';
apply = configuredMaxAttachmentSize: "${toString (configuredMaxAttachmentSize * 1.3)}M";
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Extra configuration for roundcube webmail instance";
};
};
config = mkIf cfg.enable {
# backward compatibility: if password is set but not passwordFile, make one.
services.roundcube.database.passwordFile = mkIf (!localDB && cfg.database.password != "") (mkDefault ("${pkgs.writeText "roundcube-password" cfg.database.password}"));
warnings = lib.optional (!localDB && cfg.database.password != "") "services.roundcube.database.password is deprecated and insecure; use services.roundcube.database.passwordFile instead";
environment.etc."roundcube/config.inc.php".text = ''
<?php
${lib.optionalString (!localDB) "$password = file_get_contents('${cfg.database.passwordFile}');"}
$config = array();
$config['db_dsnw'] = 'pgsql://${cfg.database.username}${lib.optionalString (!localDB) ":' . $password . '"}@${if localDB then "unix(/run/postgresql)" else cfg.database.host}/${cfg.database.dbname}';
$config['log_driver'] = 'syslog';
$config['max_message_size'] = '${cfg.maxAttachmentSize}';
$config['plugins'] = [${concatMapStringsSep "," (p: "'${p}'") cfg.plugins}];
$config['des_key'] = file_get_contents('/var/lib/roundcube/des_key');
$config['mime_types'] = '${pkgs.nginx}/conf/mime.types';
$config['enable_spellcheck'] = ${if cfg.dicts == [] then "false" else "true"};
# by default, spellchecking uses a third-party cloud services
$config['spellcheck_engine'] = 'pspell';
$config['spellcheck_languages'] = array(${lib.concatMapStringsSep ", " (dict: let p = builtins.parseDrvName dict.shortName; in "'${p.name}' => '${dict.fullName}'") cfg.dicts});
${cfg.extraConfig}
'';
services.nginx = {
enable = true;
virtualHosts = {
${cfg.hostName} = {
forceSSL = mkDefault true;
enableACME = mkDefault true;
locations."/" = {
root = cfg.package;
index = "index.php";
extraConfig = ''
location ~* \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${fpm.socket};
include ${config.services.nginx.package}/conf/fastcgi_params;
include ${pkgs.nginx}/conf/fastcgi.conf;
}
'';
};
};
};
};
services.postgresql = mkIf localDB {
enable = true;
ensureDatabases = [ cfg.database.dbname ];
ensureUsers = [ {
name = cfg.database.username;
ensurePermissions = {
"DATABASE ${cfg.database.username}" = "ALL PRIVILEGES";
};
} ];
};
users.users.${user} = mkIf localDB {
group = user;
isSystemUser = true;
createHome = false;
};
users.groups.${user} = mkIf localDB {};
services.phpfpm.pools.roundcube = {
user = if localDB then user else "nginx";
phpOptions = ''
error_log = 'stderr'
log_errors = on
post_max_size = ${cfg.maxAttachmentSize}
upload_max_filesize = ${cfg.maxAttachmentSize}
'';
settings = mapAttrs (name: mkDefault) {
"listen.owner" = "nginx";
"listen.group" = "nginx";
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"catch_workers_output" = true;
};
phpPackage = phpWithPspell;
phpEnv.ASPELL_CONF = "dict-dir ${pkgs.aspellWithDicts (_: cfg.dicts)}/lib/aspell";
};
systemd.services.phpfpm-roundcube.after = [ "roundcube-setup.service" ];
# Restart on config changes.
systemd.services.phpfpm-roundcube.restartTriggers = [
config.environment.etc."roundcube/config.inc.php".source
];
systemd.services.roundcube-setup = mkMerge [
(mkIf (cfg.database.host == "localhost") {
requires = [ "postgresql.service" ];
after = [ "postgresql.service" ];
path = [ config.services.postgresql.package ];
})
{
wantedBy = [ "multi-user.target" ];
script = let
psql = "${lib.optionalString (!localDB) "PGPASSFILE=${cfg.database.passwordFile}"} ${pkgs.postgresql}/bin/psql ${lib.optionalString (!localDB) "-h ${cfg.database.host} -U ${cfg.database.username} "} ${cfg.database.dbname}";
in
''
version="$(${psql} -t <<< "select value from system where name = 'roundcube-version';" || true)"
if ! (grep -E '[a-zA-Z0-9]' <<< "$version"); then
${psql} -f ${cfg.package}/SQL/postgres.initial.sql
fi
if [ ! -f /var/lib/roundcube/des_key ]; then
base64 /dev/urandom | head -c 24 > /var/lib/roundcube/des_key;
# we need to log out everyone in case change the des_key
# from the default when upgrading from nixos 19.09
${psql} <<< 'TRUNCATE TABLE session;'
fi
${phpWithPspell}/bin/php ${cfg.package}/bin/update.sh
'';
serviceConfig = {
Type = "oneshot";
StateDirectory = "roundcube";
User = if localDB then user else "nginx";
# so that the des_key is not world readable
StateDirectoryMode = "0700";
};
}
];
};
}

View file

@ -0,0 +1,446 @@
{ config, options, pkgs, lib, ... }:
with lib;
let
cfg = config.services.rspamd;
opt = options.services.rspamd;
postfixCfg = config.services.postfix;
bindSocketOpts = {options, config, ... }: {
options = {
socket = mkOption {
type = types.str;
example = "localhost:11333";
description = ''
Socket for this worker to listen on in a format acceptable by rspamd.
'';
};
mode = mkOption {
type = types.str;
default = "0644";
description = "Mode to set on unix socket";
};
owner = mkOption {
type = types.str;
default = "${cfg.user}";
description = "Owner to set on unix socket";
};
group = mkOption {
type = types.str;
default = "${cfg.group}";
description = "Group to set on unix socket";
};
rawEntry = mkOption {
type = types.str;
internal = true;
};
};
config.rawEntry = let
maybeOption = option:
optionalString options.${option}.isDefined " ${option}=${config.${option}}";
in
if (!(hasPrefix "/" config.socket)) then "${config.socket}"
else "${config.socket}${maybeOption "mode"}${maybeOption "owner"}${maybeOption "group"}";
};
traceWarning = w: x: builtins.trace "warning: ${w}" x;
workerOpts = { name, options, ... }: {
options = {
enable = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Whether to run the rspamd worker.";
};
name = mkOption {
type = types.nullOr types.str;
default = name;
description = "Name of the worker";
};
type = mkOption {
type = types.nullOr (types.enum [
"normal" "controller" "fuzzy" "rspamd_proxy" "lua" "proxy"
]);
description = ''
The type of this worker. The type <literal>proxy</literal> is
deprecated and only kept for backwards compatibility and should be
replaced with <literal>rspamd_proxy</literal>.
'';
apply = let
from = "services.rspamd.workers.\"${name}\".type";
files = options.type.files;
warning = "The option `${from}` defined in ${showFiles files} has enum value `proxy` which has been renamed to `rspamd_proxy`";
in x: if x == "proxy" then traceWarning warning "rspamd_proxy" else x;
};
bindSockets = mkOption {
type = types.listOf (types.either types.str (types.submodule bindSocketOpts));
default = [];
description = ''
List of sockets to listen, in format acceptable by rspamd
'';
example = [{
socket = "/run/rspamd.sock";
mode = "0666";
owner = "rspamd";
} "*:11333"];
apply = value: map (each: if (isString each)
then if (isUnixSocket each)
then {socket = each; owner = cfg.user; group = cfg.group; mode = "0644"; rawEntry = "${each}";}
else {socket = each; rawEntry = "${each}";}
else each) value;
};
count = mkOption {
type = types.nullOr types.int;
default = null;
description = ''
Number of worker instances to run
'';
};
includes = mkOption {
type = types.listOf types.str;
default = [];
description = ''
List of files to include in configuration
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Additional entries to put verbatim into worker section of rspamd config file.";
};
};
config = mkIf (name == "normal" || name == "controller" || name == "fuzzy" || name == "rspamd_proxy") {
type = mkDefault name;
includes = mkDefault [ "$CONFDIR/worker-${if name == "rspamd_proxy" then "proxy" else name}.inc" ];
bindSockets =
let
unixSocket = name: {
mode = "0660";
socket = "/run/rspamd/${name}.sock";
owner = cfg.user;
group = cfg.group;
};
in mkDefault (if name == "normal" then [(unixSocket "rspamd")]
else if name == "controller" then [ "localhost:11334" ]
else if name == "rspamd_proxy" then [ (unixSocket "proxy") ]
else [] );
};
};
isUnixSocket = socket: hasPrefix "/" (if (isString socket) then socket else socket.socket);
mkBindSockets = enabled: socks: concatStringsSep "\n "
(flatten (map (each: "bind_socket = \"${each.rawEntry}\";") socks));
rspamdConfFile = pkgs.writeText "rspamd.conf"
''
.include "$CONFDIR/common.conf"
options {
pidfile = "$RUNDIR/rspamd.pid";
.include "$CONFDIR/options.inc"
.include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/options.inc"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/options.inc"
}
logging {
type = "syslog";
.include "$CONFDIR/logging.inc"
.include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/logging.inc"
.include(try=true; priority=10) "$LOCAL_CONFDIR/override.d/logging.inc"
}
${concatStringsSep "\n" (mapAttrsToList (name: value: let
includeName = if name == "rspamd_proxy" then "proxy" else name;
tryOverride = boolToString (value.extraConfig == "");
in ''
worker "${value.type}" {
type = "${value.type}";
${optionalString (value.enable != null)
"enabled = ${if value.enable != false then "yes" else "no"};"}
${mkBindSockets value.enable value.bindSockets}
${optionalString (value.count != null) "count = ${toString value.count};"}
${concatStringsSep "\n " (map (each: ".include \"${each}\"") value.includes)}
.include(try=true; priority=1,duplicate=merge) "$LOCAL_CONFDIR/local.d/worker-${includeName}.inc"
.include(try=${tryOverride}; priority=10) "$LOCAL_CONFDIR/override.d/worker-${includeName}.inc"
}
'') cfg.workers)}
${optionalString (cfg.extraConfig != "") ''
.include(priority=10) "$LOCAL_CONFDIR/override.d/extra-config.inc"
''}
'';
filterFiles = files: filterAttrs (n: v: v.enable) files;
rspamdDir = pkgs.linkFarm "etc-rspamd-dir" (
(mapAttrsToList (name: file: { name = "local.d/${name}"; path = file.source; }) (filterFiles cfg.locals)) ++
(mapAttrsToList (name: file: { name = "override.d/${name}"; path = file.source; }) (filterFiles cfg.overrides)) ++
(optional (cfg.localLuaRules != null) { name = "rspamd.local.lua"; path = cfg.localLuaRules; }) ++
[ { name = "rspamd.conf"; path = rspamdConfFile; } ]
);
configFileModule = prefix: { name, config, ... }: {
options = {
enable = mkOption {
type = types.bool;
default = true;
description = ''
Whether this file ${prefix} should be generated. This
option allows specific ${prefix} files to be disabled.
'';
};
text = mkOption {
default = null;
type = types.nullOr types.lines;
description = "Text of the file.";
};
source = mkOption {
type = types.path;
description = "Path of the source file.";
};
};
config = {
source = mkIf (config.text != null) (
let name' = "rspamd-${prefix}-" + baseNameOf name;
in mkDefault (pkgs.writeText name' config.text));
};
};
configOverrides =
(mapAttrs' (n: v: nameValuePair "worker-${if n == "rspamd_proxy" then "proxy" else n}.inc" {
text = v.extraConfig;
})
(filterAttrs (n: v: v.extraConfig != "") cfg.workers))
// (if cfg.extraConfig == "" then {} else {
"extra-config.inc".text = cfg.extraConfig;
});
in
{
###### interface
options = {
services.rspamd = {
enable = mkEnableOption "rspamd, the Rapid spam filtering system";
debug = mkOption {
type = types.bool;
default = false;
description = "Whether to run the rspamd daemon in debug mode.";
};
locals = mkOption {
type = with types; attrsOf (submodule (configFileModule "locals"));
default = {};
description = ''
Local configuration files, written into <filename>/etc/rspamd/local.d/{name}</filename>.
'';
example = literalExpression ''
{ "redis.conf".source = "/nix/store/.../etc/dir/redis.conf";
"arc.conf".text = "allow_envfrom_empty = true;";
}
'';
};
overrides = mkOption {
type = with types; attrsOf (submodule (configFileModule "overrides"));
default = {};
description = ''
Overridden configuration files, written into <filename>/etc/rspamd/override.d/{name}</filename>.
'';
example = literalExpression ''
{ "redis.conf".source = "/nix/store/.../etc/dir/redis.conf";
"arc.conf".text = "allow_envfrom_empty = true;";
}
'';
};
localLuaRules = mkOption {
default = null;
type = types.nullOr types.path;
description = ''
Path of file to link to <filename>/etc/rspamd/rspamd.local.lua</filename> for local
rules written in Lua
'';
};
workers = mkOption {
type = with types; attrsOf (submodule workerOpts);
description = ''
Attribute set of workers to start.
'';
default = {
normal = {};
controller = {};
};
example = literalExpression ''
{
normal = {
includes = [ "$CONFDIR/worker-normal.inc" ];
bindSockets = [{
socket = "/run/rspamd/rspamd.sock";
mode = "0660";
owner = "''${config.${opt.user}}";
group = "''${config.${opt.group}}";
}];
};
controller = {
includes = [ "$CONFDIR/worker-controller.inc" ];
bindSockets = [ "[::1]:11334" ];
};
}
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = ''
Extra configuration to add at the end of the rspamd configuration
file.
'';
};
user = mkOption {
type = types.str;
default = "rspamd";
description = ''
User to use when no root privileges are required.
'';
};
group = mkOption {
type = types.str;
default = "rspamd";
description = ''
Group to use when no root privileges are required.
'';
};
postfix = {
enable = mkOption {
type = types.bool;
default = false;
description = "Add rspamd milter to postfix main.conf";
};
config = mkOption {
type = with types; attrsOf (oneOf [ bool str (listOf str) ]);
description = ''
Addon to postfix configuration
'';
default = {
smtpd_milters = ["unix:/run/rspamd/rspamd-milter.sock"];
non_smtpd_milters = ["unix:/run/rspamd/rspamd-milter.sock"];
};
};
};
};
};
###### implementation
config = mkIf cfg.enable {
services.rspamd.overrides = configOverrides;
services.rspamd.workers = mkIf cfg.postfix.enable {
controller = {};
rspamd_proxy = {
bindSockets = [ {
mode = "0660";
socket = "/run/rspamd/rspamd-milter.sock";
owner = cfg.user;
group = postfixCfg.group;
} ];
extraConfig = ''
upstream "local" {
default = yes; # Self-scan upstreams are always default
self_scan = yes; # Enable self-scan
}
'';
};
};
services.postfix.config = mkIf cfg.postfix.enable cfg.postfix.config;
systemd.services.postfix = mkIf cfg.postfix.enable {
serviceConfig.SupplementaryGroups = [ postfixCfg.group ];
};
# Allow users to run 'rspamc' and 'rspamadm'.
environment.systemPackages = [ pkgs.rspamd ];
users.users.${cfg.user} = {
description = "rspamd daemon";
uid = config.ids.uids.rspamd;
group = cfg.group;
};
users.groups.${cfg.group} = {
gid = config.ids.gids.rspamd;
};
environment.etc.rspamd.source = rspamdDir;
systemd.services.rspamd = {
description = "Rspamd Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
restartTriggers = [ rspamdDir ];
serviceConfig = {
ExecStart = "${pkgs.rspamd}/bin/rspamd ${optionalString cfg.debug "-d"} -c /etc/rspamd/rspamd.conf -f";
Restart = "always";
User = "${cfg.user}";
Group = "${cfg.group}";
SupplementaryGroups = mkIf cfg.postfix.enable [ postfixCfg.group ];
RuntimeDirectory = "rspamd";
RuntimeDirectoryMode = "0755";
StateDirectory = "rspamd";
StateDirectoryMode = "0700";
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
# we need to chown socket to rspamd-milter
PrivateUsers = !cfg.postfix.enable;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
UMask = "0077";
};
};
};
imports = [
(mkRemovedOptionModule [ "services" "rspamd" "socketActivation" ]
"Socket activation never worked correctly and could at this time not be fixed and so was removed")
(mkRenamedOptionModule [ "services" "rspamd" "bindSocket" ] [ "services" "rspamd" "workers" "normal" "bindSockets" ])
(mkRenamedOptionModule [ "services" "rspamd" "bindUISocket" ] [ "services" "rspamd" "workers" "controller" "bindSockets" ])
(mkRemovedOptionModule [ "services" "rmilter" ] "Use services.rspamd.* instead to set up milter service")
];
}

View file

@ -0,0 +1,135 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.rss2email;
in {
###### interface
options = {
services.rss2email = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable rss2email.";
};
to = mkOption {
type = types.str;
description = "Mail address to which to send emails";
};
interval = mkOption {
type = types.str;
default = "12h";
description = "How often to check the feeds, in systemd interval format";
};
config = mkOption {
type = with types; attrsOf (oneOf [ str int bool ]);
default = {};
description = ''
The configuration to give rss2email.
Default will use system-wide <literal>sendmail</literal> to send the
email. This is rss2email's default when running
<literal>r2e new</literal>.
This set contains key-value associations that will be set in the
<literal>[DEFAULT]</literal> block along with the
<literal>to</literal> parameter.
See <literal>man r2e</literal> for more information on which
parameters are accepted.
'';
};
feeds = mkOption {
description = "The feeds to watch.";
type = types.attrsOf (types.submodule {
options = {
url = mkOption {
type = types.str;
description = "The URL at which to fetch the feed.";
};
to = mkOption {
type = with types; nullOr str;
default = null;
description = ''
Email address to which to send feed items.
If <literal>null</literal>, this will not be set in the
configuration file, and rss2email will make it default to
<literal>rss2email.to</literal>.
'';
};
};
});
};
};
};
###### implementation
config = mkIf cfg.enable {
users.groups = {
rss2email.gid = config.ids.gids.rss2email;
};
users.users = {
rss2email = {
description = "rss2email user";
uid = config.ids.uids.rss2email;
group = "rss2email";
};
};
environment.systemPackages = with pkgs; [ rss2email ];
services.rss2email.config.to = cfg.to;
systemd.tmpfiles.rules = [
"d /var/rss2email 0700 rss2email rss2email - -"
];
systemd.services.rss2email = let
conf = pkgs.writeText "rss2email.cfg" (lib.generators.toINI {} ({
DEFAULT = cfg.config;
} // lib.mapAttrs' (name: feed: nameValuePair "feed.${name}" (
{ inherit (feed) url; } //
lib.optionalAttrs (feed.to != null) { inherit (feed) to; }
)) cfg.feeds
));
in
{
preStart = ''
cp ${conf} /var/rss2email/conf.cfg
if [ ! -f /var/rss2email/db.json ]; then
echo '{"version":2,"feeds":[]}' > /var/rss2email/db.json
fi
'';
path = [ pkgs.system-sendmail ];
serviceConfig = {
ExecStart =
"${pkgs.rss2email}/bin/r2e -c /var/rss2email/conf.cfg -d /var/rss2email/db.json run";
User = "rss2email";
};
};
systemd.timers.rss2email = {
partOf = [ "rss2email.service" ];
wantedBy = [ "timers.target" ];
timerConfig.OnBootSec = "0";
timerConfig.OnUnitActiveSec = cfg.interval;
};
};
meta.maintainers = with lib.maintainers; [ ekleog ];
}

View file

@ -0,0 +1,191 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.spamassassin;
spamassassin-local-cf = pkgs.writeText "local.cf" cfg.config;
in
{
options = {
services.spamassassin = {
enable = mkEnableOption "the SpamAssassin daemon";
debug = mkOption {
type = types.bool;
default = false;
description = "Whether to run the SpamAssassin daemon in debug mode";
};
config = mkOption {
type = types.lines;
description = ''
The SpamAssassin local.cf config
If you are using this configuration:
add_header all Status _YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_
Then you can Use this sieve filter:
require ["fileinto", "reject", "envelope"];
if header :contains "X-Spam-Flag" "YES" {
fileinto "spam";
}
Or this procmail filter:
:0:
* ^X-Spam-Flag: YES
/var/vpopmail/domains/lastlog.de/js/.maildir/.spam/new
To filter your messages based on the additional mail headers added by spamassassin.
'';
example = ''
#rewrite_header Subject [***** SPAM _SCORE_ *****]
required_score 5.0
use_bayes 1
bayes_auto_learn 1
add_header all Status _YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_
'';
default = "";
};
initPreConf = mkOption {
type = with types; either str path;
description = "The SpamAssassin init.pre config.";
apply = val: if builtins.isPath val then val else pkgs.writeText "init.pre" val;
default =
''
#
# to update this list, run this command in the rules directory:
# grep 'loadplugin.*Mail::SpamAssassin::Plugin::.*' -o -h * | sort | uniq
#
#loadplugin Mail::SpamAssassin::Plugin::AccessDB
#loadplugin Mail::SpamAssassin::Plugin::AntiVirus
loadplugin Mail::SpamAssassin::Plugin::AskDNS
# loadplugin Mail::SpamAssassin::Plugin::ASN
loadplugin Mail::SpamAssassin::Plugin::AutoLearnThreshold
#loadplugin Mail::SpamAssassin::Plugin::AWL
loadplugin Mail::SpamAssassin::Plugin::Bayes
loadplugin Mail::SpamAssassin::Plugin::BodyEval
loadplugin Mail::SpamAssassin::Plugin::Check
#loadplugin Mail::SpamAssassin::Plugin::DCC
loadplugin Mail::SpamAssassin::Plugin::DKIM
loadplugin Mail::SpamAssassin::Plugin::DNSEval
loadplugin Mail::SpamAssassin::Plugin::FreeMail
loadplugin Mail::SpamAssassin::Plugin::Hashcash
loadplugin Mail::SpamAssassin::Plugin::HeaderEval
loadplugin Mail::SpamAssassin::Plugin::HTMLEval
loadplugin Mail::SpamAssassin::Plugin::HTTPSMismatch
loadplugin Mail::SpamAssassin::Plugin::ImageInfo
loadplugin Mail::SpamAssassin::Plugin::MIMEEval
loadplugin Mail::SpamAssassin::Plugin::MIMEHeader
# loadplugin Mail::SpamAssassin::Plugin::PDFInfo
#loadplugin Mail::SpamAssassin::Plugin::PhishTag
loadplugin Mail::SpamAssassin::Plugin::Pyzor
loadplugin Mail::SpamAssassin::Plugin::Razor2
# loadplugin Mail::SpamAssassin::Plugin::RelayCountry
loadplugin Mail::SpamAssassin::Plugin::RelayEval
loadplugin Mail::SpamAssassin::Plugin::ReplaceTags
# loadplugin Mail::SpamAssassin::Plugin::Rule2XSBody
# loadplugin Mail::SpamAssassin::Plugin::Shortcircuit
loadplugin Mail::SpamAssassin::Plugin::SpamCop
loadplugin Mail::SpamAssassin::Plugin::SPF
#loadplugin Mail::SpamAssassin::Plugin::TextCat
# loadplugin Mail::SpamAssassin::Plugin::TxRep
loadplugin Mail::SpamAssassin::Plugin::URIDetail
loadplugin Mail::SpamAssassin::Plugin::URIDNSBL
loadplugin Mail::SpamAssassin::Plugin::URIEval
# loadplugin Mail::SpamAssassin::Plugin::URILocalBL
loadplugin Mail::SpamAssassin::Plugin::VBounce
loadplugin Mail::SpamAssassin::Plugin::WhiteListSubject
loadplugin Mail::SpamAssassin::Plugin::WLBLEval
'';
};
};
};
config = mkIf cfg.enable {
environment.etc."mail/spamassassin/init.pre".source = cfg.initPreConf;
environment.etc."mail/spamassassin/local.cf".source = spamassassin-local-cf;
# Allow users to run 'spamc'.
environment.systemPackages = [ pkgs.spamassassin ];
users.users.spamd = {
description = "Spam Assassin Daemon";
uid = config.ids.uids.spamd;
group = "spamd";
};
users.groups.spamd = {
gid = config.ids.gids.spamd;
};
systemd.services.sa-update = {
# Needs to be able to contact the update server.
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = "spamd";
Group = "spamd";
StateDirectory = "spamassassin";
ExecStartPost = "+${config.systemd.package}/bin/systemctl -q --no-block try-reload-or-restart spamd.service";
};
script = ''
set +e
${pkgs.spamassassin}/bin/sa-update --verbose --gpghomedir=/var/lib/spamassassin/sa-update-keys/
rc=$?
set -e
if [[ $rc -gt 1 ]]; then
# sa-update failed.
exit $rc
fi
if [[ $rc -eq 1 ]]; then
# No update was available, exit successfully.
exit 0
fi
# An update was available and installed. Compile the rules.
${pkgs.spamassassin}/bin/sa-compile
'';
};
systemd.timers.sa-update = {
description = "sa-update-service";
partOf = [ "sa-update.service" ];
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "1:*";
Persistent = true;
};
};
systemd.services.spamd = {
description = "SpamAssassin Server";
wantedBy = [ "multi-user.target" ];
wants = [ "sa-update.service" ];
after = [
"network.target"
"sa-update.service"
];
serviceConfig = {
User = "spamd";
Group = "spamd";
ExecStart = "+${pkgs.spamassassin}/bin/spamd ${optionalString cfg.debug "-D"} --username=spamd --groupname=spamd --virtual-config-dir=%S/spamassassin/user-%u --allow-tell --pidfile=/run/spamd.pid";
ExecReload = "+${pkgs.coreutils}/bin/kill -HUP $MAINPID";
StateDirectory = "spamassassin";
};
};
};
}

View file

@ -0,0 +1,590 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.sympa;
dataDir = "/var/lib/sympa";
user = "sympa";
group = "sympa";
pkg = pkgs.sympa;
fqdns = attrNames cfg.domains;
usingNginx = cfg.web.enable && cfg.web.server == "nginx";
mysqlLocal = cfg.database.createLocally && cfg.database.type == "MySQL";
pgsqlLocal = cfg.database.createLocally && cfg.database.type == "PostgreSQL";
sympaSubServices = [
"sympa-archive.service"
"sympa-bounce.service"
"sympa-bulk.service"
"sympa-task.service"
];
# common for all services including wwsympa
commonServiceConfig = {
StateDirectory = "sympa";
ProtectHome = true;
ProtectSystem = "full";
ProtectControlGroups = true;
};
# wwsympa has its own service config
sympaServiceConfig = srv: {
Type = "simple";
Restart = "always";
ExecStart = "${pkg}/bin/${srv}.pl --foreground";
PIDFile = "/run/sympa/${srv}.pid";
User = user;
Group = group;
# avoid duplicating log messageges in journal
StandardError = "null";
} // commonServiceConfig;
configVal = value:
if isBool value then
if value then "on" else "off"
else toString value;
configGenerator = c: concatStrings (flip mapAttrsToList c (key: val: "${key}\t${configVal val}\n"));
mainConfig = pkgs.writeText "sympa.conf" (configGenerator cfg.settings);
robotConfig = fqdn: domain: pkgs.writeText "${fqdn}-robot.conf" (configGenerator domain.settings);
transport = pkgs.writeText "transport.sympa" (concatStringsSep "\n" (flip map fqdns (domain: ''
${domain} error:User unknown in recipient table
sympa@${domain} sympa:sympa@${domain}
listmaster@${domain} sympa:listmaster@${domain}
bounce@${domain} sympabounce:sympa@${domain}
abuse-feedback-report@${domain} sympabounce:sympa@${domain}
'')));
virtual = pkgs.writeText "virtual.sympa" (concatStringsSep "\n" (flip map fqdns (domain: ''
sympa-request@${domain} postmaster@localhost
sympa-owner@${domain} postmaster@localhost
'')));
listAliases = pkgs.writeText "list_aliases.tt2" ''
#--- [% list.name %]@[% list.domain %]: list transport map created at [% date %]
[% list.name %]@[% list.domain %] sympa:[% list.name %]@[% list.domain %]
[% list.name %]-request@[% list.domain %] sympa:[% list.name %]-request@[% list.domain %]
[% list.name %]-editor@[% list.domain %] sympa:[% list.name %]-editor@[% list.domain %]
#[% list.name %]-subscribe@[% list.domain %] sympa:[% list.name %]-subscribe@[%list.domain %]
[% list.name %]-unsubscribe@[% list.domain %] sympa:[% list.name %]-unsubscribe@[% list.domain %]
[% list.name %][% return_path_suffix %]@[% list.domain %] sympabounce:[% list.name %]@[% list.domain %]
'';
enabledFiles = filterAttrs (n: v: v.enable) cfg.settingsFile;
in
{
###### interface
options.services.sympa = with types; {
enable = mkEnableOption "Sympa mailing list manager";
lang = mkOption {
type = str;
default = "en_US";
example = "cs";
description = ''
Default Sympa language.
See <link xlink:href='https://github.com/sympa-community/sympa/tree/sympa-6.2/po/sympa' />
for available options.
'';
};
listMasters = mkOption {
type = listOf str;
example = [ "postmaster@sympa.example.org" ];
description = ''
The list of the email addresses of the listmasters
(users authorized to perform global server commands).
'';
};
mainDomain = mkOption {
type = nullOr str;
default = null;
example = "lists.example.org";
description = ''
Main domain to be used in <filename>sympa.conf</filename>.
If <literal>null</literal>, one of the <option>services.sympa.domains</option> is chosen for you.
'';
};
domains = mkOption {
type = attrsOf (submodule ({ name, config, ... }: {
options = {
webHost = mkOption {
type = nullOr str;
default = null;
example = "archive.example.org";
description = ''
Domain part of the web interface URL (no web interface for this domain if <literal>null</literal>).
DNS record of type A (or AAAA or CNAME) has to exist with this value.
'';
};
webLocation = mkOption {
type = str;
default = "/";
example = "/sympa";
description = "URL path part of the web interface.";
};
settings = mkOption {
type = attrsOf (oneOf [ str int bool ]);
default = {};
example = {
default_max_list_members = 3;
};
description = ''
The <filename>robot.conf</filename> configuration file as key value set.
See <link xlink:href='https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html' />
for list of configuration parameters.
'';
};
};
config.settings = mkIf (cfg.web.enable && config.webHost != null) {
wwsympa_url = mkDefault "https://${config.webHost}${strings.removeSuffix "/" config.webLocation}";
};
}));
description = ''
Email domains handled by this instance. There have
to be MX records for keys of this attribute set.
'';
example = literalExpression ''
{
"lists.example.org" = {
webHost = "lists.example.org";
webLocation = "/";
};
"sympa.example.com" = {
webHost = "example.com";
webLocation = "/sympa";
};
}
'';
};
database = {
type = mkOption {
type = enum [ "SQLite" "PostgreSQL" "MySQL" ];
default = "SQLite";
example = "MySQL";
description = "Database engine to use.";
};
host = mkOption {
type = nullOr str;
default = null;
description = ''
Database host address.
For MySQL, use <literal>localhost</literal> to connect using Unix domain socket.
For PostgreSQL, use path to directory (e.g. <filename>/run/postgresql</filename>)
to connect using Unix domain socket located in this directory.
Use <literal>null</literal> to fall back on Sympa default, or when using
<option>services.sympa.database.createLocally</option>.
'';
};
port = mkOption {
type = nullOr port;
default = null;
description = "Database port. Use <literal>null</literal> for default port.";
};
name = mkOption {
type = str;
default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa";
defaultText = literalExpression ''if database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"'';
description = ''
Database name. When using SQLite this must be an absolute
path to the database file.
'';
};
user = mkOption {
type = nullOr str;
default = user;
description = "Database user. The system user name is used as a default.";
};
passwordFile = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/sympa-dbpassword";
description = ''
A file containing the password for <option>services.sympa.database.user</option>.
'';
};
createLocally = mkOption {
type = bool;
default = true;
description = "Whether to create a local database automatically.";
};
};
web = {
enable = mkOption {
type = bool;
default = true;
description = "Whether to enable Sympa web interface.";
};
server = mkOption {
type = enum [ "nginx" "none" ];
default = "nginx";
description = ''
The webserver used for the Sympa web interface. Set it to `none` if you want to configure it yourself.
Further nginx configuration can be done by adapting
<option>services.nginx.virtualHosts.<replaceable>name</replaceable></option>.
'';
};
https = mkOption {
type = bool;
default = true;
description = ''
Whether to use HTTPS. When nginx integration is enabled, this option forces SSL and enables ACME.
Please note that Sympa web interface always uses https links even when this option is disabled.
'';
};
fcgiProcs = mkOption {
type = ints.positive;
default = 2;
description = "Number of FastCGI processes to fork.";
};
};
mta = {
type = mkOption {
type = enum [ "postfix" "none" ];
default = "postfix";
description = ''
Mail transfer agent (MTA) integration. Use <literal>none</literal> if you want to configure it yourself.
The <literal>postfix</literal> integration sets up local Postfix instance that will pass incoming
messages from configured domains to Sympa. You still need to configure at least outgoing message
handling using e.g. <option>services.postfix.relayHost</option>.
'';
};
};
settings = mkOption {
type = attrsOf (oneOf [ str int bool ]);
default = {};
example = literalExpression ''
{
default_home = "lists";
viewlogs_page_size = 50;
}
'';
description = ''
The <filename>sympa.conf</filename> configuration file as key value set.
See <link xlink:href='https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html' />
for list of configuration parameters.
'';
};
settingsFile = mkOption {
type = attrsOf (submodule ({ name, config, ... }: {
options = {
enable = mkOption {
type = bool;
default = true;
description = "Whether this file should be generated. This option allows specific files to be disabled.";
};
text = mkOption {
default = null;
type = nullOr lines;
description = "Text of the file.";
};
source = mkOption {
type = path;
description = "Path of the source file.";
};
};
config.source = mkIf (config.text != null) (mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text));
}));
default = {};
example = literalExpression ''
{
"list_data/lists.example.org/help" = {
text = "subject This list provides help to users";
};
}
'';
description = "Set of files to be linked in <filename>${dataDir}</filename>.";
};
};
###### implementation
config = mkIf cfg.enable {
services.sympa.settings = (mapAttrs (_: v: mkDefault v) {
domain = if cfg.mainDomain != null then cfg.mainDomain else head fqdns;
listmaster = concatStringsSep "," cfg.listMasters;
lang = cfg.lang;
home = "${dataDir}/list_data";
arc_path = "${dataDir}/arc";
bounce_path = "${dataDir}/bounce";
sendmail = "${pkgs.system-sendmail}/bin/sendmail";
db_type = cfg.database.type;
db_name = cfg.database.name;
}
// (optionalAttrs (cfg.database.host != null) {
db_host = cfg.database.host;
})
// (optionalAttrs mysqlLocal {
db_host = "localhost"; # use unix domain socket
})
// (optionalAttrs pgsqlLocal {
db_host = "/run/postgresql"; # use unix domain socket
})
// (optionalAttrs (cfg.database.port != null) {
db_port = cfg.database.port;
})
// (optionalAttrs (cfg.database.user != null) {
db_user = cfg.database.user;
})
// (optionalAttrs (cfg.mta.type == "postfix") {
sendmail_aliases = "${dataDir}/sympa_transport";
aliases_program = "${pkgs.postfix}/bin/postmap";
aliases_db_type = "hash";
})
// (optionalAttrs cfg.web.enable {
static_content_path = "${dataDir}/static_content";
css_path = "${dataDir}/static_content/css";
pictures_path = "${dataDir}/static_content/pictures";
mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc";
}));
services.sympa.settingsFile = {
"virtual.sympa" = mkDefault { source = virtual; };
"transport.sympa" = mkDefault { source = transport; };
"etc/list_aliases.tt2" = mkDefault { source = listAliases; };
}
// (flip mapAttrs' cfg.domains (fqdn: domain:
nameValuePair "etc/${fqdn}/robot.conf" (mkDefault { source = robotConfig fqdn domain; })));
environment = {
systemPackages = [ pkg ];
};
users.users.${user} = {
description = "Sympa mailing list manager user";
group = group;
home = dataDir;
createHome = false;
isSystemUser = true;
};
users.groups.${group} = {};
assertions = [
{ assertion = cfg.database.createLocally -> cfg.database.user == user;
message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true";
}
{ assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = "a password cannot be specified if services.sympa.database.createLocally is set to true";
}
];
systemd.tmpfiles.rules = [
"d ${dataDir} 0711 ${user} ${group} - -"
"d ${dataDir}/etc 0700 ${user} ${group} - -"
"d ${dataDir}/spool 0700 ${user} ${group} - -"
"d ${dataDir}/list_data 0700 ${user} ${group} - -"
"d ${dataDir}/arc 0700 ${user} ${group} - -"
"d ${dataDir}/bounce 0700 ${user} ${group} - -"
"f ${dataDir}/sympa_transport 0600 ${user} ${group} - -"
# force-copy static_content so it's up to date with package
# set permissions for wwsympa which needs write access (...)
"R ${dataDir}/static_content - - - - -"
"C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/var/lib/sympa/static_content"
"e ${dataDir}/static_content/* 0711 ${user} ${group} - -"
"d /run/sympa 0755 ${user} ${group} - -"
]
++ (flip concatMap fqdns (fqdn: [
"d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
"d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
]))
#++ (flip mapAttrsToList enabledFiles (k: v:
# "L+ ${dataDir}/${k} - - - - ${v.source}"
#))
++ (concatLists (flip mapAttrsToList enabledFiles (k: v: [
# sympa doesn't handle symlinks well (e.g. fails to create locks)
# force-copy instead
"R ${dataDir}/${k} - - - - -"
"C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}"
])));
systemd.services.sympa = {
description = "Sympa mailing list manager";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = sympaSubServices;
before = sympaSubServices;
serviceConfig = sympaServiceConfig "sympa_msg";
preStart = ''
umask 0077
cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
${optionalString (cfg.database.passwordFile != null) ''
chmod u+w ${dataDir}/etc/sympa.conf
echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf
cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf
''}
${optionalString (cfg.mta.type == "postfix") ''
${pkgs.postfix}/bin/postmap hash:${dataDir}/virtual.sympa
${pkgs.postfix}/bin/postmap hash:${dataDir}/transport.sympa
''}
${pkg}/bin/sympa_newaliases.pl
${pkg}/bin/sympa.pl --health_check
'';
};
systemd.services.sympa-archive = {
description = "Sympa mailing list manager (archiving)";
bindsTo = [ "sympa.service" ];
serviceConfig = sympaServiceConfig "archived";
};
systemd.services.sympa-bounce = {
description = "Sympa mailing list manager (bounce processing)";
bindsTo = [ "sympa.service" ];
serviceConfig = sympaServiceConfig "bounced";
};
systemd.services.sympa-bulk = {
description = "Sympa mailing list manager (message distribution)";
bindsTo = [ "sympa.service" ];
serviceConfig = sympaServiceConfig "bulk";
};
systemd.services.sympa-task = {
description = "Sympa mailing list manager (task management)";
bindsTo = [ "sympa.service" ];
serviceConfig = sympaServiceConfig "task_manager";
};
systemd.services.wwsympa = mkIf usingNginx {
wantedBy = [ "multi-user.target" ];
after = [ "sympa.service" ];
serviceConfig = {
Type = "forking";
PIDFile = "/run/sympa/wwsympa.pid";
Restart = "always";
ExecStart = ''${pkgs.spawn_fcgi}/bin/spawn-fcgi \
-u ${user} \
-g ${group} \
-U nginx \
-M 0600 \
-F ${toString cfg.web.fcgiProcs} \
-P /run/sympa/wwsympa.pid \
-s /run/sympa/wwsympa.socket \
-- ${pkg}/lib/sympa/cgi/wwsympa.fcgi
'';
} // commonServiceConfig;
};
services.nginx.enable = mkIf usingNginx true;
services.nginx.virtualHosts = mkIf usingNginx (let
vHosts = unique (remove null (mapAttrsToList (_k: v: v.webHost) cfg.domains));
hostLocations = host: map (v: v.webLocation) (filter (v: v.webHost == host) (attrValues cfg.domains));
httpsOpts = optionalAttrs cfg.web.https { forceSSL = mkDefault true; enableACME = mkDefault true; };
in
genAttrs vHosts (host: {
locations = genAttrs (hostLocations host) (loc: {
extraConfig = ''
include ${config.services.nginx.package}/conf/fastcgi_params;
fastcgi_pass unix:/run/sympa/wwsympa.socket;
'';
}) // {
"/static-sympa/".alias = "${dataDir}/static_content/";
};
} // httpsOpts));
services.postfix = mkIf (cfg.mta.type == "postfix") {
enable = true;
recipientDelimiter = "+";
config = {
virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ];
virtual_mailbox_maps = [
"hash:${dataDir}/transport.sympa"
"hash:${dataDir}/sympa_transport"
"hash:${dataDir}/virtual.sympa"
];
virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ];
transport_maps = [
"hash:${dataDir}/transport.sympa"
"hash:${dataDir}/sympa_transport"
];
};
masterConfig = {
"sympa" = {
type = "unix";
privileged = true;
chroot = false;
command = "pipe";
args = [
"flags=hqRu"
"user=${user}"
"argv=${pkg}/libexec/queue"
"\${nexthop}"
];
};
"sympabounce" = {
type = "unix";
privileged = true;
chroot = false;
command = "pipe";
args = [
"flags=hqRu"
"user=${user}"
"argv=${pkg}/libexec/bouncequeue"
"\${nexthop}"
];
};
};
};
services.mysql = optionalAttrs mysqlLocal {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{ name = cfg.database.user;
ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
}
];
};
services.postgresql = optionalAttrs pgsqlLocal {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{ name = cfg.database.user;
ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; };
}
];
};
};
meta.maintainers = with maintainers; [ mmilata sorki ];
}