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.
This commit is contained in:
Lucas Savva 2024-11-10 19:32:40 +00:00
parent 73cf49b8ad
commit 84af416af6

View File

@ -15,6 +15,9 @@ let
mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}"; mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}";
accountDirRoot = "/var/lib/acme/.lego/accounts/"; 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/"; lockdir = "/run/acme/";
concurrencyLockfiles = map (n: "${toString n}.lock") (lib.range 1 cfg.maxConcurrentRenewals); 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. # 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 # Ensures that directories which are shared across all certs
# exist and have the correct user and group, since group # exist and have the correct user and group, since group
# is configurable on a per-cert basis. # is configurable on a per-cert basis.
userMigrationService = let # writeShellScriptBin is used as it produces a nicer binary name, which
script = with builtins; '' # 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 chown -R ${user} .lego/accounts
'' + (lib.concatStringsSep "\n" (lib.mapAttrsToList (cert: data: '' '' + (lib.concatStringsSep "\n" (lib.mapAttrsToList (cert: data: ''
for fixpath in ${lib.escapeShellArg cert} .lego/${lib.escapeShellArg cert}; do for fixpath in ${lib.escapeShellArg cert} .lego/${lib.escapeShellArg cert}; do
@ -144,43 +125,59 @@ let
chown -R ${user}:${data.group} "$fixpath" chown -R ${user}:${data.group} "$fixpath"
fi fi
done done
'') certConfigs)); '') certConfigs))
in { );
description = "Fix owner and group of all ACME certificates";
# 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 // { 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}";
# We don't want this to run every time a renewal happens # We don't want this to run every time a renewal happens
RemainAfterExit = true; RemainAfterExit = true;
# StateDirectory entries are a cleaner, service-level mechanism # StateDirectory entries are a cleaner, service-level mechanism
# for dealing with persistent service data # for dealing with persistent service data
StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ]; StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ];
StateDirectoryMode = 755; StateDirectoryMode = "0755";
WorkingDirectory = "/var/lib/acme";
# Run the start script as root # Creates ${lockdir}. Earlier RemainAfterExit=true means
ExecStart = "+" + (pkgs.writeShellScript "acme-fixperms" script); # it does not get deleted immediately.
}; RuntimeDirectory = "acme";
}; RuntimeDirectoryMode = "0700";
lockfilePrepareService = {
description = "Manage lock files for acme services";
# ensure all required lock files exist, but none more # 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 = '' script = ''
GLOBIGNORE="${lib.concatStringsSep ":" concurrencyLockfiles}" test -e ca/key.pem || minica \
rm -f -- * --ca-key ca/key.pem \
unset GLOBIGNORE --ca-cert ca/cert.pem \
--domains selfsigned.local
xargs touch <<< "${toString concurrencyLockfiles}"
''; '';
serviceConfig = {
serviceConfig = commonServiceConfig // { StateDirectory = [ "acme/.minica" ];
# We don't want this to run every time a renewal happens BindPaths = "/var/lib/acme/.minica:/tmp/ca";
RemainAfterExit = true;
WorkingDirectory = lockdir;
}; };
}; })
];
certToConfig = cert: data: let certToConfig = cert: data: let
acmeServer = data.server; acmeServer = data.server;
@ -286,10 +283,10 @@ let
selfsignService = lockfileName: { selfsignService = lockfileName: {
description = "Generate self-signed certificate for ${cert}"; description = "Generate self-signed certificate for ${cert}";
after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ] ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; after = [ "acme-setup.service" ];
requires = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ] ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; requires = [ "acme-setup.service" ];
path = with pkgs; [ minica ]; path = [ pkgs.minica ];
unitConfig = { unitConfig = {
ConditionPathExists = "!/var/lib/acme/${cert}/key.pem"; ConditionPathExists = "!/var/lib/acme/${cert}/key.pem";
@ -334,8 +331,9 @@ let
renewService = lockfileName: { renewService = lockfileName: {
description = "Renew ACME certificate for ${cert}"; 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"; after = [ "network.target" "network-online.target" "acme-setup.service" "nss-lookup.target" ] ++ selfsignedDeps;
wants = [ "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps ++ lib.optional (cfg.maxConcurrentRenewals > 0) "acme-lockfiles.service"; wants = [ "network-online.target" ] ++ selfsignedDeps;
requires = [ "acme-setup.service" ];
# https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099
wantedBy = lib.optionals (!config.boot.isContainer) [ "multi-user.target" ]; wantedBy = lib.optionals (!config.boot.isContainer) [ "multi-user.target" ];
@ -974,12 +972,6 @@ in {
users.groups.acme = {}; 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 systemd.services = let
renewServiceFunctions = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewService) certConfigs; renewServiceFunctions = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewService) certConfigs;
renewServices = if cfg.maxConcurrentRenewals > 0 renewServices = if cfg.maxConcurrentRenewals > 0
@ -990,12 +982,9 @@ in {
then roundRobinApplyAttrs selfsignServiceFunctions concurrencyLockfiles then roundRobinApplyAttrs selfsignServiceFunctions concurrencyLockfiles
else lib.mapAttrs (_: f: f null) selfsignServiceFunctions; else lib.mapAttrs (_: f: f null) selfsignServiceFunctions;
in in
{ "acme-fixperms" = userMigrationService; } { acme-setup = setupService; }
// (lib.optionalAttrs (cfg.maxConcurrentRenewals > 0) {"acme-lockfiles" = lockfilePrepareService; })
// renewServices // renewServices
// (lib.optionalAttrs (cfg.preliminarySelfsigned) ({ // lib.optionalAttrs cfg.preliminarySelfsigned selfsignServices;
"acme-selfsigned-ca" = selfsignCAService;
} // selfsignServices));
systemd.timers = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewTimer) certConfigs; systemd.timers = lib.mapAttrs' (cert: conf: lib.nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;