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";
147 meta.maintainers = lib.teams.acme.members;
150 # The fake ACME server which will respond to client requests
151 acme = { nodes, ... }: {
152 imports = [ ./common/acme/server ];
153 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
156 # A fake DNS server which can be configured with records as desired
157 # Used to test DNS-01 challenge
158 dnsserver = { nodes, ... }: {
159 networking.firewall.allowedTCPPorts = [ 8055 53 ];
160 networking.firewall.allowedUDPPorts = [ 53 ];
161 systemd.services.pebble-challtestsrv = {
163 description = "Pebble ACME challenge test server";
164 wantedBy = [ "network.target" ];
166 ExecStart = "${pkgs.pebble}/bin/pebble-challtestsrv -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.webserver.networking.primaryIPAddress}'";
167 # Required to bind on privileged ports.
168 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
173 # A web server which will be the node requesting certs
174 webserver = { nodes, config, ... }: {
175 imports = [ commonConfig ];
176 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
177 networking.firewall.allowedTCPPorts = [ 80 443 ];
179 # OpenSSL will be used for more thorough certificate validation
180 environment.systemPackages = [ pkgs.openssl ];
182 # Set log level to info so that we can see when the service is reloaded
183 services.nginx.logError = "stderr info";
186 # Tests HTTP-01 verification using Lego's built-in web server
187 http01lego.configuration = simpleConfig;
189 renew.configuration = lib.mkMerge [
192 # Pebble provides 5 year long certs,
193 # needs to be higher than that to test renewal
194 security.acme.certs."http.example.test".validMinDays = 9999;
198 # Tests that account creds can be safely changed.
199 accountchange.configuration = lib.mkMerge [
202 security.acme.certs."http.example.test".email = "admin@example.test";
206 # First derivation used to test general ACME features
207 general.configuration = { ... }: let
208 caDomain = nodes.acme.test-support.acme.caDomain;
209 email = config.security.acme.defaults.email;
210 # Exit 99 to make it easier to track if this is the reason a renew failed
211 accountCreateTester = ''
212 test -e accounts/${caDomain}/${email}/account.json || exit 99
217 # Used to test that account creation is collated into one service.
218 # These should not run until after acme-finished-a.example.test.target
219 systemd.services."b.example.test".preStart = accountCreateTester;
220 systemd.services."c.example.test".preStart = accountCreateTester;
222 services.nginx.virtualHosts."b.example.test" = vhostBase // {
225 services.nginx.virtualHosts."c.example.test" = vhostBase // {
232 ocsp-stapling.configuration = { ... }: lib.mkMerge [
235 security.acme.certs."a.example.test".ocspMustStaple = true;
236 services.nginx.virtualHosts."a.example.test" = {
239 ssl_stapling_verify on;
245 # Validate service relationships by adding a slow start service to nginx' wants.
246 # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
247 slow-startup.configuration = { ... }: lib.mkMerge [
250 systemd.services.my-slow-service = {
251 wantedBy = [ "multi-user.target" "nginx.service" ];
252 before = [ "nginx.service" ];
253 preStart = "sleep 5";
254 script = "${pkgs.python3}/bin/python -m http.server";
257 services.nginx.virtualHosts."slow.example.test" = {
260 locations."/".proxyPass = "http://localhost:8000";
265 # Test lego internal server (listenHTTP option)
266 # Also tests useRoot option
267 lego-server.configuration = { ... }: {
268 security.acme.useRoot = true;
269 security.acme.certs."lego.example.test" = {
273 services.nginx.enable = true;
274 services.nginx.virtualHosts."lego.example.test" = {
275 useACMEHost = "lego.example.test";
280 # Test compatiblity with Caddy
281 # It only supports useACMEHost, hence not using mkServerConfigs
283 baseCaddyConfig = { nodes, config, ... }: {
285 defaults = (dnsConfig nodes);
286 # One manual wildcard cert
287 certs."example.test" = {
288 domain = "*.example.test";
292 users.users."${config.services.caddy.user}".extraGroups = ["acme"];
296 virtualHosts."a.exmaple.test" = {
297 useACMEHost = "example.test";
299 root * ${documentRoot}
305 caddy.configuration = baseCaddyConfig;
307 # Test that the server reloads when only the acme configuration is changed.
308 "caddy-change-acme-conf".configuration = { nodes, config, ... }: lib.mkMerge [
310 inherit nodes config;
313 security.acme.certs."example.test" = {
319 # Test compatibility with Nginx
320 }) // (mkServerConfigs {
323 vhostBaseData = vhostBase;
326 # Test compatibility with Apache HTTPD
327 // (mkServerConfigs {
330 vhostBaseData = vhostBaseHttpd;
332 services.httpd.adminAddr = config.security.acme.defaults.email;
337 # The client will be used to curl the webserver to validate configuration
338 client = { nodes, ... }: {
339 imports = [ commonConfig ];
340 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
342 # OpenSSL will be used for more thorough certificate validation
343 environment.systemPackages = [ pkgs.openssl ];
347 testScript = { nodes, ... }:
349 caDomain = nodes.acme.test-support.acme.caDomain;
350 newServerSystem = nodes.webserver.config.system.build.toplevel;
351 switchToNewServer = "${newServerSystem}/bin/switch-to-configuration test";
353 # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
354 # this is because a oneshot goes from inactive => activating => inactive, and never
355 # reaches the active state. Targets do not have this issue.
360 def switch_to(node, name):
361 # On first switch, this will create a symlink to the current system so that we can
362 # quickly switch between derivations
363 root_specs = "/tmp/specialisation"
365 f"test -e {root_specs}"
366 f" || ln -s $(readlink /run/current-system)/specialisation {root_specs}"
369 switcher_path = f"/run/current-system/specialisation/{name}/bin/switch-to-configuration"
370 rc, _ = node.execute(f"test -e '{switcher_path}'")
372 switcher_path = f"/tmp/specialisation/{name}/bin/switch-to-configuration"
375 f"{switcher_path} test"
379 # Ensures the issuer of our cert matches the chain
380 # and matches the issuer we expect it to be.
381 # It's a good validation to ensure the cert.pem and fullchain.pem
382 # are not still selfsigned afer verification
383 def check_issuer(node, cert_name, issuer):
384 for fname in ("cert.pem", "fullchain.pem"):
385 actual_issuer = node.succeed(
386 f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}"
388 print(f"{fname} issuer: {actual_issuer}")
389 assert issuer.lower() in actual_issuer.lower()
392 # Ensure cert comes before chain in fullchain.pem
393 def check_fullchain(node, cert_name):
394 subject_data = node.succeed(
395 f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem"
396 " | openssl pkcs7 -print_certs -noout"
398 for line in subject_data.lower().split("\n"):
399 if "subject" in line:
400 print(f"First subject in fullchain.pem: {line}")
401 assert cert_name.lower() in line
407 def check_connection(node, domain, retries=3):
408 assert retries >= 0, f"Failed to connect to https://{domain}"
410 result = node.succeed(
411 "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt"
412 f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1"
415 for line in result.lower().split("\n"):
416 if "verification" in line and "error" in line:
418 return check_connection(node, domain, retries - 1)
421 def check_connection_key_bits(node, domain, bits, retries=3):
422 assert retries >= 0, f"Did not find expected number of bits ({bits}) in key"
424 result = node.succeed(
425 "openssl s_client -CAfile /tmp/ca.crt"
426 f" -servername {domain} -connect {domain}:443 < /dev/null"
427 " | openssl x509 -noout -text | grep -i Public-Key"
429 print("Key type:", result)
431 if bits not in result:
433 return check_connection_key_bits(node, domain, bits, retries - 1)
436 def check_stapling(node, domain, retries=3):
437 assert retries >= 0, "OCSP Stapling check failed"
439 # Pebble doesn't provide a full OCSP responder, so just check the URL
440 result = node.succeed(
441 "openssl s_client -CAfile /tmp/ca.crt"
442 f" -servername {domain} -connect {domain}:443 < /dev/null"
443 " | openssl x509 -noout -ocsp_uri"
445 print("OCSP Responder URL:", result)
447 if "${caDomain}:4002" not in result.lower():
449 return check_stapling(node, domain, retries - 1)
452 def download_ca_certs(node, retries=5):
453 assert retries >= 0, "Failed to connect to pebble to download root CA certs"
455 exit_code, _ = node.execute("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt")
456 exit_code_2, _ = node.execute(
457 "curl https://${caDomain}:15000/intermediate-keys/0 >> /tmp/ca.crt"
460 if exit_code + exit_code_2 > 0:
462 return download_ca_certs(node, retries - 1)
467 dnsserver.wait_for_unit("pebble-challtestsrv.service")
468 client.wait_for_unit("default.target")
471 'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
474 acme.wait_for_unit("network-online.target")
475 acme.wait_for_unit("pebble.service")
477 download_ca_certs(client)
479 # Perform http-01 w/ lego test first
480 with subtest("Can request certificate with Lego's built in web server"):
481 switch_to(webserver, "http01lego")
482 webserver.wait_for_unit("acme-finished-http.example.test.target")
483 check_fullchain(webserver, "http.example.test")
484 check_issuer(webserver, "http.example.test", "pebble")
486 # Perform renewal test
487 with subtest("Can renew certificates when they expire"):
488 hash = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
489 switch_to(webserver, "renew")
490 webserver.wait_for_unit("acme-finished-http.example.test.target")
491 check_fullchain(webserver, "http.example.test")
492 check_issuer(webserver, "http.example.test", "pebble")
493 hash_after = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
494 assert hash != hash_after
496 # Perform account change test
497 with subtest("Handles email change correctly"):
498 hash = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
499 switch_to(webserver, "accountchange")
500 webserver.wait_for_unit("acme-finished-http.example.test.target")
501 check_fullchain(webserver, "http.example.test")
502 check_issuer(webserver, "http.example.test", "pebble")
503 hash_after = webserver.succeed("sha256sum /var/lib/acme/http.example.test/cert.pem")
504 # Has to do a full run to register account, which creates new certs.
505 assert hash != hash_after
507 # Perform general tests
508 switch_to(webserver, "general")
510 with subtest("Can request certificate with HTTP-01 challenge"):
511 webserver.wait_for_unit("acme-finished-a.example.test.target")
512 check_fullchain(webserver, "a.example.test")
513 check_issuer(webserver, "a.example.test", "pebble")
514 webserver.wait_for_unit("nginx.service")
515 check_connection(client, "a.example.test")
517 with subtest("Runs 1 cert for account creation before others"):
518 webserver.wait_for_unit("acme-finished-b.example.test.target")
519 webserver.wait_for_unit("acme-finished-c.example.test.target")
520 check_connection(client, "b.example.test")
521 check_connection(client, "c.example.test")
523 with subtest("Certificates and accounts have safe + valid permissions"):
524 # Nginx will set the group appropriately when enableACME is used
527 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"
530 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"
533 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"
536 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"
539 # Selfsigned certs tests happen late so we aren't fighting the system init triggering cert renewal
540 with subtest("Can generate valid selfsigned certs"):
541 webserver.succeed("systemctl clean acme-a.example.test.service --what=state")
542 webserver.succeed("systemctl start acme-selfsigned-a.example.test.service")
543 check_fullchain(webserver, "a.example.test")
544 check_issuer(webserver, "a.example.test", "minica")
545 # Check selfsigned permissions
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"
549 # Will succeed if nginx can load the certs
550 webserver.succeed("systemctl start nginx-config-reload.service")
552 with subtest("Correctly implements OCSP stapling"):
553 switch_to(webserver, "ocsp-stapling")
554 webserver.wait_for_unit("acme-finished-a.example.test.target")
555 check_stapling(client, "a.example.test")
557 with subtest("Can request certificate with HTTP-01 using lego's internal web server"):
558 switch_to(webserver, "lego-server")
559 webserver.wait_for_unit("acme-finished-lego.example.test.target")
560 webserver.wait_for_unit("nginx.service")
561 webserver.succeed("echo HENLO && systemctl cat nginx.service")
562 webserver.succeed("test \"$(stat -c '%U' /var/lib/acme/* | uniq)\" = \"root\"")
563 check_connection(client, "a.example.test")
564 check_connection(client, "lego.example.test")
566 with subtest("Can request certificate with HTTP-01 when nginx startup is delayed"):
567 webserver.execute("systemctl stop nginx")
568 switch_to(webserver, "slow-startup")
569 webserver.wait_for_unit("acme-finished-slow.example.test.target")
570 check_issuer(webserver, "slow.example.test", "pebble")
571 webserver.wait_for_unit("nginx.service")
572 check_connection(client, "slow.example.test")
574 with subtest("Works with caddy"):
575 switch_to(webserver, "caddy")
576 webserver.wait_for_unit("acme-finished-example.test.target")
577 webserver.wait_for_unit("caddy.service")
578 # FIXME reloading caddy is not sufficient to load new certs.
579 # Restart it manually until this is fixed.
580 webserver.succeed("systemctl restart caddy.service")
581 check_connection(client, "a.example.test")
583 with subtest("security.acme changes reflect on caddy"):
584 switch_to(webserver, "caddy-change-acme-conf")
585 webserver.wait_for_unit("acme-finished-example.test.target")
586 webserver.wait_for_unit("caddy.service")
587 # FIXME reloading caddy is not sufficient to load new certs.
588 # Restart it manually until this is fixed.
589 webserver.succeed("systemctl restart caddy.service")
590 check_connection_key_bits(client, "a.example.test", "384")
592 domains = ["http", "dns", "wildcard"]
593 for server, logsrc in [
594 ("nginx", "journalctl -n 30 -u nginx.service"),
595 ("httpd", "tail -n 30 /var/log/httpd/*.log"),
597 wait_for_server = lambda: webserver.wait_for_unit(f"{server}.service")
598 with subtest(f"Works with {server}"):
600 switch_to(webserver, server)
601 # Skip wildcard domain for this check ([:-1])
602 for domain in domains[:-1]:
603 webserver.wait_for_unit(
604 f"acme-finished-{server}-{domain}.example.test.target"
606 except Exception as err:
607 _, output = webserver.execute(
608 f"{logsrc} && ls -al /var/lib/acme/acme-challenge"
615 for domain in domains[:-1]:
616 check_issuer(webserver, f"{server}-{domain}.example.test", "pebble")
617 for domain in domains:
618 check_connection(client, f"{server}-{domain}.example.test")
619 check_connection(client, f"{server}-{domain}-alias.example.test")
621 test_domain = f"{server}-{domains[0]}.example.test"
623 with subtest(f"Can reload {server} when timer triggers renewal"):
624 # Switch to selfsigned first
625 webserver.succeed(f"systemctl clean acme-{test_domain}.service --what=state")
626 webserver.succeed(f"systemctl start acme-selfsigned-{test_domain}.service")
627 check_issuer(webserver, test_domain, "minica")
628 webserver.succeed(f"systemctl start {server}-config-reload.service")
629 webserver.succeed(f"systemctl start test-renew-{server}.target")
630 check_issuer(webserver, test_domain, "pebble")
631 check_connection(client, test_domain)
633 with subtest("Can remove an alias from a domain + cert is updated"):
634 test_alias = f"{server}-{domains[0]}-alias.example.test"
635 switch_to(webserver, f"{server}-remove-alias")
636 webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
638 check_connection(client, test_domain)
639 rc, _s = client.execute(
640 f"openssl s_client -CAfile /tmp/ca.crt -connect {test_alias}:443"
641 " </dev/null 2>/dev/null | openssl x509 -noout -text"
642 f" | grep DNS: | grep {test_alias}"
644 assert rc > 0, "Removed extraDomainName was not removed from the cert"
646 with subtest("security.acme changes reflect on web server"):
647 # Switch back to normal server config first, reset everything.
648 switch_to(webserver, server)
650 switch_to(webserver, f"{server}-change-acme-conf")
651 webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
653 check_connection_key_bits(client, test_domain, "384")