about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorFlorian Klink <flokli@flokli.de>2020-02-10 11:47:30 +0100
committerGitHub <noreply@github.com>2020-02-10 11:47:30 +0100
commit4e0fea3fe294d112ac6eef5adbed2357b295f11d (patch)
tree45b53b3094ef53b294dded1ad97f91d92ff0feb7 /nixos
parentb2abf364677d3cfc360dbe8cef44cd5ff15c383c (diff)
parent75fa8027ebbfaa31e67bf2e931b8b3d428494692 (diff)
Merge pull request #77578 from m1cr0man/master
Replace simp-le with lego and support DNS-01 challenge
Diffstat (limited to 'nixos')
-rw-r--r--nixos/doc/manual/release-notes/rl-2003.xml15
-rw-r--r--nixos/modules/security/acme.nix173
-rw-r--r--nixos/modules/security/acme.xml2
-rw-r--r--nixos/tests/acme.nix118
-rw-r--r--nixos/tests/common/letsencrypt/common.nix3
5 files changed, 250 insertions, 61 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2003.xml b/nixos/doc/manual/release-notes/rl-2003.xml
index d7614cd3488c8..eac50b705a88c 100644
--- a/nixos/doc/manual/release-notes/rl-2003.xml
+++ b/nixos/doc/manual/release-notes/rl-2003.xml
@@ -661,6 +661,21 @@ auth required pam_succeed_if.so uid >= 1000 quiet
        now uses the short rather than full version string.
      </para>
    </listitem>
+   <listitem>
+    <para>
+     The ACME module has switched from simp-le to <link xlink:href="https://github.com/go-acme/lego">lego</link>
+     which allows us to support DNS-01 challenges and wildcard certificates. The following options have been added:
+     <link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link>,
+     <link linkend="opt-security.acme.certs">security.acme.certs.&lt;name&gt;.dnsProvider</link>,
+     <link linkend="opt-security.acme.certs">security.acme.certs.&lt;name&gt;.credentialsFile</link>,
+     <link linkend="opt-security.acme.certs">security.acme.certs.&lt;name&gt;.dnsPropagationCheck</link>.
+     As well as this, the options <literal>security.acme.acceptTerms</literal> and either
+     <literal>security.acme.email</literal> or <literal>security.acme.certs.&lt;name&gt;.email</literal>
+     must be set in order to use the ACME module.
+     Certificates will be regenerated anew on the next renewal date. The credentials for simp-le are
+     preserved and thus it is possible to roll back to previous versions without breaking certificate
+     generation.
+   </listitem>
     <listitem>
     <para>
     It is now possible to unlock LUKS-Encrypted file systems using a FIDO2 token
diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix
index 890c421b0ea9d..7da6666f79c67 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;
@@ -9,7 +7,8 @@ let
   certOpts = { name, ... }: {
     options = {
       webroot = mkOption {
-        type = types.str;
+        type = types.nullOr types.str;
+        default = null;
         example = "/var/lib/acme/acme-challenges";
         description = ''
           Where the webroot of the HTTP vhost is located.
@@ -38,7 +37,7 @@ let
 
       email = mkOption {
         type = types.nullOr types.str;
-        default = null;
+        default = cfg.email;
         description = "Contact email address for the CA to be able to reach you.";
       };
 
@@ -76,20 +75,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 {
         type = types.str;
         readOnly = true;
@@ -111,6 +96,46 @@ let
           own server roots if needed.
         '';
       };
+
+      keyType = mkOption {
+        type = types.str;
+        default = "ec384";
+        description = ''
+          Key type to use for private keys.
+          For an up to date list of supported values check the --key-type option
+          at https://go-acme.github.io/lego/usage/cli/#usage.
+        '';
+      };
+
+      dnsProvider = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "route53";
+        description = ''
+          DNS Challenge provider. For a list of supported providers, see the "code"
+          field of the DNS providers listed at https://go-acme.github.io/lego/dns/.
+        '';
+      };
+
+      credentialsFile = mkOption {
+        type = types.path;
+        description = ''
+          Path to an EnvironmentFile for the cert's service containing any required and
+          optional environment variables for your selected dnsProvider.
+          To find out what values you need to set, consult the documentation at
+          https://go-acme.github.io/lego/dns/ for the corresponding dnsProvider.
+        '';
+        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 +155,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 +205,15 @@ in
         '';
       };
 
+      acceptTerms = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Accept the CA's terms of service. The default provier is Let's Encrypt,
+          you can find their ToS at https://letsencrypt.org/repository/
+        '';
+      };
+
       certs = mkOption {
         default = { };
         type = with types; attrsOf (submodule certOpts);
@@ -204,27 +245,55 @@ 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.<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.
+          '';
+        }
+        {
+          assertion = cfg.acceptTerms;
+          message = ''
+            You must accept the CA's terms of service before using
+            the ACME module by setting `security.acme.acceptTerms`
+            to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/
+          '';
+        }
+      ];
+
       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" "." "--key-type" data.keyType ]
