nixos/acme: replace simp-le with lego client

Lego allows users to use the DNS-01 challenge to validate their
certificates. It is mostly backwards compatible, with a few
caveats.

 - extraDomains can no longer have different webroots to the
   main webroot for the cert.
 - An email address is now mandatory for account creation

The following other changes were required:
 - Deprecate security.acme.certs.<name>.plugins, as this was
   specific to simp-le
 - Rename security.acme.validMin to validMinDays, to avoid
   confusion and errors. Lego requires the TTL to be specified in
   days
 - Add options to cover DNS challenge (dnsProvider,
   credentialsFile, dnsPropagationCheck)
 - A shared state directory is now used (/var/lib/acme/.lego)
   to avoid account creation rate limits and share credentials
   between certs
This commit is contained in:
Lucas Savva 2020-01-12 21:05:57 +00:00
parent 832d1f4a57
commit 1e3607d331
2 changed files with 103 additions and 40 deletions

View File

@ -1,7 +1,5 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
with lib; with lib;
let let
cfg = config.security.acme; cfg = config.security.acme;
@ -76,20 +74,6 @@ let
''; '';
}; };
plugins = mkOption {
type = types.listOf (types.enum [
"cert.der" "cert.pem" "chain.pem" "external.sh"
"fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json" "account_reg.json"
]);
default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" "account_reg.json" ];
description = ''
Plugins to enable. With default settings simp_le will
store public certificate bundle in <filename>fullchain.pem</filename>,
private key in <filename>key.pem</filename> and those two previous
files combined in <filename>full.pem</filename> in its state directory.
'';
};
directory = mkOption { directory = mkOption {
type = types.str; type = types.str;
readOnly = true; readOnly = true;
@ -111,6 +95,31 @@ let
own server roots if needed. own server roots if needed.
''; '';
}; };
dnsProvider = mkOption {
type = types.nullOr types.str;
example = "route53";
default = null;
description = "DNS Challenge provider";
};
credentialsFile = mkOption {
type = types.str;
description = ''
File containing DNS provider credentials passed as environment variables.
See https://go-acme.github.io/lego/dns/ for more information.
'';
example = "/var/src/secrets/example.org-route53-api-token";
};
dnsPropagationCheck = mkOption {
type = types.bool;
default = true;
description = ''
Toggles LEGo DNS propagation check, which is used alongside DNS-01
challenge to ensure the DNS entries required are available
'';
};
}; };
}; };
@ -130,14 +139,21 @@ in
(mkRemovedOptionModule [ "security" "acme" "directory"] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.") (mkRemovedOptionModule [ "security" "acme" "directory"] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.")
(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") (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")
(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") (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")
(mkChangedOptionModule [ "security" "acme" "validMin"] [ "security" "acme" "validMinDays"] (config: config.security.acme.validMin / (24 * 3600)))
]; ];
options = { options = {
security.acme = { security.acme = {
validMin = mkOption { validMinDays = mkOption {
type = types.int; type = types.int;
default = 30 * 24 * 3600; default = 30;
description = "Minimum remaining validity before renewal in seconds."; description = "Minimum remaining validity before renewal in days.";
};
email = mkOption {
type = types.nullOr types.str;
default = null;
description = "Contact email address for the CA to be able to reach you.";
}; };
renewInterval = mkOption { renewInterval = mkOption {
@ -173,6 +189,15 @@ in
''; '';
}; };
acceptTerms = mkOption {
type = types.bool;
default = true;
description = ''
Accept the current Let's Encrypt terms of service.
See https://letsencrypt.org/repository/
'';
};
certs = mkOption { certs = mkOption {
default = { }; default = { };
type = with types; attrsOf (submodule certOpts); type = with types; attrsOf (submodule certOpts);
@ -204,27 +229,47 @@ in
config = mkMerge [ config = mkMerge [
(mkIf (cfg.certs != { }) { (mkIf (cfg.certs != { }) {
assertions = let
certs = (mapAttrsToList (k: v: v) cfg.certs);
in [
{
assertion = all (certOpts: certOpts.dnsProvider == null || certOpts.webroot == null) certs;
message = ''
Options `security.acme.certs.<name>.dnsProvider` and
`security.acme.certs.<name>.webroot` are mutually exclusive.
'';
}
{
assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs;
message = ''
You must define `security.acme.certs.<name>.email` or
`security.acme.email` to register with the CA.
'';
}
];
systemd.services = let systemd.services = let
services = concatLists servicesLists; services = concatLists servicesLists;
servicesLists = mapAttrsToList certToServices cfg.certs; servicesLists = mapAttrsToList certToServices cfg.certs;
certToServices = cert: data: certToServices = cert: data:
let let
# StateDirectory must be relative, and will be created under /var/lib by systemd
lpath = "acme/${cert}"; lpath = "acme/${cert}";
apath = "/var/lib/${lpath}";
spath = "/var/lib/acme/.lego";
rights = if data.allowKeysForGroup then "750" else "700"; rights = if data.allowKeysForGroup then "750" else "700";
cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ] globalOpts = [ "-d" data.domain "--email" data.email "--path" "." ]
++ optionals (data.email != null) [ "--email" data.email ] ++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
++ concatMap (p: [ "-f" p ]) data.plugins ++ optionals (data.dnsProvider != null && !cfg.dnsPropagationCheck) [ "--dns.disable-cp" ]
++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains) ++ concatLists (mapAttrsToList (name: root: [ "-d" name ]) data.extraDomains)
++ (if data.dnsProvider != null then [ "--dns" data.dnsProvider ] else [ "--http" "--http.webroot" data.webroot ])
++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)]; ++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)];
runOpts = escapeShellArgs (globalOpts ++ [ "run" ]);
renewOpts = escapeShellArgs (globalOpts ++ [ "renew" "--days" (toString cfg.validMinDays) ]);
acmeService = { acmeService = {
description = "Renew ACME Certificate for ${cert}"; description = "Renew ACME Certificate for ${cert}";
after = [ "network.target" "network-online.target" ]; after = [ "network.target" "network-online.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
# simp_le uses requests, which uses certifi under the hood,
# which doesn't respect the system trust store.
# At least in the acme test, we provision a fake CA, impersonating the LE endpoint.
# REQUESTS_CA_BUNDLE is a way to teach python requests to use something else
environment.REQUESTS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
# With RemainAfterExit the service is considered active even # With RemainAfterExit the service is considered active even
@ -233,18 +278,36 @@ in
# the permissions of the StateDirectory get adjusted # the permissions of the StateDirectory get adjusted
# according to the specified group # according to the specified group
RemainAfterExit = true; RemainAfterExit = true;
SuccessExitStatus = [ "0" "1" ];
User = data.user; User = data.user;
Group = data.group; Group = data.group;
PrivateTmp = true; PrivateTmp = true;
StateDirectory = lpath; StateDirectory = "acme/.lego ${lpath}";
StateDirectoryMode = rights; StateDirectoryMode = rights;
WorkingDirectory = "/var/lib/${lpath}"; WorkingDirectory = spath;
ExecStart = "${pkgs.simp_le}/bin/simp_le ${escapeShellArgs cmdline}"; # Only try loading the credentialsFile if the dns challenge is enabled
EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null;
ExecStart = pkgs.writeScript "acme-start" ''
#!${pkgs.runtimeShell} -e
${pkgs.lego}/bin/lego ${renewOpts} || ${pkgs.lego}/bin/lego ${runOpts}
'';
ExecStartPost = ExecStartPost =
let let
script = pkgs.writeScript "acme-post-start" '' script = pkgs.writeScript "acme-post-start" ''
#!${pkgs.runtimeShell} -e #!${pkgs.runtimeShell} -e
cd ${apath}
# Test that existing cert is older than new cert
KEY=${spath}/certificates/*${data.domain}.key
if [ -e $KEY -a $KEY -nt key.pem ]; then
cp -p ${spath}/certificates/*${data.domain}.key key.pem
cp -p ${spath}/certificates/*${data.domain}.crt cert.pem
cp -p ${spath}/certificates/*${data.domain}.issuer.crt chain.pem
cat cert.pem chain.pem > fullchain.pem
cat key.pem cert.pem chain.pem > full.pem
chmod ${rights} *.pem
chown '${data.user}:${data.group}' *.pem
fi
${data.postRun} ${data.postRun}
''; '';
in in
@ -276,17 +339,17 @@ in
-out $workdir/server.crt -out $workdir/server.crt
# Copy key to destination # Copy key to destination
cp $workdir/server.key /var/lib/${lpath}/key.pem cp $workdir/server.key ${apath}/key.pem
# Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates) # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
cat $workdir/{server.crt,ca.crt} > "/var/lib/${lpath}/fullchain.pem" cat $workdir/{server.crt,ca.crt} > "${apath}/fullchain.pem"
# Create full.pem for e.g. lighttpd # Create full.pem for e.g. lighttpd
cat $workdir/{server.key,server.crt,ca.crt} > "/var/lib/${lpath}/full.pem" cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem"
# Give key acme permissions # Give key acme permissions
chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem
chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem chmod ${rights} "${apath}/"{key,fullchain,full}.pem
''; '';
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
@ -297,7 +360,7 @@ in
}; };
unitConfig = { unitConfig = {
# Do not create self-signed key when key already exists # Do not create self-signed key when key already exists
ConditionPathExists = "!/var/lib/${lpath}/key.pem"; ConditionPathExists = "!${apath}/key.pem";
}; };
}; };
in ( in (
@ -334,7 +397,7 @@ in
]; ];
meta = { meta = {
maintainers = with lib.maintainers; [ abbradar fpletz globin ]; maintainers = with lib.maintainers; [ abbradar fpletz globin m1cr0man ];
doc = ./acme.xml; doc = ./acme.xml;
}; };
} }

View File

@ -7,7 +7,7 @@
<para> <para>
NixOS supports automatic domain validation &amp; certificate retrieval and NixOS supports automatic domain validation &amp; certificate retrieval and
renewal using the ACME protocol. This is currently only implemented by and renewal using the ACME protocol. This is currently only implemented by and
for Let's Encrypt. The alternative ACME client <literal>simp_le</literal> is for Let's Encrypt. The alternative ACME client <literal>LEGo</literal> is
used under the hood. used under the hood.
</para> </para>
<section xml:id="module-security-acme-prerequisites"> <section xml:id="module-security-acme-prerequisites">