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 "Sympa mailing list manager";
84
85 lang = mkOption {
86 type = str;
87 default = "en_US";
88 example = "cs";
89 description = ''
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 = ''
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 = ''
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 = ''
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 = "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 = ''
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 = ''
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 = "Database engine to use.";
176 };
177
178 host = mkOption {
179 type = nullOr str;
180 default = null;
181 description = ''
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 = "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 = ''
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 = "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 = ''
221 A file containing the password for {option}`services.sympa.database.name`.
222 '';
223 };
224
225 createLocally = mkOption {
226 type = bool;
227 default = true;
228 description = "Whether to create a local database automatically.";
229 };
230 };
231
232 web = {
233 enable = mkOption {
234 type = bool;
235 default = true;
236 description = "Whether to enable Sympa web interface.";
237 };
238
239 server = mkOption {
240 type = enum [ "nginx" "none" ];
241 default = "nginx";
242 description = ''
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 = ''
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 = "Number of FastCGI processes to fork.";
262 };
263 };
264
265 mta = {
266 type = mkOption {
267 type = enum [ "postfix" "none" ];
268 default = "postfix";
269 description = ''
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 = ''
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 = "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 = "Text of the file.";
307 };
308 source = mkOption {
309 type = path;
310 description = "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 = "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 db_user = cfg.database.name;
346 }
347 // (optionalAttrs (cfg.database.host != null) {
348 db_host = cfg.database.host;
349 })
350 // (optionalAttrs mysqlLocal {
351 db_host = "localhost"; # use unix domain socket
352 })
353 // (optionalAttrs pgsqlLocal {
354 db_host = "/run/postgresql"; # use unix domain socket
355 })
356 // (optionalAttrs (cfg.database.port != null) {
357 db_port = cfg.database.port;
358 })
359 // (optionalAttrs (cfg.mta.type == "postfix") {
360 sendmail_aliases = "${dataDir}/sympa_transport";
361 aliases_program = "${pkgs.postfix}/bin/postmap";
362 aliases_db_type = "hash";
363 })
364 // (optionalAttrs cfg.web.enable {
365 static_content_path = "${dataDir}/static_content";
366 css_path = "${dataDir}/static_content/css";
367 pictures_path = "${dataDir}/static_content/pictures";
368 mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc";
369 }));
370
371 services.sympa.settingsFile = {
372 "virtual.sympa" = mkDefault { source = virtual; };
373 "transport.sympa" = mkDefault { source = transport; };
374 "etc/list_aliases.tt2" = mkDefault { source = listAliases; };
375 }
376 // (flip mapAttrs' cfg.domains (fqdn: domain:
377 nameValuePair "etc/${fqdn}/robot.conf" (mkDefault { source = robotConfig fqdn domain; })));
378
379 environment = {
380 systemPackages = [ pkg ];
381 };
382
383 users.users.${user} = {
384 description = "Sympa mailing list manager user";
385 group = group;
386 home = dataDir;
387 createHome = false;
388 isSystemUser = true;
389 };
390
391 users.groups.${group} = {};
392
393 assertions = [
394 { assertion = cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
395 message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true";
396 }
397 { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
398 message = "a password cannot be specified if services.sympa.database.createLocally is set to true";
399 }
400 ];
401
402 systemd.tmpfiles.rules = [
403 "d ${dataDir} 0711 ${user} ${group} - -"
404 "d ${dataDir}/etc 0700 ${user} ${group} - -"
405 "d ${dataDir}/spool 0700 ${user} ${group} - -"
406 "d ${dataDir}/list_data 0700 ${user} ${group} - -"
407 "d ${dataDir}/arc 0700 ${user} ${group} - -"
408 "d ${dataDir}/bounce 0700 ${user} ${group} - -"
409 "f ${dataDir}/sympa_transport 0600 ${user} ${group} - -"
410
411 # force-copy static_content so it's up to date with package
412 # set permissions for wwsympa which needs write access (...)
413 "R ${dataDir}/static_content - - - - -"
414 "C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/var/lib/sympa/static_content"
415 "e ${dataDir}/static_content/* 0711 ${user} ${group} - -"
416
417 "d /run/sympa 0755 ${user} ${group} - -"
418 ]
419 ++ (flip concatMap fqdns (fqdn: [
420 "d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
421 "d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
422 ]))
423 #++ (flip mapAttrsToList enabledFiles (k: v:
424 # "L+ ${dataDir}/${k} - - - - ${v.source}"
425 #))
426 ++ (concatLists (flip mapAttrsToList enabledFiles (k: v: [
427 # sympa doesn't handle symlinks well (e.g. fails to create locks)
428 # force-copy instead
429 "R ${dataDir}/${k} - - - - -"
430 "C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}"
431 ])));
432
433 systemd.services.sympa = {
434 description = "Sympa mailing list manager";
435
436 wantedBy = [ "multi-user.target" ];
437 after = [ "network-online.target" ];
438 wants = sympaSubServices ++ [ "network-online.target" ];
439 before = sympaSubServices;
440 serviceConfig = sympaServiceConfig "sympa_msg";
441
442 preStart = ''
443 umask 0077
444
445 cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
446 ${optionalString (cfg.database.passwordFile != null) ''
447 chmod u+w ${dataDir}/etc/sympa.conf
448 echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf
449 cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf
450 ''}
451
452 ${optionalString (cfg.mta.type == "postfix") ''
453 ${pkgs.postfix}/bin/postmap hash:${dataDir}/virtual.sympa
454 ${pkgs.postfix}/bin/postmap hash:${dataDir}/transport.sympa
455 ''}
456 ${pkg}/bin/sympa_newaliases.pl
457 ${pkg}/bin/sympa.pl --health_check
458 '';
459 };
460 systemd.services.sympa-archive = {
461 description = "Sympa mailing list manager (archiving)";
462 bindsTo = [ "sympa.service" ];
463 serviceConfig = sympaServiceConfig "archived";
464 };
465 systemd.services.sympa-bounce = {
466 description = "Sympa mailing list manager (bounce processing)";
467 bindsTo = [ "sympa.service" ];
468 serviceConfig = sympaServiceConfig "bounced";
469 };
470 systemd.services.sympa-bulk = {
471 description = "Sympa mailing list manager (message distribution)";
472 bindsTo = [ "sympa.service" ];
473 serviceConfig = sympaServiceConfig "bulk";
474 };
475 systemd.services.sympa-task = {
476 description = "Sympa mailing list manager (task management)";
477 bindsTo = [ "sympa.service" ];
478 serviceConfig = sympaServiceConfig "task_manager";
479 };
480
481 systemd.services.wwsympa = mkIf usingNginx {
482 wantedBy = [ "multi-user.target" ];
483 after = [ "sympa.service" ];
484 serviceConfig = {
485 Type = "forking";
486 PIDFile = "/run/sympa/wwsympa.pid";
487 Restart = "always";
488 ExecStart = ''${pkgs.spawn_fcgi}/bin/spawn-fcgi \
489 -u ${user} \
490 -g ${group} \
491 -U nginx \
492 -M 0600 \
493 -F ${toString cfg.web.fcgiProcs} \
494 -P /run/sympa/wwsympa.pid \
495 -s /run/sympa/wwsympa.socket \
496 -- ${pkg}/lib/sympa/cgi/wwsympa.fcgi
497 '';
498
499 } // commonServiceConfig;
500 };
501
502 services.nginx.enable = mkIf usingNginx true;
503 services.nginx.virtualHosts = mkIf usingNginx (let
504 vHosts = unique (remove null (mapAttrsToList (_k: v: v.webHost) cfg.domains));
505 hostLocations = host: map (v: v.webLocation) (filter (v: v.webHost == host) (attrValues cfg.domains));
506 httpsOpts = optionalAttrs cfg.web.https { forceSSL = mkDefault true; enableACME = mkDefault true; };
507 in
508 genAttrs vHosts (host: {
509 locations = genAttrs (hostLocations host) (loc: {
510 extraConfig = ''
511 include ${config.services.nginx.package}/conf/fastcgi_params;
512
513 fastcgi_pass unix:/run/sympa/wwsympa.socket;
514 '';
515 }) // {
516 "/static-sympa/".alias = "${dataDir}/static_content/";
517 };
518 } // httpsOpts));
519
520 services.postfix = mkIf (cfg.mta.type == "postfix") {
521 enable = true;
522 recipientDelimiter = "+";
523 config = {
524 virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ];
525 virtual_mailbox_maps = [
526 "hash:${dataDir}/transport.sympa"
527 "hash:${dataDir}/sympa_transport"
528 "hash:${dataDir}/virtual.sympa"
529 ];
530 virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ];
531 transport_maps = [
532 "hash:${dataDir}/transport.sympa"
533 "hash:${dataDir}/sympa_transport"
534 ];
535 };
536 masterConfig = {
537 "sympa" = {
538 type = "unix";
539 privileged = true;
540 chroot = false;
541 command = "pipe";
542 args = [
543 "flags=hqRu"
544 "user=${user}"
545 "argv=${pkg}/libexec/queue"
546 "\${nexthop}"
547 ];
548 };
549 "sympabounce" = {
550 type = "unix";
551 privileged = true;
552 chroot = false;
553 command = "pipe";
554 args = [
555 "flags=hqRu"
556 "user=${user}"
557 "argv=${pkg}/libexec/bouncequeue"
558 "\${nexthop}"
559 ];
560 };
561 };
562 };
563
564 services.mysql = optionalAttrs mysqlLocal {
565 enable = true;
566 package = mkDefault pkgs.mariadb;
567 ensureDatabases = [ cfg.database.name ];
568 ensureUsers = [
569 { name = cfg.database.user;
570 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
571 }
572 ];
573 };
574
575 services.postgresql = optionalAttrs pgsqlLocal {
576 enable = true;
577 ensureDatabases = [ cfg.database.name ];
578 ensureUsers = [
579 { name = cfg.database.user;
580 ensureDBOwnership = true;
581 }
582 ];
583 };
584
585 };
586
587 meta.maintainers = with maintainers; [ mmilata sorki ];
588}