push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.automx2;
format = pkgs.formats.json { };
in
{
options = {
services.automx2 = {
enable = lib.mkEnableOption "automx2";
package = lib.mkPackageOption pkgs [
"python3Packages"
"automx2"
] { };
domain = lib.mkOption {
type = lib.types.str;
example = "example.com";
description = ''
E-Mail-Domain for which mail client autoconfig/autoconfigure should be set up.
The `autoconfig` and `autodiscover` subdomains are automatically prepended and set up with ACME.
The names of those domains are hardcoded in the mail clients and are not configurable.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 4243;
description = "Port used by automx2.";
};
settings = lib.mkOption {
inherit (format) type;
description = ''
Bootstrap json to populate database.
See [docs](https://rseichter.github.io/automx2/#_sqlite) for details.
'';
};
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = true;
virtualHosts = {
"autoconfig.${cfg.domain}" = {
enableACME = true;
forceSSL = true;
serverAliases = [ "autodiscover.${cfg.domain}" ];
locations = {
"/".proxyPass = "http://127.0.0.1:${toString cfg.port}/";
"/initdb".extraConfig = ''
# Limit access to clients connecting from localhost
allow 127.0.0.1;
deny all;
'';
};
};
};
};
systemd.services.automx2 = {
after = [ "network.target" ];
postStart = ''
sleep 3
${lib.getExe pkgs.curl} -X POST --json @${format.generate "automx2.json" cfg.settings} http://127.0.0.1:${toString cfg.port}/initdb/
'';
serviceConfig = {
Environment = [
"AUTOMX2_CONF=${pkgs.writeText "automx2-conf" ''
[automx2]
loglevel = WARNING
db_uri = sqlite:///:memory:
proxy_count = 1
''}"
"FLASK_APP=automx2.server:app"
"FLASK_CONFIG=production"
];
ExecStart = "${
pkgs.python3.buildEnv.override { extraLibs = [ cfg.package ]; }
}/bin/flask run --host=127.0.0.1 --port=${toString cfg.port}";
Restart = "always";
StateDirectory = "automx2";
User = "automx2";
WorkingDirectory = "/var/lib/automx2";
};
unitConfig = {
Description = "MUA configuration service";
Documentation = "https://rseichter.github.io/automx2/";
};
wantedBy = [ "multi-user.target" ];
};
users = {
groups.automx2 = { };
users.automx2 = {
group = "automx2";
isSystemUser = true;
};
};
};
}

View File

@@ -0,0 +1,375 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.cyrus-imap;
cyrus-imapdPkg = pkgs.cyrus-imapd;
inherit (lib)
mkEnableOption
mkIf
mkOption
optionalAttrs
optionalString
generators
mapAttrsToList
;
inherit (lib.strings) concatStringsSep;
inherit (lib.types)
attrsOf
submodule
listOf
oneOf
str
int
bool
nullOr
path
;
mkCyrusConfig =
settings:
let
mkCyrusOptionsList =
v:
mapAttrsToList (
p: q:
if (q != null) then
if builtins.isInt q then
"${p}=${builtins.toString q}"
else
"${p}=\"${if builtins.isList q then (concatStringsSep " " q) else q}\""
else
""
) v;
mkCyrusOptionsString = v: concatStringsSep " " (mkCyrusOptionsList v);
in
concatStringsSep "\n " (mapAttrsToList (n: v: n + " " + (mkCyrusOptionsString v)) settings);
cyrusConfig = lib.concatStringsSep "\n" (
lib.mapAttrsToList (n: v: ''
${n} {
${mkCyrusConfig v}
}
'') cfg.cyrusSettings
);
imapdConfig =
with generators;
toKeyValue {
mkKeyValue = mkKeyValueDefault {
mkValueString =
v:
if builtins.isBool v then
if v then "yes" else "no"
else if builtins.isList v then
concatStringsSep " " v
else
mkValueStringDefault { } v;
} ": ";
} cfg.imapdSettings;
in
{
imports = [
(lib.mkRenamedOptionModule
[ "services" "cyrus-imap" "sslServerCert" ]
[ "services" "cyrus-imap" "imapdSettings" "tls_server_cert" ]
)
(lib.mkRenamedOptionModule
[ "services" "cyrus-imap" "sslServerKey" ]
[ "services" "cyrus-imap" "imapdSettings" "tls_server_key" ]
)
(lib.mkRenamedOptionModule
[ "services" "cyrus-imap" "sslCACert" ]
[ "services" "cyrus-imap" "imapdSettings" "tls_client_ca_file" ]
)
];
options.services.cyrus-imap = {
enable = mkEnableOption "Cyrus IMAP, an email, contacts and calendar server";
debug = mkEnableOption "debugging messages for the Cyrus master process";
listenQueue = mkOption {
type = int;
default = 32;
description = ''
Socket listen queue backlog size. See {manpage}`listen(2)` for more information about a backlog.
Default is 32, which may be increased if you have a very high connection rate.
'';
};
tmpDBDir = mkOption {
type = path;
default = "/run/cyrus/db";
description = ''
Location where DB files are stored.
Databases in this directory are recreated upon startup, so ideally they should live in ephemeral storage for best performance.
'';
};
cyrusSettings = mkOption {
type = submodule {
freeformType = attrsOf (
attrsOf (oneOf [
bool
int
(listOf str)
])
);
options = {
START = mkOption {
default = {
recover = {
cmd = [
"ctl_cyrusdb"
"-r"
];
};
};
description = ''
This section lists the processes to run before any SERVICES are spawned.
This section is typically used to initialize databases.
Master itself will not startup until all tasks in START have completed, so put no blocking commands here.
'';
};
SERVICES = mkOption {
default = {
imap = {
cmd = [ "imapd" ];
listen = "imap";
prefork = 0;
};
pop3 = {
cmd = [ "pop3d" ];
listen = "pop3";
prefork = 0;
};
lmtpunix = {
cmd = [ "lmtpd" ];
listen = "/run/cyrus/lmtp";
prefork = 0;
};
notify = {
cmd = [ "notifyd" ];
listen = "/run/cyrus/notify";
proto = "udp";
prefork = 0;
};
};
description = ''
This section is the heart of the cyrus.conf file. It lists the processes that should be spawned to handle client connections made on certain Internet/UNIX sockets.
'';
};
EVENTS = mkOption {
default = {
tlsprune = {
cmd = [ "tls_prune" ];
at = 400;
};
delprune = {
cmd = [
"cyr_expire"
"-E"
"3"
];
at = 400;
};
deleteprune = {
cmd = [
"cyr_expire"
"-E"
"4"
"-D"
"28"
];
at = 430;
};
expungeprune = {
cmd = [
"cyr_expire"
"-E"
"4"
"-X"
"28"
];
at = 445;
};
checkpoint = {
cmd = [
"ctl_cyrusdb"
"-c"
];
period = 30;
};
};
description = ''
This section lists processes that should be run at specific intervals, similar to cron jobs. This section is typically used to perform scheduled cleanup/maintenance.
'';
};
DAEMON = mkOption {
default = { };
description = ''
This section lists long running daemons to start before any SERVICES are spawned. {manpage}`master(8)` will ensure that these processes are running, restarting any process which dies or forks. All listed processes will be shutdown when {manpage}`master(8)` is exiting.
'';
};
};
};
description = "Cyrus configuration settings. See [cyrus.conf(5)](https://www.cyrusimap.org/imap/reference/manpages/configs/cyrus.conf.html)";
};
imapdSettings = mkOption {
type = submodule {
freeformType = attrsOf (oneOf [
str
int
bool
(listOf str)
]);
options = {
configdirectory = mkOption {
type = path;
default = "/var/lib/cyrus";
description = ''
The pathname of the IMAP configuration directory.
'';
};
lmtpsocket = mkOption {
type = path;
default = "/run/cyrus/lmtp";
description = ''
Unix socket that lmtpd listens on, used by {manpage}`deliver(8)`. This should match the path specified in {manpage}`cyrus.conf(5)`.
'';
};
idlesocket = mkOption {
type = path;
default = "/run/cyrus/idle";
description = ''
Unix socket that idled listens on.
'';
};
notifysocket = mkOption {
type = path;
default = "/run/cyrus/notify";
description = ''
Unix domain socket that the mail notification daemon listens on.
'';
};
};
};
default = {
admins = [ "cyrus" ];
allowplaintext = true;
defaultdomain = "localhost";
defaultpartition = "default";
duplicate_db_path = "/run/cyrus/db/deliver.db";
hashimapspool = true;
httpmodules = [
"carddav"
"caldav"
];
mboxname_lockpath = "/run/cyrus/lock";
partition-default = "/var/lib/cyrus/storage";
popminpoll = 1;
proc_path = "/run/cyrus/proc";
ptscache_db_path = "/run/cyrus/db/ptscache.db";
sasl_auto_transition = true;
sasl_pwcheck_method = [ "saslauthd" ];
sievedir = "/var/lib/cyrus/sieve";
statuscache_db_path = "/run/cyrus/db/statuscache.db";
syslog_prefix = "cyrus";
tls_client_ca_dir = "/etc/ssl/certs";
tls_session_timeout = 1440;
tls_sessions_db_path = "/run/cyrus/db/tls_sessions.db";
virtdomains = "on";
};
description = "IMAP configuration settings. See [imapd.conf(5)](https://www.cyrusimap.org/imap/reference/manpages/configs/imapd.conf.html)";
};
user = mkOption {
type = nullOr str;
default = null;
description = "Cyrus IMAP user name. If this is not set, a user named `cyrus` will be created.";
};
group = mkOption {
type = nullOr str;
default = null;
description = "Cyrus IMAP group name. If this is not set, a group named `cyrus` will be created.";
};
imapdConfigFile = mkOption {
type = nullOr path;
default = null;
description = "Path to the configuration file used for cyrus-imap.";
apply = v: if v != null then v else pkgs.writeText "imapd.conf" imapdConfig;
};
cyrusConfigFile = mkOption {
type = nullOr path;
default = null;
description = "Path to the configuration file used for Cyrus.";
apply = v: if v != null then v else pkgs.writeText "cyrus.conf" cyrusConfig;
};
};
config = mkIf cfg.enable {
users.users.cyrus = optionalAttrs (cfg.user == null) {
description = "Cyrus IMAP user";
isSystemUser = true;
group = optionalString (cfg.group == null) "cyrus";
};
users.groups.cyrus = optionalAttrs (cfg.group == null) { };
environment.etc."imapd.conf".source = cfg.imapdConfigFile;
environment.etc."cyrus.conf".source = cfg.cyrusConfigFile;
systemd.services.cyrus-imap = {
description = "Cyrus IMAP server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [
"/etc/imapd.conf"
"/etc/cyrus.conf"
];
startLimitIntervalSec = 60;
environment = {
CYRUS_VERBOSE = mkIf cfg.debug "1";
LISTENQUEUE = builtins.toString cfg.listenQueue;
};
serviceConfig = {
User = if (cfg.user == null) then "cyrus" else cfg.user;
Group = if (cfg.group == null) then "cyrus" else cfg.group;
Type = "simple";
ExecStart = "${cyrus-imapdPkg}/libexec/master -l $LISTENQUEUE -C /etc/imapd.conf -M /etc/cyrus.conf -p /run/cyrus/master.pid -D";
Restart = "on-failure";
RestartSec = "1s";
RuntimeDirectory = "cyrus";
StateDirectory = "cyrus";
# Hardening
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "full";
CapabilityBoundingSet = [ "~CAP_NET_ADMIN CAP_SYS_ADMIN CAP_SYS_BOOT CAP_SYS_MODULE" ];
MemoryDenyWriteExecute = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
};
preStart = ''
mkdir -p '${cfg.imapdSettings.configdirectory}/socket' '${cfg.tmpDBDir}' /run/cyrus/proc /run/cyrus/lock
'';
};
environment.systemPackages = [ cyrus-imapdPkg ];
};
}

View File

