1 { pkgs, lib, ... }: let
2 commonConfig = ./common/acme/client;
4 dnsServerIP = nodes: nodes.dnsserver.networking.primaryIPAddress;
7 dnsAddress = dnsServerIP nodes;
8 in pkgs.writeShellScript "dns-hook.sh" ''
10 echo '[INFO]' "[$2]" 'dns-hook.sh' $*
11 if [ "$1" = "present" ]; then
12 ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
14 ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
20 dnsPropagationCheck = false;
21 credentialsFile = pkgs.writeText "wildcard.env" ''
22 EXEC_PATH=${dnsScript nodes}
23 EXEC_POLLING_INTERVAL=1
24 EXEC_PROPAGATION_TIMEOUT=1
25 EXEC_SEQUENCE_INTERVAL=1
29 documentRoot = pkgs.runCommand "docroot" {} ''
31 echo hello world > "$out/index.html"
36 locations."/".root = documentRoot;
46 certs."http.example.test" = {
51 networking.firewall.allowedTCPPorts = [ 80 ];
54 # Base specialisation config for testing general ACME features
55 webserverBasicConfig = {
56 services.nginx.enable = true;
57 services.nginx.virtualHosts."a.example.test" = vhostBase // {
62 # Generate specialisations for testing a web server
63 mkServerConfigs = { server, group, vhostBaseData, extraConfig ? {} }: let
64 baseConfig = { nodes, config, specialConfig ? {} }: lib.mkMerge [
67 defaults = (dnsConfig nodes);
68 # One manual wildcard cert
69 certs."example.test" = {
70 domain = "*.example.test";
74 users.users."${config.services."${server}".user}".extraGroups = ["acme"];
76 services."${server}" = {
79 # Run-of-the-mill vhost using HTTP-01 validation
80 "${server}-http.example.test" = vhostBaseData // {
81 serverAliases = [ "${server}-http-alias.example.test" ];
85 # Another which inherits the DNS-01 config
86 "${server}-dns.example.test" = vhostBaseData // {
87 serverAliases = [ "${server}-dns-alias.example.test" ];
89 # Set acmeRoot to null instead of using the default of "/var/lib/acme/acme-challenge"
90 # webroot + dnsProvider are mutually exclusive.
94 # One using the wildcard certificate
95 "${server}-wildcard.example.test" = vhostBaseData // {
96 serverAliases = [ "${server}-wildcard-alias.example.test" ];
97 useACMEHost = "example.test";
102 # Used to determine if service reload was triggered
103 systemd.targets."test-renew-${server}" = {
104 wants = [ "acme-${server}-http.example.test.service" ];
105 after = [ "acme-${server}-http.example.test.service" "${server}-config-reload.service" ];
112 "${server}".configuration = { nodes, config, ... }: baseConfig {
113 inherit nodes config;
116 # Test that server reloads when an alias is removed (and subsequently test removal works in acme)
117 "${server}-remove-alias".configuration = { nodes, config, ... }: baseConfig {
118 inherit nodes config;
120 # Remove an alias, but create a standalone vhost in its place for testing.
121 # This configuration results in certificate errors as useACMEHost does not imply
122 # append extraDomains, and thus we can validate the SAN is removed.
123 services."${server}" = {
124 virtualHosts."${server}-http.example.test".serverAliases = lib.mkForce [];
125 virtualHosts."${server}-http-alias.example.test" = vhostBaseData // {
126 useACMEHost = "${server}-http.example.test";
132 # Test that the server reloads when only the acme configuration is changed.
133 "${server}-change-acme-conf".configuration = { nodes, config, ... }: baseConfig {
134 inherit nodes config;
136 security.acme.certs."${server}-http.example.test" = {
138 # Also test that postRun is exec'd as root
139 postRun = "id | grep root";
148 maintainers = lib.teams.acme.members;
149 # Hard timeout in seconds. Average run time is about 7 minutes.
154 # The fake ACME server which will respond to client requests
155 acme = { nodes, ... }: {
156 imports = [ ./common/acme/server ];
157 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
160 # A fake DNS server which can be configured with records as desired
161 # Used to test DNS-01 challenge
162 dnsserver = { nodes, ... }: {
163 networking.firewall.allowedTCPPorts = [ 8055 53 ];
164 networking.firewall.allowedUDPPorts = [ 53 ];
165 systemd.services.pebble-challtestsrv = {
167 description = "Pebble ACME challenge test server";
168 wantedBy = [ "network.target" ];
170 ExecStart = "${pkgs.pebble}/bin/pebble-challtestsrv -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.webserver.networking.primaryIPAddress}'";
171 # Required to bind on privileged ports.
172 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
177 # A web server which will be the node requesting certs
178 webserver = { nodes, config, ... }: {
179 imports = [ commonConfig ];
180 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
181 networking.firewall.allowedTCPPorts = [ 80 443 ];
183 # OpenSSL will be used for more thorough certificate validation
184 environment.systemPackages = [ pkgs.openssl ];
186 # Set log level to info so that we can see when the service is reloaded
187 services.nginx.logError = "stderr info";
190 # Tests HTTP-01 verification using Lego's built-in web server
191 http01lego.configuration = simpleConfig;
193 renew.configuration = lib.mkMerge [
196 # Pebble provides 5 year long certs,
197 # needs to be higher than that to test renewal
198 security.acme.certs."http.example.test".validMinDays = 9999;
202 # Tests that account creds can be safely changed.
203 accountchange.configuration = lib.mkMerge [
206 security.acme.certs."http.example.test".email = "admin@example.test";
210 # First derivation used to test general ACME features
211 general.configuration = { ... }: let
212 caDomain = nodes.acme.test-support.acme.caDomain;
213 email = config.security.acme.defaults.email;
214 # Exit 99 to make it easier to track if this is the reason a renew failed
215 accountCreateTester = ''
216 test -e accounts/${caDomain}/${email}/account.json || exit 99
221 # Used to test that account creation is collated into one service.
222 # These should not run until after acme-finished-a.example.test.target
223 systemd.services."b.example.test".preStart = accountCreateTester;
224 systemd.services."c.example.test".preStart = accountCreateTester;
226 services.nginx.virtualHosts."b.example.test" = vhostBase // {
229 services.nginx.virtualHosts."c.example.test" = vhostBase // {
236 ocsp-stapling.configuration = { ... }: lib.mkMerge [
239 security.acme.certs."a.example.test".ocspMustStaple = true;
240 services.nginx.virtualHosts."a.example.test" = {
243 ssl_stapling_verify on;
249 # Validate service relationships by adding a slow start service to nginx' wants.
250 # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
251 slow-startup.configuration = { ... }: lib.mkMerge [
254 systemd.services.my-slow-service = {
255 wantedBy = [ "multi-user.target" "nginx.service" ];
256 before = [ "nginx.service" ];
257 preStart = "sleep 5";
258 script = "${pkgs.python3}/bin/python -m http.server";
261 services.nginx.virtualHosts."slow.example.test" = {
264 locations."/".proxyPass = "http://localhost:8000";
269 # Test lego internal server (listenHTTP option)
270 # Also tests useRoot option
271 lego-server.configuration = { ... }: {
272 security.acme.useRoot = true;
273 security.acme.certs."lego.example.test" = {
277 services.nginx.enable = true;
278 services.nginx.virtualHosts."lego.example.test" = {
279 useACMEHost = "lego.example.test";
284 # Test compatibility with Caddy
285 # It only supports useACMEHost, hence not using mkServerConfigs
287 baseCaddyConfig = { nodes, config, ... }: {
289 defaults = (dnsConfig nodes);
290 # One manual wildcard cert
291 certs."example.test" = {
292 domain = "*.example.test";
296 users.users."${config.services.caddy.user}".extraGroups = ["acme"];
300 virtualHosts."a.exmaple.test" = {
301 useACMEHost = "example.test";
303 root * ${documentRoot}
309 caddy.configuration = baseCaddyConfig;
311 # Test that the server reloads when only the acme configuration is changed.
312 "caddy-change-acme-conf".configuration = { nodes, config, ... }: lib.mkMerge [
314 inherit nodes config;
317 security.acme.certs."example.test" = {
323 # Test compatibility with Nginx
324 }) // (mkServerConfigs {
327 vhostBaseData = vhostBase;
330 # Test compatibility with Apache HTTPD
331 // (mkServerConfigs {
334 vhostBaseData = vhostBaseHttpd;
336 services.httpd.adminAddr = config.security.acme.defaults.email;
341 # The client will be used to curl the webserver to validate configuration
342 client = { nodes, ... }: {
343 imports = [ commonConfig ];
344 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
346 # OpenSSL will be used for more thorough certificate validation
347 environment.systemPackages = [ pkgs.openssl ];
351 testScript = { nodes, ... }:
353 caDomain = nodes.acme.test-support.acme.caDomain;
354 newServerSystem = nodes.webserver.config.system.build.toplevel;
355 switchToNewServer = "${newServerSystem}/bin/switch-to-configuration test";
357 # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
358 # this is because a oneshot goes from inactive => activating => inactive, and never
359 # reaches the active state. Targets do not have this issue.
367 class BackoffTracker(object):
371 def handle_fail(self, retries, message) -> int:
372 assert retries < TOTAL_RETRIES, message
374 print(f"Retrying in {self.delay}s, {retries + 1}/{TOTAL_RETRIES}")
375 time.sleep(self.delay)
377 # Only increment after the first try
379 self.delay += self.increment
385 backoff = BackoffTracker()
388 def switch_to(node, name):
389 # On first switch, this will create a symlink to the current system so that we can
390 # quickly switch between derivations
391 root_specs = "/tmp/specialisation"
393 f"test -e {root_specs}"
394 f" || ln -s $(readlink /run/current-system)/specialisation {root_specs}"
397 switcher_path = f"/run/current-system/specialisation/{name}/bin/switch-to-configuration"
398 rc, _ = node.execute(f"test -e '{switcher_path}'")
400 switcher_path = f"/tmp/specialisation/{name}/bin/switch-to-configuration"
403 f"{switcher_path} test"
407 # Ensures the issuer of our cert matches the chain
408 # and matches the issuer we expect it to be.
409 # It's a good validation to ensure the cert.pem and fullchain.pem
410 # are not still selfsigned after verification
411 def check_issuer(node, cert_name, issuer):
412 for fname in ("cert.pem", "fullchain.pem"):
413 actual_issuer = node.succeed(
414 f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}"
416 print(f"{fname} issuer: {actual_issuer}")
417 assert issuer.lower() in actual_issuer.lower()
420 # Ensure cert comes before chain in fullchain.pem
421 def check_fullchain(node, cert_name):
422 subject_data = node.succeed(
423 f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem"
424 " | openssl pkcs7 -print_certs -noout"
426 for line in subject_data.lower().split("\n"):
427 if "subject" in line:
428 print(f"First subject in fullchain.pem: {line}")
429 assert cert_name.lower() in line
435 def check_connection(node, domain, retries=0):
436 result = node.succeed(
437 "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt"
438 f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1"
441 for line in result.lower().split("\n"):
442 if "verification" in line and "error" in line:
443 retries = backoff.handle_fail(retries, f"Failed to connect to https://{domain}")
444 return check_connection(node, domain, retries)
447 def check_connection_key_bits(node, domain, bits, retries=0):
448 result = node.succeed(
449 "openssl s_client -CAfile /tmp/ca.crt"
450 f" -servername {domain} -connect {domain}:443 < /dev/null"
451 " | openssl x509 -noout -text | grep -i Public-Key"
453 print("Key type:", result)
455 if bits not in result:
456 retries = backoff.handle_fail(retries, f"Did not find expected number of bits ({bits}) in key")
457 return check_connection_key_bits(node, domain, bits, retries)
460 def check_stapling(node, domain, retries=0):
461 # Pebble doesn't provide a full OCSP responder, so just check the URL
462 result = node.succeed(
463 "openssl s_client -CAfile /tmp/ca.crt"
464 f" -servername {domain} -connect {domain}:443 < /dev/null"
465 " | openssl x509 -noout -ocsp_uri"
467 print("OCSP Responder URL:", result)
469 if "${caDomain}:4002" not in result.lower():
470 retries = backoff.handle_fail(retries, "OCSP Stapling check failed")
471 return check_stapling(node, domain, retries)
474 def download_ca_certs(node, retries=0):
475 exit_code, _ = node.execute("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt")
476 exit_code_2, _ = node.execute(
477 "curl https://${caDomain}:15000/intermediate-keys/0 >> /tmp/ca.crt"
480 if exit_code + exit_code_2 > 0:
481 retries = backoff.handle_fail(retries, "Failed to connect to pebble to download root CA certs")
482 return download_ca_certs(node, retries)
487 dnsserver.wait_for_unit("pebble-challtestsrv.service")
488 client.wait_for_unit("default.target")
491 'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
494 acme.wait_for_unit("network-online.target")
495 acme.wait_for_unit("pebble.service")
497 download_ca_certs(client)
499 # Perform http-01 w/ lego test first
500 with subtest("Can request certificate with Lego's built in web server"):
501 switch_to(webserver, "http01lego")
502 webserver.wait_for_unit("acme-finished-http.example.test.target")
503 check_fullchain(webserver, "http.example.test")
504 check_issuer(webserver, "http.example.test", "pebble")
506 # Perform renewal test
507 with subtest("Can renew certificates when they expire"):
508 hash = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
509 switch_to(webserver, "renew")
510 webserver.wait_for_unit("acme-finished-http.example.test.target")
511 check_fullchain(webserver, "http.example.test")
512 check_issuer(webserver, "http.example.test", "pebble")
513 hash_after = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
514 assert hash != hash_after
516 # Perform account change test
517 with subtest("Handles email change correctly"):
518 hash = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
519 switch_to(webserver, "accountchange")
520 webserver.wait_for_unit("acme-finished-http.example.test.target")
521 check_fullchain(webserver, "http.example.test")
522 check_issuer(webserver, "http.example.test", "pebble")
523 hash_after = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
524 # Has to do a full run to register account, which creates new certs.
525 assert hash != hash_after
527 # Perform general tests
528 switch_to(webserver, "general")
530 with subtest("Can request certificate with HTTP-01 challenge"):
531 webserver.wait_for_unit("acme-finished-a.example.test.target")
532 check_fullchain(webserver, "a.example.test")
533 check_issuer(webserver, "a.example.test", "pebble")
534 webserver.wait_for_unit("nginx.service")
535 check_connection(client, "a.example.test")
537 with subtest("Runs 1 cert for account creation before others"):
538 webserver.wait_for_unit("acme-finished-b.example.test.target")
539 webserver.wait_for_unit("acme-finished-c.example.test.target")
540 check_connection(client, "b.example.test")
541 check_connection(client, "c.example.test")
543 with subtest("Certificates and accounts have safe + valid permissions"):
544 # Nginx will set the group appropriately when enableACME is used
547 f"test $(stat -L -c '%a %U %G' /var/lib/acme/a.example.test/*.pem | tee /dev/stderr | grep '640 acme {group}' | wc -l) -eq 5"
550 f"test $(stat -L -c '%a %U %G' /var/lib/acme/.lego/a.example.test/**/a.example.test* | tee /dev/stderr | grep '600 acme {group}' | wc -l) -eq 4"
553 f"test $(stat -L -c '%a %U %G' /var/lib/acme/a.example.test | tee /dev/stderr | grep '750 acme {group}' | wc -l) -eq 1"
556 f"test $(find /var/lib/acme/accounts -type f -exec stat -L -c '%a %U %G' {{}} \\; | tee /dev/stderr | grep -v '600 acme {group}' | wc -l) -eq 0"
559 # Selfsigned certs tests happen late so we aren't fighting the system init triggering cert renewal
560 with subtest("Can generate valid selfsigned certs"):
561 webserver.succeed("systemctl clean acme-a.example.test.service --what=state")
562 webserver.succeed("systemctl start acme-selfsigned-a.example.test.service")
563 check_fullchain(webserver, "a.example.test")
564 check_issuer(webserver, "a.example.test", "minica")
565 # Check selfsigned permissions
567 f"test $(stat -L -c '%a %U %G' /var/lib/acme/a.example.test/*.pem | tee /dev/stderr | grep '640 acme {group}' | wc -l) -eq 5"
569 # Will succeed if nginx can load the certs
570 webserver.succeed("systemctl start nginx-config-reload.service")
572 with subtest("Correctly implements OCSP stapling"):
573 switch_to(webserver, "ocsp-stapling")
574 webserver.wait_for_unit("acme-finished-a.example.test.target")
575 check_stapling(client, "a.example.test")
577 with subtest("Can request certificate with HTTP-01 using lego's internal web server"):
578 switch_to(webserver, "lego-server")
579 webserver.wait_for_unit("acme-finished-lego.example.test.target")
580 webserver.wait_for_unit("nginx.service")
581 webserver.succeed("echo HENLO && systemctl cat nginx.service")
582 webserver.succeed("test \"$(stat -c '%U' /var/lib/acme/* | uniq)\" = \"root\"")
583 check_connection(client, "a.example.test")
584 check_connection(client, "lego.example.test")
586 with subtest("Can request certificate with HTTP-01 when nginx startup is delayed"):
587 webserver.execute("systemctl stop nginx")
588 switch_to(webserver, "slow-startup")
589 webserver.wait_for_unit("acme-finished-slow.example.test.target")
590 check_issuer(webserver, "slow.example.test", "pebble")
591 webserver.wait_for_unit("nginx.service")
592 check_connection(client, "slow.example.test")
594 with subtest("Works with caddy"):
595 switch_to(webserver, "caddy")
596 webserver.wait_for_unit("acme-finished-example.test.target")
597 webserver.wait_for_unit("caddy.service")
598 # FIXME reloading caddy is not sufficient to load new certs.
599 # Restart it manually until this is fixed.
600 webserver.succeed("systemctl restart caddy.service")
601 check_connection(client, "a.example.test")
603 with subtest("security.acme changes reflect on caddy"):
604 switch_to(webserver, "caddy-change-acme-conf")
605 webserver.wait_for_unit("acme-finished-example.test.target")
606 webserver.wait_for_unit("caddy.service")
607 # FIXME reloading caddy is not sufficient to load new certs.
608 # Restart it manually until this is fixed.
609 webserver.succeed("systemctl restart caddy.service")
610 check_connection_key_bits(client, "a.example.test", "384")
612 domains = ["http", "dns", "wildcard"]
613 for server, logsrc in [
614 ("nginx", "journalctl -n 30 -u nginx.service"),
615 ("httpd", "tail -n 30 /var/log/httpd/*.log"),
617 wait_for_server = lambda: webserver.wait_for_unit(f"{server}.service")
618 with subtest(f"Works with {server}"):
620 switch_to(webserver, server)
621 # Skip wildcard domain for this check ([:-1])
622 for domain in domains[:-1]:
623 webserver.wait_for_unit(
624 f"acme-finished-{server}-{domain}.example.test.target"
626 except Exception as err:
627 _, output = webserver.execute(
628 f"{logsrc} && ls -al /var/lib/acme/acme-challenge"
635 for domain in domains[:-1]:
636 check_issuer(webserver, f"{server}-{domain}.example.test", "pebble")
637 for domain in domains:
638 check_connection(client, f"{server}-{domain}.example.test")
639 check_connection(client, f"{server}-{domain}-alias.example.test")
641 test_domain = f"{server}-{domains[0]}.example.test"
643 with subtest(f"Can reload {server} when timer triggers renewal"):
644 # Switch to selfsigned first
645 webserver.succeed(f"systemctl clean acme-{test_domain}.service --what=state")
646 webserver.succeed(f"systemctl start acme-selfsigned-{test_domain}.service")
647 check_issuer(webserver, test_domain, "minica")
648 webserver.succeed(f"systemctl start {server}-config-reload.service")
649 webserver.succeed(f"systemctl start test-renew-{server}.target")
650 check_issuer(webserver, test_domain, "pebble")
651 check_connection(client, test_domain)
653 with subtest("Can remove an alias from a domain + cert is updated"):
654 test_alias = f"{server}-{domains[0]}-alias.example.test"
655 switch_to(webserver, f"{server}-remove-alias")
656 webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
658 check_connection(client, test_domain)
659 rc, _s = client.execute(
660 f"openssl s_client -CAfile /tmp/ca.crt -connect {test_alias}:443"
661 " </dev/null 2>/dev/null | openssl x509 -noout -text"
662 f" | grep DNS: | grep {test_alias}"
664 assert rc > 0, "Removed extraDomainName was not removed from the cert"
666 with subtest("security.acme changes reflect on web server"):
667 # Switch back to normal server config first, reset everything.
668 switch_to(webserver, server)
670 switch_to(webserver, f"{server}-change-acme-conf")
671 webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
673 check_connection_key_bits(client, test_domain, "384")