From 83af4a9aed805366fc92a249eaf6d8e6617733d5 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 14 Jul 2025 00:58:45 +0200 Subject: [PATCH] nixos/postsrsd: migrate to rfc42 settings Allow a freeform configuration approach to satisfy different configuration complexities. Remove confinement options and make its hardening options more explicit and removed the deprecated PermissionStartOnly= option. --- nixos/modules/services/mail/postsrsd.nix | 221 ++++++++++++++++++----- 1 file changed, 173 insertions(+), 48 deletions(-) diff --git a/nixos/modules/services/mail/postsrsd.nix b/nixos/modules/services/mail/postsrsd.nix index bc91edbbe000..2ff28f96d97b 100644 --- a/nixos/modules/services/mail/postsrsd.nix +++ b/nixos/modules/services/mail/postsrsd.nix @@ -7,32 +7,59 @@ let cfg = config.services.postsrsd; - runtimeDirectoryName = "postsrsd"; - runtimeDirectory = "/run/${runtimeDirectoryName}"; - # TODO: follow RFC 42, but we need a libconfuse format first: - # https://github.com/NixOS/nixpkgs/issues/401565 - # Arrays in `libconfuse` look like this: {"Life", "Universe", "Everything"} - # See https://www.nongnu.org/confuse/tutorial-html/ar01s03.html. - # - # Note: We're using `builtins.toJSON` to escape strings, but JSON strings - # don't have exactly the same semantics as libconfuse strings. For example, - # "${F}" gets treated as an env var reference, see above issue for details. - libconfuseDomains = "{ " + lib.concatMapStringsSep ", " builtins.toJSON cfg.domains + " }"; - configFile = pkgs.writeText "postsrsd.conf" '' - secrets-file = "''${CREDENTIALS_DIRECTORY}/secrets-file" - domains = ${libconfuseDomains} - separator = "${cfg.separator}" - socketmap = "unix:${cfg.socketPath}" - # Disable postsrsd's jailing in favor of confinement with systemd. - unprivileged-user = "" - chroot-dir = "" - ''; + inherit (lib) + concatMapStringsSep + concatMapAttrsStringSep + isBool + isFloat + isInt + isPath + isString + isList + mkRemovedOptionModule + mkRenamedOptionModule + ; + # This is a implementation of a simple libconfuse config renderer sufficient + # for the postsrsd configuration file complexity. + # TODO: Replace with pkgs.formats.libconfuse, once implemented (https://github.com/NixOS/nixpkgs/issues/401565) + renderValue = + value: + if isBool value then + if value then "true" else "false" + else if isString value || isPath value then + builtins.toJSON value # for escaping + else if isInt value || isFloat value then + toString value + else if isList value then + "{${concatMapStringsSep "," renderValue value}}" + else + throw "postsrsd: unsupported value type in settings option"; + + renderAttr = + attrs: concatMapAttrsStringSep "\n" (name: value: "${name} = ${renderValue value}") attrs; + + configFile = pkgs.writeText "postsrsd.conf" ( + renderAttr (lib.filterAttrsRecursive (_: v: v != null) cfg.settings) + ); in { imports = - map + [ + (mkRemovedOptionModule [ "services" "postsrsd" "socketPath" ] '' + Configure/reference `services.postsrsd.settings.socketmap` instead. Note that its now required to start with the `inet:` or `unix:` prefix. + '') + (mkRenamedOptionModule + [ "services" "postsrsd" "domains" ] + [ "services" "postsrsd" "settings" "domains" ] + ) + (mkRenamedOptionModule + [ "services" "postsrsd" "separator" ] + [ "services" "postsrsd" "settings" "separator" ] + ) + ] + ++ map ( name: lib.mkRemovedOptionModule [ "services" "postsrsd" name ] '' @@ -62,24 +89,125 @@ in secretsFile = lib.mkOption { type = lib.types.path; default = "/var/lib/postsrsd/postsrsd.secret"; - description = "Secret keys used for signing and verification"; + description = '' + Secret keys used for signing and verification. + + ::: {.note} + The secret will be generated, if it does not exist at the given path. + ::: + ''; }; - domains = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "Domain names for rewrite"; - default = [ config.networking.hostName ]; - defaultText = lib.literalExpression "[ config.networking.hostName ]"; - }; + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = + with lib.types; + attrsOf (oneOf [ + bool + float + int + path + str + (listOf str) + ]); - separator = lib.mkOption { - type = lib.types.enum [ - "-" - "=" - "+" - ]; - default = "="; - description = "First separator character in generated addresses"; + options = { + domains = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + example = [ "example.com" ]; + description = '' + List of local domains, that do not require rewriting. + ''; + }; + + secrets-file = lib.mkOption { + type = lib.types.str; + default = "\${CREDENTIALS_DIRECTORY}/secrets-file"; + readOnly = true; + description = '' + Path to the file containing the secret keys. + + ::: {.note} + Secrets are passed using `LoadCredential=` on the systemd unit, + so this options is read-only. + + Configure {option}`services.postsrsd.secretsFile` instead. + ''; + }; + + separator = lib.mkOption { + type = lib.types.enum [ + "-" + "=" + "+" + ]; + default = "="; + description = '' + SRS tag separator used in generated sender addresses. + + Unless you have a very good reason, you should leave this + setting at its default. + ''; + }; + + srs-domain = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + example = "srs.example.com"; + description = '' + Dedicated mail domain used for ephemeral SRS envelope addresses. + + Recommended to configure, when hosting multiple unrelated mail + domains (e.g. for different customers), to prevent privacy + issues. + + Set to `null` to not configure any `srs-domain`. + ''; + }; + + socketmap = lib.mkOption { + type = lib.types.strMatching "^(unix|inet):.+"; + default = "unix:/run/postsrsd/socket"; + example = "inet:localhost:10003"; + description = '' + Listener configuration in socket map format native to Postfix configuration. + ''; + }; + + chroot-dir = lib.mkOption { + type = lib.types.str; + default = ""; + readOnly = true; + description = '' + Path to chroot into at runtime as an additional layer of protection. + + ::: {.note} + We confine the runtime environment through systemd hardening instead, so this option is read-only. + ::: + ''; + }; + + unprivileged-user = lib.mkOption { + type = lib.types.str; + default = ""; + readOnly = true; + description = '' + Unprivileged user to drop privileges to. + + ::: {.note} + Our systemd unit never runs postsrsd as a privileged process, so this option is read-only. + ::: + ''; + }; + }; + }; + default = { }; + description = '' + Configuration options for the postsrsd.conf file. + + See the [example configuration](https://github.com/roehling/postsrsd/blob/main/doc/postsrsd.conf) for possible values. + ''; }; user = lib.mkOption { @@ -93,15 +221,6 @@ in default = "postsrsd"; description = "Group for the daemon"; }; - - socketPath = lib.mkOption { - type = lib.types.path; - default = "${runtimeDirectory}/socket"; - readOnly = true; - description = '' - Path to the Unix socket for connecting to postsrsd. - Read-only, intended for usage when integrating postsrsd into other NixOS config.''; - }; }; }; @@ -143,15 +262,21 @@ in before = [ "postfix.service" ]; wantedBy = [ "multi-user.target" ]; requires = [ "postsrsd-generate-secrets.service" ]; - confinement.enable = true; serviceConfig = { ExecStart = "${lib.getExe pkgs.postsrsd} -C ${configFile}"; User = cfg.user; Group = cfg.group; - PermissionsStartOnly = true; - RuntimeDirectory = runtimeDirectoryName; + RuntimeDirectory = "postsrsd"; LoadCredential = "secrets-file:${cfg.secretsFile}"; + + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; }; }; };