@@ -0,0 +1,147 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.davmail;
configType =
with lib.types;
oneOf [
(attrsOf configType)
str
int
bool
]
// {
description = "davmail config type (str, int, bool or attribute set thereof)";
};
toStr = val: if lib.isBool val then lib.boolToString val else toString val;
linesForAttrs =
attrs:
lib.concatMap (
name:
let
value = attrs.${name};
in
if lib.isAttrs value then
map (line: name + "." + line) (linesForAttrs value)
else
[ "${name}=${toStr value}" ]
) (lib.attrNames attrs);
configFile = pkgs.writeText "davmail.properties" (
lib.concatStringsSep "\n" (linesForAttrs cfg.config)
);
in
{
options.services.davmail = {
enable = lib.mkEnableOption "davmail, an MS Exchange gateway";
url = lib.mkOption {
type = lib.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 = lib.mkOption {
type = configType;
default = { };
description = ''
Davmail configuration. Refer to
<http://davmail.sourceforge.net/serversetup.html>
and <http://davmail.sourceforge.net/advanced.html>
for details on supported values.
'';
example = lib.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 = lib.mkIf cfg.enable {
services.davmail.config = {
davmail = lib.mapAttrs (name: lib.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 = lib.mkDefault "WARN";
logger.httpclient.wire = lib.mkDefault "WARN";
logger.org.apache.commons.httpclient = lib.mkDefault "WARN";
rootLogger = lib.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";
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectSystem = "strict";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
SystemCallErrorNumber = "EPERM";
UMask = "0077";
};
};
environment.systemPackages = [ pkgs.davmail ];
};
}

View File

@@ -0,0 +1,123 @@
{
config,
lib,
pkgs,
...
}:
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 = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.str;
example = "127.0.0.1:10027";
description = "Address:port DKIMproxy should listen on.";
};
relay = lib.mkOption {
type = lib.types.str;
example = "127.0.0.1:10028";
description = "Address:port DKIMproxy should forward mail to.";
};
domains = lib.mkOption {
type = with lib.types; listOf str;
example = [
"example.org"
"example.com"
];
description = "List of domains DKIMproxy can sign for.";
};
selector = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.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 ${lib.concatStringsSep "," cfg.domains}
selector ${cfg.selector}
signature dkim(c=relaxed/relaxed)
keyfile ${privkey}
'';
in
lib.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,844 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
attrValues
concatMapStringsSep
concatStrings
concatStringsSep
flatten
imap1
literalExpression
mapAttrsToList
mkEnableOption
mkIf
mkOption
mkRemovedOptionModule
optional
optionalAttrs
optionalString
singleton
types
mkRenamedOptionModule
nameValuePair
mapAttrs'
listToAttrs
filter
;
inherit (lib.strings) match;
cfg = config.services.dovecot2;
dovecotPkg = pkgs.dovecot;
baseDir = "/run/dovecot2";
stateDir = "/var/lib/dovecot";
sieveScriptSettings = mapAttrs' (
to: _: nameValuePair "sieve_${to}" "${stateDir}/sieve/${to}"
) cfg.sieve.scripts;
imapSieveMailboxSettings = listToAttrs (
flatten (
imap1 (
idx: el:
singleton {
name = "imapsieve_mailbox${toString idx}_name";
value = el.name;
}
++ optional (el.from != null) {
name = "imapsieve_mailbox${toString idx}_from";
value = el.from;
}
++ optional (el.causes != [ ]) {
name = "imapsieve_mailbox${toString idx}_causes";
value = concatStringsSep "," el.causes;
}
++ optional (el.before != null) {
name = "imapsieve_mailbox${toString idx}_before";
value = "file:${stateDir}/imapsieve/before/${baseNameOf el.before}";
}
++ optional (el.after != null) {
name = "imapsieve_mailbox${toString idx}_after";
value = "file:${stateDir}/imapsieve/after/${baseNameOf el.after}";
}
) cfg.imapsieve.mailbox
)
);
mkExtraConfigCollisionWarning = term: ''
You referred to ${term} in `services.dovecot2.extraConfig`.
Due to gradual transition to structured configuration for plugin configuration, it is possible
this will cause your plugin configuration to be ignored.
Consider setting `services.dovecot2.pluginSettings.${term}` instead.
'';
# Those settings are automatically set based on other parts
# of this module.
automaticallySetPluginSettings = [
"sieve_plugins"
"sieve_extensions"
"sieve_global_extensions"
"sieve_pipe_bin_dir"
]
++ (builtins.attrNames sieveScriptSettings)
++ (builtins.attrNames imapSieveMailboxSettings);
# The idea is to match everything that looks like `$term =`
# but not `# $term something something`
# or `# $term = some value` because those are comments.
configContainsSetting = lines: term: (match "[[:blank:]]*${term}[[:blank:]]*=.*" lines) != null;
warnAboutExtraConfigCollisions = map mkExtraConfigCollisionWarning (
filter (configContainsSetting cfg.extraConfig) automaticallySetPluginSettings
);
sievePipeBinScriptDirectory = pkgs.linkFarm "sieve-pipe-bins" (
map (el: {
name = builtins.unsafeDiscardStringContext (baseNameOf el);
path = el;
}) cfg.sieve.pipeBins
);
dovecotConf = concatStrings [
''
base_dir = ${baseDir}
protocols = ${concatStringsSep " " cfg.protocols}
sendmail_path = /run/wrappers/bin/sendmail
mail_plugin_dir = /run/current-system/sw/lib/dovecot/modules
# 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.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
}
'')
# General plugin settings:
# - sieve is mostly generated here, refer to `pluginSettings` to follow
# the control flow.
''
plugin {
${concatStringsSep "\n" (mapAttrsToList (key: value: " ${key} = ${value}") cfg.pluginSettings)}
}
''
cfg.extraConfig
];
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" ] "")
(mkRemovedOptionModule [
"services"
"dovecot2"
"modules"
] "Now need to use `environment.systemPackages` to load additional Dovecot modules")
(mkRenamedOptionModule
[ "services" "dovecot2" "sieveScripts" ]
[ "services" "dovecot2" "sieve" "scripts" ]
)
];
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)";
hasNewUnitName = mkOption {
type = types.bool;
default = true;
readOnly = true;
internal = true;
description = ''
Inspectable option to confirm that the dovecot module uses the new
`dovecot.service` name, instead of `dovecot2.service`.
This is a helper added for the nixos-mailserver project and can be
removed after branching off nixos-25.11.
'';
};
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} `$mail_plugins` 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` and the group
given in {option}`services.dovecot.group`''
// {
default = true;
};
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 "ssl_dh and generation of primes for the key exchange" // {
default = true;
};
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 %.";
};
pluginSettings = mkOption {
# types.str does not coerce from packages, like `sievePipeBinScriptDirectory`.
type = types.attrsOf (
types.oneOf [
types.str
types.package
]
);
default = { };
example = literalExpression ''
{
sieve = "file:~/sieve;active=~/.dovecot.sieve";
}
'';
description = ''
Plugin settings for dovecot in general, e.g. `sieve`, `sieve_default`, etc.
Some of the other knobs of this module will influence by default the plugin settings, but you
can still override any plugin settings.
If you override a plugin setting, its value is cleared and you have to copy over the defaults.
'';
};
imapsieve.mailbox = mkOption {
default = [ ];
description = "Configure Sieve filtering rules on IMAP actions";
type = types.listOf (
types.submodule (
{ config, ... }:
{
options = {
name = mkOption {
description = ''
This setting configures the name of a mailbox for which administrator scripts are configured.
The settings defined hereafter with matching sequence numbers apply to the mailbox named by this setting.
This setting supports wildcards with a syntax compatible with the IMAP LIST command, meaning that this setting can apply to multiple or even all ("*") mailboxes.
'';
example = "Junk";
type = types.str;
};
from = mkOption {
default = null;
description = ''
Only execute the administrator Sieve scripts for the mailbox configured with services.dovecot2.imapsieve.mailbox.<name>.name when the message originates from the indicated mailbox.
This setting supports wildcards with a syntax compatible with the IMAP LIST command, meaning that this setting can apply to multiple or even all ("*") mailboxes.
'';
example = "*";
type = types.nullOr types.str;
};
causes = mkOption {
default = [ ];
description = ''
Only execute the administrator Sieve scripts for the mailbox configured with services.dovecot2.imapsieve.mailbox.<name>.name when one of the listed IMAPSIEVE causes apply.
This has no effect on the user script, which is always executed no matter the cause.
'';
example = [
"COPY"
"APPEND"
];
type = types.listOf (
types.enum [
"APPEND"
"COPY"
"FLAG"
]
);
};
before = mkOption {
default = null;
description = ''
When an IMAP event of interest occurs, this sieve script is executed before any user script respectively.
This setting each specify the location of a single sieve script. The semantics of this setting is similar to sieve_before: the specified scripts form a sequence together with the user script in which the next script is only executed when an (implicit) keep action is executed.
'';
example = literalExpression "./report-spam.sieve";
type = types.nullOr types.path;
};
after = mkOption {
default = null;
description = ''
When an IMAP event of interest occurs, this sieve script is executed after any user script respectively.
This setting each specify the location of a single sieve script. The semantics of this setting is similar to sieve_after: the specified scripts form a sequence together with the user script in which the next script is only executed when an (implicit) keep action is executed.
'';
example = literalExpression "./report-spam.sieve";
type = types.nullOr types.path;
};
};
}
)
);
};
sieve = {
plugins = mkOption {
default = [ ];
example = [ "sieve_extprograms" ];
description = "Sieve plugins to load";
type = types.listOf types.str;
};
extensions = mkOption {
default = [ ];
description = "Sieve extensions for use in user scripts";
example = [
"notify"
"imapflags"
"vnd.dovecot.filter"
];
type = types.listOf types.str;
};
globalExtensions = mkOption {
default = [ ];
example = [ "vnd.dovecot.environment" ];
description = "Sieve extensions for use in global scripts";
type = types.listOf types.str;
};
scripts = mkOption {
type = types.attrsOf types.path;
default = { };
description = "Sieve scripts to be executed. Key is a sequence, e.g. 'before2', 'after' etc.";
};
pipeBins = mkOption {
default = [ ];
example = literalExpression ''
map lib.getExe [
(pkgs.writeShellScriptBin "learn-ham.sh" "exec ''${pkgs.rspamd}/bin/rspamc learn_ham")
(pkgs.writeShellScriptBin "learn-spam.sh" "exec ''${pkgs.rspamd}/bin/rspamc learn_spam")
]
'';
description = "Programs available for use by the vnd.dovecot.pipe extension";
type = types.listOf types.path;
};
};
};
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";
mailPlugins = mkIf cfg.enableQuota {
globally.enable = [ "quota" ];
perProtocol.imap.enable = [ "imap_quota" ];
};
sieve.plugins =
optional (cfg.imapsieve.mailbox != [ ]) "sieve_imapsieve"
++ optional (cfg.sieve.pipeBins != [ ]) "sieve_extprograms";
sieve.globalExtensions = optional (cfg.sieve.pipeBins != [ ]) "vnd.dovecot.pipe";
pluginSettings = lib.mapAttrs (n: lib.mkDefault) (
{
sieve_plugins = concatStringsSep " " cfg.sieve.plugins;
sieve_extensions = concatStringsSep " " (map (el: "+${el}") cfg.sieve.extensions);
sieve_global_extensions = concatStringsSep " " (map (el: "+${el}") cfg.sieve.globalExtensions);
sieve_pipe_bin_dir = sievePipeBinScriptDirectory;
}
// sieveScriptSettings
// imapSieveMailboxSettings
);
};
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/dovecot.conf".source = cfg.configFile;
systemd.services.dovecot = {
aliases = [ "dovecot2.service" ];
description = "Dovecot IMAP/POP3 server";
documentation = [
"man:dovecot(1)"
"https://doc.dovecot.org"
];
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ cfg.configFile ];
startLimitIntervalSec = 60; # 1 min
serviceConfig = {
Type = "notify";
ExecStart = "${dovecotPkg}/sbin/dovecot -F";
ExecReload = "${dovecotPkg}/sbin/doveadm reload";
CapabilityBoundingSet = [
"CAP_CHOWN"
"CAP_DAC_OVERRIDE"
"CAP_FOWNER"
"CAP_KILL" # Required for child process management
"CAP_NET_BIND_SERVICE"
"CAP_SETGID"
"CAP_SETUID"
"CAP_SYS_CHROOT"
"CAP_SYS_RESOURCE"
];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = false; # e.g for sendmail
OOMPolicy = "continue";
PrivateTmp = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = lib.mkDefault false;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "full";
PrivateDevices = true;
Restart = "on-failure";
RestartSec = "1s";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK" # e.g. getifaddrs in sieve handling
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = false; # sets sgid on maildirs
RuntimeDirectory = [ "dovecot2" ];
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service @resources"
"~@privileged"
"@chown @setuid capset chroot"
];
};
# 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 ${stateDir}/imapsieve
''
+ optionalString (cfg.sieve.scripts != { }) ''
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.sieve.scripts
)}
chown -R '${cfg.mailUser}:${cfg.mailGroup}' '${stateDir}/sieve'
''
+ optionalString (cfg.imapsieve.mailbox != [ ]) ''
mkdir -p ${stateDir}/imapsieve/{before,after}
${concatMapStringsSep "\n" (
el:
optionalString (el.before != null) ''
cp -p ${el.before} ${stateDir}/imapsieve/before/${baseNameOf el.before}
${pkgs.dovecot_pigeonhole}/bin/sievec '${stateDir}/imapsieve/before/${baseNameOf el.before}'
''
+ optionalString (el.after != null) ''
cp -p ${el.after} ${stateDir}/imapsieve/after/${baseNameOf el.after}
${pkgs.dovecot_pigeonhole}/bin/sievec '${stateDir}/imapsieve/after/${baseNameOf el.after}'
''
) cfg.imapsieve.mailbox}
${optionalString (
cfg.mailUser != null && cfg.mailGroup != null
) "chown -R '${cfg.mailUser}:${cfg.mailGroup}' '${stateDir}/imapsieve'"}
'';
};
environment.systemPackages = [ dovecotPkg ];
warnings = warnAboutExtraConfigCollisions;
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.sieve.scripts != { } -> (cfg.mailUser != null && cfg.mailGroup != null);
message = "dovecot requires mailUser and mailGroup to be set when `sieve.scripts` is set";
}
{
assertion = config.systemd.services ? dovecot2 == false;
message = ''
Your configuration sets options on the `dovecot2` systemd service. These have no effect until they're migrated to the `dovecot` service.
'';
}
];
};
meta.maintainers = [ lib.maintainers.dblsaiko ];
}

View File

@@ -0,0 +1,154 @@
{
config,
lib,
pkgs,
...
}:
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
${lib.optionalString (cfg.domainSocket != null) ''
ServerDomainSocketPath "${cfg.domainSocket}"
ClientHost "${cfg.domainSocket}"
''}
${cfg.extraConfig}
'';
in
{
###### interface
options = {
services.dspam = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the dspam spam filter.";
};
user = lib.mkOption {
type = lib.types.str;
default = "dspam";
description = "User for the dspam daemon.";
};
group = lib.mkOption {
type = lib.types.str;
default = "dspam";
description = "Group for the dspam daemon.";
};
storageDriver = lib.mkOption {
type = lib.types.str;
default = "hash";
description = "Storage driver backend to use for dspam.";
};
domainSocket = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Additional dspam configuration.";
};
maintenanceInterval = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, maintenance script will be run at specified (in systemd.timer format) interval";
};
};
};
###### implementation
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
users.users = lib.optionalAttrs (cfg.user == "dspam") {
dspam = {
group = cfg.group;
uid = config.ids.uids.dspam;
};
};
users.groups = lib.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.target" ];
restartTriggers = [ cfgfile ];
serviceConfig = {
ExecStart = "${dspam}/bin/dspam --daemon --nofork";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = lib.optional (cfg.domainSocket == defaultSock) "dspam";
RuntimeDirectoryMode = lib.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";
};
};
}
(lib.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,133 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
literalExpression
mkIf
mkOption
singleton
types
mkPackageOption
;
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 = mkPackageOption pkgs "exim" {
extraDescription = ''
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 = {
ExecStartPre = "+${coreutils}/bin/install --group=${cfg.group} --owner=${cfg.user} --mode=0700 --directory ${cfg.spoolDir}";
ExecStart = "!${cfg.package}/bin/exim -bdf -q${cfg.queueRunnerInterval}";
ExecReload = "!${coreutils}/bin/kill -HUP $MAINPID";
User = cfg.user;
};
};
};
}

View File

@@ -0,0 +1,80 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.goeland;
tomlFormat = pkgs.formats.toml { };
in
{
options.services.goeland = {
enable = lib.mkEnableOption "goeland, an alternative to rss2email";
settings = lib.mkOption {
description = ''
Configuration of goeland.
See the [example config file](https://github.com/slurdge/goeland/blob/master/cmd/asset/config.default.toml) for the available options.
'';
default = { };
type = tomlFormat.type;
};
schedule = lib.mkOption {
type = lib.types.str;
default = "12h";
example = "Mon, 00:00:00";
description = "How often to run goeland, in systemd time format.";
};
stateDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/goeland";
description = ''
The data directory for goeland where the database will reside if using the unseen filter.
If left as the default value this directory will automatically be created before the goeland
server starts, otherwise you are responsible for ensuring the directory exists with
appropriate ownership and permissions.
'';
};
};
config = lib.mkIf cfg.enable {
services.goeland.settings.database = "${cfg.stateDir}/goeland.db";
systemd.services.goeland = {
serviceConfig =
let
confFile = tomlFormat.generate "config.toml" cfg.settings;
in
lib.mkMerge [
{
ExecStart = "${pkgs.goeland}/bin/goeland run -c ${confFile}";
User = "goeland";
Group = "goeland";
}
(lib.mkIf (cfg.stateDir == "/var/lib/goeland") {
StateDirectory = "goeland";
StateDirectoryMode = "0750";
})
];
startAt = cfg.schedule;
};
users.users.goeland = {
description = "goeland user";
group = "goeland";
isSystemUser = true;
};
users.groups.goeland = { };
warnings = lib.optionals (lib.hasAttr "password" cfg.settings.email) [
''
It is not recommended to set the "services.goeland.settings.email.password"
option as it will be in cleartext in the Nix store.
Please use "services.goeland.settings.email.password_file" instead.
''
];
};
meta.maintainers = with lib.maintainers; [ sweenu ];
}

View File

@@ -0,0 +1,248 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.listmonk;
tomlFormat = pkgs.formats.toml { };
cfgFile = tomlFormat.generate "listmonk.toml" cfg.settings;
# Escaping is done according to https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
setDatabaseOption =
key: value:
"UPDATE settings SET value = '${
lib.replaceStrings [ "'" ] [ "''" ] (builtins.toJSON value)
}' WHERE key = '${key}';";
updateDatabaseConfigSQL = pkgs.writeText "update-database-config.sql" (
lib.concatStringsSep "\n" (
lib.mapAttrsToList setDatabaseOption (
if (cfg.database.settings != null) then cfg.database.settings else { }
)
)
);
updateDatabaseConfigScript = pkgs.writeShellScriptBin "update-database-config.sh" ''
${
if cfg.database.mutableSettings then
''
if [ ! -f /var/lib/listmonk/.db_settings_initialized ]; then
${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL} ;
touch /var/lib/listmonk/.db_settings_initialized
fi
''
else
"${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL}"
}
'';
databaseSettingsOpts = with lib.types; {
freeformType = attrsOf (oneOf [
(listOf str)
(listOf (attrsOf anything))
str
int
bool
]);
options = {
"app.notify_emails" = lib.mkOption {
type = listOf str;
default = [ ];
description = "Administrator emails for system notifications";
};
"privacy.exportable" = lib.mkOption {
type = listOf str;
default = [
"profile"
"subscriptions"
"campaign_views"
"link_clicks"
];
description = "List of fields which can be exported through an automatic export request";
};
"privacy.domain_blocklist" = lib.mkOption {
type = listOf str;
default = [ ];
description = "E-mail addresses with these domains are disallowed from subscribing.";
};
smtp = lib.mkOption {
type = listOf (submodule {
freeformType = with lib.types; attrsOf anything;
options = {
enabled = lib.mkEnableOption "this SMTP server for listmonk";
host = lib.mkOption {
type = lib.types.str;
description = "Hostname for the SMTP server";
};
port = lib.mkOption {
type = lib.types.port;
description = "Port for the SMTP server";
};
max_conns = lib.mkOption {
type = lib.types.int;
description = "Maximum number of simultaneous connections, defaults to 1";
default = 1;
};
tls_type = lib.mkOption {
type = lib.types.enum [
"none"
"STARTTLS"
"TLS"
];
description = "Type of TLS authentication with the SMTP server";
};
};
});
description = "List of outgoing SMTP servers";
};
# TODO: refine this type based on the smtp one.
"bounce.mailboxes" = lib.mkOption {
type = listOf (submodule {
freeformType = with lib.types; listOf (attrsOf anything);
});
default = [ ];
description = "List of bounce mailboxes";
};
messengers = lib.mkOption {
type = listOf str;
default = [ ];
description = "List of messengers, see: <https://github.com/knadh/listmonk/blob/master/models/settings.go#L64-L74> for options.";
};
};
};
in
{
###### interface
options = {
services.listmonk = {
enable = lib.mkEnableOption "Listmonk, this module assumes a reverse proxy to be set";
database = {
createLocally = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Create the PostgreSQL database and database user locally.";
};
settings = lib.mkOption {
default = null;
type = with lib.types; nullOr (submodule databaseSettingsOpts);
description = "Dynamic settings in the PostgreSQL database, set by a SQL script, see <https://github.com/knadh/listmonk/blob/master/schema.sql#L177-L230> for details.";
};
mutableSettings = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Database settings will be reset to the value set in this module if this is not enabled.
Enable this if you want to persist changes you have done in the application.
'';
};
};
package = lib.mkPackageOption pkgs "listmonk" { };
settings = lib.mkOption {
type = lib.types.submodule { freeformType = tomlFormat.type; };
description = ''
Static settings set in the config.toml, see <https://github.com/knadh/listmonk/blob/master/config.toml.sample> for details.
You can set secrets using the secretFile option with environment variables following <https://listmonk.app/docs/configuration/#environment-variables>.
'';
};
secretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "A file containing secrets as environment variables. See <https://listmonk.app/docs/configuration/#environment-variables> for details on supported values.";
};
};
};
###### implementation
config = lib.mkIf cfg.enable {
# Default parameters from https://github.com/knadh/listmonk/blob/master/config.toml.sample
services.listmonk.settings."app".address = lib.mkDefault "localhost:9000";
services.listmonk.settings."db" = lib.mkMerge [
{
max_open = lib.mkDefault 25;
max_idle = lib.mkDefault 25;
max_lifetime = lib.mkDefault "300s";
}
(lib.mkIf cfg.database.createLocally {
host = lib.mkDefault "/run/postgresql";
port = lib.mkDefault 5432;
user = lib.mkDefault "listmonk";
database = lib.mkDefault "listmonk";
})
];
services.postgresql = lib.mkIf cfg.database.createLocally {
enable = true;
ensureUsers = [
{
name = "listmonk";
ensureDBOwnership = true;
}
];
ensureDatabases = [ "listmonk" ];
};
systemd.services.listmonk = {
description = "Listmonk - newsletter and mailing list manager";
after = [ "network.target" ] ++ lib.optional cfg.database.createLocally "postgresql.target";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "exec";
EnvironmentFile = lib.mkIf (cfg.secretFile != null) [ cfg.secretFile ];
ExecStartPre = [
# StateDirectory cannot be used when DynamicUser = true is set this way.
# Indeed, it will try to create all the folders and realize one of them already exist.
# Therefore, we have to create it ourselves.
''${pkgs.coreutils}/bin/mkdir -p "''${STATE_DIRECTORY}/listmonk/uploads"''
# setup database if not already done
"${cfg.package}/bin/listmonk --config ${cfgFile} --idempotent --install --yes"
# apply db migrations (setup and migrations can not be done in one step
# with "--install --upgrade" listmonk ignores the upgrade)
"${cfg.package}/bin/listmonk --config ${cfgFile} --upgrade --yes"
"${updateDatabaseConfigScript}/bin/update-database-config.sh"
];
ExecStart = "${cfg.package}/bin/listmonk --config ${cfgFile}";
Restart = "on-failure";
StateDirectory = [ "listmonk" ];
User = "listmonk";
Group = "listmonk";
DynamicUser = true;
NoNewPrivileges = true;
CapabilityBoundingSet = "";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
PrivateDevices = true;
ProtectControlGroups = true;
ProtectKernelTunables = true;
ProtectHome = true;
RestrictNamespaces = true;
RestrictRealtime = true;
UMask = "0027";
MemoryDenyWriteExecute = true;
LockPersonality = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
ProtectKernelModules = true;
PrivateUsers = true;
};
};
};
}

View File

@@ -0,0 +1,494 @@
{
config,
lib,
pkgs,
...
}:
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!
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 = lib.mkEnableOption "Maddy, a free an open source mail server";
package = lib.mkPackageOption pkgs "maddy" { };
user = lib.mkOption {
default = "maddy";
type = with lib.types; uniq str;
description = ''
User account under which maddy runs.
::: {.note}
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.
:::
'';
};
group = lib.mkOption {
default = "maddy";
type = with lib.types; uniq str;
description = ''
Group account under which maddy runs.
::: {.note}
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.
:::
'';
};
hostname = lib.mkOption {
default = "localhost";
type = with lib.types; uniq str;
example = ''example.com'';
description = ''
Hostname to use. It should be FQDN.
'';
};
primaryDomain = lib.mkOption {
default = "localhost";
type = with lib.types; uniq str;
example = ''mail.example.com'';
description = ''
Primary MX domain to use. It should be FQDN.
'';
};
localDomains = lib.mkOption {
type = with lib.types; listOf str;
default = [ "$(primary_domain)" ];
example = [
"$(primary_domain)"
"example.com"
"other.example.com"
];
description = ''
Define list of allowed domains.
'';
};
config = lib.mkOption {
type = with lib.types; nullOr lines;
default = defaultConfig;
description = ''
Server configuration, see
[https://maddy.email](https://maddy.email) for
more information. The default configuration of this module will setup
minimal Maddy instance for mail transfer without TLS encryption.
::: {.note}
This should not be used in a production environment.
:::
'';
};
tls = {
loader = lib.mkOption {
type =
with lib.types;
nullOr (enum [
"off"
"file"
"acme"
]);
default = "off";
description = ''
TLS certificates are obtained by modules called "certificate
loaders".
The `file` loader module reads certificates from files specified by
the `certificates` option.
Alternatively the `acme` module can be used to automatically obtain
certificates using the ACME protocol.
Module configuration is done via the `tls.extraConfig` option.
Secrets such as API keys or passwords should not be supplied in
plaintext. Instead the `secrets` option can be used to read secrets
at runtime as environment variables. Secrets can be referenced with
`{env:VAR}`.
'';
};
certificates = lib.mkOption {
type =
with lib.types;
listOf (submodule {
options = {
keyPath = lib.mkOption {
type = lib.types.path;
example = "/etc/ssl/mx1.example.org.key";
description = ''
Path to the private key used for TLS.
'';
};
certPath = lib.mkOption {
type = lib.types.path;
example = "/etc/ssl/mx1.example.org.crt";
description = ''
Path to the certificate used for TLS.
'';
};
};
});
default = [ ];
example = lib.literalExpression ''
[{
keyPath = "/etc/ssl/mx1.example.org.key";
certPath = "/etc/ssl/mx1.example.org.crt";
}]
'';
description = ''
A list of attribute sets containing paths to TLS certificates and
keys. Maddy will use SNI if multiple pairs are selected.
'';
};
extraConfig = lib.mkOption {
type = with lib.types; nullOr lines;
description = ''
Arguments for the specified certificate loader.
In case the `tls` loader is set, the defaults are considered secure
and there is no need to change anything in most cases.
For available options see [upstream manual](https://maddy.email/reference/tls/).
For ACME configuration, see [following page](https://maddy.email/reference/tls-acme).
'';
default = "";
};
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open the configured incoming and outgoing mail server ports.
'';
};
ensureAccounts = lib.mkOption {
type = with lib.types; listOf str;
default = [ ];
description = ''
List of IMAP accounts which get automatically created. Note that for
a complete setup, user credentials for these accounts are required
and can be created using the `ensureCredentials` option.
This option does not delete accounts which are not (anymore) listed.
'';
example = [
"user1@localhost"
"user2@localhost"
];
};
ensureCredentials = lib.mkOption {
default = { };
description = ''
List of user accounts which get automatically created if they don't
exist yet. Note that for a complete setup, corresponding mail boxes
have to get created using the `ensureAccounts` option.
This option does not delete accounts which are not (anymore) listed.
'';
example = {
"user1@localhost".passwordFile = /secrets/user1-localhost;
"user2@localhost".passwordFile = /secrets/user2-localhost;
};
type = lib.types.attrsOf (
lib.types.submodule {
options = {
passwordFile = lib.mkOption {
type = lib.types.path;
example = "/path/to/file";
default = null;
description = ''
Specifies the path to a file containing the
clear text password for the user.
'';
};
};
}
);
};
secrets = lib.mkOption {
type = with lib.types; listOf path;
description = ''
A list of files containing the various secrets. Should be in the format
expected by systemd's `EnvironmentFile` directory. Secrets can be
referenced in the format `{env:VAR}`.
'';
default = [ ];
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.tls.loader == "file" -> cfg.tls.certificates != [ ];
message = ''
If Maddy is configured to use TLS, tls.certificates with attribute sets
of certPath and keyPath must be provided.
Read more about obtaining TLS certificates here:
https://maddy.email/tutorials/setting-up/#tls-certificates
'';
}
{
assertion = cfg.tls.loader == "acme" -> cfg.tls.extraConfig != "";
message = ''
If Maddy is configured to obtain TLS certificates using the ACME
loader, extra configuration options must be supplied via
tls.extraConfig option.
See upstream documentation for more details:
https://maddy.email/reference/tls-acme
'';
}
];
systemd = {
packages = [ cfg.package ];
services = {
maddy = {
serviceConfig = {
User = cfg.user;
Group = cfg.group;
StateDirectory = [ "maddy" ];
EnvironmentFile = cfg.secrets;
};
restartTriggers = [ config.environment.etc."maddy/maddy.conf".source ];
wantedBy = [ "multi-user.target" ];
};
maddy-ensure-accounts = {
script = ''
${lib.optionalString (cfg.ensureAccounts != [ ]) ''
${lib.concatMapStrings (account: ''
if ! ${cfg.package}/bin/maddyctl imap-acct list | grep "${account}"; then
${cfg.package}/bin/maddyctl imap-acct create ${account}
fi
'') cfg.ensureAccounts}
''}
${lib.optionalString (cfg.ensureCredentials != { }) ''
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: credentials: ''
if ! ${cfg.package}/bin/maddyctl creds list | grep "${name}"; then
${cfg.package}/bin/maddyctl creds create --password $(cat ${lib.escapeShellArg credentials.passwordFile}) ${name}
fi
'') cfg.ensureCredentials
)}
''}
'';
serviceConfig = {
Type = "oneshot";
User = "maddy";
};
after = [ "maddy.service" ];
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}
${
if (cfg.tls.loader == "file") then
''
tls file ${lib.concatStringsSep " " (map (x: x.certPath + " " + x.keyPath) cfg.tls.certificates)} ${
lib.optionalString (cfg.tls.extraConfig != "") ''
{ ${cfg.tls.extraConfig} }
''
}
''
else if (cfg.tls.loader == "acme") then
''
tls {
loader acme {
${cfg.tls.extraConfig}
}
}
''
else if (cfg.tls.loader == "off") then
''
tls off
''
else
""
}
${cfg.config}
'';
};
users.users = lib.optionalAttrs (cfg.user == name) {
${name} = {
isSystemUser = true;
group = cfg.group;
description = "Maddy mail transfer agent user";
};
};
users.groups = lib.optionalAttrs (cfg.group == name) {
${cfg.group} = { };
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
25
143
587
];
};
environment.systemPackages = [
cfg.package
];
};
}

View File

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

View File

@@ -0,0 +1,83 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.mailcatcher;
inherit (lib)
mkEnableOption
mkIf
mkOption
types
optionalString
;
in
{
# interface
options = {
services.mailcatcher = {
enable = mkEnableOption "MailCatcher, an SMTP server and web interface to locally test outbound emails";
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,107 @@
{
config,
lib,
pkgs,
...
}:
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
);
mhsendmail = pkgs.writeShellScriptBin "mailhog-sendmail" ''
exec ${lib.getExe pkgs.mailhog} sendmail $@
'';
in
{
###### interface
imports = [
(lib.mkRemovedOptionModule [
"services"
"mailhog"
"user"
] "")
];
options = {
services.mailhog = {
enable = lib.mkEnableOption "MailHog, web and API based SMTP testing";
setSendmail = lib.mkEnableOption "set the system sendmail to mailhogs's" // {
default = true;
};
storage = lib.mkOption {
type = lib.types.enum [
"maildir"
"memory"
];
default = "memory";
description = "Store mails on disk or in memory.";
};
apiPort = lib.mkOption {
type = lib.types.port;
default = 8025;
description = "Port on which the API endpoint will listen.";
};
smtpPort = lib.mkOption {
type = lib.types.port;
default = 1025;
description = "Port on which the SMTP endpoint will listen.";
};
uiPort = lib.mkOption {
type = lib.types.port;
default = 8025;
description = "Port on which the HTTP UI will listen.";
};
extraArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of additional arguments to pass to the MailHog process.";
};
};
};
###### implementation
config = lib.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 = "${lib.getExe pkgs.mailhog} ${args}";
DynamicUser = true;
Restart = "on-failure";
StateDirectory = "mailhog";
};
};
services.mail.sendmailSetuidWrapper = lib.mkIf cfg.setSendmail {
program = "sendmail";
source = lib.getExe mhsendmail;
# Communication happens through the network, no data is written to disk
owner = "nobody";
group = "nogroup";
};
};
meta.maintainers = with lib.maintainers; [ RTUnreal ];
}

View File

@@ -0,0 +1,90 @@
# Mailman {#module-services-mailman}
[Mailman](https://www.list.org) 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.
## Basic usage with Postfix {#module-services-mailman-basic-usage}
For a basic configuration with Postfix as the MTA, the following settings are suggested:
```nix
{ config, ... }:
{
services.postfix = {
enable = true;
settings.main = {
transport_maps = [ "hash:/var/lib/mailman/data/postfix_lmtp" ];
local_recipient_maps = [ "hash:/var/lib/mailman/data/postfix_lmtp" ];
relay_domains = [ "hash:/var/lib/mailman/data/postfix_domains" ];
smtpd_tls_chain_files = [
(config.security.acme.certs."lists.example.org".directory + "/full.pem")
(config.security.acme.certs."lists.example.org".directory + "/key.pem")
];
};
};
services.mailman = {
enable = true;
serve.enable = true;
hyperkitty.enable = true;
webHosts = [ "lists.example.org" ];
siteOwner = "mailman@example.org";
};
services.nginx.virtualHosts."lists.example.org".enableACME = true;
networking.firewall.allowedTCPPorts = [
25
80
443
];
}
```
DNS records will also be required:
- `AAAA` and `A` records pointing to the host in question, in order for browsers to be able to discover the address of the web server;
- An `MX` 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.
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.
## Using with other MTAs {#module-services-mailman-other-mtas}
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:
```nix
{ config, ... }:
{
services = {
mailman = {
enable = true;
siteOwner = "mailman@example.org";
enablePostfix = 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;
};
};
}
```
The exim config needs some special additions to work with Mailman. Currently
NixOS can't manage Exim config with such granularity. Please refer to
[Mailman documentation](https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html)
for more info on configuring Mailman for working with Exim.

View File

@@ -0,0 +1,799 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.mailman;
inherit
(cfg.packageSet.buildEnvs {
withHyperkitty = cfg.hyperkitty.enable;
withLDAP = cfg.ldap.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";
};
}
// lib.optionalAttrs cfg.enablePostfix {
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend";
EMAIL_HOST = "127.0.0.1";
EMAIL_PORT = 25;
}
// 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: ${lib.getExe' config.services.postfix.package "postmap"}
transport_file_type: hash
'';
mailmanCfg = lib.generators.toINI { } (
lib.recursiveUpdate cfg.settings {
webservice.admin_pass = "#NIXOS_MAILMAN_REST_API_PASS_SECRET#";
}
);
mailmanCfgFile = pkgs.writeText "mailman-raw.cfg" mailmanCfg;
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 = [
(lib.mkRenamedOptionModule
[ "services" "mailman" "hyperkittyBaseUrl" ]
[ "services" "mailman" "hyperkitty" "baseUrl" ]
)
(lib.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.
'')
(lib.mkRemovedOptionModule [ "services" "mailman" "package" ] ''
Didn't have an effect for several years.
'')
(lib.mkRemovedOptionModule [ "services" "mailman" "extraPythonPackages" ] ''
Didn't have an effect for several years.
'')
];
options = {
services.mailman = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
};
packageSet = lib.mkPackageOption pkgs "mailmanPackages" { } // {
type = lib.types.attrs;
};
ldap = {
enable = lib.mkEnableOption "LDAP auth";
serverUri = lib.mkOption {
type = lib.types.str;
example = "ldaps://ldap.host";
description = ''
LDAP host to connect against.
'';
};
bindDn = lib.mkOption {
type = lib.types.str;
example = "cn=root,dc=nixos,dc=org";
description = ''
Service account to bind against.
'';
};
bindPasswordFile = lib.mkOption {
type = lib.types.str;
example = "/run/secrets/ldap-bind";
description = ''
Path to the file containing the bind password of the service account
defined by [](#opt-services.mailman.ldap.bindDn).
'';
};
superUserGroup = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "cn=admin,ou=groups,dc=nixos,dc=org";
description = ''
Group where a user must be a member of to gain superuser rights.
'';
};
userSearch = {
query = lib.mkOption {
type = lib.types.str;
example = "(&(objectClass=inetOrgPerson)(|(uid=%(user)s)(mail=%(user)s)))";
description = ''
Query to find a user in the LDAP database.
'';
};
ou = lib.mkOption {
type = lib.types.str;
example = "ou=users,dc=nixos,dc=org";
description = ''
Organizational unit to look up a user.
'';
};
};
groupSearch = {
type = lib.mkOption {
type = lib.types.enum [
"posixGroup"
"groupOfNames"
"memberDNGroup"
"nestedMemberDNGroup"
"nestedGroupOfNames"
"groupOfUniqueNames"
"nestedGroupOfUniqueNames"
"activeDirectoryGroup"
"nestedActiveDirectoryGroup"
"organizationalRoleGroup"
"nestedOrganizationalRoleGroup"
];
default = "posixGroup";
apply = v: "${lib.toUpper (lib.substring 0 1 v)}${lib.substring 1 (lib.stringLength v) v}Type";
description = ''
Type of group to perform a group search against.
'';
};
query = lib.mkOption {
type = lib.types.str;
example = "(objectClass=groupOfNames)";
description = ''
Query to find a group associated to a user in the LDAP database.
'';
};
ou = lib.mkOption {
type = lib.types.str;
example = "ou=groups,dc=nixos,dc=org";
description = ''
Organizational unit to look up a group.
'';
};
};
attrMap = {
username = lib.mkOption {
default = "uid";
type = lib.types.str;
description = ''
LDAP-attribute that corresponds to the `username`-attribute in mailman.
'';
};
firstName = lib.mkOption {
default = "givenName";
type = lib.types.str;
description = ''
LDAP-attribute that corresponds to the `firstName`-attribute in mailman.
'';
};
lastName = lib.mkOption {
default = "sn";
type = lib.types.str;
description = ''
LDAP-attribute that corresponds to the `lastName`-attribute in mailman.
'';
};
email = lib.mkOption {
default = "mail";
type = lib.types.str;
description = ''
LDAP-attribute that corresponds to the `email`-attribute in mailman.
'';
};
};
};
enablePostfix = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.listOf lib.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 = lib.mkOption {
type = lib.types.str;
default = "mailman-web";
description = ''
User to run mailman-web as
'';
};
webSettings = lib.mkOption {
type = lib.types.attrs;
default = { };
description = ''
Overrides for the default mailman-web Django settings.
'';
};
restApiPassFile = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
Path to the file containing the value for `MAILMAN_REST_API_PASS`.
'';
};
serve = {
enable = lib.mkEnableOption "automatic nginx and uwsgi setup for mailman-web";
uwsgiSettings = lib.mkOption {
default = { };
example = {
uwsgi.buffer-size = 8192;
};
inherit (pkgs.formats.json { }) type;
description = ''
Extra configuration to merge into uwsgi config.
'';
};
virtualRoot = lib.mkOption {
default = "/";
example = lib.literalExpression "/lists";
type = lib.types.str;
description = ''
Path to mount the mailman-web django application on.
'';
};
};
settings = lib.mkOption {
description = "Settings for mailman.cfg";
type = lib.types.attrsOf (lib.types.attrsOf lib.types.str);
default = { };
};
hyperkitty = {
enable = lib.mkEnableOption "the Hyperkitty archiver for Mailman";
baseUrl = lib.mkOption {
type = lib.types.str;
default = "http://localhost:18507/archives/";
description = ''
Where can Mailman connect to Hyperkitty's internal API, preferably on
localhost?
'';
};
};
};
};
###### implementation
config = lib.mkIf cfg.enable {
services.mailman.settings = {
mailman.site_owner = lib.mkDefault cfg.siteOwner;
mailman.layout = "fhs";
"paths.fhs" = {
bin_dir = "${cfg.packageSet.mailman}/bin";
var_dir = "/var/lib/mailman";
queue_dir = "$var_dir/queue";
template_dir = "$var_dir/templates";
log_dir = "/var/log/mailman";
lock_dir = "/run/mailman/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:
let
expected = "hash:/var/lib/mailman/data/${dataFile}";
value = lib.attrByPath optionPath [ ] postfix;
in
{
assertion = postfix.enable -> lib.isList value && lib.elem expected value;
message = ''
services.postfix.${lib.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 [ "settings" "main" "relay_domains" ] "postfix_domains")
(requirePostfixHash [ "settings" "main" "transport_maps" ] "postfix_lmtp")
(requirePostfixHash [ "settings" "main" "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."mailman3/settings.py".text = ''
import os
from configparser import ConfigParser
# 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))
with open('/etc/mailman.cfg') as f:
config = ConfigParser()
config.read_file(f)
MAILMAN_REST_API_PASS = config['webservice']['admin_pass']
${lib.optionalString (cfg.ldap.enable) ''
import ldap
from django_auth_ldap.config import LDAPSearch, ${cfg.ldap.groupSearch.type}
AUTH_LDAP_SERVER_URI = "${cfg.ldap.serverUri}"
AUTH_LDAP_BIND_DN = "${cfg.ldap.bindDn}"
with open("${cfg.ldap.bindPasswordFile}") as f:
AUTH_LDAP_BIND_PASSWORD = f.read().rstrip('\n')
AUTH_LDAP_USER_SEARCH = LDAPSearch("${cfg.ldap.userSearch.ou}",
ldap.SCOPE_SUBTREE, "${cfg.ldap.userSearch.query}")
AUTH_LDAP_GROUP_TYPE = ${cfg.ldap.groupSearch.type}()
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("${cfg.ldap.groupSearch.ou}",
ldap.SCOPE_SUBTREE, "${cfg.ldap.groupSearch.query}")
AUTH_LDAP_USER_ATTR_MAP = {
${lib.concatStrings (
lib.flip lib.mapAttrsToList cfg.ldap.attrMap (
key: value: ''
"${key}": "${value}",
''
)
)}
}
${lib.optionalString (cfg.ldap.superUserGroup != null) ''
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_superuser": "${cfg.ldap.superUserGroup}"
}
''}
AUTHENTICATION_BACKENDS = (
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend"
)
''}
'';
services.nginx = lib.mkIf (cfg.serve.enable && cfg.webHosts != [ ]) {
enable = lib.mkDefault true;
virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
locations = {
${cfg.serve.virtualRoot}.uwsgiPass = "unix:/run/mailman-web.socket";
"${lib.removeSuffix "/" cfg.serve.virtualRoot}/static/".alias = webSettings.STATIC_ROOT + "/";
};
});
proxyTimeout = lib.mkDefault "120s";
};
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
''
+ lib.optionalString config.security.sudo.enable ''
mv $out/bin/mailman $out/bin/.mailman-wrapped
echo '#!${pkgs.runtimeShell}
sudo=exec
if [[ "$USER" != mailman ]]; then
sudo="exec /run/wrappers/bin/sudo -u mailman"
fi
$sudo ${placeholder "out"}/bin/.mailman-wrapped "$@"
' > $out/bin/mailman
chmod +x $out/bin/mailman
'';
})
];
services.postfix = lib.mkIf cfg.enablePostfix {
settings.main = {
owner_request_special = "no"; # Mailman handles -owner addresses on its own
recipient_delimiter = "+"; # bake recipient addresses in mail envelopes via VERP
};
};
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.target";
restartTriggers = [ mailmanCfgFile ];
requires = lib.optional withPostgresql "postgresql.target";
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";
Restart = "on-failure";
TimeoutStartSec = 180;
TimeoutStopSec = 180;
};
};
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 = lib.optional withPostgresql "postgresql.target";
requires = lib.optional withPostgresql "postgresql.target";
serviceConfig.RemainAfterExit = true;
serviceConfig.Type = "oneshot";
script = ''
install -m0750 -o mailman -g mailman ${mailmanCfgFile} /etc/mailman.cfg
${
if cfg.restApiPassFile == null then
''
sed -i "s/#NIXOS_MAILMAN_REST_API_PASS_SECRET#/$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)/g" \
/etc/mailman.cfg
''
else
''
${pkgs.replace-secret}/bin/replace-secret \
'#NIXOS_MAILMAN_REST_API_PASS_SECRET#' \
${cfg.restApiPassFile} \
/etc/mailman.cfg
''
}
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)
install -m 0440 -o root -g mailman \
<(jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
--arg archiver_key "$hyperkittyApiKey" \
--arg secret_key "$secretKey") \
"$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 = [
"hyperkitty.service"
"mailman-uwsgi.service"
];
requiredBy = [
"hyperkitty.service"
"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 = lib.mkIf cfg.serve.enable (
let
uwsgiConfig = lib.recursiveUpdate {
uwsgi = {
type = "normal";
plugins = [ "python3" ];
home = webEnv;
http = "127.0.0.1:18507";
buffer-size = 8192;
}
// (
if cfg.serve.virtualRoot == "/" then
{ module = "mailman_web.wsgi:application"; }
else
{
mount = "${cfg.serve.virtualRoot}=mailman_web.wsgi:application";
manage-script-name = true;
}
);
} cfg.serve.uwsgiSettings;
uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
in
{
wantedBy = [ "multi-user.target" ];
after = lib.optional withPostgresql "postgresql.target";
requires = [
"mailman-uwsgi.socket"
"mailman-web-setup.service"
]
++ lib.optional withPostgresql "postgresql.target";
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" ];
python3 = webEnv.python;
}
}/bin/uwsgi --json ${uwsgiConfigFile}";
User = cfg.webUser;
Group = "mailman";
RuntimeDirectory = "mailman-uwsgi";
Restart = "on-failure";
};
}
);
mailman-daily = {
description = "Trigger daily Mailman events";
startAt = "daily";
restartTriggers = [ mailmanCfgFile ];
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";
Restart = "on-failure";
};
};
}
//
lib.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; [ qyliss ];
doc = ./mailman.md;
};
}

View File

@@ -0,0 +1,106 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (config.services.mailpit) instances;
inherit (lib)
cli
concatStringsSep
const
filterAttrs
getExe
mapAttrs'
mkIf
mkOption
nameValuePair
types
;
isNonNull = v: v != null;
genCliFlags =
settings: concatStringsSep " " (cli.toGNUCommandLine { } (filterAttrs (const isNonNull) settings));
in
{
options.services.mailpit.instances = mkOption {
default = { };
type = types.attrsOf (
types.submodule {
freeformType = types.attrsOf (
types.oneOf [
types.str
types.int
types.bool
]
);
options = {
database = mkOption {
type = types.nullOr types.str;
default = null;
example = "mailpit.db";
description = ''
Specify the local database filename to store persistent data.
If `null`, a temporary file will be created that will be removed when the application stops.
It's recommended to specify a relative path. The database will be written into the service's
state directory then.
'';
};
max = mkOption {
type = types.ints.unsigned;
default = 500;
description = ''
Maximum number of emails to keep. If the number is exceeded, old emails
will be deleted.
Set to `0` to never prune old emails.
'';
};
listen = mkOption {
default = "127.0.0.1:8025";
type = types.str;
description = ''
HTTP bind interface and port for UI.
'';
};
smtp = mkOption {
default = "127.0.0.1:1025";
type = types.str;
description = ''
SMTP bind interface and port.
'';
};
};
}
);
description = ''
Configure mailpit instances. The attribute-set values are
CLI flags passed to the `mailpit` CLI.
See [upstream docs](https://mailpit.axllent.org/docs/configuration/runtime-options/)
for all available options.
'';
};
config = mkIf (instances != { }) {
systemd.services = mapAttrs' (
name: cfg:
nameValuePair "mailpit-${name}" {
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
DynamicUser = true;
StateDirectory = "mailpit";
WorkingDirectory = "%S/mailpit";
ExecStart = "${getExe pkgs.mailpit} ${genCliFlags cfg}";
Restart = "on-failure";
};
}
) instances;
};
meta.maintainers = lib.teams.flyingcircus.members;
}

View File

@@ -0,0 +1,181 @@
{
config,
lib,
pkgs,
...
}:
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 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable mlmmj";
};
user = lib.mkOption {
type = lib.types.str;
default = "mlmmj";
description = "mailinglist local user";
};
group = lib.mkOption {
type = lib.types.str;
default = "mlmmj";
description = "mailinglist local group";
};
listDomain = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = "Set the mailing list domain";
};
mailLists = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "The collection of hosted maillists";
};
maintInterval = lib.mkOption {
type = lib.types.str;
default = "20min";
description = ''
Time interval between mlmmj-maintd runs, see
{manpage}`systemd.time(7)` for format information.
'';
};
};
};
###### implementation
config = lib.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;
settings.main = {
recipient_delimiter = "+";
propagate_unmatched_extensions = "virtual";
};
settings.master.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;
virtual = concatMapLines (virtual cfg.listDomain) cfg.mailLists;
transport = concatMapLines (transport cfg.listDomain) cfg.mailLists;
};
environment.systemPackages = [ pkgs.mlmmj ];
systemd.tmpfiles.settings."10-mlmmj" = {
${stateDir}.d = { };
"${spoolDir}/${cfg.listDomain}".d = { };
${spoolDir}.Z = {
inherit (cfg) user group;
};
};
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}";
};
preStart = ''
${concatMapLines (createList cfg.listDomain) cfg.mailLists}
${lib.getExe' config.services.postfix.package "postmap"} /etc/postfix/virtual
${lib.getExe' config.services.postfix.package "postmap"} /etc/postfix/transport
'';
};
systemd.timers.mlmmj-maintd = {
description = "mlmmj maintenance timer";
timerConfig.OnUnitActiveSec = cfg.maintInterval;
wantedBy = [ "timers.target" ];
};
};
}

View File

@@ -0,0 +1,269 @@
{
config,
lib,
pkgs,
...
}:
{
options = {
services.nullmailer = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable nullmailer daemon.";
};
user = lib.mkOption {
type = lib.types.str;
default = "nullmailer";
description = ''
User to use to run nullmailer-send.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "nullmailer";
description = ''
Group to use to run nullmailer-send.
'';
};
setSendmail = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to set the system sendmail to nullmailer's.";
};
remotesFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Path to the `remotes` control file. This file contains a
list of remote servers to which to send each message.
See `man 8 nullmailer-send` for syntax and available
options.
'';
};
config = {
adminaddr = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
If set, content will override the envelope sender on all messages.
'';
};
defaultdomain = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type = lib.types.nullOr lib.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 = lib.mkOption {
type =
with lib.types;
nullOr (oneOf [
str
int
]);
default = null;
description = ''
The maximum time to pause between successive queue runs, in seconds.
Defaults to 24 hours (86400).
'';
};
me = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
The fully-qualifiled host name of the computer running nullmailer.
Defaults to the literal name me.
'';
};
pausetime = lib.mkOption {
type =
with lib.types;
nullOr (oneOf [
str
int
]);
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 = lib.mkOption {
type = lib.types.nullOr lib.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 `man 8 nullmailer-send` 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
`remotesFile` option instead.
'';
};
sendtimeout = lib.mkOption {
type =
with lib.types;
nullOr (oneOf [
str
int
]);
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
lib.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 = lib.mapAttrs (_: toString) (lib.filterAttrs (_: value: value != null) cfg.config);
in
(lib.foldl' (as: name: as // { "nullmailer/${name}".text = validAttrs.${name}; }) { } (
lib.attrNames validAttrs
))
// lib.optionalAttrs (cfg.remotesFile != null) { "nullmailer/remotes".source = cfg.remotesFile; };
};
users = {
users.${cfg.user} = {
description = "Nullmailer relay-only mta user";
inherit (cfg) group;
isSystemUser = true;
};
groups.${cfg.group} = { };
};
systemd.tmpfiles.rules = [
"d /var/spool/nullmailer - ${cfg.user} ${cfg.group} - -"
"d /var/spool/nullmailer/failed 770 ${cfg.user} ${cfg.group} - -"
"d /var/spool/nullmailer/queue 770 ${cfg.user} ${cfg.group} - -"
"d /var/spool/nullmailer/tmp 770 ${cfg.user} ${cfg.group} - -"
];
systemd.services.nullmailer = {
description = "nullmailer";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart = ''
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 = lib.mkIf cfg.setSendmail {
program = "sendmail";
source = "${pkgs.nullmailer}/bin/sendmail";
owner = cfg.user;
inherit (cfg) group;
setuid = true;
setgid = true;
};
};
}

View File

@@ -0,0 +1,71 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.offlineimap;
in
{
options.services.offlineimap = {
enable = lib.mkEnableOption "OfflineIMAP, a software to dispose your mailbox(es) as a local Maildir(s)";
install = lib.mkOption {
type = lib.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
{var}`services.offlineimap.enable`.
'';
};
package = lib.mkPackageOption pkgs "offlineimap" { };
path = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
example = lib.literalExpression "[ pkgs.pass pkgs.bash pkgs.notmuch ]";
description = "List of derivations to put in Offlineimap's path.";
};
onCalendar = lib.mkOption {
type = lib.types.str;
default = "*:0/3"; # every 3 minutes
description = "How often is offlineimap started. Default is '*:0/3' meaning every 3 minutes. See {manpage}`systemd.time(7)` for more information about the format.";
};
timeoutStartSec = lib.mkOption {
type = lib.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 {manpage}`systemd.time(7)` for more information about the format.";
};
};
config = lib.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";
};
}
// lib.optionalAttrs cfg.enable { wantedBy = [ "default.target" ]; };
};
}

View File

@@ -0,0 +1,190 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.opendkim;
defaultSock = "local:/run/opendkim/opendkim.sock";
args = [
"-f"
"-l"
"-p"
cfg.socket
"-d"
cfg.domains
"-k"
"${cfg.keyPath}/${cfg.selector}.private"
"-s"
cfg.selector
]
++ lib.optionals (cfg.configFile != null) [
"-x"
cfg.configFile
];
configFile = pkgs.writeText "opendkim.conf" (
lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${value}") cfg.settings)
);
in
{
imports = [
(lib.mkRenamedOptionModule [ "services" "opendkim" "keyFile" ] [ "services" "opendkim" "keyPath" ])
];
options = {
services.opendkim = {
enable = lib.mkEnableOption "OpenDKIM sender authentication system";
socket = lib.mkOption {
type = lib.types.str;
default = defaultSock;
description = "Socket which is used for communication with OpenDKIM.";
};
user = lib.mkOption {
type = lib.types.str;
default = "opendkim";
description = "User for the daemon.";
};
group = lib.mkOption {
type = lib.types.str;
default = "opendkim";
description = "Group for the daemon.";
};
domains = lib.mkOption {
type = lib.types.str;
default = "csl:${config.networking.hostName}";
defaultText = lib.literalExpression ''"csl:''${config.networking.hostName}"'';
example = "csl:example.com,mydomain.net";
description = ''
Local domains set (see {manpage}`opendkim(8)` for more information on datasets).
Messages from them are signed, not verified.
'';
};
keyPath = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.str;
description = "Selector to use when signing.";
};
# TODO: deprecate this?
configFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Additional opendkim configuration as a file.";
};
settings = lib.mkOption {
type =
with lib.types;
submodule {
freeformType = attrsOf str;
};
default = { };
description = "Additional opendkim configuration";
};
};
};
config = lib.mkIf cfg.enable {
users.users = lib.optionalAttrs (cfg.user == "opendkim") {
opendkim = {
group = cfg.group;
uid = config.ids.uids.opendkim;
};
};
users.groups = lib.optionalAttrs (cfg.group == "opendkim") {
opendkim.gid = config.ids.gids.opendkim;
};
environment = {
etc = lib.mkIf (cfg.settings != { }) {
"opendkim/opendkim.conf".source = configFile;
};
systemPackages = [ pkgs.opendkim ];
};
services.opendkim.configFile = lib.mkIf (cfg.settings != { }) configFile;
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 ${lib.escapeShellArgs args}";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = lib.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,161 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.opensmtpd;
conf = pkgs.writeText "smtpd.conf" cfg.serverConfiguration;
args = lib.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 = [
(lib.mkRenamedOptionModule
[ "services" "opensmtpd" "addSendmailToSystemPath" ]
[ "services" "opensmtpd" "setSendmail" ]
)
];
options = {
services.opensmtpd = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable the OpenSMTPD server.";
};
package = lib.mkPackageOption pkgs "opensmtpd" { };
setSendmail = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to set the system sendmail to OpenSMTPD's.";
};
extraServerArgs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"-v"
"-P mta"
];
description = ''
Extra command line arguments provided when the smtpd process
is started.
'';
};
serverConfiguration = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
Packages to search for filters, tables, queues, and schedulers.
Add packages here if you want to use them as as such, for example
from the opensmtpd-table-* packages.
'';
};
};
};
###### implementation
config = lib.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 = lib.mkIf cfg.setSendmail (
security.wrappers.smtpctl
// {
source = "${sendmail}/bin/sendmail";
program = "sendmail";
}
);
systemd.tmpfiles.settings.opensmtpd = {
"/var/spool/smtpd".d = {
mode = "0711";
user = "root";
};
"/var/spool/smtpd/offline".d = {
mode = "0770";
user = "root";
group = "smtpq";
};
"/var/spool/smtpd/purge".d = {
mode = "0700";
user = "smtpq";
group = "root";
};
"/var/spool/smtpd/queue".d = {
mode = "0700";
user = "smtpq";
group = "root";
};
};
systemd.services.opensmtpd =
let
procEnv = pkgs.buildEnv {
name = "opensmtpd-procs";
paths = [ cfg.package ] ++ cfg.procPackages;
pathsToLink = [ "/libexec/smtpd" ];
};
in
{
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig.ExecStart = "${cfg.package}/sbin/smtpd -d -f ${conf} ${args}";
environment.OPENSMTPD_PROC_PATH = "${procEnv}/libexec/smtpd";
};
};
}

View File

@@ -0,0 +1,81 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.pfix-srsd;
in
{
###### interface
options = {
services.pfix-srsd = {
enable = lib.mkOption {
default = false;
type = lib.types.bool;
description = "Whether to run the postfix sender rewriting scheme daemon.";
};
domain = lib.mkOption {
description = "The domain for which to enable srs";
type = lib.types.str;
example = "example.com";
};
secretsFile = lib.mkOption {
description = ''
The secret data used to encode the SRS address.
to generate, use a command like:
`for n in $(seq 5); do dd if=/dev/urandom count=1 bs=1024 status=none | sha256sum | sed 's/ -$//' | sed 's/^/ /'; done`
'';
type = lib.types.path;
default = "/var/lib/pfix-srsd/secrets";
};
configurePostfix = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to configure the required settings to use pfix-srsd in the local Postfix instance.
'';
};
};
};
###### implementation
config = lib.mkMerge [
(lib.mkIf (cfg.enable && cfg.configurePostfix && config.services.postfix.enable) {
services.postfix.settings.main = {
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" ];
};
})
(lib.mkIf cfg.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,246 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
hasPrefix
literalExpression
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
types
;
cfg = config.services.postfix-tlspol;
format = pkgs.formats.yaml_1_2 { };
configFile = format.generate "postfix-tlspol.yaml" cfg.settings;
in
{
meta.maintainers = pkgs.postfix-tlspol.meta.maintainers;
options.services.postfix-tlspol = {
enable = mkEnableOption "postfix-tlspol";
package = mkPackageOption pkgs "postfix-tlspol" { };
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
server = {
address = mkOption {
type = types.str;
default = "unix:/run/postfix-tlspol/tlspol.sock";
example = "127.0.0.1:8642";
description = ''
Path or address/port where postfix-tlspol binds its socket to.
'';
};
socket-permissions = mkOption {
type = types.str;
default = "0660";
readOnly = true;
description = ''
Permissions to the UNIX socket, if configured.
::: {.note}
Due to hardening on the systemd unit the socket can never be created world readable/writable.
:::
'';
apply = value: (builtins.fromTOML "v=0o${value}").v;
};
log-level = mkOption {
type = types.enum [
"debug"
"info"
"warn"
"error"
];
default = "info";
example = "warn";
description = ''
Log level
'';
};
prefetch = mkOption {
type = types.bool;
default = true;
example = false;
description = ''
Whether to prefetch DNS records when the TTL of a cached record is about to expire.
'';
};
cache-file = mkOption {
type = types.path;
default = "/var/cache/postfix-tlspol/cache.db";
readOnly = true;
description = ''
Path to the cache file.
'';
};
};
dns = {
address = mkOption {
type = with types; nullOr str;
default = null;
example = "127.0.0.1:53";
description = ''
IP and port to your DNS resolver.
Uses resolvers from /etc/resolv.conf if unset.
::: {.note}
The configured DNS resolver must validate DNSSEC signatures.
:::
'';
};
};
};
};
default = { };
description = ''
The postfix-tlspol configuration file as a Nix attribute set.
See the reference documentation for possible options.
<https://github.com/Zuplu/postfix-tlspol/blob/main/configs/config.default.yaml>
'';
};
configurePostfix = mkOption {
type = types.bool;
default = true;
description = ''
Whether to configure the required settings to use postfix-tlspol in the local Postfix instance.
'';
};
};
config = mkMerge [
(mkIf (cfg.enable && config.services.postfix.enable && cfg.configurePostfix) {
# https://github.com/Zuplu/postfix-tlspol#postfix-configuration
services.postfix.settings.main = {
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
smtp_tls_policy_maps =
let
address =
if (hasPrefix "unix:" cfg.settings.server.address) then
cfg.settings.server.address
else
"inet:${cfg.settings.server.address}";
in
[ "socketmap:${address}:QUERYwithTLSRPT" ];
};
systemd.services.postfix = {
wants = [ "postfix-tlspol.service" ];
after = [ "postfix-tlspol.service" ];
};
users.users.postfix.extraGroups = [ "postfix-tlspol" ];
})
(mkIf cfg.enable {
environment.etc."postfix-tlspol/config.yaml".source = configFile;
environment.systemPackages = [ cfg.package ];
users.users.postfix-tlspol = {
isSystemUser = true;
group = "postfix-tlspol";
};
users.groups.postfix-tlspol = { };
systemd.services.postfix-tlspol = {
after = [
"nss-lookup.target"
"network-online.target"
];
wants = [
"nss-lookup.target"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
description = "Postfix DANE/MTA-STS TLS policy socketmap service";
documentation = [ "https://github.com/Zuplu/postfix-tlspol" ];
restartTriggers = [ configFile ];
# https://github.com/Zuplu/postfix-tlspol/blob/main/init/postfix-tlspol.service
serviceConfig = {
ExecStart = toString [
(lib.getExe cfg.package)
"-config"
"/etc/postfix-tlspol/config.yaml"
];
ExecReload = "${lib.getExe' pkgs.util-linux "kill"} -HUP $MAINPID";
Restart = "always";
RestartSec = 5;
User = "postfix-tlspol";
Group = "postfix-tlspol";
CacheDirectory = "postfix-tlspol";
CapabilityBoundingSet = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
ReadOnlyPaths = [ "/etc/postfix-tlspol/config.yaml" ];
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
]
++ lib.optionals (lib.hasPrefix "unix:" cfg.settings.server.address) [
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @resources"
];
SystemCallErrorNumber = "EPERM";
SecureBits = [
"noroot"
"noroot-locked"
];
RuntimeDirectory = "postfix-tlspol";
RuntimeDirectoryMode = "1750";
WorkingDirectory = "/var/cache/postfix-tlspol";
UMask = "0117";
};
};
})
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.postgrey;
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 = port;
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 {manpage}`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 = ints.unsigned;
default = 300;
description = "Greylist for N seconds";
};
maxAge = mkOption {
type = ints.unsigned;
default = 35;
description = "Delete entries from whitelist if they haven't been seen for N days";
};
retryWindow = mkOption {
type = either str ints.unsigned;
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 = ints.unsigned;
default = 24;
description = "Strip N bits from IPv4 addresses if lookupBySubnet is true";
};
IPv6CIDR = mkOption {
type = ints.unsigned;
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 ints.positive;
default = 5;
description = "Whitelist clients after successful delivery of N messages";
};
whitelistClients = mkOption {
type = listOf path;
default = [ ];
description = "Client address whitelist files (see {manpage}`postgrey(8)`)";
};
whitelistRecipients = mkOption {
type = listOf path;
default = [ ];
description = "Recipient address whitelist files (see {manpage}`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,349 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
cfg = config.services.postsrsd;
inherit (lib)
concatMapStringsSep
concatMapAttrsStringSep
isBool
isFloat
isInt
isPath
isString
isList
mkEnableOption
mkPackageOption
mkRemovedOptionModule
mkRenamedOptionModule
;
# This is a implementation of a simple libconfuse config renderer sufficient
# for the postsrsd configuration file complexity.
# TODO: Replace with pkgs.formats.libconfuse, once implemented (https://github.com/NixOS/nixpkgs/issues/401565)
renderValue =
value:
if isBool value then
if value then "true" else "false"
else if isString value || isPath value then
builtins.toJSON value # for escaping
else if isInt value || isFloat value then
toString value
else if isList value then
"{${concatMapStringsSep "," renderValue value}}"
else
throw "postsrsd: unsupported value type in settings option";
renderAttr =
attrs: concatMapAttrsStringSep "\n" (name: value: "${name} = ${renderValue value}") attrs;
configFile = pkgs.writeText "postsrsd.conf" (
renderAttr (lib.filterAttrsRecursive (_: v: v != null) cfg.settings)
);
in
{
imports = [
(mkRemovedOptionModule [ "services" "postsrsd" "socketPath" ] ''
Configure/reference `services.postsrsd.settings.socketmap` instead. Note that its now required to start with the `inet:` or `unix:` prefix.
'')
(mkRenamedOptionModule
[ "services" "postsrsd" "domains" ]
[ "services" "postsrsd" "settings" "domains" ]
)
(mkRenamedOptionModule
[ "services" "postsrsd" "separator" ]
[ "services" "postsrsd" "settings" "separator" ]
)
]
++
map
(
name:
lib.mkRemovedOptionModule [ "services" "postsrsd" name ] ''
`postsrsd` was upgraded to `>= 2.0.0`, with some different behaviors and configuration settings:
- NixOS Release Notes: https://nixos.org/manual/nixos/unstable/release-notes#sec-nixpkgs-release-25.05-incompatibilities
- NixOS Options Reference: https://nixos.org/manual/nixos/unstable/options#opt-services.postsrsd.enable
- Migration instructions: https://github.com/roehling/postsrsd/blob/2.0.10/README.rst#migrating-from-version-1x
- Postfix Setup: https://github.com/roehling/postsrsd/blob/2.0.10/README.rst#postfix-setup
''
)
[
"domain"
"forwardPort"
"reversePort"
"timeout"
"excludeDomains"
];
options = {
services.postsrsd = {
enable = mkEnableOption "the postsrsd SRS server for Postfix.";
package = mkPackageOption pkgs "postsrsd" { };
secretsFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/postsrsd/postsrsd.secret";
description = ''
Secret keys used for signing and verification.
::: {.note}
The secret will be generated, if it does not exist at the given path.
:::
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (oneOf [
bool
float
int
path
str
(listOf str)
]);
options = {
domains = lib.mkOption {
type = with lib.types; listOf str;
default = [ ];
example = [ "example.com" ];
description = ''
List of local domains, that do not require rewriting.
'';
};
secrets-file = lib.mkOption {
type = lib.types.str;
default = "\${CREDENTIALS_DIRECTORY}/secrets-file";
readOnly = true;
description = ''
Path to the file containing the secret keys.
::: {.note}
Secrets are passed using `LoadCredential=` on the systemd unit,
so this options is read-only.
Configure {option}`services.postsrsd.secretsFile` instead.
'';
};
separator = lib.mkOption {
type = lib.types.enum [
"-"
"="
"+"
];
default = "=";
description = ''
SRS tag separator used in generated sender addresses.
Unless you have a very good reason, you should leave this
setting at its default.
'';
};
srs-domain = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "srs.example.com";
description = ''
Dedicated mail domain used for ephemeral SRS envelope addresses.
Recommended to configure, when hosting multiple unrelated mail
domains (e.g. for different customers), to prevent privacy
issues.
Set to `null` to not configure any `srs-domain`.
'';
};
socketmap = lib.mkOption {
type = lib.types.strMatching "^(unix|inet):.+";
default = "unix:/run/postsrsd/socket";
example = "inet:localhost:10003";
description = ''
Listener configuration in socket map format native to Postfix configuration.
'';
};
chroot-dir = lib.mkOption {
type = lib.types.str;
default = "";
readOnly = true;
description = ''
Path to chroot into at runtime as an additional layer of protection.
::: {.note}
We confine the runtime environment through systemd hardening instead, so this option is read-only.
:::
'';
};
unprivileged-user = lib.mkOption {
type = lib.types.str;
default = "";
readOnly = true;
description = ''
Unprivileged user to drop privileges to.
::: {.note}
Our systemd unit never runs postsrsd as a privileged process, so this option is read-only.
:::
'';
};
};
};
default = { };
description = ''
Configuration options for the postsrsd.conf file.
See the [example configuration](https://github.com/roehling/postsrsd/blob/${cfg.package.version}/doc/postsrsd.conf) for possible values.
'';
};
configurePostfix = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to configure the required settings to use postsrsd in the local Postfix instance.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "postsrsd";
description = "User for the daemon";
};
group = lib.mkOption {
type = lib.types.str;
default = "postsrsd";
description = "Group for the daemon";
};
};
};
config = lib.mkMerge [
(lib.mkIf (cfg.enable && cfg.configurePostfix && config.services.postfix.enable) {
services.postfix.settings.main = {
# https://github.com/roehling/postsrsd#configuration
sender_canonical_maps = "socketmap:${cfg.settings.socketmap}:forward";
sender_canonical_classes = "envelope_sender";
recipient_canonical_maps = "socketmap:${cfg.settings.socketmap}:reverse";
recipient_canonical_classes = [
"envelope_recipient"
"header_recipient"
];
};
users.users.postfix.extraGroups = [ cfg.group ];
})
(lib.mkIf cfg.enable {
users.users = lib.optionalAttrs (cfg.user == "postsrsd") {
postsrsd = {
group = cfg.group;
uid = config.ids.uids.postsrsd;
};
};
users.groups = lib.optionalAttrs (cfg.group == "postsrsd") {
postsrsd.gid = config.ids.gids.postsrsd;
};
systemd.services.postsrsd-generate-secrets = {
path = [ pkgs.coreutils ];
script = ''
if [ -e "${cfg.secretsFile}" ]; then
echo "Secrets file exists. Nothing to do!"
else
echo "WARNING: secrets file not found, autogenerating!"
DIR="$(dirname "${cfg.secretsFile}")"
install -m 750 -o ${cfg.user} -g ${cfg.group} -d "$DIR"
install -m 600 -o ${cfg.user} -g ${cfg.group} <(dd if=/dev/random bs=18 count=1 | base64) "${cfg.secretsFile}"
fi
'';
serviceConfig = {
Type = "oneshot";
};
};
environment.etc."postsrsd.conf".source = configFile;
systemd.services.postsrsd = {
description = "PostSRSd SRS rewriting server";
after = [
"network.target"
"postsrsd-generate-secrets.service"
];
before = [ "postfix.service" ];
wantedBy = [ "multi-user.target" ];
requires = [ "postsrsd-generate-secrets.service" ];
restartTriggers = [ configFile ];
serviceConfig = {
ExecStart = utils.escapeSystemdExecArgs [
(lib.getExe cfg.package)
"-C"
"/etc/postsrsd.conf"
];
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = "postsrsd";
RuntimeDirectoryMode = "0750";
LoadCredential = "secrets-file:${cfg.secretsFile}";
CapabilityBoundingSet = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateNetwork = lib.hasPrefix "unix:" cfg.settings.socketmap;
PrivateTmp = true;
PrivateUsers = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
ProtectProc = "invisible";
ProcSubset = "pid";
RemoveIPC = true;
RestrictAddressFamilies =
if lib.hasPrefix "unix:" cfg.settings.socketmap then
[ "AF_UNIX" ]
else
[
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @resources"
];
UMask = "0027";
};
};
})
];
# package version referenced in option documentation
meta.buildDocsInSandbox = false;
}

