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