about summary refs log tree commit diff
path: root/nixos/tests/acme.nix
diff options
context:
space:
mode:
authorLucas Savva <lucas@m1cr0man.com>2020-06-19 20:27:46 +0100
committerLucas Savva <lucas@m1cr0man.com>2020-09-02 19:22:43 +0100
commit982c5a1f0e7f282f856391304aa4da7bb36c45b8 (patch)
tree4cf0e93b6cd4e1ae2371c0d9184fca87ae8e43ca /nixos/tests/acme.nix
parent6ab387699a9f23201cf76091d0f7d4ff09fa510e (diff)
nixos/acme: Restructure module
- Use an acme user and group, allow group override only
- Use hashes to determine when certs actually need to regenerate
- Avoid running lego more than necessary
- Harden permissions
- Support "systemctl clean" for cert regeneration
- Support reuse of keys between some configuration changes
- Permissions fix services solves for previously root owned certs
- Add a note about multiple account creation and emails
- Migrate extraDomains to a list
- Deprecate user option
- Use minica for self-signed certs
- Rewrite all tests

I thought of a few more cases where things may go wrong,
and added tests to cover them. In particular, the web server
reload services were depending on the target - which stays alive,
meaning that the renewal timer wouldn't be triggering a reload
and old certs would stay on the web servers.

I encountered some problems ensuring that the reload took place
without accidently triggering it as part of the test. The sync
commands I added ended up being essential and I'm not sure why,
it seems like either node.succeed ends too early or there's an
oddity of the vm's filesystem I'm not aware of.

- Fix duplicate systemd rules on reload services

Since useACMEHost is not unique to every vhost, if one cert
was reused many times it would create duplicate entries in
${server}-config-reload.service for wants, before and
ConditionPathExists
Diffstat (limited to 'nixos/tests/acme.nix')
-rw-r--r--nixos/tests/acme.nix343
1 files changed, 208 insertions, 135 deletions
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
index a81884737213e..37e82993b4e35 100644
--- a/nixos/tests/acme.nix
+++ b/nixos/tests/acme.nix
@@ -1,29 +1,43 @@
 let
   commonConfig = ./common/acme/client;
 
-  dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" ''
-    #!${bash}/bin/bash
+  dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress;
+
+  dnsScript = {pkgs, nodes}: let
+    dnsAddress = dnsServerIP nodes;
+  in pkgs.writeShellScript "dns-hook.sh" ''
     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