View File

@@ -0,0 +1,60 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.protonmail-bridge;
in
{
options.services.protonmail-bridge = {
enable = lib.mkEnableOption "protonmail bridge";
package = lib.mkPackageOption pkgs "protonmail-bridge" { };
path = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
example = lib.literalExpression "with pkgs; [ pass gnome-keyring ]";
description = "List of derivations to put in protonmail-bridge's path.";
};
logLevel = lib.mkOption {
type = lib.types.nullOr (
lib.types.enum [
"panic"
"fatal"
"error"
"warn"
"info"
"debug"
]
);
default = null;
description = "Log level of the Proton Mail Bridge service. If set to null then the service uses it's default log level.";
};
};
config = lib.mkIf cfg.enable {
systemd.user.services.protonmail-bridge = {
description = "protonmail bridge";
wantedBy = [ "graphical-session.target" ];
after = [ "graphical-session.target" ];
serviceConfig =
let
logLevel = lib.optionalString (cfg.logLevel != null) "--log-level ${cfg.logLevel}";
in
{
ExecStart = "${lib.getExe cfg.package} --noninteractive ${logLevel}";
Restart = "always";
};
path = cfg.path;
};
environment.systemPackages = [ cfg.package ];
};
meta.maintainers = with lib.maintainers; [ mzacho ];
}

