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