{ lib, pkgs, config, ... }:
with lib;
let
cfg = config.services.public-inbox;
stateDir = "/var/lib/public-inbox";
manref = name: vol: "${name}${toString vol}";
singleIniAtom = with types; nullOr (oneOf [ bool int float str ]) // {
description = "INI atom (null, bool, int, float or string)";
};
iniAtom = with types; coercedTo singleIniAtom singleton (listOf singleIniAtom) // {
description = singleIniAtom.description + " or a list of them for duplicate keys";
};
iniAttrs = with types; attrsOf (either (attrsOf iniAtom) iniAtom);
gitIni = {
type = with types; attrsOf iniAttrs;
generate = name: value: pkgs.writeText name (generators.toGitINI value);
};
environment = {
PI_EMERGENCY = "${stateDir}/emergency";
PI_CONFIG = gitIni.generate "public-inbox.ini"
(filterAttrsRecursive (n: v: v != null) cfg.settings);
};
useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
cfg.settings.publicinboxwatch.spamcheck == "spamc";
serviceConfig = srv: {
# 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"
# Create RootDirectory= in the host's mount namespace.
"public-inbox-${srv}/root"
];
RuntimeDirectoryMode = "700";
# Avoid mounting RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
InaccessiblePaths = ["-+/run/public-inbox-${srv}/root"];
# This is for BindPaths= and BindReadOnlyPaths=
# to allow traversal of directories they create in RootDirectory=.
UMask = "0066";
RootDirectory = "/run/public-inbox-${srv}/root";
RootDirectoryStartOnly = true;
WorkingDirectory = stateDir;
MountAPIVFS = true;
BindReadOnlyPaths = [
builtins.storeDir
"/etc"
"/run"
];
BindPaths = [
stateDir
];
# 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;
PrivateDevices = true;
PrivateMounts = true;
PrivateNetwork = mkDefault false;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallFilter = optionals (srv != "init") [
"@system-service"
"~@aio" "~@chown" "~@keyring" "~@memlock"
"~@resources" "~@setuid" "~@timer" "~@privileged"
];
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
};
in
{
options.services.public-inbox = {
enable = mkEnableOption "the public-inbox mail archiver";
package = mkOption {
type = types.package;
default = pkgs.public-inbox;
description = "public-inbox package to use.";
};
path = mkOption {
type = with types; listOf package;
default = [];
example = literalExample "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.submodule {
freeformType = types.attrsOf (types.submodule ({name, ...}: {
freeformType = types.attrsOf iniAtom;
options.inboxdir = mkOption {
type = types.str;
default = "${stateDir}/inboxes/${name}";
description = "The absolute path to the directory which hosts the public-inbox.";
};
options.address = mkOption {
type = with types; listOf str;
example = "example-discuss@example.org";
description = "The email addresses of the public-inbox.";
};
options.url = mkOption {
type = with types; nullOr str;
default = null;
example = "https://example.org/lists/example-discuss";
description = "URL where this inbox can be accessed over HTTP.";
};
options.description = mkOption {
type = types.str;
example = "user/dev discussion of public-inbox itself";
description = "User-visible description for the repository.";
};
options.newsgroup = mkOption {
type = with types; nullOr str;
default = null;
description = "NNTP group name for the inbox.";
};
options.watch = mkOption {
type = with types; listOf str;
default = [];
description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail.";
example = [ "maildir:/path/to/test.example.com.git" ];
};
options.watchheader = mkOption {
type = with types; nullOr str;
default = null;
example = "List-Id:";
description = ''
If specified, ${manref "public-inbox-watch" 1} will only process
mail containing a matching header.
'';
};
options.coderepo = mkOption {
type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
description = "list of coderepo names";
};
default = [];
description = "Nicknames of a 'coderepo' section associated with the inbox.";
};
}));
};
};
mda = {
enable = mkEnableOption "the public-inbox Mail Delivery Agent";
args = mkOption {
type = with types; listOf str;
default = [];
description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}.";
};
};
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 = mkOption {
type = with types; listOf str;
default = ["-W0"];
description = "Command-line arguments to pass to ${manref "public-inbox-httpd" 1}.";
};
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.
'';
};
};
imap = {
enable = mkEnableOption "the public-inbox IMAP server";
args = mkOption {
type = with types; listOf str;
default = ["-W0"];
description = "Command-line arguments to pass to ${manref "public-inbox-imapd" 1}.";
};
port = mkOption {
type = with types; nullOr port;
default = 993;
description = ''
Listening port.
Set to null and use systemd.sockets.public-inbox-imapd.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 public-inbox IMAP connections.";
};
key = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/key.pem";
description = "Path to TLS key to use for public-inbox IMAP connections.";
};
};
nntp = {
enable = mkEnableOption "the public-inbox NNTP server";
port = mkOption {
type = with types; nullOr port;
default = 563;
description = ''
Listening port.
Set to null and use systemd.sockets.public-inbox-nntpd.listenStreams
if you need a more advanced listening.
'';
};
args = mkOption {
type = with types; listOf str;
default = ["-W0"];
description = "Command-line arguments to pass to ${manref "public-inbox-nntpd" 1}.";
};
cert = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/fullchain.pem";
description = "Path to TLS certificate to use for public-inbox NNTP connections";
};
key = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/key.pem";
description = "Path to TLS key to use for public-inbox NNTP connections.";
};
};
spamAssassinRules = mkOption {
type = with types; nullOr path;
default = "${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.";
default = {};
type = types.submodule {
freeformType = gitIni.type;
options.publicinbox = mkOption {
default = {};
description = "public-inbox configuration.";
type = types.submodule {
freeformType = iniAttrs;
options.css = mkOption {
type = with types; listOf str;
default = [];
description = "The local path name of a CSS file for the PSGI web interface.";
};
options.nntpserver = mkOption {
type = with types; listOf str;
default = [];
example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
description = "NNTP URLs to this public-inbox instance";
};
options.wwwlisting = mkOption {
type = with types; enum [ "all" "404" "match=domain" ];
default = "404";
description = ''
Controls which lists (if any) are listed for when the root
public-inbox URL is accessed over HTTP.
'';
};
};
};
options.publicinboxmda = mkOption {
default = {};
description = "mailbox delivery agent";
type = types.submodule {
freeformType = iniAttrs;
options.spamcheck = mkOption {
type = with types; enum [ "spamc" "none" ];
default = "none";
description = ''
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
using SpamAssassin.
'';
};
};
};
options.publicinboxwatch = mkOption {
default = {};
description = "mailbox watcher";
type = types.submodule {
freeformType = iniAttrs;
options.spamcheck = mkOption {
type = with types; enum [ "spamc" "none" ];
default = "none";
description = ''
If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
using SpamAssassin.
'';
};
options.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.submodule {
freeformType = types.attrsOf (types.submodule {
freeformType = types.either (types.attrsOf iniAtom) 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 = {
# Use runCommand instead of linkFarm,
# because Postfix rejects .forward if it's a symlink.
home = pkgs.runCommand "public-inbox-home" {} (''
install -D -p ${environment.PI_CONFIG} $out/.public-inbox/config
ln -s ${stateDir}/emergency $out/.public-inbox/emergency
ln -s ${stateDir}/spamassassin $out/.spamassassin
'' + optionalString cfg.mda.enable ''
cp ${let env = concatStringsSep " " (mapAttrsToList (n: v: "${n}=${escapeShellArg v}") environment); in
pkgs.writeText "forward" ''
|"env ${env} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
''} $out/.forward
'');
group = "public-inbox";
isSystemUser = true;
};
groups.public-inbox = {};
};
networking.firewall = mkIf cfg.openFirewall
{ allowedTCPPorts = mkMerge [
(mkIf (cfg.http.enable && types.port.check cfg.http.port) [ cfg.http.port ])
(mkIf (cfg.imap.enable && types.port.check cfg.imap.port) [ cfg.imap.port ])
(mkIf (cfg.nntp.enable && types.port.check cfg.nntp.port) [ cfg.nntp.port ])
];
};
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" ];
};
}
) [ "http" "imap" "nntp" ]);
systemd.services = mkMerge [
(mkIf cfg.http.enable
{ public-inbox-httpd = {
inherit environment;
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = serviceConfig "httpd" // {
ExecStart = escapeShellArgs (
[ "${cfg.package}/bin/public-inbox-httpd" ] ++
cfg.http.args ++
[ (pkgs.writeText "public-inbox.psgi" ''
#!${cfg.package.fullperl} -w
use strict;
use PublicInbox::WWW;
use Plack::Builder;
my $www = PublicInbox::WWW->new;
$www->preload;
builder {
enable 'Head';
enable 'ReverseProxy';
${concatMapStrings (path: ''
mount q(${path}) => sub { $www->call(@_); };
'') cfg.http.mounts}
}
'') ]
);
};
};
})
(mkIf cfg.imap.enable
{ public-inbox-imapd = {
inherit environment;
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = serviceConfig "imapd" // {
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.nntp.enable
{ public-inbox-nntpd = {
inherit environment;
after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
requires = [ "public-inbox-init.service" ];
serviceConfig = serviceConfig "nntpd" // {
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 = {
inherit environment;
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 = serviceConfig "watch" // {
ExecStart = "${cfg.package}/bin/public-inbox-watch";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
};
};
})
({ public-inbox-init = {
inherit environment;
wantedBy = [ "multi-user.target" ];
restartIfChanged = true;
restartTriggers = [ environment.PI_CONFIG ];
script = ''
set -ux
${optionalString useSpamAssassin ''
install -m 0700 -o spamd -d ${stateDir}/spamassassin
${optionalString (cfg.spamAssassinRules != null) ''
ln -sf ${cfg.spamAssassinRules} ${stateDir}/spamassassin/user_prefs
''}
''}
${concatStrings (mapAttrsToList (name: inbox: ''
if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
# public-inbox-init creates an inbox and adds it to a config file.
# It tries to atomically write the config file by creating
# another file in the same directory, and renaming it.
# This has the sad consequence that we can't use
# /dev/null, or it would try to create a file in /dev.
conf_dir="$(mktemp -d)"
PI_CONFIG=$conf_dir/conf \
${cfg.package}/bin/public-inbox-init -V2 \
${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
rm -rf $conf_dir
fi
ln -sf ${pkgs.writeText "description" inbox.description} \
${stateDir}/inboxes/${escapeShellArg name}/description
export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
if test -d "$GIT_DIR"; then
# Config is inherited by each epoch repository,
# so just needs to be set for all.git.
${pkgs.git}/bin/git config core.sharedRepository 0640
fi
'') cfg.inboxes)}
shopt -s nullglob
for inbox in ${stateDir}/inboxes/*/; do
ls -1 "$inbox" | ${pkgs.gnugrep}/bin/grep -q '^xap' && continue
# This should be idempotent, but only do it for new
# inboxes anyway because it's only needed once, and could
# be slow for large pre-existing inboxes.
${cfg.package}/bin/public-inbox-index "$inbox"
done
'';
serviceConfig = serviceConfig "init" // {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = [
"public-inbox"
"public-inbox/emergency"
"public-inbox/inboxes"
];
StateDirectoryMode = "0750";
};
};
})
];
environment.systemPackages = with pkgs; [ cfg.package ];
};
meta.maintainers = with lib.maintainers; [ julm ];
}