View File

@@ -0,0 +1,685 @@
{
lib,
pkgs,
config,
...
}:
with lib;
let
cfg = config.services.public-inbox;
stateDir = "/var/lib/public-inbox";
gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
iniAtom = gitIni.lib.types.atom;
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 {manpage}`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 `systemd.sockets.public-inbox-${proto}d.listenStreams`
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 {manpage}`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 {manpage}`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
++ filter (x: x != null) [
cfg.${proto}.cert or null
cfg.${proto}.key or null
];
# 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 = "tmpfs";
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";
};
confinement = {
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 = mkPackageOption pkgs "public-inbox" { };
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 = types.nonEmptyStr;
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 {manpage}`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, {manpage}`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:
`locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";`
Set to null and use `systemd.sockets.public-inbox-httpd.listenStreams`
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 {manpage}`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 [public-inbox config file](https://public-inbox.org/public-inbox-config.html).
'';
default = { };
type = types.submodule {
freeformType = gitIni.type;
options.publicinbox = mkOption {
default = { };
description = "public inboxes";
type = types.submodule {
# Support both global options like `services.public-inbox.settings.publicinbox.imapserver`
# and inbox specific options like `services.public-inbox.settings.publicinbox.foo.address`.
freeformType =
with types;
attrsOf (oneOf [
iniAtom
(attrsOf 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.imapserver = mkOption {
type = with types; listOf str;
default = [ ];
example = [ "imap.public-inbox.org" ];
description = "IMAP URLs to this public-inbox instance";
};
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.pop3server = mkOption {
type = with types; listOf str;
default = [ ];
example = [ "pop.public-inbox.org" ];
description = "POP3 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, {manpage}`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, {manpage}`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.
settings.main.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
settings.master.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 = {
BindReadOnlyPaths = map (c: c.dir) (lib.attrValues cfg.settings.coderepo);
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 ${escapeShellArg inbox.inboxdir} ]; 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
inbox.inboxdir
inbox.url
]
++ inbox.address
)}
rm -rf $conf_dir
fi
ln -sf ${inbox.description} \
${escapeShellArg inbox.inboxdir}/description
export GIT_DIR=${escapeShellArg inbox.inboxdir}/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
);
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = [
"public-inbox/.public-inbox"
"public-inbox/.public-inbox/emergency"
"public-inbox/inboxes"
];
};
}
];
}
];
environment.systemPackages = [ cfg.package ];
};
meta.maintainers = with lib.maintainers; [
julm
qyliss
];
}

View File

@@ -0,0 +1,318 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.roundcube;
fpm = config.services.phpfpm.pools.roundcube;
localDB = cfg.database.host == "localhost";
user = cfg.database.username;
phpWithPspell = pkgs.php83.withExtensions ({ enabled, all }: [ all.pspell ] ++ enabled);
in
{
options.services.roundcube = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to enable roundcube.
Also enables nginx virtual host management.
Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
See [](#opt-services.nginx.virtualHosts) for further information.
'';
};
hostName = lib.mkOption {
type = lib.types.str;
example = "webmail.example.com";
description = "Hostname to use for the nginx vhost";
};
package = lib.mkPackageOption pkgs "roundcube" {
example = "roundcube.withPlugins (plugins: [ plugins.persistent_login ])";
};
database = {
username = lib.mkOption {
type = lib.types.str;
default = "roundcube";
description = ''
Username for the postgresql connection.
If `database.host` is set to `localhost`, a unix user and group of the same name will be created as well.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
Host of the postgresql server. If this is not set to
`localhost`, you have to create the
postgresql user and database yourself, with appropriate
permissions.
'';
};
password = lib.mkOption {
type = lib.types.str;
description = "Password for the postgresql connection. Do not use: the password will be stored world readable in the store; use `passwordFile` instead.";
default = "";
};
passwordFile = lib.mkOption {
type = lib.types.path;
example = lib.literalExpression ''
pkgs.writeText "roundcube-postgres-passwd.txt" '''
hostname:port:database:username:password
'''
'';
description = ''
Password file for the postgresql connection.
Must be formatted according to PostgreSQL .pgpass standard (see <https://www.postgresql.org/docs/current/libpq-pgpass.html>)
but only one line, no comments and readable by user `nginx`.
Ignored if `database.host` is set to `localhost`, as peer authentication will be used.
'';
};
dbname = lib.mkOption {
type = lib.types.str;
default = "roundcube";
description = "Name of the postgresql database";
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
List of roundcube plugins to enable. Currently, only those directly shipped with Roundcube are supported.
'';
};
dicts = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "with pkgs.aspellDicts; [ en fr de ]";
description = ''
List of aspell dictionaries for spell checking. If empty, spell checking is disabled.
'';
};
maxAttachmentSize = lib.mkOption {
type = lib.types.int;
default = 18;
apply = configuredMaxAttachmentSize: "${toString (configuredMaxAttachmentSize * 1.37)}M";
description = ''
The maximum attachment size in MB.
[upstream issue comment]: https://github.com/roundcube/roundcubemail/issues/7979#issuecomment-808879209
::: {.note}
Since there is some overhead in base64 encoding applied to attachments, + 37% will be added
to the value set in this option in order to offset the overhead. For example, setting
`maxAttachmentSize` to `100` would result in `137M` being the real value in the configuration.
See [upstream issue comment] for more details on the motivations behind this.
:::
'';
};
configureNginx = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Configure nginx as a reverse proxy for roundcube.";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra configuration for roundcube webmail instance";
};
};
config = lib.mkIf cfg.enable {
# backward compatibility: if password is set but not passwordFile, make one.
services.roundcube.database.passwordFile = lib.mkIf (!localDB && cfg.database.password != "") (
lib.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('${cfg.database.passwordFile}')[0];
$password = preg_split('~\\\\.(*SKIP)(*FAIL)|\:~s', $password);
$password = rtrim(end($password));
$password = str_replace("\\:", ":", $password);
$password = str_replace("\\\\", "\\", $password);
''}
$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'] = [${lib.concatMapStringsSep "," (p: "'${p}'") cfg.plugins}];
$config['des_key'] = file_get_contents('/var/lib/roundcube/des_key');
$config['mime_types'] = '${pkgs.nginx}/conf/mime.types';
# Roundcube uses PHP-FPM which has `PrivateTmp = true;`
$config['temp_dir'] = '/tmp';
$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 = lib.mkIf cfg.configureNginx {
enable = true;
virtualHosts = {
${cfg.hostName} = {
forceSSL = lib.mkDefault true;
enableACME = lib.mkDefault true;
root = cfg.package;
locations."/" = {
index = "index.php";
priority = 1100;
extraConfig = ''
add_header Cache-Control 'public, max-age=604800, must-revalidate';
'';
};
locations."~ ^/(SQL|bin|config|logs|temp|vendor)/" = {
priority = 3110;
extraConfig = ''
return 404;
'';
};
locations."~ ^/(CHANGELOG.md|INSTALL|LICENSE|README.md|SECURITY.md|UPGRADING|composer.json|composer.lock)" =
{
priority = 3120;
extraConfig = ''
return 404;
'';
};
locations."~* \\.php(/|$)" = {
priority = 3130;
extraConfig = ''
fastcgi_pass unix:${fpm.socket};
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include ${config.services.nginx.package}/conf/fastcgi.conf;
'';
};
};
};
};
assertions = [
{
assertion = localDB -> cfg.database.username == cfg.database.dbname;
message = ''
When setting up a DB and its owner user, the owner and the DB name must be
equal!
'';
}
];
services.postgresql = lib.mkIf localDB {
enable = true;
ensureDatabases = [ cfg.database.dbname ];
ensureUsers = [
{
name = cfg.database.username;
ensureDBOwnership = true;
}
];
};
users.users.${user} = lib.mkIf localDB {
group = user;
isSystemUser = true;
createHome = false;
};
users.groups.${user} = lib.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 = lib.mapAttrs (name: lib.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 = lib.mkMerge [
(lib.mkIf localDB {
requires = [ "postgresql.target" ];
after = [ "postgresql.target" ];
})
{
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [
(if localDB then config.services.postgresql.package else pkgs.postgresql)
];
script =
let
psql = "${lib.optionalString (!localDB) "PGPASSFILE=${cfg.database.passwordFile}"} 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,79 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.rspamd-trainer;
format = pkgs.formats.toml { };
in
{
options.services.rspamd-trainer = {
enable = lib.mkEnableOption "Spam/ham trainer for rspamd";
settings = lib.mkOption {
default = { };
description = ''
IMAP authentication configuration for rspamd-trainer. For supplying
the IMAP password, use the `secrets` option.
'';
type = lib.types.submodule {
freeformType = format.type;
};
example = lib.literalExpression ''
{
HOST = "localhost";
USERNAME = "spam@example.com";
INBOXPREFIX = "INBOX/";
}
'';
};
secrets = lib.mkOption {
type = with lib.types; listOf path;
description = ''
A list of files containing the various secrets. Should be in the
format expected by systemd's `EnvironmentFile` directory. For the
IMAP account password use `PASSWORD = mypassword`.
'';
default = [ ];
};
};
config = lib.mkIf cfg.enable {
systemd = {
services.rspamd-trainer = {
description = "Spam/ham trainer for rspamd";
serviceConfig = {
ExecStart = "${pkgs.rspamd-trainer}/bin/rspamd-trainer";
WorkingDirectory = "/var/lib/rspamd-trainer";
StateDirectory = [ "rspamd-trainer/log" ];
Type = "oneshot";
DynamicUser = true;
EnvironmentFile = [
(format.generate "rspamd-trainer-env" cfg.settings)
cfg.secrets
];
};
};
timers."rspamd-trainer" = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "10m";
OnUnitActiveSec = "10m";
Unit = "rspamd-trainer.service";
};
};
};
};
meta.maintainers = with lib.maintainers; [ onny ];
}

View File

@@ -0,0 +1,547 @@
{
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 `proxy` is
deprecated and only kept for backwards compatibility and should be
replaced with `rspamd_proxy`.
'';
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))
// (lib.optionalAttrs (cfg.extraConfig != "") {
"extra-config.inc".text = cfg.extraConfig;
});
in
{
###### interface
options = {
services.rspamd = {
enable = mkEnableOption "rspamd, the Rapid spam filtering system";
package = lib.mkPackageOption pkgs "rspamd" { };
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 {file}`/etc/rspamd/local.d/{name}`.
'';
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 {file}`/etc/rspamd/override.d/{name}`.
'';
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 {file}`/etc/rspamd/rspamd.local.lua` 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.settings.main = 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 = [ cfg.package ];
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 = "${cfg.package}/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,152 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.rss2email;
in
{
###### interface
options = {
services.rss2email = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable rss2email.";
};
to = lib.mkOption {
type = lib.types.str;
description = "Mail address to which to send emails";
};
interval = lib.mkOption {
type = lib.types.str;
default = "12h";
description = "How often to check the feeds, in systemd interval format";
};
config = lib.mkOption {
type =
with lib.types;
attrsOf (oneOf [
str
int
bool
]);
default = { };
description = ''
The configuration to give rss2email.
Default will use system-wide `sendmail` to send the
email. This is rss2email's default when running
`r2e new`.
This set contains key-value associations that will be set in the
`[DEFAULT]` block along with the
`to` parameter.
See `man r2e` for more information on which
parameters are accepted.
'';
};
feeds = lib.mkOption {
description = "The feeds to watch.";
type = lib.types.attrsOf (
lib.types.submodule {
options = {
url = lib.mkOption {
type = lib.types.str;
description = "The URL at which to fetch the feed.";
};
to = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
Email address to which to send feed items.
If `null`, this will not be set in the
configuration file, and rss2email will make it default to
`rss2email.to`.
'';
};
};
}
);
};
};
};
###### implementation
config = lib.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.settings."10-rss2email"."/var/rss2email".d = {
user = "rss2email";
group = "rss2email";
mode = "0700";
};
systemd.services.rss2email =
let
conf = pkgs.writeText "rss2email.cfg" (
lib.generators.toINI { } (
{
DEFAULT = cfg.config;
}
// lib.mapAttrs' (
name: feed:
lib.nameValuePair "feed.${name}" (
{ inherit (feed) url; } // lib.optionalAttrs (feed.to != null) { inherit (feed) to; }
)
) cfg.feeds
)
);
in
{
preStart = ''
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 ${conf} -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,183 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.schleuder;
settingsFormat = pkgs.formats.yaml { };
postfixMap =
entries: lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${value}") entries);
writePostfixMap = name: entries: pkgs.writeText name (postfixMap entries);
configScript = pkgs.writeScript "schleuder-cfg" ''
#!${pkgs.runtimeShell}
set -exuo pipefail
umask 0077
${pkgs.yq}/bin/yq \
--slurpfile overrides <(${pkgs.yq}/bin/yq . <${lib.escapeShellArg cfg.extraSettingsFile}) \
< ${settingsFormat.generate "schleuder.yml" cfg.settings} \
'. * $overrides[0]' \
> /etc/schleuder/schleuder.yml
chown schleuder: /etc/schleuder/schleuder.yml
'';
in
{
options.services.schleuder = {
enable = lib.mkEnableOption "Schleuder secure remailer";
enablePostfix = lib.mkEnableOption "automatic postfix integration" // {
default = true;
};
lists = lib.mkOption {
description = ''
List of list addresses that should be handled by Schleuder.
Note that this is only handled by the postfix integration, and
the setup of the lists, their members and their keys has to be
performed separately via schleuder's API, using a tool such as
schleuder-cli.
'';
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"widget-team@example.com"
"security@example.com"
];
};
/*
maybe one day....
domains = lib.mkOption {
description = "Domains for which all mail should be handled by Schleuder.";
type = lib.types.listOf lib.types.str;
default = [];
example = ["securelists.example.com"];
};
*/
settings = lib.mkOption {
description = ''
Settings for schleuder.yml.
Check the [example configuration](https://0xacab.org/schleuder/schleuder/blob/master/etc/schleuder.yml) for possible values.
'';
type = lib.types.submodule {
freeformType = settingsFormat.type;
options.keyserver = lib.mkOption {
type = lib.types.str;
description = ''
Key server from which to fetch and update keys.
Note that NixOS uses a different default from upstream, since the upstream default sks-keyservers.net is deprecated.
'';
default = "keys.openpgp.org";
};
};
default = { };
};
extraSettingsFile = lib.mkOption {
description = "YAML file to merge into the schleuder config at runtime. This can be used for secrets such as API keys.";
type = lib.types.nullOr lib.types.path;
default = null;
};
listDefaults = lib.mkOption {
description = ''
Default settings for lists (list-defaults.yml).
Check the [example configuration](https://0xacab.org/schleuder/schleuder/-/blob/master/etc/list-defaults.yml) for possible values.
'';
type = settingsFormat.type;
default = { };
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !(cfg.settings.api ? valid_api_keys);
message = ''
services.schleuder.settings.api.valid_api_keys is set. Defining API keys via NixOS config results in them being copied to the world-readable Nix store. Please use the extraSettingsFile option to store API keys in a non-public location.
'';
}
{
assertion = !(lib.any (db: db ? password) (lib.attrValues cfg.settings.database or { }));
message = ''
A password is defined for at least one database in services.schleuder.settings.database. Defining passwords via NixOS config results in them being copied to the world-readable Nix store. Please use the extraSettingsFile option to store database passwords in a non-public location.
'';
}
];
users.users.schleuder.isSystemUser = true;
users.users.schleuder.group = "schleuder";
users.groups.schleuder = { };
environment.systemPackages = [
pkgs.schleuder-cli
];
services.postfix = lib.mkIf cfg.enablePostfix {
extraMasterConf = ''
schleuder unix - n n - - pipe
flags=DRhu user=schleuder argv=/${pkgs.schleuder}/bin/schleuder work ''${recipient}
'';
transport = lib.mkIf (cfg.lists != [ ]) (postfixMap (lib.genAttrs cfg.lists (_: "schleuder:")));
settings.main.schleuder_destination_recipient_limit = 1;
# review: does this make sense?
localRecipients = lib.mkIf (cfg.lists != [ ]) cfg.lists;
};
systemd.services =
let
commonServiceConfig = {
# We would have liked to use DynamicUser, but since the default
# database is SQLite and lives in StateDirectory, and that same
# database needs to be readable from the postfix service, this
# isn't trivial to do.
User = "schleuder";
StateDirectory = "schleuder";
StateDirectoryMode = "0700";
};
in
{
schleuder-init = {
serviceConfig = commonServiceConfig // {
ExecStartPre = lib.mkIf (cfg.extraSettingsFile != null) [
"+${configScript}"
];
ExecStart = [ "${pkgs.schleuder}/bin/schleuder install" ];
Type = "oneshot";
};
};
schleuder-api-daemon = {
after = [
"local-fs.target"
"network.target"
"schleuder-init.service"
];
wantedBy = [ "multi-user.target" ];
requires = [ "schleuder-init.service" ];
serviceConfig = commonServiceConfig // {
ExecStart = [ "${pkgs.schleuder}/bin/schleuder-api-daemon" ];
};
};
schleuder-weekly-key-maintenance = {
after = [
"local-fs.target"
"network.target"
];
startAt = "weekly";
serviceConfig = commonServiceConfig // {
ExecStart = [
"${pkgs.schleuder}/bin/schleuder refresh_keys"
"${pkgs.schleuder}/bin/schleuder check_keys"
];
};
};
};
environment.etc."schleuder/schleuder.yml" = lib.mkIf (cfg.extraSettingsFile == null) {
source = settingsFormat.generate "schleuder.yml" cfg.settings;
};
environment.etc."schleuder/list-defaults.yml".source =
settingsFormat.generate "list-defaults.yml" cfg.listDefaults;
services.schleuder = {
#lists_dir = "/var/lib/schleuder.lists";
settings.filters_dir = lib.mkDefault "/var/lib/schleuder/filters";
settings.keyword_handlers_dir = lib.mkDefault "/var/lib/schleuder/keyword_handlers";
};
};
}

View File

@@ -0,0 +1,201 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.spamassassin;
spamassassin-local-cf = pkgs.writeText "local.cf" cfg.config;
in
{
options = {
services.spamassassin = {
enable = lib.mkEnableOption "the SpamAssassin daemon";
debug = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to run the SpamAssassin daemon in debug mode";
};
config = lib.mkOption {
type = lib.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 = lib.mkOption {
type = with lib.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::DMARC
loadplugin Mail::SpamAssassin::Plugin::DNSEval
loadplugin Mail::SpamAssassin::Plugin::FreeMail
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 = lib.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";
home = "/var/lib/spamassassin";
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"
];
reloadTriggers = [
config.environment.etc."mail/spamassassin/init.pre".source
config.environment.etc."mail/spamassassin/local.cf".source
];
serviceConfig = {
User = "spamd";
Group = "spamd";
ExecStart = "+${pkgs.spamassassin}/bin/spamd ${lib.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,255 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.stalwart-mail;
configFormat = pkgs.formats.toml { };
configFile = configFormat.generate "stalwart-mail.toml" cfg.settings;
useLegacyStorage = lib.versionOlder config.system.stateVersion "24.11";
parsePorts =
listeners:
let
parseAddresses = listeners: lib.flatten (lib.mapAttrsToList (name: value: value.bind) listeners);
splitAddress = addr: lib.splitString ":" addr;
extractPort = addr: lib.toInt (builtins.foldl' (a: b: b) "" (splitAddress addr));
in
builtins.map (address: extractPort address) (parseAddresses listeners);
in
{
options.services.stalwart-mail = {
enable = lib.mkEnableOption "the Stalwart all-in-one email server";
package = lib.mkPackageOption pkgs "stalwart-mail" { };
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to open TCP firewall ports, which are specified in
{option}`services.stalwart-mail.settings.server.listener` on all interfaces.
'';
};
settings = lib.mkOption {
inherit (configFormat) type;
default = { };
description = ''
Configuration options for the Stalwart email server.
See <https://stalw.art/docs/category/configuration> for available options.
By default, the module is configured to store everything locally.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/stalwart-mail";
description = ''
Data directory for stalwart
'';
};
credentials = lib.mkOption {
description = ''
Credentials envs used to configure Stalwart-Mail secrets.
These secrets can be accessed in configuration values with
the macros such as
`%{file:/run/credentials/stalwart-mail.service/VAR_NAME}%`.
'';
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
user_admin_password = "/run/keys/stalwart_admin_password";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion =
!(
(lib.hasAttrByPath [ "settings" "queue" ] cfg)
&& (builtins.any (lib.hasAttrByPath [
"value"
"next-hop"
]) (lib.attrsToList cfg.settings.queue))
);
message = ''
Stalwart deprecated `next-hop` in favor of "virtual queues" `queue.strategy.route` \
with v0.13.0 see [Outbound Strategy](https://stalw.art/docs/mta/outbound/strategy/#configuration) \
and [release announcement](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md#upgrading-from-v012x-and-v011x-to-v013x).
'';
}
];
# Default config: all local
services.stalwart-mail.settings = {
tracer.stdout = {
type = lib.mkDefault "stdout";
level = lib.mkDefault "info";
ansi = lib.mkDefault false; # no colour markers to journald
enable = lib.mkDefault true;
};
store =
if useLegacyStorage then
{
# structured data in SQLite, blobs on filesystem
db.type = lib.mkDefault "sqlite";
db.path = lib.mkDefault "${cfg.dataDir}/data/index.sqlite3";
fs.type = lib.mkDefault "fs";
fs.path = lib.mkDefault "${cfg.dataDir}/data/blobs";
}
else
{
# everything in RocksDB
db.type = lib.mkDefault "rocksdb";
db.path = lib.mkDefault "${cfg.dataDir}/db";
db.compression = lib.mkDefault "lz4";
};
storage.data = lib.mkDefault "db";
storage.fts = lib.mkDefault "db";
storage.lookup = lib.mkDefault "db";
storage.blob = lib.mkDefault (if useLegacyStorage then "fs" else "db");
directory.internal.type = lib.mkDefault "internal";
directory.internal.store = lib.mkDefault "db";
storage.directory = lib.mkDefault "internal";
resolver.type = lib.mkDefault "system";
resolver.public-suffix = lib.mkDefault [
"file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
];
spam-filter.resource = lib.mkDefault "file://${cfg.package.spam-filter}/spam-filter.toml";
webadmin =
let
hasHttpListener = builtins.any (listener: listener.protocol == "http") (
lib.attrValues (cfg.settings.server.listener or { })
);
in
{
path = "/var/cache/stalwart-mail";
resource = lib.mkIf hasHttpListener (lib.mkDefault "file://${cfg.package.webadmin}/webadmin.zip");
};
};
# This service stores a potentially large amount of data.
# Running it as a dynamic user would force chown to be run everytime the
# service is restarted on a potentially large number of files.
# That would cause unnecessary and unwanted delays.
users = {
groups.stalwart-mail = { };
users.stalwart-mail = {
isSystemUser = true;
group = "stalwart-mail";
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' - stalwart-mail stalwart-mail - -"
];
systemd = {
packages = [ cfg.package ];
services.stalwart-mail = {
wantedBy = [ "multi-user.target" ];
after = [
"local-fs.target"
"network.target"
];
preStart =
if useLegacyStorage then
''
mkdir -p ${cfg.dataDir}/data/blobs
''
else
''
mkdir -p ${cfg.dataDir}/db
'';
serviceConfig = {
ExecStart = [
""
"${lib.getExe cfg.package} --config=${configFile}"
];
LoadCredential = lib.mapAttrsToList (key: value: "${key}:${value}") cfg.credentials;
StandardOutput = "journal";
StandardError = "journal";
ReadWritePaths = [
cfg.dataDir
];
CacheDirectory = "stalwart-mail";
StateDirectory = "stalwart-mail";
# Upstream uses "stalwart" as the username since 0.12.0
User = "stalwart-mail";
Group = "stalwart-mail";
# Bind standard privileged ports
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
# Hardening
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE
ProcSubset = "pid";
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
unitConfig.ConditionPathExists = [
""
"${configFile}"
];
};
};
# Make admin commands available in the shell
environment.systemPackages = [ cfg.package ];
networking.firewall =
lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" cfg.settings.server))
{
allowedTCPPorts = parsePorts cfg.settings.server.listener;
};
};
meta = {
maintainers = with lib.maintainers; [
happysalada
euxane
onny
norpol
];
};
}

View File

@@ -0,0 +1,662 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.sympa;
dataDir = "/var/lib/sympa";
user = "sympa";
group = "sympa";
pkg = pkgs.sympa;
fqdns = lib.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 lib.isBool value then if value then "on" else "off" else toString value;
configGenerator =
c: lib.concatStrings (lib.flip lib.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" (
lib.concatStringsSep "\n" (
lib.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" (
lib.concatStringsSep "\n" (
lib.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 = lib.filterAttrs (n: v: v.enable) cfg.settingsFile;
in
{
###### interface
options.services.sympa = with lib.types; {
enable = lib.mkEnableOption "Sympa mailing list manager";
lang = lib.mkOption {
type = str;
default = "en_US";
example = "cs";
description = ''
Default Sympa language.
See <https://github.com/sympa-community/sympa/tree/sympa-6.2/po/sympa>
for available options.
'';
};
listMasters = lib.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 = lib.mkOption {
type = nullOr str;
default = null;
example = "lists.example.org";
description = ''
Main domain to be used in {file}`sympa.conf`.
If `null`, one of the {option}`services.sympa.domains` is chosen for you.
'';
};
domains = lib.mkOption {
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = {
webHost = lib.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 `null`).
DNS record of type A (or AAAA or CNAME) has to exist with this value.
'';
};
webLocation = lib.mkOption {
type = str;
default = "/";
example = "/sympa";
description = "URL path part of the web interface.";
};
settings = lib.mkOption {
type = attrsOf (oneOf [
str
int
bool
]);
default = { };
example = {
default_max_list_members = 3;
};
description = ''
The {file}`robot.conf` configuration file as key value set.
See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
for list of configuration parameters.
'';
};
};
config.settings = lib.mkIf (cfg.web.enable && config.webHost != null) {
wwsympa_url = lib.mkDefault "https://${config.webHost}${lib.removeSuffix "/" config.webLocation}";
};
}
)
);
description = ''
Email domains handled by this instance. There have
to be MX records for keys of this attribute set.
'';
example = lib.literalExpression ''
{
"lists.example.org" = {
webHost = "lists.example.org";
webLocation = "/";
};
"sympa.example.com" = {
webHost = "example.com";
webLocation = "/sympa";
};
}
'';
};
database = {
type = lib.mkOption {
type = enum [
"SQLite"
"PostgreSQL"
"MySQL"
];
default = "SQLite";
example = "MySQL";
description = "Database engine to use.";
};
host = lib.mkOption {
type = nullOr str;
default = null;
description = ''
Database host address.
For MySQL, use `localhost` to connect using Unix domain socket.
For PostgreSQL, use path to directory (e.g. {file}`/run/postgresql`)
to connect using Unix domain socket located in this directory.
Use `null` to fall back on Sympa default, or when using
{option}`services.sympa.database.createLocally`.
'';
};
port = lib.mkOption {
type = nullOr port;
default = null;
description = "Database port. Use `null` for default port.";
};
name = lib.mkOption {
type = str;
default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa";
defaultText = lib.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 = lib.mkOption {
type = nullOr str;
default = user;
description = "Database user. The system user name is used as a default.";
};
passwordFile = lib.mkOption {
type = nullOr path;
default = null;
example = "/run/keys/sympa-dbpassword";
description = ''
A file containing the password for {option}`services.sympa.database.name`.
'';
};
createLocally = lib.mkOption {
type = bool;
default = true;
description = "Whether to create a local database automatically.";
};
};
web = {
enable = lib.mkOption {
type = bool;
default = true;
description = "Whether to enable Sympa web interface.";
};
server = lib.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.«name»`.
'';
};
https = lib.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 = lib.mkOption {
type = ints.positive;
default = 2;
description = "Number of FastCGI processes to fork.";
};
};
mta = {
type = lib.mkOption {
type = enum [
"postfix"
"none"
];
default = "postfix";
description = ''
Mail transfer agent (MTA) integration. Use `none` if you want to configure it yourself.
The `postfix` 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`.
'';
};
};
settings = lib.mkOption {
type = attrsOf (oneOf [
str
int
bool
]);
default = { };
example = lib.literalExpression ''
{
default_home = "lists";
viewlogs_page_size = 50;
}
'';
description = ''
The {file}`sympa.conf` configuration file as key value set.
See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
for list of configuration parameters.
'';
};
settingsFile = lib.mkOption {
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = {
enable = lib.mkOption {
type = bool;
default = true;
description = "Whether this file should be generated. This option allows specific files to be disabled.";
};
text = lib.mkOption {
default = null;
type = nullOr lines;
description = "Text of the file.";
};
source = lib.mkOption {
type = path;
description = "Path of the source file.";
};
};
config.source = lib.mkIf (config.text != null) (
lib.mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text)
);
}
)
);
default = { };
example = lib.literalExpression ''
{
"list_data/lists.example.org/help" = {
text = "subject This list provides help to users";
};
}
'';
description = "Set of files to be linked in {file}`${dataDir}`.";
};
};
###### implementation
config = lib.mkIf cfg.enable {
services.sympa.settings = (
lib.mapAttrs (_: v: lib.mkDefault v) {
domain = if cfg.mainDomain != null then cfg.mainDomain else lib.head fqdns;
listmaster = lib.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;
db_user = cfg.database.name;
}
// (lib.optionalAttrs (cfg.database.host != null) {
db_host = cfg.database.host;
})
// (lib.optionalAttrs mysqlLocal {
db_host = "localhost"; # use unix domain socket
})
// (lib.optionalAttrs pgsqlLocal {
db_host = "/run/postgresql"; # use unix domain socket
})
// (lib.optionalAttrs (cfg.database.port != null) {
db_port = cfg.database.port;
})
// (lib.optionalAttrs (cfg.mta.type == "postfix") {
sendmail_aliases = "${dataDir}/sympa_transport";
aliases_program = lib.getExe' config.services.postfix.package "postmap";
aliases_db_type = "hash";
})
// (lib.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" = lib.mkDefault { source = virtual; };
"transport.sympa" = lib.mkDefault { source = transport; };
"etc/list_aliases.tt2" = lib.mkDefault { source = listAliases; };
}
// (lib.flip lib.mapAttrs' cfg.domains (
fqdn: domain:
lib.nameValuePair "etc/${fqdn}/robot.conf" (lib.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 && cfg.database.name == cfg.database.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} - -"
]
++ (lib.flip lib.concatMap fqdns (fqdn: [
"d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
"d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
]))
#++ (lib.flip lib.mapAttrsToList enabledFiles (k: v:
# "L+ ${dataDir}/${k} - - - - ${v.source}"
#))
++ (lib.concatLists (
lib.flip lib.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 ++ [ "network-online.target" ];
before = sympaSubServices;
serviceConfig = sympaServiceConfig "sympa_msg";
preStart = ''
umask 0077
cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
${lib.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
''}
${lib.optionalString (cfg.mta.type == "postfix") ''
${lib.getExe' config.services.postfix.package "postmap"} hash:${dataDir}/virtual.sympa
${lib.getExe' config.services.postfix.package "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 = lib.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 = lib.mkIf usingNginx true;
services.nginx.virtualHosts = lib.mkIf usingNginx (
let
vHosts = lib.unique (lib.remove null (lib.mapAttrsToList (_k: v: v.webHost) cfg.domains));
hostLocations =
host: map (v: v.webLocation) (lib.filter (v: v.webHost == host) (lib.attrValues cfg.domains));
httpsOpts = lib.optionalAttrs cfg.web.https {
forceSSL = lib.mkDefault true;
enableACME = lib.mkDefault true;
};
in
lib.genAttrs vHosts (
host:
{
locations =
lib.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 = lib.mkIf (cfg.mta.type == "postfix") {
enable = true;
settings = {
main = {
recipient_delimiter = "+";
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"
];
};
master = {
"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 = lib.optionalAttrs mysqlLocal {
enable = true;
package = lib.mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.postgresql = lib.optionalAttrs pgsqlLocal {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensureDBOwnership = true;
}
];
};
};
meta.maintainers = with lib.maintainers; [
sorki
];
}

View File

@@ -0,0 +1,356 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
mkPackageOption
types
;
cfg = config.services.tlsrpt;
format = pkgs.formats.ini { };
dropNullValues = lib.filterAttrsRecursive (_: value: value != null);
commonServiceSettings = {
DynamicUser = true;
User = "tlsrpt";
Restart = "always";
StateDirectory = "tlsrpt";
StateDirectoryMode = "0700";
# Hardening
CapabilityBoundingSet = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateUsers = false;
ProcSubset = "pid";
ProtectControlGroups = true;
ProtectClock = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "noaccess";
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @resources"
];
};
collectdConfigFile = format.generate "tlsrpt-collectd.cfg" {
tlsrpt_collectd = dropNullValues cfg.collectd.settings;
};
fetcherConfigFile = format.generate "tlsrpt-fetcher.cfg" {
tlsrpt_fetcher = dropNullValues cfg.fetcher.settings;
};
reportdConfigFile = format.generate "tlsrpt-reportd.cfg" {
tlsrpt_reportd = dropNullValues cfg.reportd.settings;
};
withPostfix = config.services.postfix.enable && cfg.configurePostfix;
in
{
options.services.tlsrpt = {
enable = mkEnableOption "the TLSRPT services";
package = mkPackageOption pkgs "tlsrpt-reporter" { };
collectd = {
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
storage = mkOption {
type = types.str;
default = "sqlite:///var/lib/tlsrpt/collectd.sqlite";
description = ''
Storage backend definition.
'';
};
socketname = mkOption {
type = types.path;
default = "/run/tlsrpt/collectd.sock";
description = ''
Path at which the UNIX socket will be created.
'';
};
socketmode = mkOption {
type = types.str;
default = "0220";
description = ''
Permissions on the UNIX socket.
'';
};
log_level = mkOption {
type = types.enum [
"debug"
"info"
"warning"
"error"
"critical"
];
default = "info";
description = ''
Level of log messages to emit.
'';
};
};
};
default = { };
description = ''
Flags from {manpage}`tlsrpt-collectd(1)` as key-value pairs.
'';
};
extraFlags = mkOption {
type = with types; listOf str;
default = [ ];
description = ''
List of extra flags to pass to the tlsrpt-reportd executable.
See {manpage}`tlsrpt-collectd(1)` for possible flags.
'';
};
};
fetcher = {
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
storage = mkOption {
type = types.str;
default = config.services.tlsrpt.collectd.settings.storage;
defaultText = lib.literalExpression ''
config.services.tlsrpt.collectd.settings.storage
'';
description = ''
Path to the collectd sqlite database.
'';
};
log_level = mkOption {
type = types.enum [
"debug"
"info"
"warning"
"error"
"critical"
];
default = "info";
description = ''
Level of log messages to emit.
'';
};
};
};
default = { };
description = ''
Flags from {manpage}`tlsrpt-fetcher(1)` as key-value pairs.
'';
};
};
reportd = {
settings = mkOption {
type = types.submodule {
freeformType = format.type;
options = {
dbname = mkOption {
type = types.str;
default = "/var/lib/tlsrpt/reportd.sqlite";
description = ''
Path to the sqlite database.
'';
};
fetchers = mkOption {
type = types.str;
default = lib.getExe' cfg.package "tlsrpt-fetcher";
defaultText = lib.literalExpression ''
lib.getExe' cfg.package "tlsrpt-fetcher"
'';
description = ''
Comma-separated list of fetcher programs that retrieve collectd data.
'';
};
log_level = mkOption {
type = types.enum [
"debug"
"info"
"warning"
"error"
"critical"
];
default = "info";
description = ''
Level of log messages to emit.
'';
};
organization_name = mkOption {
type = types.str;
example = "ACME Corp.";
description = ''
Name of the organization sending out the reports.
'';
};
contact_info = mkOption {
type = types.str;
example = "smtp-tls-reporting@example.com";
description = ''
Contact information embedded into the reports.
'';
};
http_script = mkOption {
type = with types; nullOr str;
default = "${lib.getExe pkgs.curl} --silent --header 'Content-Type: application/tlsrpt+gzip' --data-binary @-";
defaultText = lib.literalExpression ''
''${lib.getExe pkgs.curl} --silent --header 'Content-Type: application/tlsrpt+gzip' --data-binary @-
'';
description = ''
Call to an HTTPS client, that accepts the URL on the commandline and the request body from stdin.
'';
};
sender_address = mkOption {
type = types.str;
example = "noreply@example.com";
description = ''
Sender address used for reports.
'';
};
sendmail_script = mkOption {
type = with types; nullOr str;
default =
if config.services.postfix.enable && config.services.postfix.setSendmail then
"/run/wrappers/bin/sendmail -i -t"
else
null;
defaultText = lib.literalExpression ''
if config.services.postfix.enable && config.services.postfix.setSendmail then
"/run/wrappers/bin/sendmail -i -t"
else
null
'';
description = ''
Path to a sendmail-compatible executable for delivery reports.
'';
};
};
};
default = { };
description = ''
Flags from {manpage}`tlsrpt-reportd(1)` as key-value pairs.
'';
};
extraFlags = mkOption {
type = with types; listOf str;
default = [ ];
description = ''
List of extra flags to pass to the tlsrpt-reportd executable.
See {manpage}`tlsrpt-report(1)` for possible flags.
'';
};
};
configurePostfix = mkOption {
type = types.bool;
default = true;
description = ''
Whether to configure permissions to allow integration with Postfix.
'';
};
};
config = mkIf cfg.enable {
environment.etc = {
"tlsrpt/collectd.cfg".source = collectdConfigFile;
"tlsrpt/fetcher.cfg".source = fetcherConfigFile;
"tlsrpt/reportd.cfg".source = reportdConfigFile;
};
users.users.tlsrpt = {
isSystemUser = true;
group = "tlsrpt";
};
users.groups.tlsrpt = { };
users.users.postfix.extraGroups = lib.mkIf withPostfix [
"tlsrpt"
];
systemd.services.tlsrpt-collectd = {
description = "TLSRPT datagram collector";
documentation = [ "man:tlsrpt-collectd(1)" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ collectdConfigFile ];
serviceConfig = commonServiceSettings // {
ExecStart = toString (
[
(lib.getExe' cfg.package "tlsrpt-collectd")
]
++ cfg.collectd.extraFlags
);
IPAddressDeny = "any";
PrivateNetwork = true;
RestrictAddressFamilies = [ "AF_UNIX" ];
RuntimeDirectory = "tlsrpt";
RuntimeDirectoryMode = "0750";
UMask = "0157";
};
};
systemd.services.tlsrpt-reportd = {
description = "TLSRPT report generator";
documentation = [ "man:tlsrpt-reportd(1)" ];
wantedBy = [ "multi-user.target" ];
restartTriggers = [ reportdConfigFile ];
serviceConfig = commonServiceSettings // {
ExecStart = toString (
[
(lib.getExe' cfg.package "tlsrpt-reportd")
]
++ cfg.reportd.extraFlags
);
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
ReadWritePaths = lib.optionals withPostfix [ "/var/lib/postfix/queue/maildrop" ];
SupplementaryGroups = lib.optionals withPostfix [ "postdrop" ];
UMask = "0077";
};
};
};
}

View File

@@ -0,0 +1,133 @@
{
config,
pkgs,
lib,
...
}:
let
cfg = config.services.zeyple;
ini = pkgs.formats.ini { };
gpgHome = pkgs.runCommand "zeyple-gpg-home" { } ''
mkdir -p $out
for file in ${lib.concatStringsSep " " cfg.keys}; do
${config.programs.gnupg.package}/bin/gpg --homedir="$out" --import "$file"
done
# Remove socket files
rm -f $out/S.*
'';
in
{
options.services.zeyple = {
enable = lib.mkEnableOption "Zeyple, an utility program to automatically encrypt outgoing emails with GPG";
user = lib.mkOption {
type = lib.types.str;
default = "zeyple";
description = ''
User to run Zeyple as.
::: {.note}
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.
:::
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "zeyple";
description = ''
Group to use to run Zeyple.
::: {.note}
If left as the default value this group will automatically be created
on system activation, otherwise the sysadmin is responsible for
ensuring the user exists.
:::
'';
};
settings = lib.mkOption {
type = ini.type;
default = { };
description = ''
Zeyple configuration. refer to
<https://github.com/infertux/zeyple/blob/master/zeyple/zeyple.conf.example>
for details on supported values.
'';
};
keys = lib.mkOption {
type = with lib.types; listOf path;
description = "List of public key files that will be imported by gpg.";
};
rotateLogs = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable rotation of log files.";
};
};
config = lib.mkIf cfg.enable {
users.groups = lib.optionalAttrs (cfg.group == "zeyple") { "${cfg.group}" = { }; };
users.users = lib.optionalAttrs (cfg.user == "zeyple") {
"${cfg.user}" = {
isSystemUser = true;
group = cfg.group;
};
};
services.zeyple.settings = {
zeyple = lib.mapAttrs (name: lib.mkDefault) {
log_file = "/var/log/zeyple/zeyple.log";
force_encrypt = true;
};
gpg = lib.mapAttrs (name: lib.mkDefault) { home = "${gpgHome}"; };
relay = lib.mapAttrs (name: lib.mkDefault) {
host = "localhost";
port = 10026;
};
};
environment.etc."zeyple.conf".source = ini.generate "zeyple.conf" cfg.settings;
systemd.tmpfiles.settings."10-zeyple".${cfg.settings.zeyple.log_file}.f = {
inherit (cfg) user group;
mode = "0600";
};
services.logrotate = lib.mkIf cfg.rotateLogs {
enable = true;
settings.zeyple = {
files = cfg.settings.zeyple.log_file;
frequency = "weekly";
rotate = 5;
compress = true;
copytruncate = true;
};
};
services.postfix.extraMasterConf = ''
zeyple unix - n n - - pipe
user=${cfg.user} argv=${pkgs.zeyple}/bin/zeyple ''${recipient}
localhost:${toString cfg.settings.relay.port} inet n - n - 10 smtpd
-o content_filter=
-o receive_override_options=no_unknown_recipient_checks,no_header_body_checks,no_milters
-o smtpd_helo_restrictions=
-o smtpd_client_restrictions=
-o smtpd_sender_restrictions=
-o smtpd_recipient_restrictions=permit_mynetworks,reject
-o mynetworks=127.0.0.0/8,[::1]/128
-o smtpd_authorized_xforward_hosts=127.0.0.0/8,[::1]/128
'';
services.postfix.settings.main.content_filter = "zeyple";
};
}