1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.sympa;
9 dataDir = "/var/lib/sympa";
10 user = "sympa";
11 group = "sympa";
12 pkg = pkgs.sympa;
13 fqdns = lib.attrNames cfg.domains;
14 usingNginx = cfg.web.enable && cfg.web.server == "nginx";
15 mysqlLocal = cfg.database.createLocally && cfg.database.type == "MySQL";
16 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "PostgreSQL";
17
18 sympaSubServices = [
19 "sympa-archive.service"
20 "sympa-bounce.service"
21 "sympa-bulk.service"
22 "sympa-task.service"
23 ];
24
25 # common for all services including wwsympa
26 commonServiceConfig = {
27 StateDirectory = "sympa";
28 ProtectHome = true;
29 ProtectSystem = "full";
30 ProtectControlGroups = true;
31 };
32
33 # wwsympa has its own service config
34 sympaServiceConfig =
35 srv:
36 {
37 Type = "simple";
38 Restart = "always";
39 ExecStart = "${pkg}/bin/${srv}.pl --foreground";
40 PIDFile = "/run/sympa/${srv}.pid";
41 User = user;
42 Group = group;
43
44 # avoid duplicating log messageges in journal
45 StandardError = "null";
46 }
47 // commonServiceConfig;
48
49 configVal = value: if lib.isBool value then if value then "on" else "off" else toString value;
50 configGenerator =
51 c: lib.concatStrings (lib.flip lib.mapAttrsToList c (key: val: "${key}\t${configVal val}\n"));
52
53 mainConfig = pkgs.writeText "sympa.conf" (configGenerator cfg.settings);
54 robotConfig = fqdn: domain: pkgs.writeText "${fqdn}-robot.conf" (configGenerator domain.settings);
55
56 transport = pkgs.writeText "transport.sympa" (
57 lib.concatStringsSep "\n" (
58 lib.flip map fqdns (domain: ''
59 ${domain} error:User unknown in recipient table
60 sympa@${domain} sympa:sympa@${domain}
61 listmaster@${domain} sympa:listmaster@${domain}
62 bounce@${domain} sympabounce:sympa@${domain}
63 abuse-feedback-report@${domain} sympabounce:sympa@${domain}
64 '')
65 )
66 );
67
68 virtual = pkgs.writeText "virtual.sympa" (
69 lib.concatStringsSep "\n" (
70 lib.flip map fqdns (domain: ''
71 sympa-request@${domain} postmaster@localhost
72 sympa-owner@${domain} postmaster@localhost
73 '')
74 )
75 );
76
77 listAliases = pkgs.writeText "list_aliases.tt2" ''
78 #--- [% list.name %]@[% list.domain %]: list transport map created at [% date %]
79 [% list.name %]@[% list.domain %] sympa:[% list.name %]@[% list.domain %]
80 [% list.name %]-request@[% list.domain %] sympa:[% list.name %]-request@[% list.domain %]
81 [% list.name %]-editor@[% list.domain %] sympa:[% list.name %]-editor@[% list.domain %]
82 #[% list.name %]-subscribe@[% list.domain %] sympa:[% list.name %]-subscribe@[%list.domain %]
83 [% list.name %]-unsubscribe@[% list.domain %] sympa:[% list.name %]-unsubscribe@[% list.domain %]
84 [% list.name %][% return_path_suffix %]@[% list.domain %] sympabounce:[% list.name %]@[% list.domain %]
85 '';
86
87 enabledFiles = lib.filterAttrs (n: v: v.enable) cfg.settingsFile;
88in
89{
90
91 ###### interface
92 options.services.sympa = with lib.types; {
93
94 enable = lib.mkEnableOption "Sympa mailing list manager";
95
96 lang = lib.mkOption {
97 type = str;
98 default = "en_US";
99 example = "cs";
100 description = ''
101 Default Sympa language.
102 See <https://github.com/sympa-community/sympa/tree/sympa-6.2/po/sympa>
103 for available options.
104 '';
105 };
106
107 listMasters = lib.mkOption {
108 type = listOf str;
109 example = [ "postmaster@sympa.example.org" ];
110 description = ''
111 The list of the email addresses of the listmasters
112 (users authorized to perform global server commands).
113 '';
114 };
115
116 mainDomain = lib.mkOption {
117 type = nullOr str;
118 default = null;
119 example = "lists.example.org";
120 description = ''
121 Main domain to be used in {file}`sympa.conf`.
122 If `null`, one of the {option}`services.sympa.domains` is chosen for you.
123 '';
124 };
125
126 domains = lib.mkOption {
127 type = attrsOf (
128 submodule (
129 { name, config, ... }:
130 {
131 options = {
132 webHost = lib.mkOption {
133 type = nullOr str;
134 default = null;
135 example = "archive.example.org";
136 description = ''
137 Domain part of the web interface URL (no web interface for this domain if `null`).
138 DNS record of type A (or AAAA or CNAME) has to exist with this value.
139 '';
140 };
141 webLocation = lib.mkOption {
142 type = str;
143 default = "/";
144 example = "/sympa";
145 description = "URL path part of the web interface.";
146 };
147 settings = lib.mkOption {
148 type = attrsOf (oneOf [
149 str
150 int
151 bool
152 ]);
153 default = { };
154 example = {
155 default_max_list_members = 3;
156 };
157 description = ''
158 The {file}`robot.conf` configuration file as key value set.
159 See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
160 for list of configuration parameters.
161 '';
162 };
163 };
164
165 config.settings = lib.mkIf (cfg.web.enable && config.webHost != null) {
166 wwsympa_url = lib.mkDefault "https://${config.webHost}${lib.removeSuffix "/" config.webLocation}";
167 };
168 }
169 )
170 );
171
172 description = ''
173 Email domains handled by this instance. There have
174 to be MX records for keys of this attribute set.
175 '';
176 example = lib.literalExpression ''
177 {
178 "lists.example.org" = {
179 webHost = "lists.example.org";
180 webLocation = "/";
181 };
182 "sympa.example.com" = {
183 webHost = "example.com";
184 webLocation = "/sympa";
185 };
186 }
187 '';
188 };
189
190 database = {
191 type = lib.mkOption {
192 type = enum [
193 "SQLite"
194 "PostgreSQL"
195 "MySQL"
196 ];
197 default = "SQLite";
198 example = "MySQL";
199 description = "Database engine to use.";
200 };
201
202 host = lib.mkOption {
203 type = nullOr str;
204 default = null;
205 description = ''
206 Database host address.
207
208 For MySQL, use `localhost` to connect using Unix domain socket.
209
210 For PostgreSQL, use path to directory (e.g. {file}`/run/postgresql`)
211 to connect using Unix domain socket located in this directory.
212
213 Use `null` to fall back on Sympa default, or when using
214 {option}`services.sympa.database.createLocally`.
215 '';
216 };
217
218 port = lib.mkOption {
219 type = nullOr port;
220 default = null;
221 description = "Database port. Use `null` for default port.";
222 };
223
224 name = lib.mkOption {
225 type = str;
226 default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa";
227 defaultText = lib.literalExpression ''if database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"'';
228 description = ''
229 Database name. When using SQLite this must be an absolute
230 path to the database file.
231 '';
232 };
233
234 user = lib.mkOption {
235 type = nullOr str;
236 default = user;
237 description = "Database user. The system user name is used as a default.";
238 };
239
240 passwordFile = lib.mkOption {
241 type = nullOr path;
242 default = null;
243 example = "/run/keys/sympa-dbpassword";
244 description = ''
245 A file containing the password for {option}`services.sympa.database.name`.
246 '';
247 };
248
249 createLocally = lib.mkOption {
250 type = bool;
251 default = true;
252 description = "Whether to create a local database automatically.";
253 };
254 };
255
256 web = {
257 enable = lib.mkOption {
258 type = bool;
259 default = true;
260 description = "Whether to enable Sympa web interface.";
261 };
262
263 server = lib.mkOption {
264 type = enum [
265 "nginx"
266 "none"
267 ];
268 default = "nginx";
269 description = ''
270 The webserver used for the Sympa web interface. Set it to `none` if you want to configure it yourself.
271 Further nginx configuration can be done by adapting
272 {option}`services.nginx.virtualHosts.«name»`.
273 '';
274 };
275
276 https = lib.mkOption {
277 type = bool;
278 default = true;
279 description = ''
280 Whether to use HTTPS. When nginx integration is enabled, this option forces SSL and enables ACME.
281 Please note that Sympa web interface always uses https links even when this option is disabled.
282 '';
283 };
284
285 fcgiProcs = lib.mkOption {
286 type = ints.positive;
287 default = 2;
288 description = "Number of FastCGI processes to fork.";
289 };
290 };
291
292 mta = {
293 type = lib.mkOption {
294 type = enum [
295 "postfix"
296 "none"
297 ];
298 default = "postfix";
299 description = ''
300 Mail transfer agent (MTA) integration. Use `none` if you want to configure it yourself.
301
302 The `postfix` integration sets up local Postfix instance that will pass incoming
303 messages from configured domains to Sympa. You still need to configure at least outgoing message
304 handling using e.g. {option}`services.postfix.relayHost`.
305 '';
306 };
307 };
308
309 settings = lib.mkOption {
310 type = attrsOf (oneOf [
311 str
312 int
313 bool
314 ]);
315 default = { };
316 example = lib.literalExpression ''
317 {
318 default_home = "lists";
319 viewlogs_page_size = 50;
320 }
321 '';
322 description = ''
323 The {file}`sympa.conf` configuration file as key value set.
324 See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
325 for list of configuration parameters.
326 '';
327 };
328
329 settingsFile = lib.mkOption {
330 type = attrsOf (
331 submodule (
332 { name, config, ... }:
333 {
334 options = {
335 enable = lib.mkOption {
336 type = bool;
337 default = true;
338 description = "Whether this file should be generated. This option allows specific files to be disabled.";
339 };
340 text = lib.mkOption {
341 default = null;
342 type = nullOr lines;
343 description = "Text of the file.";
344 };
345 source = lib.mkOption {
346 type = path;
347 description = "Path of the source file.";
348 };
349 };
350
351 config.source = lib.mkIf (config.text != null) (
352 lib.mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text)
353 );
354 }
355 )
356 );
357 default = { };
358 example = lib.literalExpression ''
359 {
360 "list_data/lists.example.org/help" = {
361 text = "subject This list provides help to users";
362 };
363 }
364 '';
365 description = "Set of files to be linked in {file}`${dataDir}`.";
366 };
367 };
368
369 ###### implementation
370
371 config = lib.mkIf cfg.enable {
372
373 services.sympa.settings = (
374 lib.mapAttrs (_: v: lib.mkDefault v) {
375 domain = if cfg.mainDomain != null then cfg.mainDomain else lib.head fqdns;
376 listmaster = lib.concatStringsSep "," cfg.listMasters;
377 lang = cfg.lang;
378
379 home = "${dataDir}/list_data";
380 arc_path = "${dataDir}/arc";
381 bounce_path = "${dataDir}/bounce";
382
383 sendmail = "${pkgs.system-sendmail}/bin/sendmail";
384
385 db_type = cfg.database.type;
386 db_name = cfg.database.name;
387 db_user = cfg.database.name;
388 }
389 // (lib.optionalAttrs (cfg.database.host != null) {
390 db_host = cfg.database.host;
391 })
392 // (lib.optionalAttrs mysqlLocal {
393 db_host = "localhost"; # use unix domain socket
394 })
395 // (lib.optionalAttrs pgsqlLocal {
396 db_host = "/run/postgresql"; # use unix domain socket
397 })
398 // (lib.optionalAttrs (cfg.database.port != null) {
399 db_port = cfg.database.port;
400 })
401 // (lib.optionalAttrs (cfg.mta.type == "postfix") {
402 sendmail_aliases = "${dataDir}/sympa_transport";
403 aliases_program = "${pkgs.postfix}/bin/postmap";
404 aliases_db_type = "hash";
405 })
406 // (lib.optionalAttrs cfg.web.enable {
407 static_content_path = "${dataDir}/static_content";
408 css_path = "${dataDir}/static_content/css";
409 pictures_path = "${dataDir}/static_content/pictures";
410 mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc";
411 })
412 );
413
414 services.sympa.settingsFile =
415 {
416 "virtual.sympa" = lib.mkDefault { source = virtual; };
417 "transport.sympa" = lib.mkDefault { source = transport; };
418 "etc/list_aliases.tt2" = lib.mkDefault { source = listAliases; };
419 }
420 // (lib.flip lib.mapAttrs' cfg.domains (
421 fqdn: domain:
422 lib.nameValuePair "etc/${fqdn}/robot.conf" (lib.mkDefault { source = robotConfig fqdn domain; })
423 ));
424
425 environment = {
426 systemPackages = [ pkg ];
427 };
428
429 users.users.${user} = {
430 description = "Sympa mailing list manager user";
431 group = group;
432 home = dataDir;
433 createHome = false;
434 isSystemUser = true;
435 };
436
437 users.groups.${group} = { };
438
439 assertions = [
440 {
441 assertion =
442 cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
443 message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true";
444 }
445 {
446 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
447 message = "a password cannot be specified if services.sympa.database.createLocally is set to true";
448 }
449 ];
450
451 systemd.tmpfiles.rules =
452 [
453 "d ${dataDir} 0711 ${user} ${group} - -"
454 "d ${dataDir}/etc 0700 ${user} ${group} - -"
455 "d ${dataDir}/spool 0700 ${user} ${group} - -"
456 "d ${dataDir}/list_data 0700 ${user} ${group} - -"
457 "d ${dataDir}/arc 0700 ${user} ${group} - -"
458 "d ${dataDir}/bounce 0700 ${user} ${group} - -"
459 "f ${dataDir}/sympa_transport 0600 ${user} ${group} - -"
460
461 # force-copy static_content so it's up to date with package
462 # set permissions for wwsympa which needs write access (...)
463 "R ${dataDir}/static_content - - - - -"
464 "C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/var/lib/sympa/static_content"
465 "e ${dataDir}/static_content/* 0711 ${user} ${group} - -"
466
467 "d /run/sympa 0755 ${user} ${group} - -"
468 ]
469 ++ (lib.flip lib.concatMap fqdns (fqdn: [
470 "d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
471 "d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
472 ]))
473 #++ (lib.flip lib.mapAttrsToList enabledFiles (k: v:
474 # "L+ ${dataDir}/${k} - - - - ${v.source}"
475 #))
476 ++ (lib.concatLists (
477 lib.flip lib.mapAttrsToList enabledFiles (
478 k: v: [
479 # sympa doesn't handle symlinks well (e.g. fails to create locks)
480 # force-copy instead
481 "R ${dataDir}/${k} - - - - -"
482 "C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}"
483 ]
484 )
485 ));
486
487 systemd.services.sympa = {
488 description = "Sympa mailing list manager";
489
490 wantedBy = [ "multi-user.target" ];
491 after = [ "network-online.target" ];
492 wants = sympaSubServices ++ [ "network-online.target" ];
493 before = sympaSubServices;
494 serviceConfig = sympaServiceConfig "sympa_msg";
495
496 preStart = ''
497 umask 0077
498
499 cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
500 ${lib.optionalString (cfg.database.passwordFile != null) ''
501 chmod u+w ${dataDir}/etc/sympa.conf
502 echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf
503 cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf
504 ''}
505
506 ${lib.optionalString (cfg.mta.type == "postfix") ''
507 ${pkgs.postfix}/bin/postmap hash:${dataDir}/virtual.sympa
508 ${pkgs.postfix}/bin/postmap hash:${dataDir}/transport.sympa
509 ''}
510 ${pkg}/bin/sympa_newaliases.pl
511 ${pkg}/bin/sympa.pl --health_check
512 '';
513 };
514 systemd.services.sympa-archive = {
515 description = "Sympa mailing list manager (archiving)";
516 bindsTo = [ "sympa.service" ];
517 serviceConfig = sympaServiceConfig "archived";
518 };
519 systemd.services.sympa-bounce = {
520 description = "Sympa mailing list manager (bounce processing)";
521 bindsTo = [ "sympa.service" ];
522 serviceConfig = sympaServiceConfig "bounced";
523 };
524 systemd.services.sympa-bulk = {
525 description = "Sympa mailing list manager (message distribution)";
526 bindsTo = [ "sympa.service" ];
527 serviceConfig = sympaServiceConfig "bulk";
528 };
529 systemd.services.sympa-task = {
530 description = "Sympa mailing list manager (task management)";
531 bindsTo = [ "sympa.service" ];
532 serviceConfig = sympaServiceConfig "task_manager";
533 };
534
535 systemd.services.wwsympa = lib.mkIf usingNginx {
536 wantedBy = [ "multi-user.target" ];
537 after = [ "sympa.service" ];
538 serviceConfig = {
539 Type = "forking";
540 PIDFile = "/run/sympa/wwsympa.pid";
541 Restart = "always";
542 ExecStart = ''
543 ${pkgs.spawn_fcgi}/bin/spawn-fcgi \
544 -u ${user} \
545 -g ${group} \
546 -U nginx \
547 -M 0600 \
548 -F ${toString cfg.web.fcgiProcs} \
549 -P /run/sympa/wwsympa.pid \
550 -s /run/sympa/wwsympa.socket \
551 -- ${pkg}/lib/sympa/cgi/wwsympa.fcgi
552 '';
553
554 } // commonServiceConfig;
555 };
556
557 services.nginx.enable = lib.mkIf usingNginx true;
558 services.nginx.virtualHosts = lib.mkIf usingNginx (
559 let
560 vHosts = lib.unique (lib.remove null (lib.mapAttrsToList (_k: v: v.webHost) cfg.domains));
561 hostLocations =
562 host: map (v: v.webLocation) (lib.filter (v: v.webHost == host) (lib.attrValues cfg.domains));
563 httpsOpts = lib.optionalAttrs cfg.web.https {
564 forceSSL = lib.mkDefault true;
565 enableACME = lib.mkDefault true;
566 };
567 in
568 lib.genAttrs vHosts (
569 host:
570 {
571 locations =
572 lib.genAttrs (hostLocations host) (loc: {
573 extraConfig = ''
574 include ${config.services.nginx.package}/conf/fastcgi_params;
575
576 fastcgi_pass unix:/run/sympa/wwsympa.socket;
577 '';
578 })
579 // {
580 "/static-sympa/".alias = "${dataDir}/static_content/";
581 };
582 }
583 // httpsOpts
584 )
585 );
586
587 services.postfix = lib.mkIf (cfg.mta.type == "postfix") {
588 enable = true;
589 recipientDelimiter = "+";
590 config = {
591 virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ];
592 virtual_mailbox_maps = [
593 "hash:${dataDir}/transport.sympa"
594 "hash:${dataDir}/sympa_transport"
595 "hash:${dataDir}/virtual.sympa"
596 ];
597 virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ];
598 transport_maps = [
599 "hash:${dataDir}/transport.sympa"
600 "hash:${dataDir}/sympa_transport"
601 ];
602 };
603 masterConfig = {
604 "sympa" = {
605 type = "unix";
606 privileged = true;
607 chroot = false;
608 command = "pipe";
609 args = [
610 "flags=hqRu"
611 "user=${user}"
612 "argv=${pkg}/libexec/queue"
613 "\${nexthop}"
614 ];
615 };
616 "sympabounce" = {
617 type = "unix";
618 privileged = true;
619 chroot = false;
620 command = "pipe";
621 args = [
622 "flags=hqRu"
623 "user=${user}"
624 "argv=${pkg}/libexec/bouncequeue"
625 "\${nexthop}"
626 ];
627 };
628 };
629 };
630
631 services.mysql = lib.optionalAttrs mysqlLocal {
632 enable = true;
633 package = lib.mkDefault pkgs.mariadb;
634 ensureDatabases = [ cfg.database.name ];
635 ensureUsers = [
636 {
637 name = cfg.database.user;
638 ensurePermissions = {
639 "${cfg.database.name}.*" = "ALL PRIVILEGES";
640 };
641 }
642 ];
643 };
644
645 services.postgresql = lib.optionalAttrs pgsqlLocal {
646 enable = true;
647 ensureDatabases = [ cfg.database.name ];
648 ensureUsers = [
649 {
650 name = cfg.database.user;
651 ensureDBOwnership = true;
652 }
653 ];
654 };
655
656 };
657
658 meta.maintainers = with lib.maintainers; [
659 sorki
660 ];
661}