nixos/acme: improve scalability - reduce superfluous unit activations

The previous setup caused all renewal units to be triggered upon
ever so slight changes in config. In larger setups (100+ certificates)
adding a new certificate caused high system load and/or large memory
consumption issues. The memory issues are already a alleviated with
the locking mechanism. However, this then causes long delays upwards
of multiple minutes depending on individual runs and also caused
superfluous activations.

In this change we streamline the overall setup of units:

1. The unit that other services can depend upon is 'acme-{cert}.service'.
We call this the 'base unit'. As this one as `RemainAfterExit` set
the `acme-finished-{cert}` targets are not required any longer.

2. We now always generate initial self-signed certificates to simplify
the dependency structure. This deprecates the `preliminarySelfsigned`
option.

3. The `acme-order-renew-{cert}` service gets activated after the base
unit and services using certificates have started and performs all acme
interactions. When it finishes others services (like web servers) will
be notified through the `reloadServices` option or they can use
`wantedBy` and `after` dependencies if they implement their own reload
units.

The renewal timer also triggers this unit.

4. The timer unit is explicitly blocked from being started by s-t-c.

5. Permission management has been cleaned up a bit: there was an
   inconsistency between having the .lego files set to 600 vs 640
   on the exposed side. This is unified to 640 now.

6. Exempt the account target from being restarted by s-t-c. This will
   happen automatically if something relevant to the account changes.
This commit is contained in:
Christian Theune 2025-08-08 16:28:42 +02:00
parent dfe6a41c36
commit 2d0a489125
14 changed files with 382 additions and 278 deletions

View File

@ -318,7 +318,7 @@ can be applied to any service.
# Now you must augment OpenSMTPD's systemd service to load # Now you must augment OpenSMTPD's systemd service to load
# the certificate files. # the certificate files.
systemd.services.opensmtpd.requires = [ "acme-finished-mail.example.com.target" ]; systemd.services.opensmtpd.requires = [ "acme-mail.example.com.service" ];
systemd.services.opensmtpd.serviceConfig.LoadCredential = systemd.services.opensmtpd.serviceConfig.LoadCredential =
let let
certDir = config.security.acme.certs."mail.example.com".directory; certDir = config.security.acme.certs."mail.example.com".directory;

View File

