Merge pull request #106857 from m1cr0man/master
nixos/acme: Fixes for account creation and remove tmpfiles usage
This commit is contained in:
commit
1030745555
@ -610,6 +610,15 @@ self: super:
|
|||||||
been dropped from upstream releases.
|
been dropped from upstream releases.
|
||||||
</para>
|
</para>
|
||||||
</listitem>
|
</listitem>
|
||||||
|
<listitem>
|
||||||
|
<para>
|
||||||
|
In the ACME module, the data used to build the hash for the account
|
||||||
|
directory has changed to accomodate new features to reduce account
|
||||||
|
rate limit issues. This will trigger new account creation on the first
|
||||||
|
rebuild following this update. No issues are expected to arise from this,
|
||||||
|
thanks to the new account creation handling.
|
||||||
|
</para>
|
||||||
|
</listitem>
|
||||||
<listitem>
|
<listitem>
|
||||||
<para>
|
<para>
|
||||||
<xref linkend="opt-users.users._name_.createHome" /> now always ensures home directory permissions to be <literal>0700</literal>.
|
<xref linkend="opt-users.users._name_.createHome" /> now always ensures home directory permissions to be <literal>0700</literal>.
|
||||||
|
@ -7,6 +7,11 @@ let
|
|||||||
numCerts = length (builtins.attrNames cfg.certs);
|
numCerts = length (builtins.attrNames cfg.certs);
|
||||||
_24hSecs = 60 * 60 * 24;
|
_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.
|
# There are many services required to make cert renewals work.
|
||||||
# They all follow a common structure:
|
# They all follow a common structure:
|
||||||
# - They inherit this commonServiceConfig
|
# - They inherit this commonServiceConfig
|
||||||
@ -19,7 +24,7 @@ let
|
|||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "acme";
|
User = "acme";
|
||||||
Group = mkDefault "acme";
|
Group = mkDefault "acme";
|
||||||
UMask = 0027;
|
UMask = 0023;
|
||||||
StateDirectoryMode = 750;
|
StateDirectoryMode = 750;
|
||||||
ProtectSystem = "full";
|
ProtectSystem = "full";
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
@ -54,23 +59,35 @@ let
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# Previously, all certs were owned by whatever user was configured in
|
# Ensures that directories which are shared across all certs
|
||||||
# config.security.acme.certs.<cert>.user. Now everything is owned by and
|
# exist and have the correct user and group, since group
|
||||||
# run by the acme user.
|
# is configurable on a per-cert basis.
|
||||||
userMigrationService = {
|
userMigrationService = let
|
||||||
description = "Fix owner and group of all ACME certificates";
|
script = with builtins; ''
|
||||||
|
chown -R acme .lego/accounts
|
||||||
script = with builtins; concatStringsSep "\n" (mapAttrsToList (cert: data: ''
|
'' + (concatStringsSep "\n" (mapAttrsToList (cert: data: ''
|
||||||
for fixpath in /var/lib/acme/${escapeShellArg cert} /var/lib/acme/.lego/${escapeShellArg cert}; do
|
for fixpath in ${escapeShellArg cert} .lego/${escapeShellArg cert}; do
|
||||||
if [ -d "$fixpath" ]; then
|
if [ -d "$fixpath" ]; then
|
||||||
chmod -R u=rwX,g=rX,o= "$fixpath"
|
chmod -R u=rwX,g=rX,o= "$fixpath"
|
||||||
chown -R acme:${data.group} "$fixpath"
|
chown -R acme:${data.group} "$fixpath"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
'') certConfigs);
|
'') certConfigs));
|
||||||
|
in {
|
||||||
|
description = "Fix owner and group of all ACME certificates";
|
||||||
|
|
||||||
# We don't want this to run every time a renewal happens
|
serviceConfig = commonServiceConfig // {
|
||||||
serviceConfig.RemainAfterExit = true;
|
# We don't want this to run every time a renewal happens
|
||||||
|
RemainAfterExit = true;
|
||||||
|
|
||||||
|
# These StateDirectory entries negate the need for tmpfiles
|
||||||
|
StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ];
|
||||||
|
StateDirectoryMode = 755;
|
||||||
|
WorkingDirectory = "/var/lib/acme";
|
||||||
|
|
||||||
|
# Run the start script as root
|
||||||
|
ExecStart = "+" + (pkgs.writeShellScript "acme-fixperms" script);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
certToConfig = cert: data: let
|
certToConfig = cert: data: let
|
||||||
@ -101,11 +118,10 @@ let
|
|||||||
${toString acmeServer} ${toString data.dnsProvider}
|
${toString acmeServer} ${toString data.dnsProvider}
|
||||||
${toString data.ocspMustStaple} ${data.keyType}
|
${toString data.ocspMustStaple} ${data.keyType}
|
||||||
'';
|
'';
|
||||||
mkHash = with builtins; val: substring 0 20 (hashString "sha256" val);
|
|
||||||
certDir = mkHash hashData;
|
certDir = mkHash hashData;
|
||||||
domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}";
|
domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}";
|
||||||
othersHash = mkHash "${toString acmeServer} ${data.keyType} ${data.email}";
|
accountHash = (mkAccountHash acmeServer data);
|
||||||
accountDir = "/var/lib/acme/.lego/accounts/" + othersHash;
|
accountDir = accountDirRoot + accountHash;
|
||||||
|
|
||||||
protocolOpts = if useDns then (
|
protocolOpts = if useDns then (
|
||||||
[ "--dns" data.dnsProvider ]
|
[ "--dns" data.dnsProvider ]
|
||||||
@ -142,9 +158,8 @@ let
|
|||||||
);
|
);
|
||||||
|
|
||||||
in {
|
in {
|
||||||
inherit accountDir selfsignedDeps;
|
inherit accountHash cert selfsignedDeps;
|
||||||
|
|
||||||
webroot = data.webroot;
|
|
||||||
group = data.group;
|
group = data.group;
|
||||||
|
|
||||||
renewTimer = {
|
renewTimer = {
|
||||||
@ -184,7 +199,10 @@ let
|
|||||||
|
|
||||||
StateDirectory = "acme/${cert}";
|
StateDirectory = "acme/${cert}";
|
||||||
|
|
||||||
BindPaths = "/var/lib/acme/.minica:/tmp/ca /var/lib/acme/${cert}:/tmp/${keyName}";
|
BindPaths = [
|
||||||
|
"/var/lib/acme/.minica:/tmp/ca"
|
||||||
|
"/var/lib/acme/${cert}:/tmp/${keyName}"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Working directory will be /tmp
|
# Working directory will be /tmp
|
||||||
@ -222,16 +240,22 @@ let
|
|||||||
serviceConfig = commonServiceConfig // {
|
serviceConfig = commonServiceConfig // {
|
||||||
Group = data.group;
|
Group = data.group;
|
||||||
|
|
||||||
# AccountDir dir will be created by tmpfiles to ensure correct permissions
|
# Keep in mind that these directories will be deleted if the user runs
|
||||||
# And to avoid deletion during systemctl clean
|
# systemctl clean --what=state
|
||||||
# acme/.lego/${cert} is listed so that it is deleted during systemctl clean
|
# acme/.lego/${cert} is listed for this reason.
|
||||||
StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir}";
|
StateDirectory = [
|
||||||
|
"acme/${cert}"
|
||||||
|
"acme/.lego/${cert}"
|
||||||
|
"acme/.lego/${cert}/${certDir}"
|
||||||
|
"acme/.lego/accounts/${accountHash}"
|
||||||
|
];
|
||||||
|
|
||||||
# Needs to be space separated, but can't use a multiline string because that'll include newlines
|
# Needs to be space separated, but can't use a multiline string because that'll include newlines
|
||||||
BindPaths =
|
BindPaths = [
|
||||||
"${accountDir}:/tmp/accounts " +
|
"${accountDir}:/tmp/accounts"
|
||||||
"/var/lib/acme/${cert}:/tmp/out " +
|
"/var/lib/acme/${cert}:/tmp/out"
|
||||||
"/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates ";
|
"/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates"
|
||||||
|
];
|
||||||
|
|
||||||
# Only try loading the credentialsFile if the dns challenge is enabled
|
# Only try loading the credentialsFile if the dns challenge is enabled
|
||||||
EnvironmentFile = mkIf useDns data.credentialsFile;
|
EnvironmentFile = mkIf useDns data.credentialsFile;
|
||||||
@ -248,13 +272,18 @@ let
|
|||||||
|
|
||||||
# Working directory will be /tmp
|
# Working directory will be /tmp
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euxo pipefail
|
||||||
|
|
||||||
|
${optionalString (data.webroot != null) ''
|
||||||
|
# Ensure the webroot exists
|
||||||
|
mkdir -p '${data.webroot}/.well-known/acme-challenge'
|
||||||
|
chown 'acme:${data.group}' ${data.webroot}/{.well-known,.well-known/acme-challenge}
|
||||||
|
''}
|
||||||
|
|
||||||
echo '${domainHash}' > domainhash.txt
|
echo '${domainHash}' > domainhash.txt
|
||||||
|
|
||||||
# Check if we can renew
|
# Check if we can renew
|
||||||
# Certificates and account credentials must exist
|
if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a -n "$(ls -1 accounts)" ]; then
|
||||||
if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a "$(ls -1 accounts)" ]; then
|
|
||||||
|
|
||||||
# When domains are updated, there's no need to do a full
|
# 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.
|
# Lego run, but it's likely renew won't work if days is too low.
|
||||||
@ -664,21 +693,33 @@ in {
|
|||||||
|
|
||||||
systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;
|
systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;
|
||||||
|
|
||||||
# .lego and .lego/accounts specified to fix any incorrect permissions
|
systemd.targets = let
|
||||||
systemd.tmpfiles.rules = [
|
# Create some targets which can be depended on to be "active" after cert renewals
|
||||||
"d /var/lib/acme/.lego - acme acme"
|
finishedTargets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
|
||||||
"d /var/lib/acme/.lego/accounts - acme acme"
|
wantedBy = [ "default.target" ];
|
||||||
] ++ (unique (concatMap (conf: [
|
requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
|
||||||
"d ${conf.accountDir} - acme acme"
|
after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
|
||||||
] ++ (optional (conf.webroot != null) "d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}")
|
}) certConfigs;
|
||||||
) (attrValues certConfigs)));
|
|
||||||
|
|
||||||
# Create some targets which can be depended on to be "active" after cert renewals
|
# Create targets to limit the number of simultaneous account creations
|
||||||
systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
|
# How it works:
|
||||||
wantedBy = [ "default.target" ];
|
# - Pick a "leader" cert service, which will be in charge of creating the account,
|
||||||
requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
|
# and run first (requires + after)
|
||||||
after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
|
# - Make all other cert services sharing the same account wait for the leader to
|
||||||
}) certConfigs;
|
# finish before starting (requiredBy + before).
|
||||||
|
# Using a target here is fine - account creation is a one time event. Even if
|
||||||
|
# systemd clean --what=state is used to delete the account, so long as the user
|
||||||
|
# then runs one of the cert services, there won't be any issues.
|
||||||
|
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;
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -162,6 +162,9 @@ services.httpd = {
|
|||||||
<xref linkend="opt-security.acme.certs"/>."foo.example.com" = {
|
<xref linkend="opt-security.acme.certs"/>."foo.example.com" = {
|
||||||
<link linkend="opt-security.acme.certs._name_.webroot">webroot</link> = "/var/lib/acme/.challenges";
|
<link linkend="opt-security.acme.certs._name_.webroot">webroot</link> = "/var/lib/acme/.challenges";
|
||||||
<link linkend="opt-security.acme.certs._name_.email">email</link> = "foo@example.com";
|
<link linkend="opt-security.acme.certs._name_.email">email</link> = "foo@example.com";
|
||||||
|
# Ensure that the web server you use can read the generated certs
|
||||||
|
# Take a look at the <link linkend="opt-services.nginx.group">group</link> option for the web server you choose.
|
||||||
|
<link linkend="opt-security.acme.certs._name_.group">group</link> = "nginx";
|
||||||
# Since we have a wildcard vhost to handle port 80,
|
# Since we have a wildcard vhost to handle port 80,
|
||||||
# we can generate certs for anything!
|
# we can generate certs for anything!
|
||||||
# Just make sure your DNS resolves them.
|
# Just make sure your DNS resolves them.
|
||||||
@ -257,10 +260,11 @@ chmod 400 /var/lib/secrets/certs.secret
|
|||||||
<para>
|
<para>
|
||||||
Should you need to regenerate a particular certificate in a hurry, such
|
Should you need to regenerate a particular certificate in a hurry, such
|
||||||
as when a vulnerability is found in Let's Encrypt, there is now a convenient
|
as when a vulnerability is found in Let's Encrypt, there is now a convenient
|
||||||
mechanism for doing so. Running <literal>systemctl clean acme-example.com.service</literal>
|
mechanism for doing so. Running
|
||||||
will remove all certificate files for the given domain, allowing you to then
|
<literal>systemctl clean --what=state acme-example.com.service</literal>
|
||||||
<literal>systemctl start acme-example.com.service</literal> to generate fresh
|
will remove all certificate files and the account data for the given domain,
|
||||||
ones.
|
allowing you to then <literal>systemctl start acme-example.com.service</literal>
|
||||||
|
to generate fresh ones.
|
||||||
</para>
|
</para>
|
||||||
</section>
|
</section>
|
||||||
<section xml:id="module-security-acme-fix-jws">
|
<section xml:id="module-security-acme-fix-jws">
|
||||||
|
@ -77,6 +77,27 @@ in import ./make-test-python.nix ({ lib, ... }: {
|
|||||||
after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
|
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".preStart = testScript;
|
||||||
|
systemd.services."c.example.test".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.
|
# Cert config changes will not cause the nginx configuration to change.
|
||||||
# This tests that the reload service is correctly triggered.
|
# This tests that the reload service is correctly triggered.
|
||||||
# It also tests that postRun is exec'd as root
|
# It also tests that postRun is exec'd as root
|
||||||
@ -289,7 +310,7 @@ in import ./make-test-python.nix ({ lib, ... }: {
|
|||||||
acme.start()
|
acme.start()
|
||||||
webserver.start()
|
webserver.start()
|
||||||
|
|
||||||
acme.wait_for_unit("default.target")
|
acme.wait_for_unit("network-online.target")
|
||||||
acme.wait_for_unit("pebble.service")
|
acme.wait_for_unit("pebble.service")
|
||||||
|
|
||||||
client.succeed("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt")
|
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_issuer(webserver, "a.example.test", "pebble")
|
||||||
check_connection(client, "a.example.test")
|
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"):
|
with subtest("Can reload web server when cert configuration changes"):
|
||||||
switch_to(webserver, "cert-change")
|
switch_to(webserver, "cert-change")
|
||||||
webserver.wait_for_unit("acme-finished-a.example.test.target")
|
webserver.wait_for_unit("acme-finished-a.example.test.target")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user