nixos/acme: improve scalability - reduce superfluous unit activations (#422076)

Changed files
+425 -329
nixos
doc
manual
modules
security
services
networking
web-servers
tests
+3
nixos/doc/manual/redirects.json
···
"module-security-acme-fix-jws": [
"index.html#module-security-acme-fix-jws"
],
+
"module-security-acme-reload-dependencies": [
+
"index.html#module-security-acme-reload-dependencies"
+
],
"module-programs-zsh-ohmyzsh": [
"index.html#module-programs-zsh-ohmyzsh"
],
+15
nixos/doc/manual/release-notes/rl-2511.section.md
···
- `services.gitea` supports sending notifications with sendmail again. To do this, activate the parameter `services.gitea.mailerUseSendmail` and configure SMTP server.
+
- Revamp of the ACME certificate acquisication and renewal process to help scale systems with lots (100+) of certificates.
+
+
Units and targets have been reshaped to better support more specific dependency propagation and avoid
+
superfluously triggering unchanged units:
+
+
If a service requires a syntactically valid certificate to start it should now depend on the `acme-{certname}.service` unit.
+
+
We now always generate initial self-signed certificates as this drastically simplifies the dependency structure. As a result, the option `security.acme.preliminarySelfsigned` has been removed.
+
+
Instead of the previous `acme-finished-{certname}.target`s there are now `acme-order-renew-{certname}.service`s that will be activated
+
in a delayed fashion to ensure that bootstrapping with servers like nginx that take part in the acquisition/renewal process works
+
smoothly. Dependencies on `acme-finished` units should move to `acme-order-renew`.
+
+
Note that system activation will complete before all certificates may have been renewed or acquired.
+
- `libvirt` now supports using `nftables` backend.
- The `virtualisation.libvirtd.firewallBackend` option can be used to configure the firewall backend used by libvirtd.
+9 -1
nixos/modules/security/acme/default.md
···
# Now you must augment OpenSMTPD's systemd service to load
# 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 =
let
certDir = config.security.acme.certs."mail.example.com".directory;
···
# Note: Do this for all certs that share the same account email address
systemctl start acme-example.com.service
```
+
+
## Ensuring dependencies for services that need to be reloaded when a certificate challenges {#module-security-acme-reload-dependencies}
+
+
Services that depend on ACME certificates and need to be reloaded can use one of two approaches to reload upon successfull certificate acquisition or renewal:
+
+
1. **Using the `security.acme.certs.<name>.reloadServices` option**: This will cause `systemctl try-reload-or-restart` to be run for the listed services.
+
+
2. **Using a separate reload unit**: if you need perform more complex actions you can implement a separate reload unit but need to ensure that it lists the `acme-renew-<name>.service` unit both as `wantedBy` AND `after`. See the nginx module implementation with its `nginx-config-reload` service.
+164 -189
nixos/modules/security/acme/default.nix
···
# 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.
-
# returns: [{fst = "element of baseList"; snd = "element of needAssignmentList"}]
-
roundRobinAssign =
-
baseList: needAssignmentList:
-
if baseList == [ ] then [ ] else _rrCycler baseList baseList needAssignmentList;
-
_rrCycler =
-
with builtins;
-
origBaseList: workingBaseList: needAssignmentList:
-
if (workingBaseList == [ ] || needAssignmentList == [ ]) then
-
[ ]
-
else
-
[
-
{
-
fst = head workingBaseList;
-
snd = head needAssignmentList;
-
}
-
]
-
++ _rrCycler origBaseList (
-
if (tail workingBaseList == [ ]) then origBaseList else tail workingBaseList
-
) (tail needAssignmentList);
-
attrsToList = lib.mapAttrsToList (
-
attrname: attrval: {
-
name = attrname;
-
value = attrval;
-
}
-
);
-
# for an AttrSet `funcsAttrs` having functions as values, apply single arguments from
-
# `argsList` to them in a round-robin manner.
-
# Returns an attribute set with the applied functions as values.
-
roundRobinApplyAttrs =
-
funcsAttrs: argsList:
-
lib.listToAttrs (
-
map (x: {
-
inherit (x.snd) name;
-
value = x.snd.value x.fst;
-
}) (roundRobinAssign argsList (attrsToList funcsAttrs))
-
);
+
wrapInFlock =
-
lockfilePath: script:
+
script:
# explainer: https://stackoverflow.com/a/60896531
''
-
exec {LOCKFD}> ${lockfilePath}
-
echo "Waiting to acquire lock ${lockfilePath}"
-
${pkgs.flock}/bin/flock ''${LOCKFD} || exit 1
-
echo "Acquired lock ${lockfilePath}"
+
maxConcurrentRenewals=${toString cfg.maxConcurrentRenewals}
+
+
acquireLock() {
+
echo "Waiting to acquire lock in ${lockdir}"
+
while true; do
+
for i in $(seq 1 $maxConcurrentRenewals); do
+
exec {LOCKFD}> "${lockdir}/$i.lock"
+
if ${pkgs.flock}/bin/flock -n ''${LOCKFD}; then
+
return 0
+
fi
+
exec {LOCKFD}>&-
+
done
+
sleep 1;
+
done
+
}
+
+
if [ "$maxConcurrentRenewals" -gt "0" ]; then
+
acquireLock
+
fi
''
-
+ script
-
+ "\n"
-
+ ''echo "Releasing lock ${lockfilePath}" # only released after process exit'';
+
+ script;
# There are many services required to make cert renewals work.
# They all follow a common structure:
···
);
# 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}";
+
setupService = {
+
description = "Set up the ACME certificate renewal infrastructure";
+
path = [ pkgs.minica ];
+
+
script = lib.mkBefore ''
+
${lib.optionalString cfg.defaults.enableDebugLogs "set -x"}
+
set -euo pipefail
+
test -e ca/key.pem || minica \
+
--ca-key ca/key.pem \
+
--ca-cert ca/cert.pem \
+
--domains selfsigned.local
+
'';
-
# We don't want this to run every time a renewal happens
-
RemainAfterExit = true;
+
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 = "0755";
+
# We don't want this to run every time a renewal happens
+
RemainAfterExit = true;
-
# Creates ${lockdir}. Earlier RemainAfterExit=true means
-
# it does not get deleted immediately.
-
RuntimeDirectory = "acme";
-
RuntimeDirectoryMode = "0700";
+
# StateDirectory entries are a cleaner, service-level mechanism
+
# for dealing with persistent service data
+
StateDirectory = [
+
"acme"
+
"acme/.lego"
+
"acme/.lego/accounts"
+
"acme/.minica"
+
];
+
BindPaths = "/var/lib/acme/.minica:/tmp/ca";
+
StateDirectoryMode = "0755";
-
# 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";
-
};
-
}
+
# Creates ${lockdir}. Earlier RemainAfterExit=true means
+
# it does not get deleted immediately.
+
RuntimeDirectory = "acme";
+
RuntimeDirectoryMode = "0700";
-
# 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";
-
};
-
})
-
];
+
# 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";
+
};
+
};
certToConfig =
cert: data:
···
acmeServer = data.server;
useDns = data.dnsProvider != null;
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
# to make this substitution to reference the output files from both programs.
···
certificateKey = if data.csrKey != null then "${data.csrKey}" else "certificates/${keyName}.key";
in
{
-
inherit accountHash cert selfsignedDeps;
+
inherit accountHash cert;
group = data.group;
renewTimer = {
description = "Renew ACME Certificate for ${cert}";
wantedBy = [ "timers.target" ];
+
# Avoid triggering certificate renewals accidentally when running s-t-c.
+
unitConfig."X-OnlyManualStart" = true;
timerConfig = {
OnCalendar = data.renewInterval;
-
Unit = "acme-${cert}.service";
+
Unit = "acme-order-renew-${cert}.service";
Persistent = "yes";
# Allow systemd to pick a convenient time within the day
···
};
};
-
selfsignService = lockfileName: {
-
description = "Generate self-signed certificate for ${cert}";
+
baseService = {
+
description = "Ensure certificate for ${cert}";
+
+
wantedBy = [ "multi-user.target" ];
+
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 ];
unitConfig = {
-
ConditionPathExists = "!/var/lib/acme/${cert}/key.pem";
StartLimitIntervalSec = 0;
};
···
Group = data.group;
UMask = "0027";
+
RemainAfterExit = true;
+
StateDirectory = "acme/${cert}";
BindPaths = [
"/var/lib/acme/.minica:/tmp/ca"
-
"/var/lib/acme/${cert}:/tmp/${keyName}"
+
"/var/lib/acme/${cert}:/tmp/out"
];
};
# Working directory will be /tmp
# minica will output to a folder sharing the name of the first domain
# in the list, which will be ${data.domain}
-
script = (if (lockfileName == null) then lib.id else wrapInFlock "${lockdir}${lockfileName}") ''
+
script = wrapInFlock ''
+
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 \
--ca-key ca/key.pem \
--ca-cert ca/cert.pem \
--domains ${lib.escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))}
# Create files to match directory layout for real certificates
-
cd '${keyName}'
-
cp ../ca/cert.pem chain.pem
-
cat cert.pem chain.pem > fullchain.pem
-
cat key.pem fullchain.pem > full.pem
+
(
+
cd '${keyName}'
+
cp -vp cert.pem ../out/cert.pem
+
cp -vp key.pem ../out/key.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
-
chown '${user}:${data.group}' -- *
+
# Fix up the output files to adhere to the group and
+
# 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
-
# Need to be readable by group
-
chmod 640 -- *
+
${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
+
)
+
''}
'';
};
-
renewService = lockfileName: {
-
description = "Renew ACME certificate for ${cert}";
+
orderRenewService = {
+
description = "Order (and renew) ACME certificate for ${cert}";
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" ];
+
"acme-${cert}.service"
+
];
+
wants = [
+
"network-online.target"
+
"acme-setup.service"
+
"acme-${cert}.service"
+
];
+
# 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; [
lego
···
};
# Working directory will be /tmp
-
script = (if (lockfileName == null) then lib.id else wrapInFlock "${lockdir}${lockfileName}") ''
+
script = wrapInFlock ''
${lib.optionalString data.enableDebugLogs "set -x"}
set -euo pipefail
···
[[ $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
-
# 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 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
-
# 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.
# Avoids #85794 and resolves #129838
···
exit 11
fi
fi
-
-
# Otherwise do a full run
+
# Do a full run
elif ! lego ${runOpts}; then
# Produce a nice error for those doing their first nixos-rebuild with these certs
echo Failed to fetch certificates. \
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.
# High number to avoid Systemd reserved codes.
exit 10
···
mv domainhash.txt certificates/
-
# Group might change between runs, re-apply it
-
chown '${user}:${data.group}' certificates/*
+
touch out/acme-success
# 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
touch out/renewed
echo Installing new certificate
···
cat out/key.pem out/fullchain.pem > out/full.pem
fi
-
# By default group will have no access to the cert files.
-
# This chmod will fix that.
-
chmod 640 out/*
-
+
# Keep permissions consistent. Needs to be in sync with the other scripts.
+
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
# Also ensure safer permissions on the account directory.
chmod -R u=rwX,g=,o= accounts/.
'';
···
options = {
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 {
type = lib.types.bool;
default = false;
···
"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" ]
-
"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" ]
-
"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
[ "security" "acme" "validMin" ]
···
systemd.services =
let
-
renewServiceFunctions = lib.mapAttrs' (
-
cert: conf: lib.nameValuePair "acme-${cert}" conf.renewService
+
orderRenewServices = lib.mapAttrs' (
+
cert: conf: lib.nameValuePair "acme-order-renew-${cert}" conf.orderRenewService
) certConfigs;
-
renewServices =
-
if cfg.maxConcurrentRenewals > 0 then
-
roundRobinApplyAttrs renewServiceFunctions concurrencyLockfiles
-
else
-
lib.mapAttrs (_: f: f null) renewServiceFunctions;
-
selfsignServiceFunctions = lib.mapAttrs' (
-
cert: conf: lib.nameValuePair "acme-selfsigned-${cert}" conf.selfsignService
+
baseServices = lib.mapAttrs' (
+
cert: conf: lib.nameValuePair "acme-${cert}" conf.baseService
) certConfigs;
-
selfsignServices =
-
if cfg.maxConcurrentRenewals > 0 then
-
roundRobinApplyAttrs selfsignServiceFunctions concurrencyLockfiles
-
else
-
lib.mapAttrs (_: f: f null) selfsignServiceFunctions;
in
acme-setup = setupService;
-
// renewServices
-
// lib.optionalAttrs cfg.preliminarySelfsigned selfsignServices;
+
// baseServices
+
// orderRenewServices;
systemd.timers = lib.mapAttrs' (
-
cert: conf: lib.nameValuePair "acme-${cert}" conf.renewTimer
+
cert: conf: lib.nameValuePair "acme-renew-${cert}" conf.renewTimer
) certConfigs;
systemd.targets =
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
# How it works:
# - Pick a "leader" cert service, which will be in charge of creating the account,
···
let
dnsConfs = builtins.filter (conf: cfg.certs.${conf.cert}.dnsProvider != null) confs;
leaderConf = if dnsConfs != [ ] then builtins.head dnsConfs else builtins.head confs;
-
leader = "acme-${leaderConf.cert}.service";
-
followers = map (conf: "acme-${conf.cert}.service") (
+
leader = "acme-order-renew-${leaderConf.cert}.service";
+
followers = map (conf: "acme-order-renew-${conf.cert}.service") (
builtins.filter (conf: conf != leaderConf) confs
);
in
···
before = followers;
requires = [ leader ];
after = [ leader ];
+
unitConfig.RefuseManualStart = true;
) (lib.groupBy (conf: conf.accountHash) (lib.attrValues certConfigs));
in
-
finishedTargets // accountTargets;
+
accountTargets;
})
];
+1 -1
nixos/modules/services/networking/doh-server.nix
···
"network.target"
]
++ 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" ];
serviceConfig = {
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+7 -10
nixos/modules/services/web-servers/apache-httpd/default.nix
···
) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts);
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 =
hostOpts:
···
systemd.services.httpd = {
description = "Apache HTTPD";
wantedBy = [ "multi-user.target" ];
-
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) vhostCertNames);
+
wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames);
after = [
"network.target"
]
-
++ 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;
+
# Ensure httpd runs with baseline certificates in place.
+
++ map (certName: "acme-${certName}.service") vhostCertNames;
+
# Ensure httpd runs (with current config) before the actual ACME jobs run
+
before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
restartTriggers = [ cfg.configFile ];
path = [
···
# 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
-
# 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.
systemd.services.httpd-config-reload =
let
-
sslServices = map (certName: "acme-${certName}.service") vhostCertNames;
-
sslTargets = map (certName: "acme-finished-${certName}.target") vhostCertNames;
+
sslServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
in
mkIf (vhostCertNames != [ ]) {
wantedBy = sslServices ++ [ "multi-user.target" ];
# Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded.
-
before = sslTargets;
after = sslServices;
restartTriggers = [ cfg.configFile ];
# Block reloading if not all certs exist yet.
+4 -9
nixos/modules/services/web-servers/caddy/default.nix
···
virtualHosts = attrValues cfg.virtualHosts;
acmeEnabledVhosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts;
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 =
hostOpts:
let
-
sslCertDir = config.security.acme.certs.${hostOpts.useACMEHost}.directory;
+
sslCertDir = certs.${hostOpts.useACMEHost}.directory;
in
''
${hostOpts.hostName} ${concatStringsSep " " hostOpts.serverAliases} {
···
++ map (
name:
mkCertOwnershipAssertion {
-
cert = config.security.acme.certs.${name};
+
cert = certs.${name};
groups = config.users.groups;
services = [ config.systemd.services.caddy ];
}
···
systemd.packages = [ cfg.package ];
systemd.services.caddy = {
-
wants = map (certName: "acme-finished-${certName}.target") vhostCertNames;
-
after =
-
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;
+
wants = map (certName: "acme-${certName}.service") vhostCertNames;
+
after = map (certName: "acme-${certName}.service") vhostCertNames;
wantedBy = [ "multi-user.target" ];
startLimitIntervalSec = 14400;
+5 -8
nixos/modules/services/web-servers/h2o/default.nix
···
systemd.services.h2o = {
description = "H2O HTTP server";
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
-
before = builtins.map (certName: "acme-${certName}.service") acmeCertNames.dependent;
+
before = builtins.map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
after = [
"network.target"
]
-
++ builtins.map (certName: "acme-selfsigned-${certName}.service") acmeCertNames.all
-
++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.independent; # avoid loading self-signed key w/ real cert, or vice-versa
+
++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.all;
serviceConfig = {
ExecStart = "${h2oExe} --mode 'master'";
···
# This service waits for all certificates to be available before reloading
# 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.
systemd.services.h2o-config-reload =
let
-
tlsTargets = map (certName: "acme-${certName}.target") acmeCertNames.all;
-
tlsServices = map (certName: "acme-${certName}.service") acmeCertNames.all;
+
tlsServices = map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
in
mkIf (acmeCertNames.all != [ ]) {
wantedBy = tlsServices ++ [ "multi-user.target" ];
-
before = tlsTargets;
after = tlsServices;
unitConfig = {
ConditionPathExists = map (
+12 -17
nixos/modules/services/web-servers/nginx/default.nix
···
vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null
) vhostsConfigs;
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 (
vhostName: vhostConfig:
let
···
auth_basic off;
auth_request off;
proxy_pass http://${vhost.acmeFallbackHost};
+
proxy_set_header Host $host;
}
''}
'';
···
systemd.services.nginx = {
description = "Nginx Web Server";
wantedBy = [ "multi-user.target" ];
-
wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) vhostCertNames);
+
wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames);
after = [
"network.target"
-
++ map (certName: "acme-selfsigned-${certName}.service") vhostCertNames
-
++ map (certName: "acme-${certName}.service") independentCertNames; # avoid loading self-signed key w/ real cert, or vice-versa
-
# Nginx needs to be started in order to be able to request certificates
-
# (it's hosting the acme challenge after all)
-
# This fixes https://github.com/NixOS/nixpkgs/issues/81842
-
before = map (certName: "acme-${certName}.service") dependentCertNames;
+
# Ensure nginx runs with baseline certificates in place.
+
++ map (certName: "acme-${certName}.service") vhostCertNames;
+
# Ensure nginx runs (with current config) before the actual ACME jobs run
+
before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
stopIfChanged = false;
preStart = ''
${cfg.preStart}
···
# This service waits for all certificates to be available
# before reloading nginx configuration.
# 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.
systemd.services.nginx-config-reload =
let
-
sslServices = map (certName: "acme-${certName}.service") vhostCertNames;
-
sslTargets = map (certName: "acme-finished-${certName}.target") vhostCertNames;
+
sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
in
mkIf (cfg.enableReload || vhostCertNames != [ ]) {
wants = optionals cfg.enableReload [ "nginx.service" ];
-
wantedBy = sslServices ++ [ "multi-user.target" ];
-
# Before the finished targets, after the renew services.
+
wantedBy = sslOrderRenewServices ++ [ "multi-user.target" ];
+
# XXX Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded.
-
before = sslTargets;
-
after = sslServices;
+
after = sslOrderRenewServices;
restartTriggers = optionals cfg.enableReload [ configFile ];
# Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs.
unitConfig = {
-
ConditionPathExists = optionals (sslServices != [ ]) (
+
ConditionPathExists = optionals (vhostCertNames != [ ]) (
map (certName: certs.${certName}.directory + "/fullchain.pem") vhostCertNames
);
# Disable rate limiting for this, because it may be triggered quickly a bunch of times
+5 -7
nixos/modules/services/web-servers/pomerium.nix
···
wants = [
"network.target"
]
-
++ (optional (cfg.useACMEHost != null) "acme-finished-${cfg.useACMEHost}.target");
+
++ (optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service");
after = [
"network.target"
]
-
++ (optional (cfg.useACMEHost != null) "acme-finished-${cfg.useACMEHost}.target");
+
++ (optional (cfg.useACMEHost != null) "acme-${cfg.useACMEHost}.service");
wantedBy = [ "multi-user.target" ];
environment = optionalAttrs (cfg.useACMEHost != null) {
CERTIFICATE_FILE = "fullchain.pem";
···
# 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
-
# 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.
systemd.services.pomerium-config-reload = mkIf (cfg.useACMEHost != null) {
# TODO(lukegb): figure out how to make config reloading work with credentials.
wantedBy = [
-
"acme-finished-${cfg.useACMEHost}.target"
+
"acme-order-renew-${cfg.useACMEHost}.service"
"multi-user.target"
];
-
# Before the finished targets, after the renew services.
-
before = [ "acme-finished-${cfg.useACMEHost}.target" ];
-
after = [ "acme-${cfg.useACMEHost}.service" ];
+
after = [ "acme-order-renew-${cfg.useACMEHost}.service" ];
# Block reloading if not all certs exist yet.
unitConfig.ConditionPathExists = [
"${config.security.acme.certs.${cfg.useACMEHost}.directory}/fullchain.pem"
+9 -18
nixos/tests/acme/caddy.nix
···
ca_domain = "${nodes.acme.test-support.acme.caDomain}"
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()
wait_for_running(acme)
acme.wait_for_open_port(443)
-
with subtest("Boot and acquire a new cert"):
-
caddy.start()
-
wait_for_running(caddy)
-
+
with subtest("Acquire a new cert"):
+
caddy.succeed(f"systemctl restart acme-{fqdn}.service")
check_issuer(caddy, fqdn, "pebble")
check_domain(caddy, fqdn, fqdn)
-
download_ca_certs(caddy, ca_domain)
-
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"):
+18 -4
nixos/tests/acme/default.nix
···
{ runTest }:
+
let
+
domain = "example.test";
+
in
{
http01-builtin = runTest ./http01-builtin.nix;
dns01 = runTest ./dns01.nix;
caddy = runTest ./caddy.nix;
nginx = runTest (
import ./webserver.nix {
+
inherit domain;
serverName = "nginx";
group = "nginx";
baseModule = {
···
addSSL = true;
useACMEHost = "proxied.example.test";
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 (
import ./webserver.nix {
+
inherit domain;
serverName = "httpd";
group = "wwwrun";
baseModule = {
···
useACMEHost = "proxied.example.test";
locations."/.well-known/acme-challenge" = {
proxyPass = "http://localhost:8080/.well-known/acme-challenge";
+
extraConfig = ''
+
ProxyPreserveHost On
+
'';
+
};
+
};
+
};
+
specialisation.nullroot.configuration = {
+
services.httpd.virtualHosts."nullroot.${domain}" = {
+
locations."/.well-known/acme-challenge" = {
+
proxyPass = "http://localhost:8081/.well-known/acme-challenge";
extraConfig = ''
ProxyPreserveHost On
'';
+68 -7
nixos/tests/acme/http01-builtin.nix
···
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 = {
renew.configuration = {
# Pebble provides 5 year long certs,
···
# old_hash will be used in the preservation tests later
old_hash = hash
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")
assert hash == hash_after, "Certificate was unexpectedly changed"
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "renew")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_issuer(builtin, cert, "pebble")
hash_after = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem | tee /dev/stderr")
assert hash != hash_after, "Certificate was not renewed"
+
check_permissions(builtin, cert, "acme")
+
with subtest("Handles email change correctly"):
hash = builtin.succeed(f"sha256sum /var/lib/acme/{cert}/cert.pem")
+
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "accountchange")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_issuer(builtin, cert, "pebble")
# 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")
···
# old_hash will be used in the preservation tests later
old_hash = hash_after
+
check_permissions(builtin, cert, "acme")
+
with subtest("Correctly implements OCSP stapling"):
check_stapling(builtin, cert, "${caDomain}", fail=True)
+
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "ocsp_stapling")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_stapling(builtin, cert, "${caDomain}")
+
check_permissions(builtin, cert, "acme")
with subtest("Handles keyType change correctly"):
check_key_bits(builtin, cert, 256)
+
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "keytype")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_key_bits(builtin, cert, 384)
# 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")
+
check_permissions(builtin, cert, "acme")
with subtest("Reuses generated, valid certs from previous configurations"):
# 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")
assert hash != old_hash, "Expected certificate to differ"
+
+
builtin.succeed("systemctl stop renew-triggered.target")
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")
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"):
check_domain(builtin, cert, f"builtin-alt.{domain}", fail=True)
+
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "add_cert_and_domain")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_issuer(builtin, cert, "pebble")
check_domain(builtin, cert, f"builtin-alt.{domain}")
check_issuer(builtin, cert2, "pebble")
check_domain(builtin, cert2, cert2)
# 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")
+
check_permissions(builtin, cert, "acme")
+
check_permissions(builtin, cert2, "acme")
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")
+
switch_to(builtin, "legacy_account_hash"
+
)
+
builtin.wait_for_unit("renew-triggered.target")
+
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"):
+
with subtest("Ensure concurrency limits work"):
+
builtin.succeed("systemctl stop renew-triggered.target")
switch_to(builtin, "concurrency")
+
builtin.wait_for_unit("renew-triggered.target")
+
check_issuer(builtin, cert3, "pebble")
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"):
+
acme.shutdown()
+
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 start acme-selfsigned-{cert}.service")
+
builtin.succeed(f"systemctl start acme-{cert}.service")
+
check_issuer(builtin, cert, "minica")
check_domain(builtin, cert, cert)
with subtest("Validate permissions (self-signed)"):
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")
'';
}
+33 -32
nixos/tests/acme/python-utils.py
···
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):
if fail:
···
# and matches the issuer we expect it to be.
# It's a good validation to ensure the cert.pem and fullchain.pem
# are not still selfsigned after verification
+
@backoff.protect
def check_issuer(node, cert_name, issuer) -> None:
for fname in ("cert.pem", "fullchain.pem"):
actual_issuer = node.succeed(
···
f"test $({stat} /var/lib/acme/{cert_name}/*.pem"
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(
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(
f"test $({stat} /var/lib/acme/{cert_name}"
···
f"test $(find /var/lib/acme/.lego/accounts -type f -exec {stat} {{}} \\;"
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
+67 -22
nixos/tests/acme/webserver.nix
···
serverName,
group,
baseModule,
-
domain ? "example.test",
+
domain,
}:
{
config,
···
timeout = 300;
};
+
interactive.sshBackdoor.enable = true;
+
nodes = {
# The fake ACME server which will respond to client requests
acme =
···
"certchange.${domain}"
"zeroconf.${domain}"
"zeroconf2.${domain}"
+
"zeroconf3.${domain}"
"nullroot.${domain}"
];
···
systemd.targets."renew-triggered" = {
wantedBy = [ "${serverName}-config-reload.service" ];
after = [ "${serverName}-config-reload.service" ];
+
unitConfig.RefuseManualStart = true;
};
security.acme.certs."proxied.${domain}" = {
···
# Test that "acmeRoot = null" still results in
# valid cert generation by inheriting defaults.
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}" = {
-
onlySSL = true;
+
addSSL = true;
enableACME = true;
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;
+
};
+
};
};
};
};
···
ca_domain = "${nodes.acme.test-support.acme.caDomain}"
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()
wait_for_running(acme)
acme.wait_for_open_port(443)
with subtest("Acquire a cert through a proxied lego"):
-
webserver.start()
-
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")
-
check_issuer(webserver, fqdn, "pebble")
-
check_connection(webserver, fqdn)
+
webserver.succeed(f"systemctl start acme-order-renew-{fqdn}.service")
+
webserver.wait_for_unit("renew-triggered.target")
+
download_ca_certs(webserver, ca_domain)
+
check_issuer(webserver, fqdn, "pebble")
+
check_connection(webserver, fqdn)
with subtest("security.acme changes reflect on web server part 1"):
check_connection(webserver, f"certchange.{domain}", fail=True)
···
switch_to(webserver, "nullroot")
webserver.wait_for_unit("renew-triggered.target")
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}...'")
'';
}
+5 -4
nixos/tests/step-ca.nix
···
caserver.wait_for_unit("step-ca.service")
caserver.wait_until_succeeds("journalctl -o cat -u step-ca.service | grep '${pkgs.step-ca.version}'")
-
caclient.wait_for_unit("acme-finished-caclient.target")
-
catester.succeed("curl https://caclient/ | grep \"Welcome to nginx!\"")
+
caclient.wait_for_unit("acme-caclient.service")
+
# The order is run asynchonously, keep trying.
+
catester.wait_until_succeeds("curl https://caclient/ | grep \"Welcome to nginx!\"")
caclientcaddy.wait_for_unit("caddy.service")
# It’s hard to know when Caddy has finished the ACME dance with
# step-ca, so we keep trying cURL until success.
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")
-
catester.succeed("curl https://caclienth2o/ | grep \"Welcome to H2O!\"")
+
catester.wait_until_succeeds("curl https://caclienth2o/ | grep \"Welcome to H2O!\"")
'';
}
)