+                          ++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
+                          ++ optionals (data.dnsProvider != null && !data.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 +302,37 @@ 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
+                        keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
                         script = pkgs.writeScript "acme-post-start" ''
                           #!${pkgs.runtimeShell} -e
+                          cd ${apath}
+
+                          # Test that existing cert is older than new cert
+                          KEY=${spath}/certificates/${keyName}.key
+                          if [ -e $KEY -a $KEY -nt key.pem ]; then
+                            cp -p ${spath}/certificates/${keyName}.key key.pem
+                            cp -p ${spath}/certificates/${keyName}.crt cert.pem
+                            cp -p ${spath}/certificates/${keyName}.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 +364,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 +385,7 @@ in
                   };
                   unitConfig = {
                     # Do not create self-signed key when key already exists
-                    ConditionPathExists = "!/var/lib/${lpath}/key.pem";
+                    ConditionPathExists = "!${apath}/key.pem";
                   };
                 };
               in (
@@ -309,8 +397,7 @@ in
           servicesAttr;
 
       systemd.tmpfiles.rules =
-        flip mapAttrsToList cfg.certs
-        (cert: data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}");
+        map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs));
 
       systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
         ("acme-${cert}")
@@ -334,7 +421,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 9d0a1995e0ff0..2b29c11748455 100644
--- a/nixos/modules/security/acme.xml
+++ b/nixos/modules/security/acme.xml
@@ -7,7 +7,7 @@
  <para>
   NixOS supports automatic domain validation &amp; certificate retrieval 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.
  </para>
  <section xml:id="module-security-acme-prerequisites">
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
index 6bd315ff1eaa4..732f41a4905d3 100644
--- a/nixos/tests/acme.nix
+++ b/nixos/tests/acme.nix
@@ -1,17 +1,50 @@
 let
   commonConfig = ./common/letsencrypt/common.nix;
