nixos/acme: prevent mass account creation

Closes #106565
When generating multiple certificates which all
share the same server + email, lego will attempt
to create an account multiple times. By adding an
account creation target certificates which share
an account will wait for one service (chosen at
config build time) to complete first.
wip/yesman
Lucas Savva 3 years ago
parent e5e7c9b0f9
commit 85769a8cd8
  1. 46
      nixos/modules/security/acme.nix
  2. 32
      nixos/tests/acme.nix

@ -7,6 +7,11 @@ let
numCerts = length (builtins.attrNames cfg.certs);
_24hSecs = 60 * 60 * 24;
# Used to make unique paths for each cert/account config set
mkHash = with builtins; val: substring 0 20 (hashString "sha256" val);
mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}";
accountDirRoot = "/var/lib/acme/.lego/accounts/";
# There are many services required to make cert renewals work.
# They all follow a common structure:
# - They inherit this commonServiceConfig
@ -101,11 +106,10 @@ let
${toString acmeServer} ${toString data.dnsProvider}
${toString data.ocspMustStaple} ${data.keyType}
'';
mkHash = with builtins; val: substring 0 20 (hashString "sha256" val);
certDir = mkHash hashData;
domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}";
othersHash = mkHash "${toString acmeServer} ${data.keyType} ${data.email}";
accountDir = "/var/lib/acme/.lego/accounts/" + othersHash;
accountHash = (mkAccountHash acmeServer data);
accountDir = accountDirRoot + accountHash;
protocolOpts = if useDns then (
[ "--dns" data.dnsProvider ]
@ -142,7 +146,7 @@ let
);
in {
inherit accountDir selfsignedDeps;
inherit accountHash accountDir cert selfsignedDeps;
webroot = data.webroot;
group = data.group;
@ -253,8 +257,7 @@ let
echo '${domainHash}' > domainhash.txt
# Check if we can renew
# Certificates and account credentials must exist
if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a "$(ls -1 accounts)" ]; then
if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a -n "$(ls -1 accounts)" ]; then
# When domains are updated, there's no need to do a full
# Lego run, but it's likely renew won't work if days is too low.
@ -670,15 +673,32 @@ in {
"d /var/lib/acme/.lego/accounts - acme acme"
] ++ (unique (concatMap (conf: [
"d ${conf.accountDir} - acme acme"
] ++ (optional (conf.webroot != null) "d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}")
] ++ (optionals (conf.webroot != null) [
"d ${conf.webroot} - acme ${conf.group}"
"d ${conf.webroot}/.well-known - acme ${conf.group}"
"d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}"
])
) (attrValues certConfigs)));
# Create some targets which can be depended on to be "active" after cert renewals
systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
wantedBy = [ "default.target" ];
requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
}) certConfigs;
systemd.targets = let
# Create some targets which can be depended on to be "active" after cert renewals
finishedTargets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
wantedBy = [ "default.target" ];
requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
}) certConfigs;
# Create targets to limit the number of simultaneous account creations
accountTargets = mapAttrs' (hash: confs: let
leader = "acme-${(builtins.head confs).cert}.service";
dependantServices = map (conf: "acme-${conf.cert}.service") (builtins.tail confs);
in nameValuePair "acme-account-${hash}" {
requiredBy = dependantServices;
before = dependantServices;
requires = [ leader ];
after = [ leader ];
}) (groupBy (conf: conf.accountHash) (attrValues certConfigs));
in finishedTargets // accountTargets;
})
];

@ -77,6 +77,27 @@ in import ./make-test-python.nix ({ lib, ... }: {
after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
};
# Test that account creation is collated into one service
specialisation.account-creation.configuration = { nodes, pkgs, lib, ... }: let
email = "newhostmaster@example.test";
caDomain = nodes.acme.config.test-support.acme.caDomain;
# Exit 99 to make it easier to track if this is the reason a renew failed
testScript = ''
test -e accounts/${caDomain}/${email}/account.json || exit 99
'';
in {
security.acme.email = lib.mkForce email;
systemd.services."b.example.test".serviceConfig.preStart = testScript;
systemd.services."c.example.test".serviceConfig.preStart = testScript;
services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // {
enableACME = true;
};
services.nginx.virtualHosts."c.example.test" = (vhostBase pkgs) // {
enableACME = true;
};
};
# Cert config changes will not cause the nginx configuration to change.
# This tests that the reload service is correctly triggered.
# It also tests that postRun is exec'd as root
@ -289,7 +310,7 @@ in import ./make-test-python.nix ({ lib, ... }: {
acme.start()
webserver.start()
acme.wait_for_unit("default.target")
acme.wait_for_unit("network-online.target")
acme.wait_for_unit("pebble.service")
client.succeed("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt")
@ -314,6 +335,15 @@ in import ./make-test-python.nix ({ lib, ... }: {
check_issuer(webserver, "a.example.test", "pebble")
check_connection(client, "a.example.test")
with subtest("Runs 1 cert for account creation before others"):
switch_to(webserver, "account-creation")
webserver.wait_for_unit("acme-finished-a.example.test.target")
check_connection(client, "a.example.test")
webserver.wait_for_unit("acme-finished-b.example.test.target")
webserver.wait_for_unit("acme-finished-c.example.test.target")
check_connection(client, "b.example.test")
check_connection(client, "c.example.test")
with subtest("Can reload web server when cert configuration changes"):
switch_to(webserver, "cert-change")
webserver.wait_for_unit("acme-finished-a.example.test.target")

Loading…
Cancel
Save