diff options
Diffstat (limited to 'nixos/tests')
-rw-r--r-- | nixos/tests/acme.nix | 515 | ||||
-rw-r--r-- | nixos/tests/common/acme/client/default.nix | 6 | ||||
-rw-r--r-- | nixos/tests/common/acme/server/default.nix | 5 |
3 files changed, 294 insertions, 232 deletions
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index 80b85502d4e..549fa9e64ee 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -1,9 +1,9 @@ -let +import ./make-test-python.nix ({ pkgs, lib, ... }: let commonConfig = ./common/acme/client; dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress; - dnsScript = {pkgs, nodes}: let + dnsScript = nodes: let dnsAddress = dnsServerIP nodes; in pkgs.writeShellScript "dns-hook.sh" '' set -euo pipefail @@ -15,30 +15,137 @@ let fi ''; - documentRoot = pkgs: pkgs.runCommand "docroot" {} '' + dnsConfig = nodes: { + dnsProvider = "exec"; + dnsPropagationCheck = false; + credentialsFile = pkgs.writeText "wildcard.env" '' + EXEC_PATH=${dnsScript nodes} + EXEC_POLLING_INTERVAL=1 + EXEC_PROPAGATION_TIMEOUT=1 + EXEC_SEQUENCE_INTERVAL=1 + ''; + }; + + documentRoot = pkgs.runCommand "docroot" {} '' mkdir -p "$out" echo hello world > "$out/index.html" ''; - vhostBase = pkgs: { + vhostBase = { forceSSL = true; - locations."/".root = documentRoot pkgs; + locations."/".root = documentRoot; + }; + + vhostBaseHttpd = { + forceSSL = true; + inherit documentRoot; + }; + + # Base specialisation config for testing general ACME features + webserverBasicConfig = { + services.nginx.enable = true; + services.nginx.virtualHosts."a.example.test" = vhostBase // { + enableACME = true; + }; + }; + + # Generate specialisations for testing a web server + mkServerConfigs = { server, group, vhostBaseData, extraConfig ? {} }: let + baseConfig = { nodes, config, specialConfig ? {} }: lib.mkMerge [ + { + security.acme = { + defaults = (dnsConfig nodes) // { + inherit group; + }; + # One manual wildcard cert + certs."example.test" = { + domain = "*.example.test"; + }; + }; + + services."${server}" = { + enable = true; + virtualHosts = { + # Run-of-the-mill vhost using HTTP-01 validation + "${server}-http.example.test" = vhostBaseData // { + serverAliases = [ "${server}-http-alias.example.test" ]; + enableACME = true; + }; + + # Another which inherits the DNS-01 config + "${server}-dns.example.test" = vhostBaseData // { + serverAliases = [ "${server}-dns-alias.example.test" ]; + enableACME = true; + # Set acmeRoot to null instead of using the default of "/var/lib/acme/acme-challenge" + # webroot + dnsProvider are mutually exclusive. + acmeRoot = null; + }; + + # One using the wildcard certificate + "${server}-wildcard.example.test" = vhostBaseData // { + serverAliases = [ "${server}-wildcard-alias.example.test" ]; + useACMEHost = "example.test"; + }; + }; + }; + + # Used to determine if service reload was triggered + systemd.targets."test-renew-${server}" = { + wants = [ "acme-${server}-http.example.test.service" ]; + after = [ "acme-${server}-http.example.test.service" "${server}-config-reload.service" ]; + }; + } + specialConfig + extraConfig + ]; + in { + "${server}".configuration = { nodes, config, ... }: baseConfig { + inherit nodes config; + }; + + # Test that server reloads when an alias is removed (and subsequently test removal works in acme) + "${server}-remove-alias".configuration = { nodes, config, ... }: baseConfig { + inherit nodes config; + specialConfig = { + # Remove an alias, but create a standalone vhost in its place for testing. + # This configuration results in certificate errors as useACMEHost does not imply + # append extraDomains, and thus we can validate the SAN is removed. + services."${server}" = { + virtualHosts."${server}-http.example.test".serverAliases = lib.mkForce []; + virtualHosts."${server}-http-alias.example.test" = vhostBaseData // { + useACMEHost = "${server}-http.example.test"; + }; + }; + }; + }; + + # Test that the server reloads when only the acme configuration is changed. + "${server}-change-acme-conf".configuration = { nodes, config, ... }: baseConfig { + inherit nodes config; + specialConfig = { + security.acme.certs."${server}-http.example.test" = { + keyType = "ec384"; + # Also test that postRun is exec'd as root + postRun = "id | grep root"; + }; + }; + }; }; -in import ./make-test-python.nix ({ lib, ... }: { +in { name = "acme"; meta.maintainers = lib.teams.acme.members; nodes = { # The fake ACME server which will respond to client requests - acme = { nodes, lib, ... }: { + acme = { nodes, ... }: { 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, ... }: { + dnsserver = { nodes, ... }: { networking.firewall.allowedTCPPorts = [ 8055 53 ]; networking.firewall.allowedUDPPorts = [ 53 ]; systemd.services.pebble-challtestsrv = { @@ -54,7 +161,7 @@ in import ./make-test-python.nix ({ lib, ... }: { }; # A web server which will be the node requesting certs - webserver = { pkgs, nodes, lib, config, ... }: { + webserver = { nodes, config, ... }: { imports = [ commonConfig ]; networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; networking.firewall.allowedTCPPorts = [ 80 443 ]; @@ -63,138 +170,88 @@ in import ./make-test-python.nix ({ lib, ... }: { 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" ]; - }; - - # Test that account creation is collated into one service - specialisation.account-creation.configuration = { nodes, pkgs, lib, ... }: let - email = "newhostmaster@example.test"; - caDomain = nodes.acme.config.test-support.acme.caDomain; - # Exit 99 to make it easier to track if this is the reason a renew failed - testScript = '' - test -e accounts/${caDomain}/${email}/account.json || exit 99 - ''; - in { - security.acme.email = lib.mkForce email; - systemd.services."b.example.test".preStart = testScript; - systemd.services."c.example.test".preStart = testScript; - - services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // { - enableACME = true; - }; - services.nginx.virtualHosts."c.example.test" = (vhostBase pkgs) // { - enableACME = true; - }; - }; - - # 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 /home/test - chown root:root /home/test - echo testing > /home/test - ''; - }; - - # Now adding an alias to ensure that the certs are updated - specialisation.nginx-aliases.configuration = { pkgs, ... }: { - services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // { - serverAliases = [ "b.example.test" ]; - }; - }; - - # Must be run after nginx-aliases - specialisation.remove-extra-domain.configuration = { pkgs, ... } : { - # This also validates that useACMEHost doesn't unexpectedly add the domain. - services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // { - useACMEHost = "a.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; }} + specialisation = { + # First derivation used to test general ACME features + general.configuration = { ... }: let + caDomain = nodes.acme.config.test-support.acme.caDomain; + email = config.security.acme.defaults.email; + # Exit 99 to make it easier to track if this is the reason a renew failed + accountCreateTester = '' + test -e accounts/${caDomain}/${email}/account.json || exit 99 ''; - }; - - 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"; - }; - }; + in lib.mkMerge [ + webserverBasicConfig + { + # Used to test that account creation is collated into one service. + # These should not run until after acme-finished-a.example.test.target + systemd.services."b.example.test".preStart = accountCreateTester; + systemd.services."c.example.test".preStart = accountCreateTester; + + services.nginx.virtualHosts."b.example.test" = vhostBase // { + enableACME = true; + }; + services.nginx.virtualHosts."c.example.test" = vhostBase // { + enableACME = true; + }; + } + ]; + + # Test OCSP Stapling + ocsp-stapling.configuration = { ... }: lib.mkMerge [ + webserverBasicConfig + { + security.acme.certs."a.example.test".ocspMustStaple = true; + services.nginx.virtualHosts."a.example.com" = { + extraConfig = '' + ssl_stapling on; + ssl_stapling_verify on; + ''; + }; + } + ]; + + # Validate service relationships by adding a slow start service to nginx' wants. + # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842 + slow-startup.configuration = { ... }: lib.mkMerge [ + webserverBasicConfig + { + 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"; + }; + } + ]; + + # Test compatibility with Nginx + } // (mkServerConfigs { + server = "nginx"; + group = "nginx"; + vhostBaseData = vhostBase; + }) + + # Test compatibility with Apache HTTPD + // (mkServerConfigs { + server = "httpd"; + group = "wwwrun"; + vhostBaseData = vhostBaseHttpd; + extraConfig = { + services.httpd.adminAddr = config.security.acme.defaults.email; + }; + }); }; # The client will be used to curl the webserver to validate configuration - client = {nodes, lib, pkgs, ...}: { + client = { nodes, ... }: { imports = [ commonConfig ]; networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; @@ -203,7 +260,7 @@ in import ./make-test-python.nix ({ lib, ... }: { }; }; - testScript = {nodes, ...}: + testScript = { nodes, ... }: let caDomain = nodes.acme.config.test-support.acme.caDomain; newServerSystem = nodes.webserver.config.system.build.toplevel; @@ -212,23 +269,26 @@ in import ./make-test-python.nix ({ lib, ... }: { # 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): + # On first switch, this will create a symlink to the current system so that we can + # quickly switch between derivations + root_specs = "/tmp/specialisation" + node.execute( + f"test -e {root_specs}" + f" || ln -s $(readlink /run/current-system)/specialisation {root_specs}" + ) + switcher_path = f"/run/current-system/specialisation/{name}/bin/switch-to-configuration" + rc, _ = node.execute(f"test -e '{switcher_path}'") + if rc > 0: + switcher_path = f"/tmp/specialisation/{name}/bin/switch-to-configuration" - 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" + f"{switcher_path} test" ) @@ -318,8 +378,7 @@ in import ./make-test-python.nix ({ lib, ... }: { return download_ca_certs(node, retries - 1) - client.start() - dnsserver.start() + start_all() dnsserver.wait_for_unit("pebble-challtestsrv.service") client.wait_for_unit("default.target") @@ -328,19 +387,30 @@ in import ./make-test-python.nix ({ lib, ... }: { 'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a' ) - acme.start() - webserver.start() - acme.wait_for_unit("network-online.target") acme.wait_for_unit("pebble.service") download_ca_certs(client) + # Perform general tests first + switch_to(webserver, "general") + 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") + webserver.wait_for_unit("nginx.service") + check_connection(client, "a.example.test") + + with subtest("Runs 1 cert for account creation before others"): + webserver.wait_for_unit("acme-finished-b.example.test.target") + webserver.wait_for_unit("acme-finished-c.example.test.target") + check_connection(client, "b.example.test") + check_connection(client, "c.example.test") with subtest("Certificates and accounts have safe + valid permissions"): - group = "${nodes.webserver.config.security.acme.certs."a.example.test".group}" + # Nginx will set the group appropriately when enableACME is used + group = "nginx" webserver.succeed( 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" ) @@ -354,12 +424,6 @@ in import ./make-test-python.nix ({ lib, ... }: { 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" ) - with subtest("Certs are accepted by web server"): - webserver.succeed("systemctl start nginx.service") - check_fullchain(webserver, "a.example.test") - check_issuer(webserver, "a.example.test", "pebble") - check_connection(client, "a.example.test") - # Selfsigned certs tests happen late so we aren't fighting the system init triggering cert renewal with subtest("Can generate valid selfsigned certs"): webserver.succeed("systemctl clean acme-a.example.test.service --what=state") @@ -373,89 +437,80 @@ in import ./make-test-python.nix ({ lib, ... }: { # 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("Runs 1 cert for account creation before others"): - switch_to(webserver, "account-creation") - webserver.wait_for_unit("acme-finished-a.example.test.target") - check_connection(client, "a.example.test") - webserver.wait_for_unit("acme-finished-b.example.test.target") - webserver.wait_for_unit("acme-finished-c.example.test.target") - check_connection(client, "b.example.test") - check_connection(client, "c.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 /home/test") - # Clean to remove the testing file (and anything else messy we did) - webserver.succeed("systemctl clean acme-a.example.test.service --what=state") - 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"): + webserver.execute("systemctl stop nginx") switch_to(webserver, "slow-startup") webserver.wait_for_unit("acme-finished-slow.example.com.target") check_issuer(webserver, "slow.example.com", "pebble") + webserver.wait_for_unit("nginx.service") 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") - 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 remove extra domains from a cert"): - switch_to(webserver, "remove-extra-domain") - webserver.wait_for_unit("acme-finished-a.example.test.target") - webserver.wait_for_unit("nginx.service") - check_connection(client, "a.example.test") - rc, _ = client.execute( - "openssl s_client -CAfile /tmp/ca.crt -connect b.example.test:443" - " </dev/null 2>/dev/null | openssl x509 -noout -text" - " | grep DNS: | grep b.example.test" - ) - assert rc > 0, "Removed extraDomainName was not removed from the cert" - - with subtest("Can request certificates for vhost + aliases (apache-httpd)"): - try: - switch_to(webserver, "httpd-aliases") - webserver.wait_for_unit("acme-finished-c.example.test.target") - except Exception as err: - _, output = webserver.execute( - "cat /var/log/httpd/*.log && ls -al /var/lib/acme/acme-challenge" + domains = ["http", "dns", "wildcard"] + for server, logsrc in [ + ("nginx", "journalctl -n 30 -u nginx.service"), + ("httpd", "tail -n 30 /var/log/httpd/*.log"), + ]: + wait_for_server = lambda: webserver.wait_for_unit(f"{server}.service") + with subtest(f"Works with {server}"): + try: + switch_to(webserver, server) + # Skip wildcard domain for this check ([:-1]) + for domain in domains[:-1]: + webserver.wait_for_unit( + f"acme-finished-{server}-{domain}.example.test.target" + ) + except Exception as err: + _, output = webserver.execute( + f"{logsrc} && ls -al /var/lib/acme/acme-challenge" + ) + print(output) + raise err + + wait_for_server() + + for domain in domains[:-1]: + check_issuer(webserver, f"{server}-{domain}.example.test", "pebble") + for domain in domains: + check_connection(client, f"{server}-{domain}.example.test") + check_connection(client, f"{server}-{domain}-alias.example.test") + + test_domain = f"{server}-{domains[0]}.example.test" + + with subtest(f"Can reload {server} when timer triggers renewal"): + # Switch to selfsigned first + webserver.succeed(f"systemctl clean acme-{test_domain}.service --what=state") + webserver.succeed(f"systemctl start acme-selfsigned-{test_domain}.service") + check_issuer(webserver, test_domain, "minica") + webserver.succeed(f"systemctl start {server}-config-reload.service") + webserver.succeed(f"systemctl start test-renew-{server}.target") + check_issuer(webserver, test_domain, "pebble") + check_connection(client, test_domain) + + with subtest("Can remove an alias from a domain + cert is updated"): + test_alias = f"{server}-{domains[0]}-alias.example.test" + switch_to(webserver, f"{server}-remove-alias") + webserver.wait_for_unit(f"acme-finished-{test_domain}.target") + wait_for_server() + check_connection(client, test_domain) + rc, _ = client.execute( + f"openssl s_client -CAfile /tmp/ca.crt -connect {test_alias}:443" + " </dev/null 2>/dev/null | openssl x509 -noout -text" + f" | grep DNS: | grep {test_alias}" ) - print(output) - raise err - 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") + assert rc > 0, "Removed extraDomainName was not removed from the cert" + + with subtest("security.acme changes reflect on web server"): + # Switch back to normal server config first, reset everything. + switch_to(webserver, server) + wait_for_server() + switch_to(webserver, f"{server}-change-acme-conf") + webserver.wait_for_unit(f"acme-finished-{test_domain}.target") + wait_for_server() + check_connection_key_bits(client, test_domain, "384") ''; }) diff --git a/nixos/tests/common/acme/client/default.nix b/nixos/tests/common/acme/client/default.nix index 1e9885e375c..9dbe345e7a0 100644 --- a/nixos/tests/common/acme/client/default.nix +++ b/nixos/tests/common/acme/client/default.nix @@ -5,9 +5,11 @@ let in { security.acme = { - server = "https://${caDomain}/dir"; - email = "hostmaster@example.test"; acceptTerms = true; + defaults = { + server = "https://${caDomain}/dir"; + email = "hostmaster@example.test"; + }; }; security.pki.certificateFiles = [ caCert ]; diff --git a/nixos/tests/common/acme/server/default.nix b/nixos/tests/common/acme/server/default.nix index 1c3bfdf76b7..450d49e6039 100644 --- a/nixos/tests/common/acme/server/default.nix +++ b/nixos/tests/common/acme/server/default.nix @@ -120,6 +120,11 @@ in { enable = true; description = "Pebble ACME server"; wantedBy = [ "network.target" ]; + environment = { + # We're not testing lego, we're just testing our configuration. + # No need to sleep. + PEBBLE_VA_NOSLEEP = "1"; + }; serviceConfig = { RuntimeDirectory = "pebble"; |