From 84af416af6265f9f5a8ac8ebded834b493b61fc2 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Sun, 10 Nov 2024 19:32:40 +0000 Subject: [PATCH] nixos/acme: Refactor setup process Over time, we added a lot of setup services to the ACME module, namely: - acme-selfsigned-ca.service: Creates the selfsigned CA certificates used to generate selfsigned certs for each configured cert. - acme-fixperms.service: Ensures permissions correctness on certs after system configuration changes. - acme-lockfiles.service: Create lockfiles used to implement maxConcurrentRenewals. These numerous setup services complicated the dependency chain for any cert renewal, and also made it difficult to track responsibility for specific setup steps, for example, creating /var/lib/acme or setting permissions of shared folders. This change proposes a new acme-setup.service which encapsulates the functionality of the previous 3 services into one. The service is still defined in 3 separate chunks (using lib.mkMerge) which allows us to keep a logical separation between each step and preserve some optionality in the features. The result is a generally simplified definition of systemd unit dependencies and an obvious entrypoint for future setup extensions. --- nixos/modules/security/acme/default.nix | 141 +++++++++++------------- 1 file changed, 65 insertions(+), 76 deletions(-) diff --git a/nixos/modules/security/acme/default.nix b/nixos/modules/security/acme/default.nix index 4cdce9eec9cb..bd3cbf279ea3 100644 --- a/nixos/modules/security/acme/default.nix +++ b/nixos/modules/security/acme/default.nix @@ -15,6 +15,9 @@ let mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}"; accountDirRoot = "/var/lib/acme/.lego/accounts/"; + # Lockdir is acme-setup.service's RuntimeDirectory. + # Since that service is a oneshot with RemainAfterExit, + # the folder will exist during all renewal services. lockdir = "/run/acme/"; concurrencyLockfiles = map (n: "${toString n}.lock") (lib.range 1 cfg.maxConcurrentRenewals); # Assign elements of `baseList` to each element of `needAssignmentList`, until the latter is exhausted. @@ -104,38 +107,16 @@ let ]; }; - # In order to avoid race conditions creating the CA for selfsigned certs, - # we have a separate service which will create the necessary files. - selfsignCAService = { - description = "Generate self-signed certificate authority"; - - path = with pkgs; [ minica ]; - - unitConfig = { - ConditionPathExists = "!/var/lib/acme/.minica/key.pem"; - StartLimitIntervalSec = 0; - }; - - serviceConfig = commonServiceConfig // { - StateDirectory = "acme/.minica"; - BindPaths = "/var/lib/acme/.minica:/tmp/ca"; - UMask = "0077"; - }; - - # Working directory will be /tmp - script = '' - minica \ - --ca-key ca/key.pem \ - --ca-cert ca/cert.pem \ - --domains selfsigned.local - ''; - }; - # Ensures that directories which are shared across all certs # exist and have the correct user and group, since group # is configurable on a per-cert basis. - userMigrationService = let - script = with builtins; '' + # writeShellScriptBin is used as it produces a nicer binary name, which + # journalctl will show when the service is running. + privilegedSetupScript = pkgs.writeShellScriptBin "acme-setup-privileged" ( + '' + ${lib.optionalString cfg.defaults.enableDebugLogs "set -x"} + set -euo pipefail + cd /var/lib/acme chown -R ${user} .lego/accounts '' + (lib.concatStringsSep "\n" (lib.mapAttrsToList (cert: data: '' for fixpath in ${lib.escapeShellArg cert} .lego/${lib.escapeShellArg cert}; do @@ -144,43 +125,59 @@ let chown -R ${user}:${data.group} "$fixpath" fi done - '') certConfigs)); - in { - description = "Fix owner and group of all ACME certificates"; + '') certConfigs)) + ); - serviceConfig = commonServiceConfig // { - # We don't want this to run every time a renewal happens - RemainAfterExit = true; + # This is defined with lib.mkMerge so that we can separate the config per function. + setupService = lib.mkMerge [ + { + description = "Set up the ACME certificate renewal infrastructure"; + script = lib.mkBefore '' + ${lib.optionalString cfg.defaults.enableDebugLogs "set -x"} + set -euo pipefail + ''; + serviceConfig = commonServiceConfig // { + # This script runs with elevated privileges, denoted by the + + # ExecStartPre is used instead of ExecStart so that the `script` continues to work. + ExecStartPre = "+${lib.getExe privilegedSetupScript}"; - # StateDirectory entries are a cleaner, service-level mechanism - # for dealing with persistent service data - StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ]; - StateDirectoryMode = 755; - WorkingDirectory = "/var/lib/acme"; + # We don't want this to run every time a renewal happens + RemainAfterExit = true; - # Run the start script as root - ExecStart = "+" + (pkgs.writeShellScript "acme-fixperms" script); - }; - }; - lockfilePrepareService = { - description = "Manage lock files for acme services"; + # StateDirectory entries are a cleaner, service-level mechanism + # for dealing with persistent service data + StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ]; + StateDirectoryMode = "0755"; - # ensure all required lock files exist, but none more - script = '' - GLOBIGNORE="${lib.concatStringsSep ":" concurrencyLockfiles}" - rm -f -- * - unset GLOBIGNORE + # Creates ${lockdir}. Earlier RemainAfterExit=true means + # it does not get deleted immediately. + RuntimeDirectory = "acme"; + RuntimeDirectoryMode = "0700"; - xargs touch <<< "${toString concurrencyLockfiles}" - ''; - - serviceConfig = commonServiceConfig // { - # We don't want this to run every time a renewal happens - RemainAfterExit = true; - WorkingDirectory = lockdir; - }; - }; + # Generally, we don't write anything that should be group accessible. + # Group varies for most ACME units, and setup files are only used + # under the acme user. + UMask = "0077"; + }; + } + # In order to avoid race conditions creating the CA for selfsigned certs, + # we have a separate service which will create the necessary files. + (lib.mkIf cfg.preliminarySelfsigned { + path = [ pkgs.minica ]; + # Working directory will be /tmp + script = '' + test -e ca/key.pem || minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains selfsigned.local + ''; + serviceConfig = { + StateDirectory = [ "acme/.minica" ]; + BindPaths = "/var/lib/acme/.minica:/tmp/ca"; + }; + }) + ]; certToConfig = cert: data: let acmeServer = data.server; @@ -286,10 +283,10 @@ let selfsignService = lockfileName: { description = "Generate self-signed certificate for ${cert}"; - after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ] ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; - requires = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ] ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; + after = [ "acme-setup.service" ]; + requires = [ "acme-setup.service" ]; - path = with pkgs; [ minica ]; + path = [ pkgs.minica ]; unitConfig = { ConditionPathExists = "!/var/lib/acme/${cert}/key.pem"; @@ -334,8 +331,9 @@ let renewService = lockfileName: { description = "Renew ACME certificate for ${cert}"; - after = [ "network.target" "network-online.target" "acme-fixperms.service" "nss-lookup.target" ] ++ selfsignedDeps ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; - wants = [ "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; + after = [ "network.target" "network-online.target" "acme-setup.service" "nss-lookup.target" ] ++ selfsignedDeps; + wants = [ "network-online.target" ] ++ selfsignedDeps; + requires = [ "acme-setup.service" ]; # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 wantedBy = lib.optionals (!config.boot.isContainer) [ "multi-user.target" ]; @@ -974,12 +972,6 @@ in { users.groups.acme = {}; - # for lock files, still use tmpfiles as they should better reside in /run - systemd.tmpfiles.rules = [ - "d ${lockdir} 0700 ${user} - - -" - "Z ${lockdir} 0700 ${user} - - -" - ]; - systemd.services = let renewServiceFunctions = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewService) certConfigs; renewServices = if cfg.maxConcurrentRenewals > 0 @@ -990,12 +982,9 @@ in { then roundRobinApplyAttrs selfsignServiceFunctions concurrencyLockfiles else lib.mapAttrs (_: f: f null) selfsignServiceFunctions; in - { "acme-fixperms" = userMigrationService; } - // (lib.optionalAttrs (cfg.maxConcurrentRenewals > 0) {"acme-lockfiles" = lockfilePrepareService; }) + { acme-setup = setupService; } // renewServices - // (lib.optionalAttrs (cfg.preliminarySelfsigned) ({ - "acme-selfsigned-ca" = selfsignCAService; - } // selfsignServices)); + // lib.optionalAttrs cfg.preliminarySelfsigned selfsignServices; systemd.timers = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;