371 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
			
		
		
	
	
			371 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Nix
		
	
	
	
	
	
| let
 | |
|   commonConfig = ./common/acme/client;
 | |
| 
 | |
|   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
 | |
|       ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
 | |
|     else
 | |
|       ${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 = {
 | |
|     # The fake ACME server which will respond to client requests
 | |
|     acme = { nodes, lib, ... }: {
 | |
|       imports = [ ./common/acme/server ];
 | |
|       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 ];
 | |
|       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.
 | |
|           AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
 | |
|         };
 | |
|       };
 | |
|     };
 | |
| 
 | |
|     # 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 ];
 | |
| 
 | |
|       # OpenSSL will be used for more thorough certificate validation
 | |
|       environment.systemPackages = [ pkgs.openssl ];
 | |
| 
 | |
|       # Set log level to info so that we can see when the service is reloaded
 | |
|       services.nginx.enable = true;
 | |
|       services.nginx.logError = "stderr info";
 | |
| 
 | |
|       # First tests configure a basic cert and run a bunch of openssl checks
 | |
|       services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
 | |
|         enableACME = true;
 | |
|       };
 | |
| 
 | |
|       # 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.
 | |
|       # It also tests that postRun is exec'd as root
 | |
|       specialisation.cert-change.configuration = { pkgs, ... }: {
 | |
|         security.acme.certs."a.example.test".keyType = "ec384";
 | |
|         security.acme.certs."a.example.test".postRun = ''
 | |
|           set -euo pipefail
 | |
|           touch test
 | |
|           chown root:root test
 | |
|           echo testing > test
 | |
|         '';
 | |
|       };
 | |
| 
 | |
|       # 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" ];
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # Test OCSP Stapling
 | |
|       specialisation.ocsp-stapling.configuration = { pkgs, ... }: {
 | |
|         security.acme.certs."a.example.test" = {
 | |
|           ocspMustStaple = true;
 | |
|         };
 | |
|         services.nginx.virtualHosts."a.example.com" = {
 | |
|           extraConfig = ''
 | |
|             ssl_stapling on;
 | |
|             ssl_stapling_verify on;
 | |
|           '';
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # 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;
 | |
|           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" ];
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # 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 = pkgs.writeText "wildcard.env" ''
 | |
|             EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
 | |
|           '';
 | |
|         };
 | |
| 
 | |
|         services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
 | |
|           useACMEHost = "example.test";
 | |
|         };
 | |
|       };
 | |
| 
 | |
|       # 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";
 | |
|         };
 | |
| 
 | |
|         services.nginx.virtualHosts."slow.example.com" = {
 | |
|           forceSSL = true;
 | |
|           enableACME = true;
 | |
|           locations."/".proxyPass = "http://localhost:8000";
 | |
|         };
 | |
|       };
 | |
|     };
 | |
| 
 | |
|     # The client will be used to curl the webserver to validate configuration
 | |
|     client = {nodes, lib, pkgs, ...}: {
 | |
|       imports = [ commonConfig ];
 | |
|       networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
 | |
| 
 | |
|       # OpenSSL will be used for more thorough certificate validation
 | |
|       environment.systemPackages = [ pkgs.openssl ];
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   testScript = {nodes, ...}:
 | |
|     let
 | |
|       caDomain = nodes.acme.config.test-support.acme.caDomain;
 | |
|       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,
 | |
|     # this is because a oneshot goes from inactive => activating => inactive, and never
 | |
|     # reaches the active state. Targets do not have this issue.
 | |
| 
 | |
|     ''
 | |
|       import time
 | |
| 
 | |
| 
 | |
|       has_switched = False
 | |
| 
 | |
| 
 | |
|       def switch_to(node, name):
 | |
|           global has_switched
 | |
|           if has_switched:
 | |
|               node.succeed(
 | |
|                   "${switchToNewServer}"
 | |
|               )
 | |
|           has_switched = True
 | |
|           node.succeed(
 | |
|               f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test"
 | |
|           )
 | |
| 
 | |
| 
 | |
|       # 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"):
 | |
|               actual_issuer = node.succeed(
 | |
|                   f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}"
 | |
|               ).partition("=")[2]
 | |
|               print(f"{fname} issuer: {actual_issuer}")
 | |
|               assert issuer.lower() in actual_issuer.lower()
 | |
| 
 | |
| 
 | |
|       # Ensure cert comes before chain in fullchain.pem
 | |
|       def check_fullchain(node, cert_name):
 | |
|           subject_data = node.succeed(
 | |
|               f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem"
 | |
|               " | openssl pkcs7 -print_certs -noout"
 | |
|           )
 | |
|           for line in subject_data.lower().split("\n"):
 | |
|               if "subject" in line:
 | |
|                   print(f"First subject in fullchain.pem: ", line)
 | |
|                   assert cert_name.lower() in line
 | |
|                   return
 | |
