From 1e3607d331a650e958b48e0c6a9231e68dd023f8 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Sun, 12 Jan 2020 21:05:57 +0000 Subject: [PATCH] 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..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 --- nixos/modules/security/acme.nix | 141 +++++++++++++++++++++++--------- nixos/modules/security/acme.xml | 2 +- 2 files changed, 103 insertions(+), 40 deletions(-) diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index 890c421b0ea9..9375115ddcbc 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -1,7 +1,5 @@ { config, lib, pkgs, ... }: - with lib; - let 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 fullchain.pem, - private key in key.pem and those two previous - files combined in full.pem in its state directory. - ''; - }; - directory = mkOption { type = types.str; readOnly = true; @@ -111,6 +95,31 @@ let 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" "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") + (mkChangedOptionModule [ "security" "acme" "validMin"] [ "security" "acme" "validMinDays"] (config: config.security.acme.validMin / (24 * 3600))) ]; options = { security.acme = { - validMin = mkOption { + validMinDays = mkOption { type = types.int; - default = 30 * 24 * 3600; - description = "Minimum remaining validity before renewal in seconds."; + default = 30; + 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 { @@ -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 { default = { }; type = with types; attrsOf (submodule certOpts); @@ -204,27 +229,47 @@ in config = mkMerge [ (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..dnsProvider` and + `security.acme.certs..webroot` are mutually exclusive. + ''; + } + { + assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs; + message = '' + You must define `security.acme.certs..email` or + `security.acme.email` to register with the CA. + ''; + } + ]; + systemd.services = let services = concatLists servicesLists; servicesLists = mapAttrsToList certToServices cfg.certs; certToServices = cert: data: let + # StateDirectory must be relative, and will be created under /var/lib by systemd lpath = "acme/${cert}"; + apath = "/var/lib/${lpath}"; + spath = "/var/lib/acme/.lego"; rights = if data.allowKeysForGroup then "750" else "700"; - cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ] - ++ optionals (data.email != null) [ "--email" data.email ] - ++ concatMap (p: [ "-f" p ]) data.plugins - ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains) + globalOpts = [ "-d" data.domain "--email" data.email "--path" "." ] + ++ optionals (cfg.acceptTerms) [ "--accept-tos" ] + ++ optionals (data.dnsProvider != null && !cfg.dnsPropagationCheck) [ "--dns.disable-cp" ] + ++ 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)]; + runOpts = escapeShellArgs (globalOpts ++ [ "run" ]); + renewOpts = escapeShellArgs (globalOpts ++ [ "renew" "--days" (toString cfg.validMinDays) ]); acmeService = { description = "Renew ACME Certificate for ${cert}"; after = [ "network.target" "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 = { Type = "oneshot"; # With RemainAfterExit the service is considered active even @@ -233,18 +278,36 @@ in # the permissions of the StateDirectory get adjusted # according to the specified group RemainAfterExit = true; - SuccessExitStatus = [ "0" "1" ]; User = data.user; Group = data.group; PrivateTmp = true; - StateDirectory = lpath; + StateDirectory = "acme/.lego ${lpath}"; StateDirectoryMode = rights; - WorkingDirectory = "/var/lib/${lpath}"; - ExecStart = "${pkgs.simp_le}/bin/simp_le ${escapeShellArgs cmdline}"; + WorkingDirectory = spath; + # 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 = let script = pkgs.writeScript "acme-post-start" '' #!${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} ''; in @@ -276,17 +339,17 @@ in -out $workdir/server.crt # 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) - 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 - 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 - chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem - chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem + chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem + chmod ${rights} "${apath}/"{key,fullchain,full}.pem ''; serviceConfig = { Type = "oneshot"; @@ -297,7 +360,7 @@ in }; unitConfig = { # Do not create self-signed key when key already exists - ConditionPathExists = "!/var/lib/${lpath}/key.pem"; + ConditionPathExists = "!${apath}/key.pem"; }; }; in ( @@ -334,7 +397,7 @@ in ]; meta = { - maintainers = with lib.maintainers; [ abbradar fpletz globin ]; + maintainers = with lib.maintainers; [ abbradar fpletz globin m1cr0man ]; doc = ./acme.xml; }; } diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml index 9d0a1995e0ff..963ac7a97c33 100644 --- a/nixos/modules/security/acme.xml +++ b/nixos/modules/security/acme.xml @@ -7,7 +7,7 @@ NixOS supports automatic domain validation & certificate retrieval and renewal using the ACME protocol. This is currently only implemented by and - for Let's Encrypt. The alternative ACME client simp_le is + for Let's Encrypt. The alternative ACME client LEGo is used under the hood.