+
+  dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" ''
+    #!${bash}/bin/bash
+    set -euo pipefail
+    echo '[INFO]' "[$2]" 'dns-hook.sh' $*
+    if [ "$1" = "present" ]; then
+      ${curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
+    else
+      ${curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
+    fi
+  '';
+
 in import ./make-test-python.nix {
   name = "acme";
 
   nodes = rec {
-    letsencrypt = ./common/letsencrypt;
+    letsencrypt = { nodes, lib, ... }: {
+      imports = [ ./common/letsencrypt ];
+      networking.nameservers = lib.mkForce [
+        nodes.dnsserver.config.networking.primaryIPAddress
+      ];
+    };
+
+    dnsserver = { nodes, pkgs, ... }: {
+      networking.firewall.allowedTCPPorts = [ 8055 53 ];
+      networking.firewall.allowedUDPPorts = [ 53 ];
+      systemd.services.pebble-challtestsrv = {
+        enable = true;
+        description = "Pebble ACME challenge test server";
+        wantedBy = [ "network.target" ];
+        serviceConfig = {
+          ExecStart = "${pkgs.pebble}/bin/pebble-challtestsrv -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.webserver.config.networking.primaryIPAddress}'";
+          # Required to bind on privileged ports.
+          User = "root";
+          Group = "root";
+        };
+      };
+    };
 
-    acmeStandalone = { config, pkgs, ... }: {
+    acmeStandalone = { nodes, lib, config, pkgs, ... }: {
       imports = [ commonConfig ];
+      networking.nameservers = lib.mkForce [
+        nodes.dnsserver.config.networking.primaryIPAddress
+      ];
       networking.firewall.allowedTCPPorts = [ 80 ];
-      networking.extraHosts = ''
-        ${config.networking.primaryIPAddress} standalone.com
-      '';
       security.acme = {
         server = "https://acme-v02.api.letsencrypt.org/dir";
         certs."standalone.com" = {
@@ -29,14 +62,12 @@ in import ./make-test-python.nix {
       };
     };
 
-    webserver = { config, pkgs, ... }: {
+    webserver = { nodes, config, pkgs, lib, ... }: {
       imports = [ commonConfig ];
       networking.firewall.allowedTCPPorts = [ 80 443 ];
-
-      networking.extraHosts = ''
-        ${config.networking.primaryIPAddress} a.example.com
-        ${config.networking.primaryIPAddress} b.example.com
-      '';
+      networking.nameservers = lib.mkForce [
+        nodes.dnsserver.config.networking.primaryIPAddress
+      ];
 
       # A target remains active. Use this to probe the fact that
       # a service fired eventhough it is not RemainAfterExit
@@ -61,14 +92,11 @@ in import ./make-test-python.nix {
 
       nesting.clone = [
         ({pkgs, ...}: {
-
-          networking.extraHosts = ''
-            ${config.networking.primaryIPAddress} b.example.com
-          '';
           systemd.targets."acme-finished-b.example.com" = {};
           systemd.services."acme-b.example.com" = {
             wants = [ "acme-finished-b.example.com.target" ];
             before = [ "acme-finished-b.example.com.target" ];
+            after = [ "nginx.service" ];
           };
           services.nginx.virtualHosts."b.example.com" = {
             enableACME = true;
@@ -79,15 +107,48 @@ in import ./make-test-python.nix {
             '';
           };
         })
+        ({pkgs, config, nodes, lib, ...}: {
+          security.acme.certs."example.com" = {
+            domain = "*.example.com";
+            dnsProvider = "exec";
+            dnsPropagationCheck = false;
+            credentialsFile = with pkgs; writeText "wildcard.env" ''
+              EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }}
+            '';
+            user = config.services.nginx.user;
+            group = config.services.nginx.group;
+          };
+          systemd.targets."acme-finished-example.com" = {};
+          systemd.services."acme-example.com" = {
+            wants = [ "acme-finished-example.com.target" ];
+            before = [ "acme-finished-example.com.target" "nginx.service" ];
+            wantedBy = [ "nginx.service" ];
+          };
+          services.nginx.virtualHosts."c.example.com" = {
+            forceSSL = true;
+            sslCertificate = config.security.acme.certs."example.com".directory + "/cert.pem";
+            sslTrustedCertificate = config.security.acme.certs."example.com".directory + "/full.pem";
+            sslCertificateKey = config.security.acme.certs."example.com".directory + "/key.pem";
+            locations."/".root = pkgs.runCommand "docroot" {} ''
+              mkdir -p "$out"
+              echo hello world > "$out/index.html"
+            '';
+          };
+        })
       ];
     };
 
-    client = commonConfig;
+    client = {nodes, lib, ...}: {
+      imports = [ commonConfig ];
+      networking.nameservers = lib.mkForce [
+        nodes.dnsserver.config.networking.primaryIPAddress
+      ];
+    };
   };
 
   testScript = {nodes, ...}:
     let
-      newServerSystem = nodes.webserver2.config.system.build.toplevel;
+      newServerSystem = nodes.webserver.config.system.build.toplevel;
       switchToNewServer = "${newServerSystem}/bin/switch-to-configuration test";
     in
     # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
@@ -97,6 +158,17 @@ in import ./make-test-python.nix {
     # can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do
     ''
       client.start()
+      dnsserver.start()
+
+      letsencrypt.wait_for_unit("default.target")
+      dnsserver.wait_for_unit("pebble-challtestsrv.service")
+      client.succeed(
+          'curl --data \'{"host": "acme-v02.api.letsencrypt.org", "addresses": ["${nodes.letsencrypt.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+      )
+      client.succeed(
+          'curl --data \'{"host": "standalone.com", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+      )
+
       letsencrypt.start()
       acmeStandalone.start()
 
@@ -129,5 +201,17 @@ in import ./make-test-python.nix {
           client.succeed(
               "curl --cacert /tmp/ca.crt https://b.example.com/ | grep -qF 'hello world'"
           )
+
+      with subtest("Can request wildcard certificates using DNS-01 challenge"):
+          webserver.succeed(
+              "${switchToNewServer}"
+          )
+          webserver.succeed(
+              "/run/current-system/fine-tune/child-2/bin/switch-to-configuration test"
+          )
+          webserver.wait_for_unit("acme-finished-example.com.target")
+          client.succeed(
+              "curl --cacert /tmp/ca.crt https://c.example.com/ | grep -qF 'hello world'"
+          )
     '';
 }
diff --git a/nixos/tests/common/letsencrypt/common.nix b/nixos/tests/common/letsencrypt/common.nix
index c530de817bf2d..bd559c8dacc54 100644
--- a/nixos/tests/common/letsencrypt/common.nix
+++ b/nixos/tests/common/letsencrypt/common.nix
@@ -5,5 +5,8 @@ in {
     nodes.letsencrypt.config.networking.primaryIPAddress
   ];
 
+  security.acme.acceptTerms = true;
+  security.acme.email = "webmaster@example.com";
+
   security.pki.certificateFiles = [ letsencrypt-ca ];
 }