]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/mail/public-inbox.nix
upnp: improve module
[sourcephile-nix.git] / nixos / modules / services / mail / public-inbox.nix
1 { lib, pkgs, config, ... }:
2
3 with lib;
4
5 let
6 cfg = config.services.public-inbox;
7 stateDir = "/var/lib/public-inbox";
8
9 manref = name: vol: "<citerefentry><refentrytitle>${name}</refentrytitle><manvolnum>${toString vol}</manvolnum></citerefentry>";
10
11 singleIniAtom = with types; nullOr (oneOf [ bool int float str ]) // {
12 description = "INI atom (null, bool, int, float or string)";
13 };
14 iniAtom = with types; coercedTo singleIniAtom singleton (listOf singleIniAtom) // {
15 description = singleIniAtom.description + " or a list of them for duplicate keys";
16 };
17 iniAttrs = with types; attrsOf (either (attrsOf iniAtom) iniAtom);
18 gitIni = {
19 type = with types; attrsOf iniAttrs;
20 generate = name: value: pkgs.writeText name (generators.toGitINI value);
21 };
22
23 environment = {
24 PI_EMERGENCY = "${stateDir}/emergency";
25 PI_CONFIG = gitIni.generate "public-inbox.ini"
26 (filterAttrsRecursive (n: v: v != null) cfg.settings);
27 };
28
29 useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
30 cfg.settings.publicinboxwatch.spamcheck == "spamc";
31
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.
37 NonBlocking = true;
38 #LimitNOFILE = 30000;
39 User = config.users.users."public-inbox".name;
40 Group = config.users.groups."public-inbox".name;
41 RuntimeDirectory = [
42 "public-inbox-${srv}/perl-inline"
43 # Create RootDirectory= in the host's mount namespace.
44 "public-inbox-${srv}/root"
45 ];
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=.
51 UMask = "0066";
52 RootDirectory = "/run/public-inbox-${srv}/root";
53 RootDirectoryStartOnly = true;
54 WorkingDirectory = stateDir;
55 MountAPIVFS = true;
56 BindReadOnlyPaths = [
57 builtins.storeDir
58 "/etc"
59 "/run"
60 ];
61 BindPaths = [
62 stateDir
63 ];
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
69 DeviceAllow = "";
70 LockPersonality = true;
71 MemoryDenyWriteExecute = true;
72 NoNewPrivileges = true;
73 PrivateDevices = true;
74 PrivateMounts = true;
75 PrivateNetwork = mkDefault false;
76 PrivateTmp = true;
77 PrivateUsers = true;
78 ProtectClock = true;
79 ProtectControlGroups = true;
80 ProtectHome = true;
81 ProtectHostname = true;
82 ProtectKernelLogs = true;
83 ProtectKernelModules = true;
84 ProtectKernelTunables = true;
85 ProtectSystem = "strict";
86 RemoveIPC = true;
87 RestrictAddressFamilies = [ "AF_UNIX" ];
88 RestrictNamespaces = true;
89 RestrictRealtime = true;
90 RestrictSUIDSGID = true;
91 SystemCallFilter = optionals (srv != "init") [
92 "@system-service"
93 "~@aio" "~@chown" "~@keyring" "~@memlock"
94 "~@resources" "~@setuid" "~@timer" "~@privileged"
95 ];
96 SystemCallArchitectures = "native";
97 SystemCallErrorNumber = "EPERM";
98 };
99 in
100
101 {
102 options.services.public-inbox = {
103 enable = mkEnableOption "the public-inbox mail archiver";
104 package = mkOption {
105 type = types.package;
106 default = pkgs.public-inbox;
107 description = "public-inbox package to use.";
108 };
109 path = mkOption {
110 type = with types; listOf package;
111 default = [];
112 example = literalExample "with pkgs; [ spamassassin ]";
113 description = ''
114 Additional packages to place in the path of public-inbox-mda,
115 public-inbox-watch, etc.
116 '';
117 };
118 inboxes = mkOption {
119 description = ''
120 Inboxes to configure, where attribute names are inbox names.
121 '';
122 default = {};
123 type = types.submodule {
124 freeformType = types.attrsOf (types.submodule ({name, ...}: {
125 freeformType = types.attrsOf iniAtom;
126 options.inboxdir = mkOption {
127 type = types.str;
128 default = "${stateDir}/inboxes/${name}";
129 description = "The absolute path to the directory which hosts the public-inbox.";
130 };
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.";
135 };
136 options.url = mkOption {
137 type = with types; nullOr str;
138 default = null;
139 example = "https://example.org/lists/example-discuss";
140 description = "URL where this inbox can be accessed over HTTP.";
141 };
142 options.description = mkOption {
143 type = types.str;
144 example = "user/dev discussion of public-inbox itself";
145 description = "User-visible description for the repository.";
146 };
147 options.newsgroup = mkOption {
148 type = with types; nullOr str;
149 default = null;
150 description = "NNTP group name for the inbox.";
151 };
152 options.watch = mkOption {
153 type = with types; listOf str;
154 default = [];
155 description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail.";
156 example = [ "maildir:/path/to/test.example.com.git" ];
157 };
158 options.watchheader = mkOption {
159 type = with types; nullOr str;
160 default = null;
161 example = "List-Id:<test@example.com>";
162 description = ''
163 If specified, ${manref "public-inbox-watch" 1} will only process
164 mail containing a matching header.
165 '';
166 };
167 options.coderepo = mkOption {
168 type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
169 description = "list of coderepo names";
170 };
171 default = [];
172 description = "Nicknames of a 'coderepo' section associated with the inbox.";
173 };
174 }));
175 };
176 };
177 mda = {
178 enable = mkEnableOption "the public-inbox Mail Delivery Agent";
179 args = mkOption {
180 type = with types; listOf str;
181 default = [];
182 description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}.";
183 };
184 };
185 http = {
186 enable = mkEnableOption "the public-inbox HTTP server";
187 mounts = mkOption {
188 type = with types; listOf str;
189 default = [ "/" ];
190 example = [ "/lists/archives" ];
191 description = ''
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.
195 '';
196 };
197 args = mkOption {
198 type = with types; listOf str;
199 default = ["-W0"];
200 description = "Command-line arguments to pass to ${manref "public-inbox-httpd" 1}.";
201 };
202 port = mkOption {
203 type = with types; nullOr (either str port);
204 default = 80;
205 example = "/run/public-inbox-httpd.sock";
206 description = ''
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.
212 '';
213 };
214 };
215 imap = {
216 enable = mkEnableOption "the public-inbox IMAP server";
217 args = mkOption {
218 type = with types; listOf str;
219 default = ["-W0"];
220 description = "Command-line arguments to pass to ${manref "public-inbox-imapd" 1}.";
221 };
222 port = mkOption {
223 type = with types; nullOr port;
224 default = 993;
225 description = ''
226 Listening port.
227 Set to null and use <code>systemd.sockets.public-inbox-imapd.listenStreams</code>
228 if you need a more advanced listening.
229 '';
230 };
231 cert = mkOption {
232 type = with types; nullOr str;
233 default = null;
234 example = "/path/to/fullchain.pem";
235 description = "Path to TLS certificate to use for public-inbox IMAP connections.";
236 };
237 key = mkOption {
238 type = with types; nullOr str;
239 default = null;
240 example = "/path/to/key.pem";
241 description = "Path to TLS key to use for public-inbox IMAP connections.";
242 };
243 };
244 nntp = {
245 enable = mkEnableOption "the public-inbox NNTP server";
246 port = mkOption {
247 type = with types; nullOr port;
248 default = 563;
249 description = ''
250 Listening port.
251 Set to null and use <code>systemd.sockets.public-inbox-nntpd.listenStreams</code>
252 if you need a more advanced listening.
253 '';
254 };
255 args = mkOption {
256 type = with types; listOf str;
257 default = ["-W0"];
258 description = "Command-line arguments to pass to ${manref "public-inbox-nntpd" 1}.";
259 };
260 cert = mkOption {
261 type = with types; nullOr str;
262 default = null;
263 example = "/path/to/fullchain.pem";
264 description = "Path to TLS certificate to use for public-inbox NNTP connections";
265 };
266 key = mkOption {
267 type = with types; nullOr str;
268 default = null;
269 example = "/path/to/key.pem";
270 description = "Path to TLS key to use for public-inbox NNTP connections.";
271 };
272 };
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.";
277 };
278 settings = mkOption {
279 description = "Settings for the public-inbox config file.";
280 default = {};
281 type = types.submodule {
282 freeformType = gitIni.type;
283 options.publicinbox = mkOption {
284 default = {};
285 description = "public-inbox configuration.";
286 type = types.submodule {
287 freeformType = iniAttrs;
288 options.css = mkOption {
289 type = with types; listOf str;
290 default = [];
291 description = "The local path name of a CSS file for the PSGI web interface.";
292 };
293 options.nntpserver = mkOption {
294 type = with types; listOf str;
295 default = [];
296 example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
297 description = "NNTP URLs to this public-inbox instance";
298 };
299 options.wwwlisting = mkOption {
300 type = with types; enum [ "all" "404" "match=domain" ];
301 default = "404";
302 description = ''
303 Controls which lists (if any) are listed for when the root
304 public-inbox URL is accessed over HTTP.
305 '';
306 };
307 };
308 };
309 options.publicinboxmda = mkOption {
310 default = {};
311 description = "mailbox delivery agent";
312 type = types.submodule {
313 freeformType = iniAttrs;
314 options.spamcheck = mkOption {
315 type = with types; enum [ "spamc" "none" ];
316 default = "none";
317 description = ''
318 If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
319 using SpamAssassin.
320 '';
321 };
322 };
323 };
324 options.publicinboxwatch = mkOption {
325 default = {};
326 description = "mailbox watcher";
327 type = types.submodule {
328 freeformType = iniAttrs;
329 options.spamcheck = mkOption {
330 type = with types; enum [ "spamc" "none" ];
331 default = "none";
332 description = ''
333 If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
334 using SpamAssassin.
335 '';
336 };
337 options.watchspam = mkOption {
338 type = with types; nullOr str;
339 default = null;
340 example = "maildir:/path/to/spam";
341 description = ''
342 If set, mail in this maildir will be trained as spam and
343 deleted from all watched inboxes
344 '';
345 };
346 };
347 };
348 options.coderepo = mkOption {
349 default = {};
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 {
355 type = types.str;
356 description = "URL of a cgit instance";
357 };
358 options.dir = mkOption {
359 type = types.str;
360 description = "Path to a git repository";
361 };
362 });
363 };
364 };
365 };
366 };
367 openFirewall = mkEnableOption "opening the firewall when using a port option";
368 };
369 config = mkIf cfg.enable {
370 assertions = [
371 { assertion = config.services.spamassassin.enable || !useSpamAssassin;
372 message = ''
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.
377 '';
378 }
379 { assertion = cfg.path != [] || !useSpamAssassin;
380 message = ''
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.
386 '';
387 }
388 ];
389 services.public-inbox.settings =
390 filterAttrsRecursive (n: v: v != null) {
391 publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
392 };
393 users = {
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}
405 ''} $out/.forward
406 '');
407 group = "public-inbox";
408 isSystemUser = true;
409 };
410 groups.public-inbox = {};
411 };
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 ])
417 ];
418 };
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" ];
424 };
425 }
426 ) [ "http" "imap" "nntp" ]);
427 systemd.services = mkMerge [
428 (mkIf cfg.http.enable
429 { public-inbox-httpd = {
430 inherit environment;
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" ] ++
436 cfg.http.args ++
437 [ (pkgs.writeText "public-inbox.psgi" ''
438 #!${cfg.package.fullperl} -w
439 use strict;
440 use PublicInbox::WWW;
441 use Plack::Builder;
442
443 my $www = PublicInbox::WWW->new;
444 $www->preload;
445
446 builder {
447 enable 'Head';
448 enable 'ReverseProxy';
449 ${concatMapStrings (path: ''
450 mount q(${path}) => sub { $www->call(@_); };
451 '') cfg.http.mounts}
452 }
453 '') ]
454 );
455 };
456 };
457 })
458 (mkIf cfg.imap.enable
459 { public-inbox-imapd = {
460 inherit environment;
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" ] ++
466 cfg.imap.args ++
467 optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
468 optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
469 );
470 };
471 };
472 })
473 (mkIf cfg.nntp.enable
474 { public-inbox-nntpd = {
475 inherit environment;
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" ] ++
481 cfg.nntp.args ++
482 optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
483 optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
484 );
485 };
486 };
487 })
488 (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
489 || cfg.settings.publicinboxwatch.watchspam != null)
490 { public-inbox-watch = {
491 inherit environment;
492 inherit (cfg) path;
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";
500 };
501 };
502 })
503 ({ public-inbox-init = {
504 inherit environment;
505 wantedBy = [ "multi-user.target" ];
506 restartIfChanged = true;
507 restartTriggers = [ environment.PI_CONFIG ];
508 script = ''
509 set -ux
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
514 ''}
515 ''}
516
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)"
525
526 PI_CONFIG=$conf_dir/conf \
527 ${cfg.package}/bin/public-inbox-init -V2 \
528 ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
529
530 rm -rf $conf_dir
531 fi
532
533 ln -sf ${pkgs.writeText "description" inbox.description} \
534 ${stateDir}/inboxes/${escapeShellArg name}/description
535
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
541 fi
542 '') cfg.inboxes)}
543
544 shopt -s nullglob
545 for inbox in ${stateDir}/inboxes/*/; do
546 ls -1 "$inbox" | ${pkgs.gnugrep}/bin/grep -q '^xap' && continue
547
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"
552 done
553 '';
554 serviceConfig = serviceConfig "init" // {
555 Type = "oneshot";
556 RemainAfterExit = true;
557 StateDirectory = [
558 "public-inbox"
559 "public-inbox/emergency"
560 "public-inbox/inboxes"
561 ];
562 StateDirectoryMode = "0750";
563 };
564 };
565 })
566 ];
567 environment.systemPackages = with pkgs; [ cfg.package ];
568 };
569 meta.maintainers = with lib.maintainers; [ julm ];
570 }