at 23.05-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 watch = true; 127 }; 128 splunk_hec = { 129 url = "https://splunkhec.example.com"; 130 token = { _secret = "/run/keys/splunk_token" }; 131 index = "email"; 132 }; 133 } 134 ''; 135 description = lib.mdDoc '' 136 Configuration parameters to set in 137 {file}`parsedmarc.ini`. For a full list of 138 available parameters, see 139 <https://domainaware.github.io/parsedmarc/#configuration-file>. 140 141 Settings containing secret data should be set to an attribute 142 set containing the attribute `_secret` - a 143 string pointing to a file containing the value the option 144 should be set to. See the example to get a better picture of 145 this: in the resulting {file}`parsedmarc.ini` 146 file, the `splunk_hec.token` key will be set 147 to the contents of the 148 {file}`/run/keys/splunk_token` file. 149 ''; 150 151 type = lib.types.submodule { 152 freeformType = ini.type; 153 154 options = { 155 general = { 156 save_aggregate = lib.mkOption { 157 type = lib.types.bool; 158 default = true; 159 description = lib.mdDoc '' 160 Save aggregate report data to Elasticsearch and/or Splunk. 161 ''; 162 }; 163 164 save_forensic = lib.mkOption { 165 type = lib.types.bool; 166 default = true; 167 description = lib.mdDoc '' 168 Save forensic report data to Elasticsearch and/or Splunk. 169 ''; 170 }; 171 }; 172 173 imap = { 174 host = lib.mkOption { 175 type = lib.types.str; 176 default = "localhost"; 177 description = lib.mdDoc '' 178 The IMAP server hostname or IP address. 179 ''; 180 }; 181 182 port = lib.mkOption { 183 type = lib.types.port; 184 default = 993; 185 description = lib.mdDoc '' 186 The IMAP server port. 187 ''; 188 }; 189 190 ssl = lib.mkOption { 191 type = lib.types.bool; 192 default = true; 193 description = lib.mdDoc '' 194 Use an encrypted SSL/TLS connection. 195 ''; 196 }; 197 198 user = lib.mkOption { 199 type = with lib.types; nullOr str; 200 default = null; 201 description = lib.mdDoc '' 202 The IMAP server username. 203 ''; 204 }; 205 206 password = lib.mkOption { 207 type = with lib.types; nullOr (either path (attrsOf path)); 208 default = null; 209 description = lib.mdDoc '' 210 The IMAP server password. 211 212 Always handled as a secret whether the value is 213 wrapped in a `{ _secret = ...; }` 214 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 215 details). 216 ''; 217 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 218 }; 219 220 watch = lib.mkOption { 221 type = lib.types.bool; 222 default = true; 223 description = lib.mdDoc '' 224 Use the IMAP IDLE command to process messages as they arrive. 225 ''; 226 }; 227 228 delete = lib.mkOption { 229 type = lib.types.bool; 230 default = false; 231 description = lib.mdDoc '' 232 Delete messages after processing them, instead of archiving them. 233 ''; 234 }; 235 }; 236 237 smtp = { 238 host = lib.mkOption { 239 type = with lib.types; nullOr str; 240 default = null; 241 description = lib.mdDoc '' 242 The SMTP server hostname or IP address. 243 ''; 244 }; 245 246 port = lib.mkOption { 247 type = with lib.types; nullOr port; 248 default = null; 249 description = lib.mdDoc '' 250 The SMTP server port. 251 ''; 252 }; 253 254 ssl = lib.mkOption { 255 type = with lib.types; nullOr bool; 256 default = null; 257 description = lib.mdDoc '' 258 Use an encrypted SSL/TLS connection. 259 ''; 260 }; 261 262 user = lib.mkOption { 263 type = with lib.types; nullOr str; 264 default = null; 265 description = lib.mdDoc '' 266 The SMTP server username. 267 ''; 268 }; 269 270 password = lib.mkOption { 271 type = with lib.types; nullOr (either path (attrsOf path)); 272 default = null; 273 description = lib.mdDoc '' 274 The SMTP server password. 275 276 Always handled as a secret whether the value is 277 wrapped in a `{ _secret = ...; }` 278 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 279 details). 280 ''; 281 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 282 }; 283 284 from = lib.mkOption { 285 type = with lib.types; nullOr str; 286 default = null; 287 description = lib.mdDoc '' 288 The `From` address to use for the 289 outgoing mail. 290 ''; 291 }; 292 293 to = lib.mkOption { 294 type = with lib.types; nullOr (listOf str); 295 default = null; 296 description = lib.mdDoc '' 297 The addresses to send outgoing mail to. 298 ''; 299 }; 300 }; 301 302 elasticsearch = { 303 hosts = lib.mkOption { 304 default = []; 305 type = with lib.types; listOf str; 306 apply = x: if x == [] then null else lib.concatStringsSep "," x; 307 description = lib.mdDoc '' 308 A list of Elasticsearch hosts to push parsed reports 309 to. 310 ''; 311 }; 312 313 user = lib.mkOption { 314 type = with lib.types; nullOr str; 315 default = null; 316 description = lib.mdDoc '' 317 Username to use when connecting to Elasticsearch, if 318 required. 319 ''; 320 }; 321 322 password = lib.mkOption { 323 type = with lib.types; nullOr (either path (attrsOf path)); 324 default = null; 325 description = lib.mdDoc '' 326 The password to use when connecting to Elasticsearch, 327 if required. 328 329 Always handled as a secret whether the value is 330 wrapped in a `{ _secret = ...; }` 331 attrset or not (refer to [](#opt-services.parsedmarc.settings) for 332 details). 333 ''; 334 apply = x: if isAttrs x || x == null then x else { _secret = x; }; 335 }; 336 337 ssl = lib.mkOption { 338 type = lib.types.bool; 339 default = false; 340 description = lib.mdDoc '' 341 Whether to use an encrypted SSL/TLS connection. 342 ''; 343 }; 344 345 cert_path = lib.mkOption { 346 type = lib.types.path; 347 default = "/etc/ssl/certs/ca-certificates.crt"; 348 description = lib.mdDoc '' 349 The path to a TLS certificate bundle used to verify 350 the server's certificate. 351 ''; 352 }; 353 }; 354 }; 355 356 }; 357 }; 358 359 }; 360 361 config = lib.mkIf cfg.enable { 362 363 services.elasticsearch.enable = lib.mkDefault cfg.provision.elasticsearch; 364 365 services.geoipupdate = lib.mkIf cfg.provision.geoIp { 366 enable = true; 367 settings = { 368 EditionIDs = [ 369 "GeoLite2-ASN" 370 "GeoLite2-City" 371 "GeoLite2-Country" 372 ]; 373 DatabaseDirectory = "/var/lib/GeoIP"; 374 }; 375 }; 376 377 services.dovecot2 = lib.mkIf cfg.provision.localMail.enable { 378 enable = true; 379 protocols = [ "imap" ]; 380 }; 381 382 services.postfix = lib.mkIf cfg.provision.localMail.enable { 383 enable = true; 384 origin = cfg.provision.localMail.hostname; 385 config = { 386 myhostname = cfg.provision.localMail.hostname; 387 mydestination = cfg.provision.localMail.hostname; 388 }; 389 }; 390 391 services.grafana = { 392 declarativePlugins = with pkgs.grafanaPlugins; 393 lib.mkIf cfg.provision.grafana.dashboard [ 394 grafana-worldmap-panel 395 grafana-piechart-panel 396 ]; 397 398 provision = { 399 enable = cfg.provision.grafana.datasource || cfg.provision.grafana.dashboard; 400 datasources = 401 let 402 esVersion = lib.getVersion config.services.elasticsearch.package; 403 in 404 lib.mkIf cfg.provision.grafana.datasource [ 405 { 406 name = "dmarc-ag"; 407 type = "elasticsearch"; 408 access = "proxy"; 409 url = "http://localhost:9200"; 410 jsonData = { 411 timeField = "date_range"; 412 inherit esVersion; 413 }; 414 } 415 { 416 name = "dmarc-fo"; 417 type = "elasticsearch"; 418 access = "proxy"; 419 url = "http://localhost:9200"; 420 jsonData = { 421 timeField = "date_range"; 422 inherit esVersion; 423 }; 424 } 425 ]; 426 dashboards = lib.mkIf cfg.provision.grafana.dashboard [{ 427 name = "parsedmarc"; 428 options.path = "${pkgs.python3Packages.parsedmarc.dashboard}"; 429 }]; 430 }; 431 }; 432 433 services.parsedmarc.settings = lib.mkMerge [ 434 (lib.mkIf cfg.provision.elasticsearch { 435 elasticsearch = { 436 hosts = [ "localhost:9200" ]; 437 ssl = false; 438 }; 439 }) 440 (lib.mkIf cfg.provision.localMail.enable { 441 imap = { 442 host = "localhost"; 443 port = 143; 444 ssl = false; 445 user = cfg.provision.localMail.recipientName; 446 password = "${pkgs.writeText "imap-password" "@imap-password@"}"; 447 watch = true; 448 }; 449 }) 450 ]; 451 452 systemd.services.parsedmarc = 453 let 454 # Remove any empty attributes from the config, i.e. empty 455 # lists, empty attrsets and null. This makes it possible to 456 # list interesting options in `settings` without them always 457 # ending up in the resulting config. 458 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null [] {} ])) cfg.settings; 459 460 # Extract secrets (attributes set to an attrset with a 461 # "_secret" key) from the settings and generate the commands 462 # to run to perform the secret replacements. 463 secretPaths = lib.catAttrs "_secret" (lib.collect isSecret filteredConfig); 464 parsedmarcConfig = ini.generate "parsedmarc.ini" filteredConfig; 465 mkSecretReplacement = file: '' 466 replace-secret ${lib.escapeShellArgs [ (hashString "sha256" file) file "/run/parsedmarc/parsedmarc.ini" ]} 467 ''; 468 secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths; 469 in 470 { 471 wantedBy = [ "multi-user.target" ]; 472 after = [ "postfix.service" "dovecot2.service" "elasticsearch.service" ]; 473 path = with pkgs; [ replace-secret openssl shadow ]; 474 serviceConfig = { 475 ExecStartPre = let 476 startPreFullPrivileges = '' 477 set -o errexit -o pipefail -o nounset -o errtrace 478 shopt -s inherit_errexit 479 480 umask u=rwx,g=,o= 481 cp ${parsedmarcConfig} /run/parsedmarc/parsedmarc.ini 482 chown parsedmarc:parsedmarc /run/parsedmarc/parsedmarc.ini 483 ${secretReplacements} 484 '' + lib.optionalString cfg.provision.localMail.enable '' 485 openssl rand -hex 64 >/run/parsedmarc/dmarc_user_passwd 486 replace-secret '@imap-password@' '/run/parsedmarc/dmarc_user_passwd' /run/parsedmarc/parsedmarc.ini 487 echo "Setting new randomized password for user '${cfg.provision.localMail.recipientName}'." 488 cat <(echo -n "${cfg.provision.localMail.recipientName}:") /run/parsedmarc/dmarc_user_passwd | chpasswd 489 ''; 490 in 491 "+${pkgs.writeShellScript "parsedmarc-start-pre-full-privileges" startPreFullPrivileges}"; 492 Type = "simple"; 493 User = "parsedmarc"; 494 Group = "parsedmarc"; 495 DynamicUser = true; 496 RuntimeDirectory = "parsedmarc"; 497 RuntimeDirectoryMode = "0700"; 498 CapabilityBoundingSet = ""; 499 PrivateDevices = true; 500 PrivateMounts = true; 501 PrivateUsers = true; 502 ProtectClock = true; 503 ProtectControlGroups = true; 504 ProtectHome = true; 505 ProtectHostname = true; 506 ProtectKernelLogs = true; 507 ProtectKernelModules = true; 508 ProtectKernelTunables = true; 509 ProtectProc = "invisible"; 510 ProcSubset = "pid"; 511 SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; 512 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; 513 RestrictRealtime = true; 514 RestrictNamespaces = true; 515 MemoryDenyWriteExecute = true; 516 LockPersonality = true; 517 SystemCallArchitectures = "native"; 518 ExecStart = "${pkgs.python3Packages.parsedmarc}/bin/parsedmarc -c /run/parsedmarc/parsedmarc.ini"; 519 }; 520 }; 521 522 users.users.${cfg.provision.localMail.recipientName} = lib.mkIf cfg.provision.localMail.enable { 523 isNormalUser = true; 524 description = "DMARC mail recipient"; 525 }; 526 }; 527 528 # Don't edit the docbook xml directly, edit the md and generate it: 529 # `pandoc parsedmarc.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > parsedmarc.xml` 530 meta.doc = ./parsedmarc.xml; 531 meta.maintainers = [ lib.maintainers.talyz ]; 532}