at 23.11-pre 20 kB view raw
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}