+      ${pkgs.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
+      ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
     fi
   '';
 
+  documentRoot = pkgs: pkgs.runCommand "docroot" {} ''
+    mkdir -p "$out"
+    echo hello world > "$out/index.html"
+  '';
+
+  vhostBase = pkgs: {
+    forceSSL = true;
+    locations."/".root = documentRoot pkgs;
+  };
+
 in import ./make-test-python.nix ({ lib, ... }: {
   name = "acme";
   meta.maintainers = lib.teams.acme.members;
 
-  nodes = rec {
+  nodes = {
+    # The fake ACME server which will respond to client requests
     acme = { nodes, lib, ... }: {
       imports = [ ./common/acme/server ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
     };
 
+    # A fake DNS server which can be configured with records as desired
+    # Used to test DNS-01 challenge
     dnsserver = { nodes, pkgs, ... }: {
       networking.firewall.allowedTCPPorts = [ 8055 53 ];
       networking.firewall.allowedUDPPorts = [ 53 ];
@@ -39,112 +53,87 @@ in import ./make-test-python.nix ({ lib, ... }: {
       };
     };
 
-    acmeStandalone = { nodes, lib, config, pkgs, ... }: {
-      imports = [ commonConfig ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
-      networking.firewall.allowedTCPPorts = [ 80 ];
-      security.acme.certs."standalone.test" = {
-        webroot = "/var/lib/acme/acme-challenges";
-      };
-      systemd.targets."acme-finished-standalone.test" = {
-        after = [ "acme-standalone.test.service" ];
-        wantedBy = [ "acme-standalone.test.service" ];
-      };
-      services.nginx.enable = true;
-      services.nginx.virtualHosts."standalone.test" = {
-        locations."/.well-known/acme-challenge".root = "/var/lib/acme/acme-challenges";
-      };
-    };
-
-    webserver = { nodes, config, pkgs, lib, ... }: {
+    # A web server which will be the node requesting certs
+    webserver = { pkgs, nodes, lib, config, ... }: {
       imports = [ commonConfig ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
       networking.firewall.allowedTCPPorts = [ 80 443 ];
-      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
-      systemd.targets."acme-finished-a.example.test" = {
-        after = [ "acme-a.example.test.service" ];
-        wantedBy = [ "acme-a.example.test.service" ];
-      };
 
-      services.nginx.enable = true;
+      # OpenSSL will be used for more thorough certificate validation
+      environment.systemPackages = [ pkgs.openssl ];
 
-      services.nginx.virtualHosts."a.example.test" = {
+      # First tests configure a basic cert and run a bunch of openssl checks
+      services.nginx.enable = true;
+      services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
         enableACME = true;
-        forceSSL = true;
-        locations."/".root = pkgs.runCommand "docroot" {} ''
-          mkdir -p "$out"
-          echo hello world > "$out/index.html"
-        '';
       };
 
-      security.acme.server = "https://acme.test/dir";
+      # Used to determine if service reload was triggered
+      systemd.targets.test-renew-nginx = {
+        wants = [ "acme-a.example.test.service" ];
+        after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
+      };
+
+      # Cert config changes will not cause the nginx configuration to change.
+      # This tests that the reload service is correctly triggered.
+      specialisation.cert-change.configuration = { pkgs, ... }: {
+        security.acme.certs."a.example.test".keyType = "ec384";
+      };
 
-      specialisation.second-cert.configuration = {pkgs, ...}: {
-        systemd.targets."acme-finished-b.example.test" = {
-          after = [ "acme-b.example.test.service" ];
-          wantedBy = [ "acme-b.example.test.service" ];
+      # Now adding an alias to ensure that the certs are updated
+      specialisation.nginx-aliases.configuration = { pkgs, ... }: {
+        services.nginx.virtualHosts."a.example.test" = {
+          serverAliases = [ "b.example.test" ];
         };
-        services.nginx.virtualHosts."b.example.test" = {
-          enableACME = true;
+      };
+
+      # Test using Apache HTTPD
+      specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: {
+        services.nginx.enable = lib.mkForce false;
+        services.httpd.enable = true;
+        services.httpd.adminAddr = config.security.acme.email;
+        services.httpd.virtualHosts."c.example.test" = {
+          serverAliases = [ "d.example.test" ];
           forceSSL = true;
-          locations."/".root = pkgs.runCommand "docroot" {} ''
-            mkdir -p "$out"
-            echo hello world > "$out/index.html"
-          '';
+          enableACME = true;
+          documentRoot = documentRoot pkgs;
+        };
+
+        # Used to determine if service reload was triggered
+        systemd.targets.test-renew-httpd = {
+          wants = [ "acme-c.example.test.service" ];
+          after = [ "acme-c.example.test.service" "httpd-config-reload.service" ];
         };
       };
 
-      specialisation.dns-01.configuration = {pkgs, config, nodes, lib, ...}: {
+      # Validation via DNS-01 challenge
+      specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: {
         security.acme.certs."example.test" = {
           domain = "*.example.test";
+          group = config.services.nginx.group;
           dnsProvider = "exec";
           dnsPropagationCheck = false;
-          credentialsFile = with pkgs; writeText "wildcard.env" ''
-            EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }}
+          credentialsFile = pkgs.writeText "wildcard.env" ''
+            EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
           '';
-          user = config.services.nginx.user;
-          group = config.services.nginx.group;
         };
-        systemd.targets."acme-finished-example.test" = {
-          after = [ "acme-example.test.service" ];
-          wantedBy = [ "acme-example.test.service" ];
-        };
-        systemd.services."acme-example.test" = {
-          before = [ "nginx.service" ];
-          wantedBy = [ "nginx.service" ];
-        };
-        services.nginx.virtualHosts."c.example.test" = {
-          forceSSL = true;
-          sslCertificate = config.security.acme.certs."example.test".directory + "/cert.pem";
-          sslTrustedCertificate = config.security.acme.certs."example.test".directory + "/full.pem";
-          sslCertificateKey = config.security.acme.certs."example.test".directory + "/key.pem";
-          locations."/".root = pkgs.runCommand "docroot" {} ''
-            mkdir -p "$out"
-            echo hello world > "$out/index.html"
-          '';
+
+        services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
+          useACMEHost = "example.test";
         };
       };
 
-      # When nginx depends on a service that is slow to start up, requesting used to fail
-      # certificates fail.  Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
-      specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ...}: {
+      # Validate service relationships by adding a slow start service to nginx' wants.
+      # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
+      specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: {
         systemd.services.my-slow-service = {
           wantedBy = [ "multi-user.target" "nginx.service" ];
           before = [ "nginx.service" ];
           preStart = "sleep 5";
           script = "${pkgs.python3}/bin/python -m http.server";
         };
-        systemd.targets."acme-finished-d.example.com" = {
-          after = [ "acme-d.example.com.service" ];
-          wantedBy = [ "acme-d.example.com.service" ];
-        };
-        services.nginx.virtualHosts."d.example.com" = {
+
+        services.nginx.virtualHosts."slow.example.com" = {
           forceSSL = true;
           enableACME = true;
           locations."/".proxyPass = "http://localhost:8000";
@@ -152,11 +141,13 @@ in import ./make-test-python.nix ({ lib, ... }: {
       };
     };
 
-    client = {nodes, lib, ...}: {
+    # The client will be used to curl the webserver to validate configuration
+    client = {nodes, lib, pkgs, ...}: {
       imports = [ commonConfig ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
+
+      # OpenSSL will be used for more thorough certificate validation
+      environment.systemPackages = [ pkgs.openssl ];
     };
   };
 
@@ -167,73 +158,155 @@ in import ./make-test-python.nix ({ lib, ... }: {
     in
     # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
     # this is because a oneshot goes from inactive => activating => inactive, and never
-    # reaches the active state. To work around this, we create some mock target units which
-    # get pulled in by the oneshot units. The target units linger after activation, and hence we
-    # can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do
+    # reaches the active state. Targets do not have this issue.
+
     ''
+      has_switched = False
+
+
+      def switch_to(node, name):
+          global has_switched
+          if has_switched:
+              node.succeed(
+                  "${switchToNewServer}"
+              )
+          has_switched = True
+          node.succeed(
+              "/run/current-system/specialisation/{}/bin/switch-to-configuration test".format(
+                  name
+              )
+          )
+
+
+      # Ensures the issuer of our cert matches the chain
+      # and matches the issuer we expect it to be.
+      # It's a good validation to ensure the cert.pem and fullchain.pem
+      # are not still selfsigned afer verification
+      def check_issuer(node, cert_name, issuer):
+          for fname in ("cert.pem", "fullchain.pem"):
+              node.succeed(
+                  (
+                      """openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname} \
+                        | tee /proc/self/fd/2 \
+                        | cut -d'=' -f2- \
+                        | grep "$(openssl x509 -noout -subject -in /var/lib/acme/{cert_name}/chain.pem \
+                        | cut -d'=' -f2-)\" \
+                        | grep -i '{issuer}'
+                      """
+                  ).format(cert_name=cert_name, issuer=issuer, fname=fname)
+              )
+
+
+      # Ensure cert comes before chain in fullchain.pem
+      def check_fullchain(node, cert_name):
+          node.succeed(
+              (
+                  """openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem \
+                    | tee /proc/self/fd/2 \
+                    | openssl pkcs7 -print_certs -noout | head -1 | grep {cert_name}
+                  """
+              ).format(cert_name=cert_name)
+          )
+
+
+      def check_connection(node, domain):
+          node.succeed(
+              (
+                  """openssl s_client -brief -verify 2 -verify_return_error -CAfile /tmp/ca.crt \
+                    -servername {domain} -connect {domain}:443 < /dev/null 2>&1 \
+                    | tee /proc/self/fd/2
+                  """
+              ).format(domain=domain)
+          )
+
+
       client.start()
       dnsserver.start()
 
-      acme.wait_for_unit("default.target")
       dnsserver.wait_for_unit("pebble-challtestsrv.service")
+      client.wait_for_unit("default.target")
+
       client.succeed(
-          'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
-      )
-      client.succeed(
-          'curl --data \'{"host": "standalone.test", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+          'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
       )
 
       acme.start()
-      acmeStandalone.start()
+      webserver.start()
 
       acme.wait_for_unit("default.target")
       acme.wait_for_unit("pebble.service")
 
-      with subtest("can request certificate with HTTPS-01 challenge"):
-          acmeStandalone.wait_for_unit("default.target")
-          acmeStandalone.succeed("systemctl start acme-standalone.test.service")
-          acmeStandalone.wait_for_unit("acme-finished-standalone.test.target")
-
-      client.wait_for_unit("default.target")
-
       client.succeed("curl https://acme.test:15000/roots/0 > /tmp/ca.crt")
       client.succeed("curl https://acme.test:15000/intermediate-keys/0 >> /tmp/ca.crt")
 
-      with subtest("Can request certificate for nginx service"):
+      with subtest("Can request certificate with HTTPS-01 challenge"):
           webserver.wait_for_unit("acme-finished-a.example.test.target")
-          client.succeed(
-              "curl --cacert /tmp/ca.crt https://a.example.test/ | grep -qF 'hello world'"
-          )
+          check_fullchain(webserver, "a.example.test")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.example.test")
 
-      with subtest("Can add another certificate for nginx service"):
-          webserver.succeed(
-              "/run/current-system/specialisation/second-cert/bin/switch-to-configuration test"
-          )
-          webserver.wait_for_unit("acme-finished-b.example.test.target")
+      with subtest("Can generate valid selfsigned certs"):
+          webserver.succeed("systemctl clean acme-a.example.test.service --what=state")
+          webserver.succeed("systemctl start acme-selfsigned-a.example.test.service")
+          check_fullchain(webserver, "a.example.test")
+          check_issuer(webserver, "a.example.test", "minica")
+          # Will succeed if nginx can load the certs
+          webserver.succeed("systemctl start nginx-config-reload.service")
+
+      with subtest("Can reload nginx when timer triggers renewal"):
+          # These syncs are required because of weird scenarios where the cert files
+          # were not actually changed when the checks run.
+          webserver.succeed("sync")
+          webserver.succeed("systemctl start test-renew-nginx.target")
+          webserver.succeed("sync")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.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")
           client.succeed(
-              "curl --cacert /tmp/ca.crt https://b.example.test/ | grep -qF 'hello world'"
+              """openssl s_client -CAfile /tmp/ca.crt -connect a.example.test:443 < /dev/null \
+                | openssl x509 -noout -text | grep -i Public-Key | grep 384
+          """
           )
 
+      with subtest("Can request certificate with HTTPS-01 when nginx startup is delayed"):
+          switch_to(webserver, "slow-startup")
+          webserver.wait_for_unit("acme-finished-slow.example.com.target")
+          check_issuer(webserver, "slow.example.com", "pebble")
+          check_connection(client, "slow.example.com")
+
+      with subtest("Can request certificate for vhost + aliases (nginx)"):
+          switch_to(webserver, "nginx-aliases")
+          webserver.wait_for_unit("acme-finished-a.example.test.target")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.example.test")
+          check_connection(client, "b.example.test")
+
+      with subtest("Can request certificates for vhost + aliases (apache-httpd)"):
+          switch_to(webserver, "httpd-aliases")
+          webserver.wait_for_unit("acme-finished-c.example.test.target")
+          check_issuer(webserver, "c.example.test", "pebble")
+          check_connection(client, "c.example.test")
+          check_connection(client, "d.example.test")
+
+      with subtest("Can reload httpd when timer triggers renewal"):
+          # Switch to selfsigned first
+          webserver.succeed("systemctl clean acme-c.example.test.service --what=state")
+          webserver.succeed("systemctl start acme-selfsigned-c.example.test.service")
+          webserver.succeed("sync")
+          check_issuer(webserver, "c.example.test", "minica")
+          webserver.succeed("systemctl start httpd-config-reload.service")
+          webserver.succeed("systemctl start test-renew-httpd.target")
+          webserver.succeed("sync")
+          check_issuer(webserver, "c.example.test", "pebble")
+          check_connection(client, "c.example.test")
+
       with subtest("Can request wildcard certificates using DNS-01 challenge"):
-          webserver.succeed(
-              "${switchToNewServer}"
-          )
-          webserver.succeed(
-              "/run/current-system/specialisation/dns-01/bin/switch-to-configuration test"
-          )
+          switch_to(webserver, "dns-01")
           webserver.wait_for_unit("acme-finished-example.test.target")
-          client.succeed(
-              "curl --cacert /tmp/ca.crt https://c.example.test/ | grep -qF 'hello world'"
-          )
-
-      with subtest("Can request certificate of nginx when startup is delayed"):
-          webserver.succeed(
-              "${switchToNewServer}"
-          )
-          webserver.succeed(
-              "/run/current-system/specialisation/slow-startup/bin/switch-to-configuration test"
-          )
-          webserver.wait_for_unit("acme-finished-d.example.com.target")
-          client.succeed("curl --cacert /tmp/ca.crt https://d.example.com/")
+          check_issuer(webserver, "example.test", "pebble")
+          check_connection(client, "dns.example.test")
     '';
 })