| 
 | |
|           assert False
 | |
| 
 | |
| 
 | |
|       def check_connection(node, domain, retries=3):
 | |
|           assert retries >= 0
 | |
| 
 | |
|           result = node.succeed(
 | |
|               "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt"
 | |
|               f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1"
 | |
|           )
 | |
| 
 | |
|           for line in result.lower().split("\n"):
 | |
|               if "verification" in line and "error" in line:
 | |
|                   time.sleep(1)
 | |
|                   return check_connection(node, domain, retries - 1)
 | |
| 
 | |
| 
 | |
|       def check_connection_key_bits(node, domain, bits, retries=3):
 | |
|           assert retries >= 0
 | |
| 
 | |
|           result = node.succeed(
 | |
|               "openssl s_client -CAfile /tmp/ca.crt"
 | |
|               f" -servername {domain} -connect {domain}:443 < /dev/null"
 | |
|               " | openssl x509 -noout -text | grep -i Public-Key"
 | |
|           )
 | |
|           print("Key type:", result)
 | |
| 
 | |
|           if bits not in result:
 | |
|               time.sleep(1)
 | |
|               return check_connection_key_bits(node, domain, bits, retries - 1)
 | |
| 
 | |
| 
 | |
|       def check_stapling(node, domain, retries=3):
 | |
|           assert retries >= 0
 | |
| 
 | |
|           # Pebble doesn't provide a full OCSP responder, so just check the URL
 | |
|           result = node.succeed(
 | |
|               "openssl s_client -CAfile /tmp/ca.crt"
 | |
|               f" -servername {domain} -connect {domain}:443 < /dev/null"
 | |
|               " | openssl x509 -noout -ocsp_uri"
 | |
|           )
 | |
|           print("OCSP Responder URL:", result)
 | |
| 
 | |
|           if "${caDomain}:4002" not in result.lower():
 | |
|               time.sleep(1)
 | |
|               return check_stapling(node, domain, retries - 1)
 | |
| 
 | |
| 
 | |
|       client.start()
 | |
|       dnsserver.start()
 | |
| 
 | |
|       dnsserver.wait_for_unit("pebble-challtestsrv.service")
 | |
|       client.wait_for_unit("default.target")
 | |
| 
 | |
|       client.succeed(
 | |
|           'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
 | |
|       )
 | |
| 
 | |
|       acme.start()
 | |
|       webserver.start()
 | |
| 
 | |
|       acme.wait_for_unit("default.target")
 | |
|       acme.wait_for_unit("pebble.service")
 | |
| 
 | |
|       client.succeed("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt")
 | |
|       client.succeed("curl https://${caDomain}:15000/intermediate-keys/0 >> /tmp/ca.crt")
 | |
| 
 | |
|       with subtest("Can request certificate with HTTPS-01 challenge"):
 | |
|           webserver.wait_for_unit("acme-finished-a.example.test.target")
 | |
|           check_fullchain(webserver, "a.example.test")
 | |
|           check_issuer(webserver, "a.example.test", "pebble")
 | |
|           check_connection(client, "a.example.test")
 | |
| 
 | |
|       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"):
 | |
|           webserver.succeed("systemctl start test-renew-nginx.target")
 | |
|           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")
 | |
|           check_connection_key_bits(client, "a.example.test", "384")
 | |
|           webserver.succeed("grep testing /var/lib/acme/a.example.test/test")
 | |
| 
 | |
|       with subtest("Correctly implements OCSP stapling"):
 | |
|           switch_to(webserver, "ocsp-stapling")
 | |
|           webserver.wait_for_unit("acme-finished-a.example.test.target")
 | |
|           check_stapling(client, "a.example.test")
 | |
| 
 | |
|       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)"):
 | |
|           # Check the key hash before and after adding an alias. It should not change.
 | |
|           # The previous test reverts the ed384 change
 | |
|           webserver.wait_for_unit("acme-finished-a.example.test.target")
 | |
|           keyhash_old = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem")
 | |
|           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")
 | |
|           keyhash_new = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem")
 | |
|           assert keyhash_old == keyhash_new
 | |
| 
 | |
|       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")
 | |
|           check_issuer(webserver, "c.example.test", "minica")
 | |
|           webserver.succeed("systemctl start httpd-config-reload.service")
 | |
|           webserver.succeed("systemctl start test-renew-httpd.target")
 | |
|           check_issuer(webserver, "c.example.test", "pebble")
 | |
|           check_connection(client, "c.example.test")
 | |
| 
 | |
|       with subtest("Can request wildcard certificates using DNS-01 challenge"):
 | |
|           switch_to(webserver, "dns-01")
 | |
|           webserver.wait_for_unit("acme-finished-example.test.target")
 | |
|           check_issuer(webserver, "example.test", "pebble")
 | |
|           check_connection(client, "dns.example.test")
 | |
|     '';
 | |
| })
 | 
