nixos/acme: improve scalability - reduce superfluous unit activations (#422076)
This commit is contained in:
commit
9b8ea589ea
@ -1468,6 +1468,9 @@
|
||||
"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"
|
||||
],
|
||||
|
||||
@ -171,6 +171,21 @@
|
||||
|
||||
- `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.
|
||||
|
||||
|
||||
@ -318,7 +318,7 @@ can be applied to any service.
|
||||
|
||||
# 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;
|
||||
@ -376,3 +376,11 @@ systemd-tmpfiles --create
|
||||
# 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.
|
||||
|
||||
@ -24,56 +24,32 @@ let
|
||||
# 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:
|
||||
@ -160,58 +136,49 @@ let
|
||||
);
|
||||
|
||||
# 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 ];
|
||||
|
||||
# We don't want this to run every time a renewal happens
|
||||
RemainAfterExit = true;
|
||||
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
|
||||
'';
|
||||
|
||||
# StateDirectory entries are a cleaner, service-level mechanism
|
||||
# for dealing with persistent service data
|
||||
StateDirectory = [
|
||||
"acme"
|
||||
"acme/.lego"
|
||||
"acme/.lego/accounts"
|
||||
];
|
||||
StateDirectoryMode = "0755";
|
||||
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}";
|
||||
|
||||
# Creates ${lockdir}. Earlier RemainAfterExit=true means
|
||||
# it does not get deleted immediately.
|
||||
RuntimeDirectory = "acme";
|
||||
RuntimeDirectoryMode = "0700";
|
||||
# We don't want this to run every time a renewal happens
|
||||
RemainAfterExit = true;
|
||||
|
||||
# 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";
|
||||
};
|
||||
}
|
||||
# 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";
|
||||
|
||||
# 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";
|
||||
};
|
||||
})
|
||||
];
|
||||
# Creates ${lockdir}. Earlier RemainAfterExit=true means
|
||||
# it does not get deleted immediately.
|
||||
RuntimeDirectory = "acme";
|
||||
RuntimeDirectoryMode = "0700";
|
||||
|
||||
# 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:
|
||||
@ -219,7 +186,6 @@ let
|
||||
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.
|
||||
@ -339,16 +305,18 @@ let
|
||||
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
|
||||
@ -364,15 +332,29 @@ let
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -380,52 +362,83 @@ let
|
||||
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
|
||||
@ -491,7 +504,7 @@ let
|
||||
};
|
||||
|
||||
# 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
|
||||
|
||||
@ -523,25 +536,12 @@ let
|
||||
[[ $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
|
||||
@ -553,13 +553,12 @@ let
|
||||
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
|
||||
@ -567,10 +566,12 @@ let
|
||||
|
||||
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
|
||||
@ -581,10 +582,13 @@ let
|
||||
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/.
|
||||
'';
|
||||
@ -905,19 +909,6 @@ in
|
||||
|
||||
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;
|
||||
@ -1003,10 +994,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."
|
||||
)
|
||||
(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" ]
|
||||
@ -1161,45 +1155,25 @@ in
|
||||
|
||||
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,
|
||||
@ -1214,8 +1188,8 @@ in
|
||||
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
|
||||
@ -1224,10 +1198,11 @@ in
|
||||
before = followers;
|
||||
requires = [ leader ];
|
||||
after = [ leader ];
|
||||
unitConfig.RefuseManualStart = true;
|
||||
}
|
||||
) (lib.groupBy (conf: conf.accountHash) (lib.attrValues certConfigs));
|
||||
in
|
||||
finishedTargets // accountTargets;
|
||||
accountTargets;
|
||||
})
|
||||
];
|
||||
|
||||
|
||||
@ -156,7 +156,7 @@ in
|
||||
"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";
|
||||
|
||||
@ -48,8 +48,6 @@ let
|
||||
) (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:
|
||||
@ -914,13 +912,14 @@ in
|
||||
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 = [
|
||||
@ -960,19 +959,17 @@ in
|
||||
|
||||
# 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.
|
||||
|
||||
@ -14,13 +14,11 @@ let
|
||||
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} {
|
||||
@ -392,7 +390,7 @@ in
|
||||
++ map (
|
||||
name:
|
||||
mkCertOwnershipAssertion {
|
||||
cert = config.security.acme.certs.${name};
|
||||
cert = certs.${name};
|
||||
groups = config.users.groups;
|
||||
services = [ config.systemd.services.caddy ];
|
||||
}
|
||||
@ -412,11 +410,8 @@ in
|
||||
|
||||
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;
|
||||
|
||||
@ -434,14 +434,13 @@ in
|
||||
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'";
|
||||
@ -490,16 +489,14 @@ in
|
||||
|
||||
# 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 (
|
||||
|
||||
@ -15,8 +15,6 @@ let
|
||||
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
|
||||
@ -442,6 +440,7 @@ let
|
||||
auth_basic off;
|
||||
auth_request off;
|
||||
proxy_pass http://${vhost.acmeFallbackHost};
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
''}
|
||||
'';
|
||||
@ -1481,16 +1480,14 @@ in
|
||||
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}
|
||||
@ -1585,26 +1582,24 @@ in
|
||||
# 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
|
||||
|
||||
@ -72,11 +72,11 @@ in
|
||||
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";
|
||||
@ -127,18 +127,16 @@ in
|
||||
|
||||
# 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"
|
||||
|
||||
@ -85,33 +85,24 @@ in
|
||||
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"):
|
||||
check_connection(caddy, f"caddy-alt.{domain}", fail=True)
|
||||
switch_to(caddy, "add_domain")
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{ 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 = {
|
||||
@ -22,17 +26,17 @@
|
||||
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 = {
|
||||
@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@ -37,6 +37,12 @@ in
|
||||
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,
|
||||
@ -177,17 +183,29 @@ in
|
||||
# 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")
|
||||
@ -202,58 +220,101 @@ in
|
||||
# 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(f"stat {legacy_account_dir} > /dev/stderr && rm -rf {legacy_account_dir}")
|
||||
builtin.succeed("systemctl stop renew-triggered.target")
|
||||
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")
|
||||
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")
|
||||
'';
|
||||
}
|
||||
|
||||
@ -3,6 +3,36 @@ import time
|
||||
|
||||
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:
|
||||
@ -39,6 +69,7 @@ def switch_to(node, name, fail=False) -> None:
|
||||
# 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(
|
||||
@ -102,9 +133,10 @@ def check_permissions(node, cert_name, group):
|
||||
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}"
|
||||
@ -115,37 +147,6 @@ def check_permissions(node, cert_name, group):
|
||||
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
|
||||
def download_ca_certs(node, ca_domain):
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
serverName,
|
||||
group,
|
||||
baseModule,
|
||||
domain ? "example.test",
|
||||
domain,
|
||||
}:
|
||||
{
|
||||
config,
|
||||
@ -18,6 +18,8 @@
|
||||
timeout = 300;
|
||||
};
|
||||
|
||||
interactive.sshBackdoor.enable = true;
|
||||
|
||||
nodes = {
|
||||
# The fake ACME server which will respond to client requests
|
||||
acme =
|
||||
@ -45,6 +47,7 @@
|
||||
"certchange.${domain}"
|
||||
"zeroconf.${domain}"
|
||||
"zeroconf2.${domain}"
|
||||
"zeroconf3.${domain}"
|
||||
"nullroot.${domain}"
|
||||
];
|
||||
|
||||
@ -57,6 +60,7 @@
|
||||
systemd.targets."renew-triggered" = {
|
||||
wantedBy = [ "${serverName}-config-reload.service" ];
|
||||
after = [ "${serverName}-config-reload.service" ];
|
||||
unitConfig.RefuseManualStart = true;
|
||||
};
|
||||
|
||||
security.acme.certs."proxied.${domain}" = {
|
||||
@ -101,13 +105,42 @@
|
||||
# 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -121,30 +154,24 @@
|
||||
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)
|
||||
@ -181,5 +208,23 @@
|
||||
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}...'")
|
||||
'';
|
||||
}
|
||||
|
||||
@ -137,17 +137,18 @@ import ./make-test-python.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!\"")
|
||||
'';
|
||||
}
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user