@ -160,13 +160,19 @@ let
); );
# This is defined with lib.mkMerge so that we can separate the config per function. # This is defined with lib.mkMerge so that we can separate the config per function.
setupService = lib.mkMerge [ setupService = {
{
description = "Set up the ACME certificate renewal infrastructure"; description = "Set up the ACME certificate renewal infrastructure";
path = [ pkgs.minica ];
script = lib.mkBefore '' script = lib.mkBefore ''
${lib.optionalString cfg.defaults.enableDebugLogs "set -x"} ${lib.optionalString cfg.defaults.enableDebugLogs "set -x"}
set -euo pipefail set -euo pipefail
test -e ca/key.pem || minica \
--ca-key ca/key.pem \
--ca-cert ca/cert.pem \
--domains selfsigned.local
''; '';
serviceConfig = commonServiceConfig // { serviceConfig = commonServiceConfig // {
# This script runs with elevated privileges, denoted by the + # This script runs with elevated privileges, denoted by the +
# ExecStartPre is used instead of ExecStart so that the `script` continues to work. # ExecStartPre is used instead of ExecStart so that the `script` continues to work.
@ -181,7 +187,9 @@ let
"acme" "acme"
"acme/.lego" "acme/.lego"
"acme/.lego/accounts" "acme/.lego/accounts"
"acme/.minica"
]; ];
BindPaths = "/var/lib/acme/.minica:/tmp/ca";
StateDirectoryMode = "0755"; StateDirectoryMode = "0755";
# Creates ${lockdir}. Earlier RemainAfterExit=true means # Creates ${lockdir}. Earlier RemainAfterExit=true means
@ -194,24 +202,7 @@ let
# under the acme user. # under the acme user.
UMask = "0077"; UMask = "0077";
}; };
}
# Avoid race conditions creating the CA for selfsigned certs
(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 = certToConfig =
cert: data: cert: data:
@ -219,7 +210,6 @@ let
acmeServer = data.server; acmeServer = data.server;
useDns = data.dnsProvider != null; useDns = data.dnsProvider != null;
destPath = "/var/lib/acme/${cert}"; destPath = "/var/lib/acme/${cert}";
selfsignedDeps = lib.optionals (cfg.preliminarySelfsigned) [ "acme-selfsigned-${cert}.service" ];
# Minica and lego have a "feature" which replaces * with _. We need # Minica and lego have a "feature" which replaces * with _. We need
# to make this substitution to reference the output files from both programs. # to make this substitution to reference the output files from both programs.
@ -339,16 +329,18 @@ let
certificateKey = if data.csrKey != null then "${data.csrKey}" else "certificates/${keyName}.key"; certificateKey = if data.csrKey != null then "${data.csrKey}" else "certificates/${keyName}.key";
in in
{ {
inherit accountHash cert selfsignedDeps; inherit accountHash cert;
group = data.group; group = data.group;
renewTimer = { renewTimer = {
description = "Renew ACME Certificate for ${cert}"; description = "Renew ACME Certificate for ${cert}";
wantedBy = [ "timers.target" ]; wantedBy = [ "timers.target" ];
# Avoid triggering certificate renewals accidentally when running s-t-c.
unitConfig."X-OnlyManualStart" = true;
timerConfig = { timerConfig = {
OnCalendar = data.renewInterval; OnCalendar = data.renewInterval;
Unit = "acme-${cert}.service"; Unit = "acme-order-renew-${cert}.service";
Persistent = "yes"; Persistent = "yes";
# Allow systemd to pick a convenient time within the day # Allow systemd to pick a convenient time within the day
@ -364,15 +356,29 @@ let
}; };
}; };
selfsignService = lockfileName: { baseService = lockfileName: {
description = "Generate self-signed certificate for ${cert}"; description = "Ensure certificate for ${cert}";
wantedBy = [ "multi-user.target" ];
after = [ "acme-setup.service" ]; after = [ "acme-setup.service" ];
requires = [ "acme-setup.service" ];
# Whenever this service starts (on boot, through dependencies, through
# changes) we trigger the acme-order-renew service to give it a chance
# to catch up with the potentially changed config.
wants = [
"acme-setup.service"
"acme-order-renew-${cert}.service"
];
before = [ "acme-order-renew-${cert}.service" ];
restartTriggers = [
config.systemd.services."acme-order-renew-${cert}".script
];
path = [ pkgs.minica ]; path = [ pkgs.minica ];
unitConfig = { unitConfig = {
ConditionPathExists = "!/var/lib/acme/${cert}/key.pem";
StartLimitIntervalSec = 0; StartLimitIntervalSec = 0;
}; };
@ -380,11 +386,13 @@ let
Group = data.group; Group = data.group;
UMask = "0027"; UMask = "0027";
RemainAfterExit = true;
StateDirectory = "acme/${cert}"; StateDirectory = "acme/${cert}";
BindPaths = [ BindPaths = [
"/var/lib/acme/.minica:/tmp/ca" "/var/lib/acme/.minica:/tmp/ca"
"/var/lib/acme/${cert}:/tmp/${keyName}" "/var/lib/acme/${cert}:/tmp/out"
]; ];
}; };
@ -392,40 +400,69 @@ let
# minica will output to a folder sharing the name of the first domain # minica will output to a folder sharing the name of the first domain
# in the list, which will be ${data.domain} # in the list, which will be ${data.domain}
script = (if (lockfileName == null) then lib.id else wrapInFlock "${lockdir}${lockfileName}") '' script = (if (lockfileName == null) then lib.id else wrapInFlock "${lockdir}${lockfileName}") ''
set -ex
# Regenerate self-signed certificates (in case the SANs change) until we
# have seen a succesfull ACME certificate at least once.
if [ -e out/acme-success ]; then
exit 0
fi
minica \ minica \
--ca-key ca/key.pem \ --ca-key ca/key.pem \
--ca-cert ca/cert.pem \ --ca-cert ca/cert.pem \
--domains ${lib.escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))} --domains ${lib.escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))}
# Create files to match directory layout for real certificates # Create files to match directory layout for real certificates
(
cd '${keyName}' cd '${keyName}'
cp ../ca/cert.pem chain.pem cp -vp cert.pem ../out/cert.pem
cat cert.pem chain.pem > fullchain.pem cp -vp key.pem ../out/key.pem
cat key.pem fullchain.pem > full.pem )
cat out/cert.pem ca/cert.pem > out/fullchain.pem
cp ca/cert.pem out/chain.pem
cat out/key.pem out/fullchain.pem > out/full.pem
# Group might change between runs, re-apply it # Fix up the output files to adhere to the group and
chown '${user}:${data.group}' -- * # have consistent permissions. This needs to be kept
# consistent with the acme-setup script above.
for fixpath in out certificates; do
if [ -d "$fixpath" ]; then
chmod -R u=rwX,g=rX,o= "$fixpath"
chown -R ${user}:${data.group} "$fixpath"
fi
done
# Default permissions make the files unreadable by group + anon ${lib.optionalString (data.webroot != null) ''
# Need to be readable by group # Ensure the webroot exists. Fixing group is required in case configuration was changed between runs.
chmod 640 -- * # Lego will fail if the webroot does not exist at all.
(
mkdir -p '${data.webroot}/.well-known/acme-challenge' \
&& chgrp '${data.group}' ${data.webroot}/.well-known/acme-challenge
) || (
echo 'Please ensure ${data.webroot}/.well-known/acme-challenge exists and is writable by acme:${data.group}' \
&& exit 1
)
''}
''; '';
}; };
renewService = lockfileName: { orderRenewService = lockfileName: {
description = "Renew ACME certificate for ${cert}"; description = "Order (and renew) ACME certificate for ${cert}";
after = [ after = [
"network.target" "network.target"
"network-online.target" "network-online.target"
"acme-setup.service" "acme-setup.service"
"nss-lookup.target" "nss-lookup.target"
] "acme-${cert}.service"
++ selfsignedDeps; ];
wants = [ "network-online.target" ] ++ selfsignedDeps; wants = [
requires = [ "acme-setup.service" ]; "network-online.target"
"acme-setup.service"
# https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 "acme-${cert}.service"
wantedBy = lib.optionals (!config.boot.isContainer) [ "multi-user.target" ]; ];
# Ensure that certificates are generated if people use `security.acme.certs`
# without having/declaring other systemd units that depend on the cert.
path = with pkgs; [ path = with pkgs; [
lego lego
@ -523,25 +560,12 @@ let
[[ $expiration_days -gt ${toString data.validMinDays} ]] [[ $expiration_days -gt ${toString data.validMinDays} ]]
} }
${lib.optionalString (data.webroot != null) ''
# Ensure the webroot exists. Fixing group is required in case configuration was changed between runs.
# Lego will fail if the webroot does not exist at all.
(
mkdir -p '${data.webroot}/.well-known/acme-challenge' \
&& chgrp '${data.group}' ${data.webroot}/.well-known/acme-challenge
) || (
echo 'Please ensure ${data.webroot}/.well-known/acme-challenge exists and is writable by acme:${data.group}' \
&& exit 1
)
''}
echo '${domainHash}' > domainhash.txt echo '${domainHash}' > domainhash.txt
# Check if we can renew. # Check if a new order is needed
# We can only renew if the list of domains has not changed. # We can only renew if the list of domains has not changed.
# We also need an account key. Avoids #190493 # We also need an account key. Avoids #190493
if cmp -s domainhash.txt certificates/domainhash.txt && [ -e '${certificateKey}' ] && [ -e 'certificates/${keyName}.crt' ] && [ -n "$(find accounts -name '${data.email}.key')" ]; then if cmp -s domainhash.txt certificates/domainhash.txt && [ -e '${certificateKey}' ] && [ -e 'certificates/${keyName}.crt' ] && [ -n "$(find accounts -name '${data.email}.key')" ]; then
# Even if a cert is not expired, it may be revoked by the CA. # Even if a cert is not expired, it may be revoked by the CA.
# Try to renew, and silently fail if the cert is not expired. # Try to renew, and silently fail if the cert is not expired.
# Avoids #85794 and resolves #129838 # Avoids #85794 and resolves #129838
@ -553,13 +577,12 @@ let
exit 11 exit 11
fi fi
fi fi
# Do a full run
# Otherwise do a full run
elif ! lego ${runOpts}; then elif ! lego ${runOpts}; then
# Produce a nice error for those doing their first nixos-rebuild with these certs # Produce a nice error for those doing their first nixos-rebuild with these certs
echo Failed to fetch certificates. \ echo Failed to fetch certificates. \
This may mean your DNS records are set up incorrectly. \ This may mean your DNS records are set up incorrectly. \
${lib.optionalString (cfg.preliminarySelfsigned) "Selfsigned certs are in place and dependant services will still start."} Self-signed certs are in place and dependant services will still start.
# Exit 10 so that users can potentially amend SuccessExitStatus to ignore this error. # Exit 10 so that users can potentially amend SuccessExitStatus to ignore this error.
# High number to avoid Systemd reserved codes. # High number to avoid Systemd reserved codes.
exit 10 exit 10
@ -567,10 +590,12 @@ let
mv domainhash.txt certificates/ mv domainhash.txt certificates/
# Group might change between runs, re-apply it touch out/acme-success
chown '${user}:${data.group}' certificates/*
# Copy all certs to the "real" certs directory # Copy all certs to the "real" certs directory
# lego has only an interesting subset of files available,
# construct reasonably compatible files that clients can consume
# as expected.
if ! cmp -s 'certificates/${keyName}.crt' out/fullchain.pem; then if ! cmp -s 'certificates/${keyName}.crt' out/fullchain.pem; then
touch out/renewed touch out/renewed
echo Installing new certificate echo Installing new certificate
@ -581,10 +606,13 @@ let
cat out/key.pem out/fullchain.pem > out/full.pem cat out/key.pem out/fullchain.pem > out/full.pem
fi fi
# By default group will have no access to the cert files. # Keep permissions consistent. Needs to be in sync with the other scripts.
# This chmod will fix that. for fixpath in out certificates; do
chmod 640 out/* if [ -d "$fixpath" ]; then
chmod -R u=rwX,g=rX,o= "$fixpath"
chown -R ${user}:${data.group} "$fixpath"
fi
done
# Also ensure safer permissions on the account directory. # Also ensure safer permissions on the account directory.
chmod -R u=rwX,g=,o= accounts/. chmod -R u=rwX,g=,o= accounts/.
''; '';
@ -905,19 +933,6 @@ in
options = { options = {
security.acme = { security.acme = {
preliminarySelfsigned = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether a preliminary self-signed certificate should be generated before
doing ACME requests. This can be useful when certificates are required in
a webserver, but ACME needs the webserver to make its requests.
With preliminary self-signed certificate the webserver can be started and
can later reload the correct ACME certificates.
'';
};
acceptTerms = lib.mkOption { acceptTerms = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
@ -1003,10 +1018,13 @@ in
"ACME Directory is now hardcoded to /var/lib/acme and its permissions are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info." "ACME Directory is now hardcoded to /var/lib/acme and its permissions are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info."
) )
(lib.mkRemovedOptionModule [ "security" "acme" "preDelay" ] (lib.mkRemovedOptionModule [ "security" "acme" "preDelay" ]
"This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal" "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service and Before=acme-\${cert}.service to the service you want to execute before the cert renewal"
) )
(lib.mkRemovedOptionModule [ "security" "acme" "activationDelay" ] (lib.mkRemovedOptionModule [ "security" "acme" "activationDelay" ]
"This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal" "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service and Before=acme-\${cert}.service to the service you want to execute before the cert renewal"
)
(lib.mkRemovedOptionModule [ "security" "acme" "preliminarySelfsigned" ]
"This option has been removed. Preliminary self-signed certificates are now always generated to simplify the dependency structure."
) )
(lib.mkChangedOptionModule (lib.mkChangedOptionModule
[ "security" "acme" "validMin" ] [ "security" "acme" "validMin" ]
@ -1161,45 +1179,35 @@ in
systemd.services = systemd.services =
let let
renewServiceFunctions = lib.mapAttrs' ( orderRenewServiceFunctions = lib.mapAttrs' (
cert: conf: lib.nameValuePair "acme-${cert}" conf.renewService cert: conf: lib.nameValuePair "acme-order-renew-${cert}" conf.orderRenewService
) certConfigs; ) certConfigs;
renewServices = orderRenewServices =
if cfg.maxConcurrentRenewals > 0 then if cfg.maxConcurrentRenewals > 0 then
roundRobinApplyAttrs renewServiceFunctions concurrencyLockfiles roundRobinApplyAttrs orderRenewServiceFunctions concurrencyLockfiles
else else
lib.mapAttrs (_: f: f null) renewServiceFunctions; lib.mapAttrs (_: f: f null) orderRenewServiceFunctions;
selfsignServiceFunctions = lib.mapAttrs' ( baseServiceFunctions = lib.mapAttrs' (
cert: conf: lib.nameValuePair "acme-selfsigned-${cert}" conf.selfsignService cert: conf: lib.nameValuePair "acme-${cert}" conf.baseService
) certConfigs; ) certConfigs;
selfsignServices = baseServices =
if cfg.maxConcurrentRenewals > 0 then if cfg.maxConcurrentRenewals > 0 then
roundRobinApplyAttrs selfsignServiceFunctions concurrencyLockfiles roundRobinApplyAttrs baseServiceFunctions concurrencyLockfiles
else else
lib.mapAttrs (_: f: f null) selfsignServiceFunctions; lib.mapAttrs (_: f: f null) baseServiceFunctions;
in in
{ {
acme-setup = setupService; acme-setup = setupService;
} }
// renewServices // baseServices
// lib.optionalAttrs cfg.preliminarySelfsigned selfsignServices; // orderRenewServices;
systemd.timers = lib.mapAttrs' ( systemd.timers = lib.mapAttrs' (
cert: conf: lib.nameValuePair "acme-${cert}" conf.renewTimer cert: conf: lib.nameValuePair "acme-renew-${cert}" conf.renewTimer
) certConfigs; ) certConfigs;
systemd.targets = systemd.targets =
let let
# Create some targets which can be depended on to be "active" after cert renewals
finishedTargets = lib.mapAttrs' (
cert: conf:
lib.nameValuePair "acme-finished-${cert}" {
wantedBy = [ "default.target" ];
requires = [ "acme-${cert}.service" ];
after = [ "acme-${cert}.service" ];
}
) certConfigs;
# Create targets to limit the number of simultaneous account creations # Create targets to limit the number of simultaneous account creations
# How it works: # How it works:
# - Pick a "leader" cert service, which will be in charge of creating the account, # - Pick a "leader" cert service, which will be in charge of creating the account,
@ -1214,8 +1222,8 @@ in
let let
dnsConfs = builtins.filter (conf: cfg.certs.${conf.cert}.dnsProvider != null) confs; dnsConfs = builtins.filter (conf: cfg.certs.${conf.cert}.dnsProvider != null) confs;
leaderConf = if dnsConfs != [ ] then builtins.head dnsConfs else builtins.head confs; leaderConf = if dnsConfs != [ ] then builtins.head dnsConfs else builtins.head confs;
leader = "acme-${leaderConf.cert}.service"; leader = "acme-order-renew-${leaderConf.cert}.service";
followers = map (conf: "acme-${conf.cert}.service") ( followers = map (conf: "acme-order-renew-${conf.cert}.service") (
builtins.filter (conf: conf != leaderConf) confs builtins.filter (conf: conf != leaderConf) confs
); );
in in
@ -1224,10 +1232,11 @@ in
before = followers; before = followers;
requires = [ leader ]; requires = [ leader ];
after = [ leader ]; after = [ leader ];
unitConfig.RefuseManualStart = true;
} }
) (lib.groupBy (conf: conf.accountHash) (lib.attrValues certConfigs)); ) (lib.groupBy (conf: conf.accountHash) (lib.attrValues certConfigs));
in in
finishedTargets // accountTargets; accountTargets;
}) })
]; ];

View File

@ -156,7 +156,7 @@ in
"network.target" "network.target"
] ]
++ lib.optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service"; ++ lib.optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service";
wants = lib.optional (cfg.useACMEHost != null) "acme-finished-${cfg.useACMEHost}.target"; wants = lib.optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
AmbientCapabilities = "CAP_NET_BIND_SERVICE"; AmbientCapabilities = "CAP_NET_BIND_SERVICE";

View File

@ -48,8 +48,6 @@ let
) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts); ) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts);
vhostCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts); vhostCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
dependentCertNames = filter (cert: certs.${cert}.dnsProvider == null) vhostCertNames; # those that might depend on the HTTP server
independentCertNames = filter (cert: certs.${cert}.dnsProvider != null) vhostCertNames; # those that don't depend on the HTTP server
mkListenInfo = mkListenInfo =
hostOpts: hostOpts:
@ -914,13 +912,14 @@ in
systemd.services.httpd = { systemd.services.httpd = {
description = "Apache HTTPD"; description = "Apache HTTPD";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) vhostCertNames); wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames);
after = [ after = [
"network.target" "network.target"
] ]
++ map (certName: "acme-selfsigned-${certName}.service") vhostCertNames # Ensure httpd runs with baseline certificates in place.
++ map (certName: "acme-${certName}.service") independentCertNames; # avoid loading self-signed key w/ real cert, or vice-versa ++ map (certName: "acme-${certName}.service") vhostCertNames;
before = map (certName: "acme-${certName}.service") dependentCertNames; # Ensure httpd runs (with current config) before the actual ACME jobs run
before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
restartTriggers = [ cfg.configFile ]; restartTriggers = [ cfg.configFile ];
path = [ path = [
@ -960,19 +959,17 @@ in
# postRun hooks on cert renew can't be used to restart Apache since renewal # postRun hooks on cert renew can't be used to restart Apache since renewal
# runs as the unprivileged acme user. sslTargets are added to wantedBy + before # runs as the unprivileged acme user. sslTargets are added to wantedBy + before
# which allows the acme-finished-$cert.target to signify the successful updating # which allows the acme-order-renew-$cert.service to signify the successful updating
# of certs end-to-end. # of certs end-to-end.
systemd.services.httpd-config-reload = systemd.services.httpd-config-reload =
let let
sslServices = map (certName: "acme-${certName}.service") vhostCertNames; sslServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
sslTargets = map (certName: "acme-finished-${certName}.target") vhostCertNames;
in in
mkIf (vhostCertNames != [ ]) { mkIf (vhostCertNames != [ ]) {
wantedBy = sslServices ++ [ "multi-user.target" ]; wantedBy = sslServices ++ [ "multi-user.target" ];
# Before the finished targets, after the renew services. # Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm # This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded. # certs are updated _after_ config has been reloaded.
before = sslTargets;
after = sslServices; after = sslServices;
restartTriggers = [ cfg.configFile ]; restartTriggers = [ cfg.configFile ];
# Block reloading if not all certs exist yet. # Block reloading if not all certs exist yet.

View File

@ -14,13 +14,11 @@ let
virtualHosts = attrValues cfg.virtualHosts; virtualHosts = attrValues cfg.virtualHosts;
acmeEnabledVhosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts; acmeEnabledVhosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts;
vhostCertNames = unique (map (hostOpts: hostOpts.useACMEHost) acmeEnabledVhosts); vhostCertNames = unique (map (hostOpts: hostOpts.useACMEHost) acmeEnabledVhosts);
dependentCertNames = filter (cert: certs.${cert}.dnsProvider == null) vhostCertNames; # those that might depend on the HTTP server
independentCertNames = filter (cert: certs.${cert}.dnsProvider != null) vhostCertNames; # those that don't depend on the HTTP server
mkVHostConf = mkVHostConf =
hostOpts: hostOpts:
let let
sslCertDir = config.security.acme.certs.${hostOpts.useACMEHost}.directory; sslCertDir = certs.${hostOpts.useACMEHost}.directory;
in in
'' ''
${hostOpts.hostName} ${concatStringsSep " " hostOpts.serverAliases} { ${hostOpts.hostName} ${concatStringsSep " " hostOpts.serverAliases} {
@ -392,7 +390,7 @@ in
++ map ( ++ map (
name: name:
mkCertOwnershipAssertion { mkCertOwnershipAssertion {
cert = config.security.acme.certs.${name}; cert = certs.${name};
groups = config.users.groups; groups = config.users.groups;
services = [ config.systemd.services.caddy ]; services = [ config.systemd.services.caddy ];
} }
@ -412,11 +410,8 @@ in
systemd.packages = [ cfg.package ]; systemd.packages = [ cfg.package ];
systemd.services.caddy = { systemd.services.caddy = {
wants = map (certName: "acme-finished-${certName}.target") vhostCertNames; wants = map (certName: "acme-${certName}.service") vhostCertNames;
after = after = map (certName: "acme-${certName}.service") vhostCertNames;
map (certName: "acme-selfsigned-${certName}.service") vhostCertNames
++ map (certName: "acme-${certName}.service") independentCertNames; # avoid loading self-signed key w/ real cert, or vice-versa
before = map (certName: "acme-${certName}.service") dependentCertNames;
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
startLimitIntervalSec = 14400; startLimitIntervalSec = 14400;

View File

@ -434,14 +434,13 @@ in
systemd.services.h2o = { systemd.services.h2o = {
description = "H2O HTTP server"; description = "H2O HTTP server";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
wants = lib.concatLists (map (certName: [ "acme-finished-${certName}.target" ]) acmeCertNames.all); wants = lib.concatLists (map (certName: [ "acme-${certName}.service" ]) acmeCertNames.all);
# Since H2O will be hosting the challenges, H2O must be started # Since H2O will be hosting the challenges, H2O must be started
before = builtins.map (certName: "acme-${certName}.service") acmeCertNames.dependent; before = builtins.map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
after = [ after = [
"network.target" "network.target"
] ]
++ builtins.map (certName: "acme-selfsigned-${certName}.service") acmeCertNames.all ++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.all;
++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.independent; # avoid loading self-signed key w/ real cert, or vice-versa
serviceConfig = { serviceConfig = {
ExecStart = "${h2oExe} --mode 'master'"; ExecStart = "${h2oExe} --mode 'master'";
@ -490,16 +489,14 @@ in
# This service waits for all certificates to be available before reloading # This service waits for all certificates to be available before reloading
# H2O configuration. `tlsTargets` are added to `wantedBy` + `before` which # H2O configuration. `tlsTargets` are added to `wantedBy` + `before` which
# allows the `acme-finished-$cert.target` to signify the successful updating # allows the `acme-order-renew-$cert.service` to signify the successful updating
# of certs end-to-end. # of certs end-to-end.
systemd.services.h2o-config-reload = systemd.services.h2o-config-reload =
let let
tlsTargets = map (certName: "acme-${certName}.target") acmeCertNames.all; tlsServices = map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
tlsServices = map (certName: "acme-${certName}.service") acmeCertNames.all;
in in
mkIf (acmeCertNames.all != [ ]) { mkIf (acmeCertNames.all != [ ]) {
wantedBy = tlsServices ++ [ "multi-user.target" ]; wantedBy = tlsServices ++ [ "multi-user.target" ];
before = tlsTargets;
after = tlsServices; after = tlsServices;
unitConfig = { unitConfig = {
ConditionPathExists = map ( ConditionPathExists = map (

View File

@ -15,8 +15,6 @@ let
vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null
) vhostsConfigs; ) vhostsConfigs;
vhostCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts); vhostCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
dependentCertNames = filter (cert: certs.${cert}.dnsProvider == null) vhostCertNames; # those that might depend on the HTTP server
independentCertNames = filter (cert: certs.${cert}.dnsProvider != null) vhostCertNames; # those that don't depend on the HTTP server
virtualHosts = mapAttrs ( virtualHosts = mapAttrs (
vhostName: vhostConfig: vhostName: vhostConfig:
let let
@ -442,6 +440,7 @@ let
auth_basic off; auth_basic off;
auth_request off; auth_request off;
proxy_pass http://${vhost.acmeFallbackHost}; proxy_pass http://${vhost.acmeFallbackHost};
proxy_set_header Host $host;
} }
''} ''}
''; '';
@ -1481,16 +1480,14 @@ in
systemd.services.nginx = { systemd.services.nginx = {
description = "Nginx Web Server"; description = "Nginx Web Server";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) vhostCertNames); wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames);
after = [ after = [
"network.target" "network.target"
] ]
++ map (certName: "acme-selfsigned-${certName}.service") vhostCertNames # Ensure nginx runs with baseline certificates in place.
++ map (certName: "acme-${certName}.service") independentCertNames; # avoid loading self-signed key w/ real cert, or vice-versa ++ map (certName: "acme-${certName}.service") vhostCertNames;
# Nginx needs to be started in order to be able to request certificates # Ensure nginx runs (with current config) before the actual ACME jobs run
# (it's hosting the acme challenge after all) before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
# This fixes https://github.com/NixOS/nixpkgs/issues/81842
before = map (certName: "acme-${certName}.service") dependentCertNames;
stopIfChanged = false; stopIfChanged = false;
preStart = '' preStart = ''
${cfg.preStart} ${cfg.preStart}
@ -1585,26 +1582,24 @@ in
# This service waits for all certificates to be available # This service waits for all certificates to be available
# before reloading nginx configuration. # before reloading nginx configuration.
# sslTargets are added to wantedBy + before # sslTargets are added to wantedBy + before
# which allows the acme-finished-$cert.target to signify the successful updating # which allows the acme-order-renew-$cert.service to signify the successful updating
# of certs end-to-end. # of certs end-to-end.
systemd.services.nginx-config-reload = systemd.services.nginx-config-reload =
let let
sslServices = map (certName: "acme-${certName}.service") vhostCertNames; sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
sslTargets = map (certName: "acme-finished-${certName}.target") vhostCertNames;
in in
mkIf (cfg.enableReload || vhostCertNames != [ ]) { mkIf (cfg.enableReload || vhostCertNames != [ ]) {
wants = optionals cfg.enableReload [ "nginx.service" ]; wants = optionals cfg.enableReload [ "nginx.service" ];
wantedBy = sslServices ++ [ "multi-user.target" ]; wantedBy = sslOrderRenewServices ++ [ "multi-user.target" ];
# Before the finished targets, after the renew services. # XXX Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm # This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded. # certs are updated _after_ config has been reloaded.
before = sslTargets; after = sslOrderRenewServices;
after = sslServices;
restartTriggers = optionals cfg.enableReload [ configFile ]; restartTriggers = optionals cfg.enableReload [ configFile ];
# Block reloading if not all certs exist yet. # Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs. # Happens when config changes add new vhosts/certs.
unitConfig = { unitConfig = {
ConditionPathExists = optionals (sslServices != [ ]) ( ConditionPathExists = optionals (vhostCertNames != [ ]) (
map (certName: certs.${certName}.directory + "/fullchain.pem") vhostCertNames map (certName: certs.${certName}.directory + "/fullchain.pem") vhostCertNames
); );
# Disable rate limiting for this, because it may be triggered quickly a bunch of times # Disable rate limiting for this, because it may be triggered quickly a bunch of times

View File

@ -72,11 +72,11 @@ in
wants = [ wants = [
"network.target" "network.target"
] ]
++ (optional (cfg.useACMEHost != null) "acme-finished-${cfg.useACMEHost}.target"); ++ (optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service");
after = [ after = [
"network.target" "network.target"
] ]
++ (optional (cfg.useACMEHost != null) "acme-finished-${cfg.useACMEHost}.target"); ++ (optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service");
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
environment = optionalAttrs (cfg.useACMEHost != null) { environment = optionalAttrs (cfg.useACMEHost != null) {
CERTIFICATE_FILE = "fullchain.pem"; CERTIFICATE_FILE = "fullchain.pem";
@ -127,18 +127,16 @@ in
# postRun hooks on cert renew can't be used to restart Nginx since renewal # postRun hooks on cert renew can't be used to restart Nginx since renewal
# runs as the unprivileged acme user. sslTargets are added to wantedBy + before # runs as the unprivileged acme user. sslTargets are added to wantedBy + before
# which allows the acme-finished-$cert.target to signify the successful updating # which allows the acme-order-renew-$cert.target to signify the successful updating
# of certs end-to-end. # of certs end-to-end.
systemd.services.pomerium-config-reload = mkIf (cfg.useACMEHost != null) { systemd.services.pomerium-config-reload = mkIf (cfg.useACMEHost != null) {
# TODO(lukegb): figure out how to make config reloading work with credentials. # TODO(lukegb): figure out how to make config reloading work with credentials.
wantedBy = [ wantedBy = [
"acme-finished-${cfg.useACMEHost}.target" "acme-order-renew-${cfg.useACMEHost}.service"
"multi-user.target" "multi-user.target"
]; ];
# Before the finished targets, after the renew services. after = [ "acme-order-renew-${cfg.useACMEHost}.service" ];
before = [ "acme-finished-${cfg.useACMEHost}.target" ];
after = [ "acme-${cfg.useACMEHost}.service" ];
# Block reloading if not all certs exist yet. # Block reloading if not all certs exist yet.
unitConfig.ConditionPathExists = [ unitConfig.ConditionPathExists = [
"${config.security.acme.certs.${cfg.useACMEHost}.directory}/fullchain.pem" "${config.security.acme.certs.${cfg.useACMEHost}.directory}/fullchain.pem"

View File

@ -85,33 +85,24 @@ in
ca_domain = "${nodes.acme.test-support.acme.caDomain}" ca_domain = "${nodes.acme.test-support.acme.caDomain}"
fqdn = "${nodes.caddy.networking.fqdn}" fqdn = "${nodes.caddy.networking.fqdn}"
with subtest("Boot and start with selfsigned certificates"):
caddy.start()
caddy.wait_for_unit("caddy.service")
check_issuer(caddy, fqdn, "minica")
# Check that the web server has picked up the selfsigned cert
check_connection(caddy, fqdn, minica=True)
acme.start() acme.start()
wait_for_running(acme) wait_for_running(acme)
acme.wait_for_open_port(443) acme.wait_for_open_port(443)
with subtest("Boot and acquire a new cert"): with subtest("Acquire a new cert"):
caddy.start() caddy.succeed(f"systemctl restart acme-{fqdn}.service")
wait_for_running(caddy)
check_issuer(caddy, fqdn, "pebble") check_issuer(caddy, fqdn, "pebble")
check_domain(caddy, fqdn, fqdn) check_domain(caddy, fqdn, fqdn)
download_ca_certs(caddy, ca_domain) download_ca_certs(caddy, ca_domain)
check_connection(caddy, fqdn) check_connection(caddy, fqdn)
with subtest("Can run on selfsigned certificates"):
# Switch to selfsigned first
caddy.succeed(f"systemctl clean acme-{fqdn}.service --what=state")
caddy.succeed(f"systemctl start acme-selfsigned-{fqdn}.service")
check_issuer(caddy, fqdn, "minica")
caddy.succeed("systemctl restart caddy.service")
# Check that the web server has picked up the selfsigned cert
check_connection(caddy, fqdn, minica=True)
caddy.succeed(f"systemctl start acme-{fqdn}.service")
# This may fail a couple of times before caddy is restarted
check_issuer(caddy, fqdn, "pebble")
check_connection(caddy, fqdn)
with subtest("security.acme changes reflect on caddy"): with subtest("security.acme changes reflect on caddy"):
check_connection(caddy, f"caddy-alt.{domain}", fail=True) check_connection(caddy, f"caddy-alt.{domain}", fail=True)
switch_to(caddy, "add_domain") switch_to(caddy, "add_domain")

View File

@ -1,10 +1,14 @@
{ runTest }: { runTest }:
let
domain = "example.test";
in
{ {
http01-builtin = runTest ./http01-builtin.nix; http01-builtin = runTest ./http01-builtin.nix;
dns01 = runTest ./dns01.nix; dns01 = runTest ./dns01.nix;
caddy = runTest ./caddy.nix; caddy = runTest ./caddy.nix;
nginx = runTest ( nginx = runTest (
import ./webserver.nix { import ./webserver.nix {
inherit domain;
serverName = "nginx"; serverName = "nginx";
group = "nginx"; group = "nginx";
baseModule = { baseModule = {
@ -22,17 +26,17 @@
addSSL = true; addSSL = true;
useACMEHost = "proxied.example.test"; useACMEHost = "proxied.example.test";
acmeFallbackHost = "localhost:8080"; acmeFallbackHost = "localhost:8080";
# lego will refuse the request if the host header is not correct
extraConfig = ''
proxy_set_header Host $host;
'';
}; };
}; };
specialisation.nullroot.configuration = {
services.nginx.virtualHosts."nullroot.${domain}".acmeFallbackHost = "localhost:8081";
};
}; };
} }
); );
httpd = runTest ( httpd = runTest (
import ./webserver.nix { import ./webserver.nix {
inherit domain;
serverName = "httpd"; serverName = "httpd";
group = "wwwrun"; group = "wwwrun";
baseModule = { baseModule = {
@ -50,6 +54,16 @@
}; };
}; };
}; };
specialisation.nullroot.configuration = {
services.httpd.virtualHosts."nullroot.${domain}" = {
locations."/.well-known/acme-challenge" = {
proxyPass = "http://localhost:8081/.well-known/acme-challenge";
extraConfig = ''
ProxyPreserveHost On
'';
};
};
};
}; };
} }
); );

View File

@ -37,6 +37,12 @@ in
listenHTTP = ":80"; listenHTTP = ":80";
}; };
systemd.targets."renew-triggered" = {
wantedBy = [ "acme-order-renew-${config.networking.fqdn}.service" ];
after = [ "acme-order-renew-${config.networking.fqdn}.service" ];
unitConfig.RefuseManualStart = true;
};
specialisation = { specialisation = {
renew.configuration = { renew.configuration = {
# Pebble provides 5 year long certs, # Pebble provides 5 year long certs,
@ -177,17 +183,29 @@ in
# old_hash will be used in the preservation tests later # old_hash will be used in the preservation tests later
old_hash = hash old_hash = hash
builtin.succeed(f"systemctl start acme-{cert}.service") builtin.succeed(f"systemctl start acme-{cert}.service")
builtin.succeed(f"systemctl start acme-order-renew-{cert}.service")
builtin.wait_for_unit("renew-triggered.target")
hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem") hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem")
assert hash == hash_after, "Certificate was unexpectedly changed" assert hash == hash_after, "Certificate was unexpectedly changed"
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "renew") switch_to(builtin, "renew")
builtin.wait_for_unit("renew-triggered.target")
check_issuer(builtin, cert, "pebble") check_issuer(builtin, cert, "pebble")
hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr") hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr")
assert hash != hash_after, "Certificate was not renewed" assert hash != hash_after, "Certificate was not renewed"
check_permissions(builtin, cert, "acme")
with subtest("Handles email change correctly"): with subtest("Handles email change correctly"):
hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem") hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem")
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "accountchange") switch_to(builtin, "accountchange")
builtin.wait_for_unit("renew-triggered.target")
check_issuer(builtin, cert, "pebble") check_issuer(builtin, cert, "pebble")
# Check that there are now 2 account directories # Check that there are now 2 account directories
builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2") builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2")
@ -202,58 +220,101 @@ in
# old_hash will be used in the preservation tests later # old_hash will be used in the preservation tests later
old_hash = hash_after old_hash = hash_after
check_permissions(builtin, cert, "acme")
with subtest("Correctly implements OCSP stapling"): with subtest("Correctly implements OCSP stapling"):
check_stapling(builtin, cert, "${caDomain}", fail=True) check_stapling(builtin, cert, "${caDomain}", fail=True)
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "ocsp_stapling") switch_to(builtin, "ocsp_stapling")
builtin.wait_for_unit("renew-triggered.target")
check_stapling(builtin, cert, "${caDomain}") check_stapling(builtin, cert, "${caDomain}")
check_permissions(builtin, cert, "acme")
with subtest("Handles keyType change correctly"): with subtest("Handles keyType change correctly"):
check_key_bits(builtin, cert, 256) check_key_bits(builtin, cert, 256)
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "keytype") switch_to(builtin, "keytype")
builtin.wait_for_unit("renew-triggered.target")
check_key_bits(builtin, cert, 384) check_key_bits(builtin, cert, 384)
# keyType is part of the accountHash, thus a new account will be created # keyType is part of the accountHash, thus a new account will be created
builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2") builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2")
check_permissions(builtin, cert, "acme")
with subtest("Reuses generated, valid certs from previous configurations"): with subtest("Reuses generated, valid certs from previous configurations"):
# Right now, the hash should not match due to the previous test # Right now, the hash should not match due to the previous test
hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr") hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr")
assert hash != old_hash, "Expected certificate to differ" assert hash != old_hash, "Expected certificate to differ"
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "preservation") switch_to(builtin, "preservation")
builtin.wait_for_unit("renew-triggered.target")
hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr") hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr")
assert hash == old_hash, "Expected certificate to match from older configuration" assert hash == old_hash, "Expected certificate to match from older configuration"
check_permissions(builtin, cert, "acme")
with subtest("Add a new cert, extend existing cert domains"): with subtest("Add a new cert, extend existing cert domains"):
check_domain(builtin, cert, f"builtin-alt.{domain}", fail=True) check_domain(builtin, cert, f"builtin-alt.{domain}", fail=True)
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "add_cert_and_domain") switch_to(builtin, "add_cert_and_domain")
builtin.wait_for_unit("renew-triggered.target")
check_issuer(builtin, cert, "pebble") check_issuer(builtin, cert, "pebble")
check_domain(builtin, cert, f"builtin-alt.{domain}") check_domain(builtin, cert, f"builtin-alt.{domain}")
check_issuer(builtin, cert2, "pebble") check_issuer(builtin, cert2, "pebble")
check_domain(builtin, cert2, cert2) check_domain(builtin, cert2, cert2)
# There should not be a new account folder created # There should not be a new account folder created
builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2") builtin.succeed("test $(ls -1 /var/lib/acme/.lego/accounts | tee /dev/stderr | wc -l) -eq 2")
check_permissions(builtin, cert, "acme")
check_permissions(builtin, cert2, "acme")
with subtest("Check account hashing compatibility with pre-24.05 settings"): with subtest("Check account hashing compatibility with pre-24.05 settings"):
switch_to(builtin, "legacy_account_hash", fail=True) builtin.succeed("systemctl stop renew-triggered.target")
builtin.succeed(f"stat {legacy_account_dir} > /dev/stderr && rm -rf {legacy_account_dir}") switch_to(builtin, "legacy_account_hash"
)
builtin.wait_for_unit("renew-triggered.target")
with subtest("Ensure Concurrency limits work"): builtin.succeed(f"stat {legacy_account_dir} > /dev/stderr && rm -rf {legacy_account_dir}")
check_permissions(builtin, cert, "acme")
with subtest("Ensure concurrency limits work"):
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "concurrency") switch_to(builtin, "concurrency")
builtin.wait_for_unit("renew-triggered.target")
check_issuer(builtin, cert3, "pebble") check_issuer(builtin, cert3, "pebble")
check_domain(builtin, cert3, cert3) check_domain(builtin, cert3, cert3)
check_permissions(builtin, cert, "acme")
with subtest("Can renew using a CSR"):
builtin.succeed(f"systemctl stop acme-{cert}.service")
builtin.succeed(f"systemctl clean acme-{cert}.service --what=state")
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "csr")
builtin.wait_for_unit("renew-triggered.target")
check_issuer(builtin, cert, "pebble")
with subtest("Generate self-signed certs"): with subtest("Generate self-signed certs"):
acme.shutdown()
check_issuer(builtin, cert, "pebble") check_issuer(builtin, cert, "pebble")
builtin.succeed(f"systemctl stop acme-{cert}.service")
builtin.succeed(f"systemctl clean acme-{cert}.service --what=state") builtin.succeed(f"systemctl clean acme-{cert}.service --what=state")
builtin.succeed(f"systemctl start acme-selfsigned-{cert}.service") builtin.succeed(f"systemctl start acme-{cert}.service")
check_issuer(builtin, cert, "minica") check_issuer(builtin, cert, "minica")
check_domain(builtin, cert, cert) check_domain(builtin, cert, cert)
with subtest("Validate permissions (self-signed)"): with subtest("Validate permissions (self-signed)"):
check_permissions(builtin, cert, "acme") check_permissions(builtin, cert, "acme")
with subtest("Can renew using a CSR"):
builtin.succeed(f"systemctl clean acme-{cert}.service --what=state")
switch_to(builtin, "csr")
check_issuer(builtin, cert, "pebble")
''; '';
} }

View File

@ -3,6 +3,36 @@ import time
TOTAL_RETRIES = 20 TOTAL_RETRIES = 20
# BackoffTracker provides a robust system for handling test retries
class BackoffTracker:
delay = 1
increment = 1
def handle_fail(self, retries, message) -> int:
assert retries < TOTAL_RETRIES, message
print(f"Retrying in {self.delay}s, {retries + 1}/{TOTAL_RETRIES}")
time.sleep(self.delay)
# Only increment after the first try
if retries == 0:
self.delay += self.increment
self.increment *= 2
return retries + 1
def protect(self, func):
def wrapper(*args, retries: int = 0, **kwargs):
try:
return func(*args, **kwargs)
except Exception as err:
retries = self.handle_fail(retries, err.args)
return wrapper(*args, retries=retries, **kwargs)
return wrapper
backoff = BackoffTracker()
def run(node, cmd, fail=False): def run(node, cmd, fail=False):
if fail: if fail:
@ -39,6 +69,7 @@ def switch_to(node, name, fail=False) -> None:
# and matches the issuer we expect it to be. # and matches the issuer we expect it to be.
# It's a good validation to ensure the cert.pem and fullchain.pem # It's a good validation to ensure the cert.pem and fullchain.pem
# are not still selfsigned after verification # are not still selfsigned after verification
@backoff.protect
def check_issuer(node, cert_name, issuer) -> None: def check_issuer(node, cert_name, issuer) -> None:
for fname in ("cert.pem", "fullchain.pem"): for fname in ("cert.pem", "fullchain.pem"):
actual_issuer = node.succeed( actual_issuer = node.succeed(
@ -102,9 +133,10 @@ def check_permissions(node, cert_name, group):
f"test $({stat} /var/lib/acme/{cert_name}/*.pem" f"test $({stat} /var/lib/acme/{cert_name}/*.pem"
f" | tee /dev/stderr | grep -v '640 acme {group}' | wc -l) -eq 0" f" | tee /dev/stderr | grep -v '640 acme {group}' | wc -l) -eq 0"
) )
node.execute(f"ls -lahR /var/lib/acme/.lego/{cert_name}/* > /dev/stderr")
node.succeed( node.succeed(
f"test $({stat} /var/lib/acme/.lego/{cert_name}/*/{cert_name}*" f"test $({stat} /var/lib/acme/.lego/{cert_name}/*/{cert_name}*"
f" | tee /dev/stderr | grep -v '600 acme {group}' | wc -l) -eq 0" f" | tee /dev/stderr | grep -v '640 acme {group}' | wc -l) -eq 0"
) )
node.succeed( node.succeed(
f"test $({stat} /var/lib/acme/{cert_name}" f"test $({stat} /var/lib/acme/{cert_name}"
@ -115,37 +147,6 @@ def check_permissions(node, cert_name, group):
f" | tee /dev/stderr | grep -v '600 acme {group}' | wc -l) -eq 0" f" | tee /dev/stderr | grep -v '600 acme {group}' | wc -l) -eq 0"
) )
# BackoffTracker provides a robust system for handling test retries
class BackoffTracker:
delay = 1
increment = 1
def handle_fail(self, retries, message) -> int:
assert retries < TOTAL_RETRIES, message
print(f"Retrying in {self.delay}s, {retries + 1}/{TOTAL_RETRIES}")
time.sleep(self.delay)
# Only increment after the first try
if retries == 0:
self.delay += self.increment
self.increment *= 2
return retries + 1
def protect(self, func):
def wrapper(*args, retries: int = 0, **kwargs):
try:
return func(*args, **kwargs)
except Exception as err:
retries = self.handle_fail(retries, err.args)
return wrapper(*args, retries=retries, **kwargs)
return wrapper
backoff = BackoffTracker()
@backoff.protect @backoff.protect
def download_ca_certs(node, ca_domain): def download_ca_certs(node, ca_domain):

View File

@ -2,7 +2,7 @@
serverName, serverName,
group, group,
baseModule, baseModule,
domain ? "example.test", domain,
}: }:
{ {
config, config,
@ -18,6 +18,8 @@
timeout = 300; timeout = 300;
}; };
interactive.sshBackdoor.enable = true;
nodes = { nodes = {
# The fake ACME server which will respond to client requests # The fake ACME server which will respond to client requests
acme = acme =
@ -45,6 +47,7 @@
"certchange.${domain}" "certchange.${domain}"
"zeroconf.${domain}" "zeroconf.${domain}"
"zeroconf2.${domain}" "zeroconf2.${domain}"
"zeroconf3.${domain}"
"nullroot.${domain}" "nullroot.${domain}"
]; ];
@ -57,6 +60,7 @@
systemd.targets."renew-triggered" = { systemd.targets."renew-triggered" = {
wantedBy = [ "${serverName}-config-reload.service" ]; wantedBy = [ "${serverName}-config-reload.service" ];
after = [ "${serverName}-config-reload.service" ]; after = [ "${serverName}-config-reload.service" ];
unitConfig.RefuseManualStart = true;
}; };
security.acme.certs."proxied.${domain}" = { security.acme.certs."proxied.${domain}" = {
@ -101,13 +105,42 @@
# Test that "acmeRoot = null" still results in # Test that "acmeRoot = null" still results in
# valid cert generation by inheriting defaults. # valid cert generation by inheriting defaults.
nullroot.configuration = { nullroot.configuration = {
security.acme.defaults.listenHTTP = ":8080"; # The default.nix has the server-type dependent config statements
# to properly set up the proxying. We need a separate port here to
# avoid hostname issues with the proxy already running on :8080
security.acme.defaults.listenHTTP = ":8081";
services.${serverName}.virtualHosts."nullroot.${domain}" = { services.${serverName}.virtualHosts."nullroot.${domain}" = {
onlySSL = true; addSSL = true;
enableACME = true; enableACME = true;
acmeRoot = null; acmeRoot = null;
}; };
}; };
# Test that a adding a second virtual host will not trigger
# other units (account and renewal service for first)
zeroconf3.configuration = {
services.${serverName}.virtualHosts = {
"zeroconf.${domain}" = {
addSSL = true;
enableACME = true;
serverAliases = [ "zeroconf2.${domain}" ];
};
"zeroconf3.${domain}" = {
addSSL = true;
enableACME = true;
};
};
# We're doing something risky with the combination of the service unit being persistent
# that could end up that the timers do not trigger properly. Show that timers have the
# desired effect.
systemd.timers."acme-renew-zeroconf3.${domain}".timerConfig = {
OnCalendar = lib.mkForce "*-*-* *:*:0/5";
AccuracySec = lib.mkForce 0;
# Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
RandomizedDelaySec = lib.mkForce 0;
FixedRandomDelay = lib.mkForce 0;
};
};
}; };
}; };
}; };
@ -121,28 +154,22 @@
ca_domain = "${nodes.acme.test-support.acme.caDomain}" ca_domain = "${nodes.acme.test-support.acme.caDomain}"
fqdn = f"proxied.{domain}" fqdn = f"proxied.{domain}"
webserver.start()
webserver.wait_for_unit("${serverName}.service")
with subtest("Can run on self-signed certificates"):
check_issuer(webserver, fqdn, "minica")
# Check that the web server has picked up the selfsigned cert
check_connection(webserver, fqdn, minica=True)
acme.start() acme.start()
wait_for_running(acme) wait_for_running(acme)
acme.wait_for_open_port(443) acme.wait_for_open_port(443)
with subtest("Acquire a cert through a proxied lego"): with subtest("Acquire a cert through a proxied lego"):
webserver.start() webserver.succeed(f"systemctl start acme-order-renew-{fqdn}.service")
webserver.succeed("systemctl is-system-running --wait")
wait_for_running(webserver)
download_ca_certs(webserver, ca_domain)
check_connection(webserver, fqdn)
with subtest("Can run on selfsigned certificates"):
# Switch to selfsigned first
webserver.succeed(f"systemctl clean acme-{fqdn}.service --what=state")
webserver.succeed(f"systemctl start acme-selfsigned-{fqdn}.service")
check_issuer(webserver, fqdn, "minica")
webserver.succeed("systemctl restart ${serverName}-config-reload.service")
# Check that the web server has picked up the selfsigned cert
check_connection(webserver, fqdn, minica=True)
webserver.succeed("systemctl stop renew-triggered.target")
webserver.succeed(f"systemctl start acme-{fqdn}.service")
webserver.wait_for_unit("renew-triggered.target") webserver.wait_for_unit("renew-triggered.target")
download_ca_certs(webserver, ca_domain)
check_issuer(webserver, fqdn, "pebble") check_issuer(webserver, fqdn, "pebble")
check_connection(webserver, fqdn) check_connection(webserver, fqdn)
@ -181,5 +208,23 @@
switch_to(webserver, "nullroot") switch_to(webserver, "nullroot")
webserver.wait_for_unit("renew-triggered.target") webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"nullroot.{domain}") check_connection(webserver, f"nullroot.{domain}")
with subtest("Ensure that adding a second vhost does not trigger first vhost acme units"):
switch_to(webserver, "zeroconf")
webserver.wait_for_unit("renew-triggered.target")
webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
switch_to(webserver, "zeroconf3")
webserver.wait_for_unit("renew-triggered.target")
output = webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
# The new certificate unit gets triggered:
t.assertIn(f"acme-zeroconf3.{domain}-start", output)
# The account generation should not be triggered again:
t.assertNotIn("acme-account-d590213ed52603e9128d.target", output)
# The other certificates should also not be triggered:
t.assertNotIn(f"acme-zeroconf.{domain}-start", output)
t.assertNotIn(f"acme-proxied.{domain}-start", output)
# Ensure the timer works, due to our shenanigans with
# RemainAfterExit=true
webserver.wait_until_succeeds(f"journalctl --cursor-file=/tmp/cursor | grep 'Starting Order (and renew) ACME certificate for zeroconf3.{domain}...'")
''; '';
} }

View File

@ -137,17 +137,18 @@ import ./make-test-python.nix (
caserver.wait_for_unit("step-ca.service") caserver.wait_for_unit("step-ca.service")
caserver.wait_until_succeeds("journalctl -o cat -u step-ca.service | grep '${pkgs.step-ca.version}'") caserver.wait_until_succeeds("journalctl -o cat -u step-ca.service | grep '${pkgs.step-ca.version}'")
caclient.wait_for_unit("acme-finished-caclient.target") caclient.wait_for_unit("acme-caclient.service")
catester.succeed("curl https://caclient/ | grep \"Welcome to nginx!\"") # The order is run asynchonously, keep trying.
catester.wait_until_succeeds("curl https://caclient/ | grep \"Welcome to nginx!\"")
caclientcaddy.wait_for_unit("caddy.service") caclientcaddy.wait_for_unit("caddy.service")
# Its hard to know when Caddy has finished the ACME dance with # Its hard to know when Caddy has finished the ACME dance with
# step-ca, so we keep trying cURL until success. # step-ca, so we keep trying cURL until success.
catester.wait_until_succeeds("curl https://caclientcaddy/ | grep \"Welcome to Caddy!\"") catester.wait_until_succeeds("curl https://caclientcaddy/ | grep \"Welcome to Caddy!\"")
caclienth2o.wait_for_unit("acme-finished-caclienth2o.target") caclienth2o.wait_for_unit("acme-caclienth2o.service")
caclienth2o.wait_for_unit("h2o.service") caclienth2o.wait_for_unit("h2o.service")
catester.succeed("curl https://caclienth2o/ | grep \"Welcome to H2O!\"") catester.wait_until_succeeds("curl https://caclienth2o/ | grep \"Welcome to H2O!\"")
''; '';
} }
) )