at 24.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 '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 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 = '' 207 The IMAP server port. 208 ''; 209 }; 210 211 ssl = lib.mkOption { 212 type = lib.types.bool; 213 default = true; 214 description = '' 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 = '' 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 = '' 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 = '' 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 = '' 255 The SMTP server port. 256 ''; 257 }; 258 259 ssl = lib.mkOption { 260 type = with lib.types; nullOr bool; 261 default = null; 262 description = '' 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 = '' 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 = '' 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 = '' 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 = '' 302 The addresses to send outgoing mail to. 303 ''; 304 apply = x: if x == [] || x == null then null else lib.concatStringsSep "," x; 305 }; 306 }; 307 308 elasticsearch = { 309 hosts = lib.mkOption { 310 default = []; 311 type = with lib.types; listOf str; 312 apply = x: if x == [] then null else lib.concatStringsSep "," x; 313 description = '' 314 A list of Elasticsearch hosts to push parsed reports 315 to. 316 ''; 317 }; 318 319 user = lib.mkOption { 320 type = with lib.types; nullOr str; 321 default = null; 322 description = '' 323 Username to use when connecting to Elasticsearch, if 324 required. 325 ''; 326 }; 327 328 password = lib.mkOption { 329 type = with lib.types; nullOr (either path (attrsOf path)); 330 default = null; 331 description = '' 332 The password to use when connecting to Elasticsearch, 333 if required. 334 335 Always handled as a secret whether the value is 336 wrapped in a `{ _secret = ...; }` 337 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 338 details). 339 ''; 340 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 341 }; 342 343 ssl = lib.mkOption { 344 type = lib.types.bool; 345 default = false; 346 description = '' 347 Whether to use an encrypted SSL/TLS connection. 348 ''; 349 }; 350 351 cert_path = lib.mkOption { 352 type = lib.types.path; 353 default = "/etc/ssl/certs/ca-certificates.crt"; 354 description = '' 355 The path to a TLS certificate bundle used to verify 356 the server's certificate. 357 ''; 358 }; 359 }; 360 }; 361 362 }; 363 }; 364 365 }; 366 367 config = lib.mkIf cfg.enable { 368 369 warnings = let 370 deprecationWarning = optname: "Starting in 8.0.0, the `${optname}` option has been moved from the `services.parsedmarc.settings.imap`" 371 + "configuration section to the `services.parsedmarc.settings.mailbox` configuration section."; 372 hasImapOpt = lib.flip builtins.hasAttr cfg.settings.imap; 373 movedOptions = [ "reports_folder" "archive_folder" "watch" "delete" "test" "batch_size" ]; 374 in builtins.map deprecationWarning (builtins.filter hasImapOpt movedOptions); 375 376 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch; 377 378 services.geoipupdate = lib.mkIf cfg.provision.geoIp { 379 enable = true; 380 settings = { 381 EditionIDs = [ 382 "GeoLite2-ASN" 383 "GeoLite2-City" 384 "GeoLite2-Country" 385 ]; 386 DatabaseDirectory = "/var/lib/GeoIP"; 387 }; 388 }; 389 390 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable { 391 enable = true; 392 protocols = [ "imap" ]; 393 }; 394 395 services.postfix = lib.mkIf cfg.provision.localMail.enable { 396 enable = true; 397 origin = cfg.provision.localMail.hostname; 398 config = { 399 myhostname = cfg.provision.localMail.hostname; 400 mydestination = cfg.provision.localMail.hostname; 401 }; 402 }; 403 404 services.grafana = { 405 declarativePlugins = with pkgs.grafanaPlugins; 406 lib.mkIf cfg.provision.grafana.dashboard [ 407 grafana-worldmap-panel 408 grafana-piechart-panel 409 ]; 410 411 provision = { 412 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard; 413 datasources.settings.datasources = 414 let 415 esVersion = lib.getVersion config.services.elasticsearch.package; 416 in 417 lib.mkIf cfg.provision.grafana.datasource [ 418 { 419 name = "dmarc-ag"; 420 type = "elasticsearch"; 421 access = "proxy"; 422 url = "http://localhost:9200"; 423 jsonData = { 424 timeField = "date_range"; 425 inherit esVersion; 426 }; 427 } 428 { 429 name = "dmarc-fo"; 430 type = "elasticsearch"; 431 access = "proxy"; 432 url = "http://localhost:9200"; 433 jsonData = { 434 timeField = "date_range"; 435 inherit esVersion; 436 }; 437 } 438 ]; 439 dashboards.settings.providers = lib.mkIf cfg.provision.grafana.dashboard [{ 440 name = "parsedmarc"; 441 options.path = "${pkgs.parsedmarc.dashboard}"; 442 }]; 443 }; 444 }; 445 446 services.parsedmarc.settings = lib.mkMerge [ 447 (lib.mkIf cfg.provision.elasticsearch { 448 elasticsearch = { 449 hosts = [ "http://localhost:9200" ]; 450 ssl = false; 451 }; 452 }) 453 (lib.mkIf cfg.provision.localMail.enable { 454 imap = { 455 host = "localhost"; 456 port = 143; 457 ssl = false; 458 user = cfg.provision.localMail.recipientName; 459 password = "${pkgs.writeText "imap-password" "@imap-password@"}"; 460 }; 461 mailbox = { 462 watch = true; 463 }; 464 }) 465 ]; 466 467 systemd.services.parsedmarc = 468 let 469 # Remove any empty attributes from the config, i.e. empty 470 # lists, empty attrsets and null. This makes it possible to 471 # list interesting options in `settings` without them always 472 # ending up in the resulting config. 473 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings; 474 475 # Extract secrets (attributes set to an attrset with a 476 # "_secret" key) from the settings and generate the commands 477 # to run to perform the secret replacements. 478 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig); 479 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig; 480 mkSecretReplacement = file: '' 481 replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]} 482 ''; 483 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 484 in 485 { 486 wantedBy = [ "multi-user.target" ]; 487 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ]; 488 path = with pkgs; [ replace-secret openssl shadow ]; 489 serviceConfig = { 490 ExecStartPre = let 491 startPreFullPrivileges = '' 492 set -o errexit -o pipefail -o nounset -o errtrace 493 shopt -s inherit_errexit 494 495 umask u=rwx,g=,o= 496 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini 497 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini 498 ${secretReplacements} 499 '' + lib.optionalString cfg.provision.localMail.enable '' 500 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd 501 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini 502 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'." 503 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd 504 ''; 505 in 506 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}"; 507 Type = "simple"; 508 User = "parsedmarc"; 509 Group = "parsedmarc"; 510 DynamicUser = true; 511 RuntimeDirectory = "parsedmarc"; 512 RuntimeDirectoryMode = "0700"; 513 CapabilityBoundingSet = ""; 514 PrivateDevices = true; 515 PrivateMounts = true; 516 PrivateUsers = true; 517 ProtectClock = true; 518 ProtectControlGroups = true; 519 ProtectHome = true; 520 ProtectHostname = true; 521 ProtectKernelLogs = true; 522 ProtectKernelModules = true; 523 ProtectKernelTunables = true; 524 ProtectProc = "invisible"; 525 ProcSubset = "pid"; 526 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; 527 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; 528 RestrictRealtime = true; 529 RestrictNamespaces = true; 530 MemoryDenyWriteExecute = true; 531 LockPersonality = true; 532 SystemCallArchitectures = "native"; 533 ExecStart = "${lib.getExe pkgs.parsedmarc} -c /run/parsedmarc/parsedmarc.ini"; 534 }; 535 }; 536 537 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable { 538 isNormalUser = true; 539 description = "DMARC mail recipient"; 540 }; 541 }; 542 543 meta.doc = ./parsedmarc.md; 544 meta.maintainers = [ lib.maintainers.talyz ]; 545}