at 25.11-pre 19 kB view raw
1{ 2 config, 3 lib, 4 options, 5 pkgs, 6 ... 7}: 8 9let 10 cfg = config.services.parsedmarc; 11 opt = options.services.parsedmarc; 12 isSecret = v: isAttrs v && v ? _secret && isString v._secret; 13 ini = pkgs.formats.ini { 14 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" rec { 15 mkValueString = 16 v: 17 if isInt v then 18 toString v 19 else if isString v then 20 v 21 else if true == v then 22 "True" 23 else if false == v then 24 "False" 25 else if isSecret v then 26 hashString "sha256" v._secret 27 else 28 throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty { }) v}"; 29 }; 30 }; 31 inherit (builtins) 32 elem 33 isAttrs 34 isString 35 isInt 36 isList 37 typeOf 38 hashString 39 ; 40in 41{ 42 options.services.parsedmarc = { 43 44 enable = lib.mkEnableOption '' 45 parsedmarc, a DMARC report monitoring service 46 ''; 47 48 provision = { 49 localMail = { 50 enable = lib.mkOption { 51 type = lib.types.bool; 52 default = false; 53 description = '' 54 Whether Postfix and Dovecot should be set up to receive 55 mail locally. parsedmarc will be configured to watch the 56 local inbox as the automatically created user specified in 57 [](#opt-services.parsedmarc.provision.localMail.recipientName) 58 ''; 59 }; 60 61 recipientName = lib.mkOption { 62 type = lib.types.str; 63 default = "dmarc"; 64 description = '' 65 The DMARC mail recipient name, i.e. the name part of the 66 email address which receives DMARC reports. 67 68 A local user with this name will be set up and assigned a 69 randomized password on service start. 70 ''; 71 }; 72 73 hostname = lib.mkOption { 74 type = lib.types.str; 75 default = config.networking.fqdn; 76 defaultText = lib.literalExpression "config.networking.fqdn"; 77 example = "monitoring.example.com"; 78 description = '' 79 The hostname to use when configuring Postfix. 80 81 Should correspond to the host's fully qualified domain 82 name and the domain part of the email address which 83 receives DMARC reports. You also have to set up an MX record 84 pointing to this domain name. 85 ''; 86 }; 87 }; 88 89 geoIp = lib.mkOption { 90 type = lib.types.bool; 91 default = true; 92 description = '' 93 Whether to enable and configure the [geoipupdate](#opt-services.geoipupdate.enable) 94 service to automatically fetch GeoIP databases. Not crucial, 95 but recommended for full functionality. 96 97 To finish the setup, you need to manually set the [](#opt-services.geoipupdate.settings.AccountID) and 98 [](#opt-services.geoipupdate.settings.LicenseKey) 99 options. 100 ''; 101 }; 102 103 elasticsearch = lib.mkOption { 104 type = lib.types.bool; 105 default = true; 106 description = '' 107 Whether to set up and use a local instance of Elasticsearch. 108 ''; 109 }; 110 111 grafana = { 112 datasource = lib.mkOption { 113 type = lib.types.bool; 114 default = cfg.provision.elasticsearch && config.services.grafana.enable; 115 defaultText = lib.literalExpression '' 116 config.${opt.provision.elasticsearch} && config.${options.services.grafana.enable} 117 ''; 118 apply = x: x && cfg.provision.elasticsearch; 119 description = '' 120 Whether the automatically provisioned Elasticsearch 121 instance should be added as a grafana datasource. Has no 122 effect unless 123 [](#opt-services.parsedmarc.provision.elasticsearch) 124 is also enabled. 125 ''; 126 }; 127 128 dashboard = lib.mkOption { 129 type = lib.types.bool; 130 default = config.services.grafana.enable; 131 defaultText = lib.literalExpression "config.services.grafana.enable"; 132 description = '' 133 Whether the official parsedmarc grafana dashboard should 134 be provisioned to the local grafana instance. 135 ''; 136 }; 137 }; 138 }; 139 140 settings = lib.mkOption { 141 example = lib.literalExpression '' 142 { 143 imap = { 144 host = "imap.example.com"; 145 user = "alice@example.com"; 146 password = { _secret = "/run/keys/imap_password" }; 147 }; 148 mailbox = { 149 watch = true; 150 batch_size = 30; 151 }; 152 splunk_hec = { 153 url = "https://splunkhec.example.com"; 154 token = { _secret = "/run/keys/splunk_token" }; 155 index = "email"; 156 }; 157 } 158 ''; 159 description = '' 160 Configuration parameters to set in 161 {file}`parsedmarc.ini`. For a full list of 162 available parameters, see 163 <https://domainaware.github.io/parsedmarc/#configuration-file>. 164 165 Settings containing secret data should be set to an attribute 166 set containing the attribute `_secret` - a 167 string pointing to a file containing the value the option 168 should be set to. See the example to get a better picture of 169 this: in the resulting {file}`parsedmarc.ini` 170 file, the `splunk_hec.token` key will be set 171 to the contents of the 172 {file}`/run/keys/splunk_token` file. 173 ''; 174 175 type = lib.types.submodule { 176 freeformType = ini.type; 177 178 options = { 179 general = { 180 save_aggregate = lib.mkOption { 181 type = lib.types.bool; 182 default = true; 183 description = '' 184 Save aggregate report data to Elasticsearch and/or Splunk. 185 ''; 186 }; 187 188 save_forensic = lib.mkOption { 189 type = lib.types.bool; 190 default = true; 191 description = '' 192 Save forensic report data to Elasticsearch and/or Splunk. 193 ''; 194 }; 195 }; 196 197 mailbox = { 198 watch = lib.mkOption { 199 type = lib.types.bool; 200 default = true; 201 description = '' 202 Use the IMAP IDLE command to process messages as they arrive. 203 ''; 204 }; 205 206 delete = lib.mkOption { 207 type = lib.types.bool; 208 default = false; 209 description = '' 210 Delete messages after processing them, instead of archiving them. 211 ''; 212 }; 213 }; 214 215 imap = { 216 host = lib.mkOption { 217 type = lib.types.str; 218 default = "localhost"; 219 description = '' 220 The IMAP server hostname or IP address. 221 ''; 222 }; 223 224 port = lib.mkOption { 225 type = lib.types.port; 226 default = 993; 227 description = '' 228 The IMAP server port. 229 ''; 230 }; 231 232 ssl = lib.mkOption { 233 type = lib.types.bool; 234 default = true; 235 description = '' 236 Use an encrypted SSL/TLS connection. 237 ''; 238 }; 239 240 user = lib.mkOption { 241 type = with lib.types; nullOr str; 242 default = null; 243 description = '' 244 The IMAP server username. 245 ''; 246 }; 247 248 password = lib.mkOption { 249 type = with lib.types; nullOr (either path (attrsOf path)); 250 default = null; 251 description = '' 252 The IMAP server password. 253 254 Always handled as a secret whether the value is 255 wrapped in a `{ _secret = ...; }` 256 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 257 details). 258 ''; 259 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 260 }; 261 }; 262 263 smtp = { 264 host = lib.mkOption { 265 type = with lib.types; nullOr str; 266 default = null; 267 description = '' 268 The SMTP server hostname or IP address. 269 ''; 270 }; 271 272 port = lib.mkOption { 273 type = with lib.types; nullOr port; 274 default = null; 275 description = '' 276 The SMTP server port. 277 ''; 278 }; 279 280 ssl = lib.mkOption { 281 type = with lib.types; nullOr bool; 282 default = null; 283 description = '' 284 Use an encrypted SSL/TLS connection. 285 ''; 286 }; 287 288 user = lib.mkOption { 289 type = with lib.types; nullOr str; 290 default = null; 291 description = '' 292 The SMTP server username. 293 ''; 294 }; 295 296 password = lib.mkOption { 297 type = with lib.types; nullOr (either path (attrsOf path)); 298 default = null; 299 description = '' 300 The SMTP server password. 301 302 Always handled as a secret whether the value is 303 wrapped in a `{ _secret = ...; }` 304 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 305 details). 306 ''; 307 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 308 }; 309 310 from = lib.mkOption { 311 type = with lib.types; nullOr str; 312 default = null; 313 description = '' 314 The `From` address to use for the 315 outgoing mail. 316 ''; 317 }; 318 319 to = lib.mkOption { 320 type = with lib.types; nullOr (listOf str); 321 default = null; 322 description = '' 323 The addresses to send outgoing mail to. 324 ''; 325 apply = x: if x == [ ] || x == null then null else lib.concatStringsSep "," x; 326 }; 327 }; 328 329 elasticsearch = { 330 hosts = lib.mkOption { 331 default = [ ]; 332 type = with lib.types; listOf str; 333 apply = x: if x == [ ] then null else lib.concatStringsSep "," x; 334 description = '' 335 A list of Elasticsearch hosts to push parsed reports 336 to. 337 ''; 338 }; 339 340 user = lib.mkOption { 341 type = with lib.types; nullOr str; 342 default = null; 343 description = '' 344 Username to use when connecting to Elasticsearch, if 345 required. 346 ''; 347 }; 348 349 password = lib.mkOption { 350 type = with lib.types; nullOr (either path (attrsOf path)); 351 default = null; 352 description = '' 353 The password to use when connecting to Elasticsearch, 354 if required. 355 356 Always handled as a secret whether the value is 357 wrapped in a `{ _secret = ...; }` 358 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 359 details). 360 ''; 361 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 362 }; 363 364 ssl = lib.mkOption { 365 type = lib.types.bool; 366 default = false; 367 description = '' 368 Whether to use an encrypted SSL/TLS connection. 369 ''; 370 }; 371 372 cert_path = lib.mkOption { 373 type = lib.types.path; 374 default = config.security.pki.caBundle; 375 defaultText = lib.literalExpression "config.security.pki.caBundle"; 376 description = '' 377 The path to a TLS certificate bundle used to verify 378 the server's certificate. 379 ''; 380 }; 381 }; 382 }; 383 384 }; 385 }; 386 387 }; 388 389 config = lib.mkIf cfg.enable { 390 391 warnings = 392 let 393 deprecationWarning = 394 optname: 395 "Starting in 8.0.0, the `${optname}` option has been moved from the `services.parsedmarc.settings.imap`" 396 + "configuration section to the `services.parsedmarc.settings.mailbox` configuration section."; 397 hasImapOpt = lib.flip builtins.hasAttr cfg.settings.imap; 398 movedOptions = [ 399 "reports_folder" 400 "archive_folder" 401 "watch" 402 "delete" 403 "test" 404 "batch_size" 405 ]; 406 in 407 builtins.map deprecationWarning (builtins.filter hasImapOpt movedOptions); 408 409 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch; 410 411 services.geoipupdate = lib.mkIf cfg.provision.geoIp { 412 enable = true; 413 settings = { 414 EditionIDs = [ 415 "GeoLite2-ASN" 416 "GeoLite2-City" 417 "GeoLite2-Country" 418 ]; 419 DatabaseDirectory = "/var/lib/GeoIP"; 420 }; 421 }; 422 423 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable { 424 enable = true; 425 protocols = [ "imap" ]; 426 }; 427 428 services.postfix = lib.mkIf cfg.provision.localMail.enable { 429 enable = true; 430 origin = cfg.provision.localMail.hostname; 431 config = { 432 myhostname = cfg.provision.localMail.hostname; 433 mydestination = cfg.provision.localMail.hostname; 434 }; 435 }; 436 437 services.grafana = { 438 declarativePlugins = 439 with pkgs.grafanaPlugins; 440 lib.mkIf cfg.provision.grafana.dashboard [ 441 grafana-worldmap-panel 442 grafana-piechart-panel 443 ]; 444 445 provision = { 446 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard; 447 datasources.settings.datasources = 448 let 449 esVersion = lib.getVersion config.services.elasticsearch.package; 450 in 451 lib.mkIf cfg.provision.grafana.datasource [ 452 { 453 name = "dmarc-ag"; 454 type = "elasticsearch"; 455 access = "proxy"; 456 url = "http://localhost:9200"; 457 jsonData = { 458 timeField = "date_range"; 459 inherit esVersion; 460 }; 461 } 462 { 463 name = "dmarc-fo"; 464 type = "elasticsearch"; 465 access = "proxy"; 466 url = "http://localhost:9200"; 467 jsonData = { 468 timeField = "date_range"; 469 inherit esVersion; 470 }; 471 } 472 ]; 473 dashboards.settings.providers = lib.mkIf cfg.provision.grafana.dashboard [ 474 { 475 name = "parsedmarc"; 476 options.path = "${pkgs.parsedmarc.dashboard}"; 477 } 478 ]; 479 }; 480 }; 481 482 services.parsedmarc.settings = lib.mkMerge [ 483 (lib.mkIf cfg.provision.elasticsearch { 484 elasticsearch = { 485 hosts = [ "http://localhost:9200" ]; 486 ssl = false; 487 }; 488 }) 489 (lib.mkIf cfg.provision.localMail.enable { 490 imap = { 491 host = "localhost"; 492 port = 143; 493 ssl = false; 494 user = cfg.provision.localMail.recipientName; 495 password = "${pkgs.writeText "imap-password" "@imap-password@"}"; 496 }; 497 mailbox = { 498 watch = true; 499 }; 500 }) 501 ]; 502 503 systemd.services.parsedmarc = 504 let 505 # Remove any empty attributes from the config, i.e. empty 506 # lists, empty attrsets and null. This makes it possible to 507 # list interesting options in `settings` without them always 508 # ending up in the resulting config. 509 filteredConfig = lib.converge (lib.filterAttrsRecursive ( 510 _: v: 511 !elem v [ 512 null 513 [ ] 514 { } 515 ] 516 )) cfg.settings; 517 518 # Extract secrets (attributes set to an attrset with a 519 # "_secret" key) from the settings and generate the commands 520 # to run to perform the secret replacements. 521 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig); 522 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig; 523 mkSecretReplacement = file: '' 524 replace-secret ${ 525 lib.escapeShellArgs [ 526 (hashString "sha256" file) 527 file 528 "/run/parsedmarc/parsedmarc.ini" 529 ] 530 } 531 ''; 532 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 533 in 534 { 535 wantedBy = [ "multi-user.target" ]; 536 after = [ 537 "postfix.service" 538 "dovecot2.service" 539 "elasticsearch.service" 540 ]; 541 path = with pkgs; [ 542 replace-secret 543 openssl 544 shadow 545 ]; 546 serviceConfig = { 547 ExecStartPre = 548 let 549 startPreFullPrivileges = 550 '' 551 set -o errexit -o pipefail -o nounset -o errtrace 552 shopt -s inherit_errexit 553 554 umask u=rwx,g=,o= 555 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini 556 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini 557 ${secretReplacements} 558 '' 559 + lib.optionalString cfg.provision.localMail.enable '' 560 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd 561 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini 562 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'." 563 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd 564 ''; 565 in 566 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}"; 567 Type = "simple"; 568 User = "parsedmarc"; 569 Group = "parsedmarc"; 570 DynamicUser = true; 571 RuntimeDirectory = "parsedmarc"; 572 RuntimeDirectoryMode = "0700"; 573 CapabilityBoundingSet = ""; 574 PrivateDevices = true; 575 PrivateMounts = true; 576 PrivateUsers = true; 577 ProtectClock = true; 578 ProtectControlGroups = true; 579 ProtectHome = true; 580 ProtectHostname = true; 581 ProtectKernelLogs = true; 582 ProtectKernelModules = true; 583 ProtectKernelTunables = true; 584 ProtectProc = "invisible"; 585 ProcSubset = "pid"; 586 SystemCallFilter = [ 587 "@system-service" 588 "~@privileged" 589 "~@resources" 590 ]; 591 RestrictAddressFamilies = [ 592 "AF_UNIX" 593 "AF_INET" 594 "AF_INET6" 595 ]; 596 RestrictRealtime = true; 597 RestrictNamespaces = true; 598 MemoryDenyWriteExecute = true; 599 LockPersonality = true; 600 SystemCallArchitectures = "native"; 601 ExecStart = "${lib.getExe pkgs.parsedmarc} -c /run/parsedmarc/parsedmarc.ini"; 602 }; 603 }; 604 605 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable { 606 isNormalUser = true; 607 description = "DMARC mail recipient"; 608 }; 609 }; 610 611 meta.doc = ./parsedmarc.md; 612 meta.maintainers = [ lib.maintainers.talyz ]; 613}