1 { lib, pkgs, config, ... }:
6 cfg = config.services.public-inbox;
7 stateDir = "/var/lib/public-inbox";
9 manref = name: vol: "<citerefentry><refentrytitle>${name}</refentrytitle><manvolnum>${toString vol}</manvolnum></citerefentry>";
11 singleIniAtom = with types; nullOr (oneOf [ bool int float str ]) // {
12 description = "INI atom (null, bool, int, float or string)";
14 iniAtom = with types; coercedTo singleIniAtom singleton (listOf singleIniAtom) // {
15 description = singleIniAtom.description + " or a list of them for duplicate keys";
17 iniAttrs = with types; attrsOf (either (attrsOf iniAtom) iniAtom);
19 type = with types; attrsOf iniAttrs;
20 generate = name: value: pkgs.writeText name (generators.toGitINI value);
24 PI_EMERGENCY = "${stateDir}/emergency";
25 PI_CONFIG = gitIni.generate "public-inbox.ini"
26 (filterAttrsRecursive (n: v: v != null) cfg.settings);
29 useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
30 cfg.settings.publicinboxwatch.spamcheck == "spamc";
32 serviceConfig = srv: {
33 # Enable JIT-compiled C (via Inline::C)
34 Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
35 # NonBlocking is REQUIRED to avoid a race condition
36 # if running simultaneous services.
39 User = config.users.users."public-inbox".name;
40 Group = config.users.groups."public-inbox".name;
42 "public-inbox-${srv}/perl-inline"
43 # Create RootDirectory= in the host's mount namespace.
44 "public-inbox-${srv}/root"
46 RuntimeDirectoryMode = "700";
47 # Avoid mounting RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
48 InaccessiblePaths = ["-+/run/public-inbox-${srv}/root"];
49 # This is for BindPaths= and BindReadOnlyPaths=
50 # to allow traversal of directories they create in RootDirectory=.
52 RootDirectory = "/run/public-inbox-${srv}/root";
53 RootDirectoryStartOnly = true;
54 WorkingDirectory = stateDir;
64 # The following options are only for optimizing:
65 # systemd-analyze security public-inbox-'*'
66 AmbientCapabilities = "";
67 CapabilityBoundingSet = "";
68 # ProtectClock= adds DeviceAllow=char-rtc r
70 LockPersonality = true;
71 MemoryDenyWriteExecute = true;
72 NoNewPrivileges = true;
73 PrivateDevices = true;
75 PrivateNetwork = mkDefault false;
79 ProtectControlGroups = true;
81 ProtectHostname = true;
82 ProtectKernelLogs = true;
83 ProtectKernelModules = true;
84 ProtectKernelTunables = true;
85 ProtectSystem = "strict";
87 RestrictAddressFamilies = [ "AF_UNIX" ];
88 RestrictNamespaces = true;
89 RestrictRealtime = true;
90 RestrictSUIDSGID = true;
91 SystemCallFilter = optionals (srv != "init") [
93 "~@aio" "~@chown" "~@keyring" "~@memlock"
94 "~@resources" "~@setuid" "~@timer" "~@privileged"
96 SystemCallArchitectures = "native";
97 SystemCallErrorNumber = "EPERM";
102 options.services.public-inbox = {
103 enable = mkEnableOption "the public-inbox mail archiver";
105 type = types.package;
106 default = pkgs.public-inbox;
107 description = "public-inbox package to use.";
110 type = with types; listOf package;
112 example = literalExample "with pkgs; [ spamassassin ]";
114 Additional packages to place in the path of public-inbox-mda,
115 public-inbox-watch, etc.
120 Inboxes to configure, where attribute names are inbox names.
123 type = types.submodule {
124 freeformType = types.attrsOf (types.submodule ({name, ...}: {
125 freeformType = types.attrsOf iniAtom;
126 options.inboxdir = mkOption {
128 default = "${stateDir}/inboxes/${name}";
129 description = "The absolute path to the directory which hosts the public-inbox.";
131 options.address = mkOption {
132 type = with types; listOf str;
133 example = "example-discuss@example.org";
134 description = "The email addresses of the public-inbox.";
136 options.url = mkOption {
137 type = with types; nullOr str;
139 example = "https://example.org/lists/example-discuss";
140 description = "URL where this inbox can be accessed over HTTP.";
142 options.description = mkOption {
144 example = "user/dev discussion of public-inbox itself";
145 description = "User-visible description for the repository.";
147 options.newsgroup = mkOption {
148 type = with types; nullOr str;
150 description = "NNTP group name for the inbox.";
152 options.watch = mkOption {
153 type = with types; listOf str;
155 description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail.";
156 example = [ "maildir:/path/to/test.example.com.git" ];
158 options.watchheader = mkOption {
159 type = with types; nullOr str;
161 example = "List-Id:<test@example.com>";
163 If specified, ${manref "public-inbox-watch" 1} will only process
164 mail containing a matching header.
167 options.coderepo = mkOption {
168 type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
169 description = "list of coderepo names";
172 description = "Nicknames of a 'coderepo' section associated with the inbox.";
178 enable = mkEnableOption "the public-inbox Mail Delivery Agent";
180 type = with types; listOf str;
182 description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}.";
186 enable = mkEnableOption "the public-inbox HTTP server";
188 type = with types; listOf str;
190 example = [ "/lists/archives" ];
192 Root paths or URLs that public-inbox will be served on.
193 If domain parts are present, only requests to those
194 domains will be accepted.
198 type = with types; listOf str;
200 description = "Command-line arguments to pass to ${manref "public-inbox-httpd" 1}.";
203 type = with types; nullOr (either str port);
205 example = "/run/public-inbox-httpd.sock";
207 Listening port or systemd's ListenStream= entry
208 to be used as a reverse proxy, eg. in nginx:
209 <code>locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";</code>
210 Set to null and use <code>systemd.sockets.public-inbox-httpd.listenStreams</code>
211 if you need a more advanced listening.
216 enable = mkEnableOption "the public-inbox IMAP server";
218 type = with types; listOf str;
220 description = "Command-line arguments to pass to ${manref "public-inbox-imapd" 1}.";
223 type = with types; nullOr port;
227 Set to null and use <code>systemd.sockets.public-inbox-imapd.listenStreams</code>
228 if you need a more advanced listening.
232 type = with types; nullOr str;
234 example = "/path/to/fullchain.pem";
235 description = "Path to TLS certificate to use for public-inbox IMAP connections.";
238 type = with types; nullOr str;
240 example = "/path/to/key.pem";
241 description = "Path to TLS key to use for public-inbox IMAP connections.";
245 enable = mkEnableOption "the public-inbox NNTP server";
247 type = with types; nullOr port;
251 Set to null and use <code>systemd.sockets.public-inbox-nntpd.listenStreams</code>
252 if you need a more advanced listening.
256 type = with types; listOf str;
258 description = "Command-line arguments to pass to ${manref "public-inbox-nntpd" 1}.";
261 type = with types; nullOr str;
263 example = "/path/to/fullchain.pem";
264 description = "Path to TLS certificate to use for public-inbox NNTP connections";
267 type = with types; nullOr str;
269 example = "/path/to/key.pem";
270 description = "Path to TLS key to use for public-inbox NNTP connections.";
273 spamAssassinRules = mkOption {
274 type = with types; nullOr path;
275 default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
276 description = "SpamAssassin configuration specific to public-inbox.";
278 settings = mkOption {
279 description = "Settings for the public-inbox config file.";
281 type = types.submodule {
282 freeformType = gitIni.type;
283 options.publicinbox = mkOption {
285 description = "public-inbox configuration.";
286 type = types.submodule {
287 freeformType = iniAttrs;
288 options.css = mkOption {
289 type = with types; listOf str;
291 description = "The local path name of a CSS file for the PSGI web interface.";
293 options.nntpserver = mkOption {
294 type = with types; listOf str;
296 example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
297 description = "NNTP URLs to this public-inbox instance";
299 options.wwwlisting = mkOption {
300 type = with types; enum [ "all" "404" "match=domain" ];
303 Controls which lists (if any) are listed for when the root
304 public-inbox URL is accessed over HTTP.
309 options.publicinboxmda = mkOption {
311 description = "mailbox delivery agent";
312 type = types.submodule {
313 freeformType = iniAttrs;
314 options.spamcheck = mkOption {
315 type = with types; enum [ "spamc" "none" ];
318 If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
324 options.publicinboxwatch = mkOption {
326 description = "mailbox watcher";
327 type = types.submodule {
328 freeformType = iniAttrs;
329 options.spamcheck = mkOption {
330 type = with types; enum [ "spamc" "none" ];
333 If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
337 options.watchspam = mkOption {
338 type = with types; nullOr str;
340 example = "maildir:/path/to/spam";
342 If set, mail in this maildir will be trained as spam and
343 deleted from all watched inboxes
348 options.coderepo = mkOption {
350 description = "code repositories";
351 type = types.submodule {
352 freeformType = types.attrsOf (types.submodule {
353 freeformType = types.either (types.attrsOf iniAtom) iniAtom;
354 options.cgitUrl = mkOption {
356 description = "URL of a cgit instance";
358 options.dir = mkOption {
360 description = "Path to a git repository";
367 openFirewall = mkEnableOption "opening the firewall when using a port option";
369 config = mkIf cfg.enable {
371 { assertion = config.services.spamassassin.enable || !useSpamAssassin;
373 public-inbox is configured to use SpamAssassin, but
374 services.spamassassin.enable is false. If you don't need
375 spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
376 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
379 { assertion = cfg.path != [] || !useSpamAssassin;
381 public-inbox is configured to use SpamAssassin, but there is
382 no spamc executable in services.public-inbox.path. If you
383 don't need spam checking, set
384 `services.public-inbox.settings.publicinboxmda.spamcheck' and
385 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
389 services.public-inbox.settings =
390 filterAttrsRecursive (n: v: v != null) {
391 publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
394 users.public-inbox = {
395 # Use runCommand instead of linkFarm,
396 # because Postfix rejects .forward if it's a symlink.
397 home = pkgs.runCommand "public-inbox-home" {} (''
398 install -D -p ${environment.PI_CONFIG} $out/.public-inbox/config
399 ln -s ${stateDir}/emergency $out/.public-inbox/emergency
400 ln -s ${stateDir}/spamassassin $out/.spamassassin
401 '' + optionalString cfg.mda.enable ''
402 cp ${let env = concatStringsSep " " (mapAttrsToList (n: v: "${n}=${escapeShellArg v}") environment); in
403 pkgs.writeText "forward" ''
404 |"env ${env} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
407 group = "public-inbox";
410 groups.public-inbox = {};
412 networking.firewall = mkIf cfg.openFirewall
413 { allowedTCPPorts = mkMerge [
414 (mkIf (cfg.http.enable && types.port.check cfg.http.port) [ cfg.http.port ])
415 (mkIf (cfg.imap.enable && types.port.check cfg.imap.port) [ cfg.imap.port ])
416 (mkIf (cfg.nntp.enable && types.port.check cfg.nntp.port) [ cfg.nntp.port ])
419 systemd.sockets = mkMerge (map (proto:
420 mkIf (cfg.${proto}.enable && cfg.${proto}.port != null)
421 { "public-inbox-${proto}d" = {
422 listenStreams = [ (toString cfg.${proto}.port) ];
423 wantedBy = [ "sockets.target" ];
426 ) [ "http" "imap" "nntp" ]);
427 systemd.services = mkMerge [
428 (mkIf cfg.http.enable
429 { public-inbox-httpd = {
431 after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
432 requires = [ "public-inbox-init.service" ];
433 serviceConfig = serviceConfig "httpd" // {
434 ExecStart = escapeShellArgs (
435 [ "${cfg.package}/bin/public-inbox-httpd" ] ++
437 [ (pkgs.writeText "public-inbox.psgi" ''
438 #!${cfg.package.fullperl} -w
440 use PublicInbox::WWW;
443 my $www = PublicInbox::WWW->new;
448 enable 'ReverseProxy';
449 ${concatMapStrings (path: ''
450 mount q(${path}) => sub { $www->call(@_); };
458 (mkIf cfg.imap.enable
459 { public-inbox-imapd = {
461 after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
462 requires = [ "public-inbox-init.service" ];
463 serviceConfig = serviceConfig "imapd" // {
464 ExecStart = escapeShellArgs (
465 [ "${cfg.package}/bin/public-inbox-imapd" ] ++
467 optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
468 optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
473 (mkIf cfg.nntp.enable
474 { public-inbox-nntpd = {
476 after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
477 requires = [ "public-inbox-init.service" ];
478 serviceConfig = serviceConfig "nntpd" // {
479 ExecStart = escapeShellArgs (
480 [ "${cfg.package}/bin/public-inbox-nntpd" ] ++
482 optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
483 optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
488 (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
489 || cfg.settings.publicinboxwatch.watchspam != null)
490 { public-inbox-watch = {
493 wants = [ "public-inbox-init.service" ];
494 requires = [ "public-inbox-init.service" ] ++
495 optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
496 wantedBy = [ "multi-user.target" ];
497 serviceConfig = serviceConfig "watch" // {
498 ExecStart = "${cfg.package}/bin/public-inbox-watch";
499 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
503 ({ public-inbox-init = {
505 wantedBy = [ "multi-user.target" ];
506 restartIfChanged = true;
507 restartTriggers = [ environment.PI_CONFIG ];
510 ${optionalString useSpamAssassin ''
511 install -m 0700 -o spamd -d ${stateDir}/spamassassin
512 ${optionalString (cfg.spamAssassinRules != null) ''
513 ln -sf ${cfg.spamAssassinRules} ${stateDir}/spamassassin/user_prefs
517 ${concatStrings (mapAttrsToList (name: inbox: ''
518 if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
519 # public-inbox-init creates an inbox and adds it to a config file.
520 # It tries to atomically write the config file by creating
521 # another file in the same directory, and renaming it.
522 # This has the sad consequence that we can't use
523 # /dev/null, or it would try to create a file in /dev.
524 conf_dir="$(mktemp -d)"
526 PI_CONFIG=$conf_dir/conf \
527 ${cfg.package}/bin/public-inbox-init -V2 \
528 ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
533 ln -sf ${pkgs.writeText "description" inbox.description} \
534 ${stateDir}/inboxes/${escapeShellArg name}/description
536 export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
537 if test -d "$GIT_DIR"; then
538 # Config is inherited by each epoch repository,
539 # so just needs to be set for all.git.
540 ${pkgs.git}/bin/git config core.sharedRepository 0640
545 for inbox in ${stateDir}/inboxes/*/; do
546 ls -1 "$inbox" | ${pkgs.gnugrep}/bin/grep -q '^xap' && continue
548 # This should be idempotent, but only do it for new
549 # inboxes anyway because it's only needed once, and could
550 # be slow for large pre-existing inboxes.
551 ${cfg.package}/bin/public-inbox-index "$inbox"
554 serviceConfig = serviceConfig "init" // {
556 RemainAfterExit = true;
559 "public-inbox/emergency"
560 "public-inbox/inboxes"
562 StateDirectoryMode = "0750";
567 environment.systemPackages = with pkgs; [ cfg.package ];
569 meta.maintainers = with lib.maintainers; [ julm ];