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}";
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;