at 22.05-pre 18 kB view raw
1let 2 commonConfig = ./common/acme/client; 3 4 dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress; 5 6 dnsScript = {pkgs, nodes}: let 7 dnsAddress = dnsServerIP nodes; 8 in pkgs.writeShellScript "dns-hook.sh" '' 9 set -euo pipefail 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 13 else 14 ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt 15 fi 16 ''; 17 18 documentRoot = pkgs: pkgs.runCommand "docroot" {} '' 19 mkdir -p "$out" 20 echo hello world > "$out/index.html" 21 ''; 22 23 vhostBase = pkgs: { 24 forceSSL = true; 25 locations."/".root = documentRoot pkgs; 26 }; 27 28in import ./make-test-python.nix ({ lib, ... }: { 29 name = "acme"; 30 meta.maintainers = lib.teams.acme.members; 31 32 nodes = { 33 # The fake ACME server which will respond to client requests 34 acme = { nodes, lib, ... }: { 35 imports = [ ./common/acme/server ]; 36 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; 37 }; 38 39 # A fake DNS server which can be configured with records as desired 40 # Used to test DNS-01 challenge 41 dnsserver = { nodes, pkgs, ... }: { 42 networking.firewall.allowedTCPPorts = [ 8055 53 ]; 43 networking.firewall.allowedUDPPorts = [ 53 ]; 44 systemd.services.pebble-challtestsrv = { 45 enable = true; 46 description = "Pebble ACME challenge test server"; 47 wantedBy = [ "network.target" ]; 48 serviceConfig = { 49 ExecStart = "${pkgs.pebble}/bin/pebble-challtestsrv -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.webserver.config.networking.primaryIPAddress}'"; 50 # Required to bind on privileged ports. 51 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; 52 }; 53 }; 54 }; 55 56 # A web server which will be the node requesting certs 57 webserver = { pkgs, nodes, lib, config, ... }: { 58 imports = [ commonConfig ]; 59 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; 60 networking.firewall.allowedTCPPorts = [ 80 443 ]; 61 62 # OpenSSL will be used for more thorough certificate validation 63 environment.systemPackages = [ pkgs.openssl ]; 64 65 # Set log level to info so that we can see when the service is reloaded 66 services.nginx.enable = true; 67 services.nginx.logError = "stderr info"; 68 69 # First tests configure a basic cert and run a bunch of openssl checks 70 services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // { 71 enableACME = true; 72 }; 73 74 # Used to determine if service reload was triggered 75 systemd.targets.test-renew-nginx = { 76 wants = [ "acme-a.example.test.service" ]; 77 after = [ "acme-a.example.test.service" "nginx-config-reload.service" ]; 78 }; 79 80 # Test that account creation is collated into one service 81 specialisation.account-creation.configuration = { nodes, pkgs, lib, ... }: let 82 email = "newhostmaster@example.test"; 83 caDomain = nodes.acme.config.test-support.acme.caDomain; 84 # Exit 99 to make it easier to track if this is the reason a renew failed 85 testScript = '' 86 test -e accounts/${caDomain}/${email}/account.json || exit 99 87 ''; 88 in { 89 security.acme.email = lib.mkForce email; 90 systemd.services."b.example.test".preStart = testScript; 91 systemd.services."c.example.test".preStart = testScript; 92 93 services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // { 94 enableACME = true; 95 }; 96 services.nginx.virtualHosts."c.example.test" = (vhostBase pkgs) // { 97 enableACME = true; 98 }; 99 }; 100 101 # Cert config changes will not cause the nginx configuration to change. 102 # This tests that the reload service is correctly triggered. 103 # It also tests that postRun is exec'd as root 104 specialisation.cert-change.configuration = { pkgs, ... }: { 105 security.acme.certs."a.example.test".keyType = "ec384"; 106 security.acme.certs."a.example.test".postRun = '' 107 set -euo pipefail 108 touch /home/test 109 chown root:root /home/test 110 echo testing > /home/test 111 ''; 112 }; 113 114 # Now adding an alias to ensure that the certs are updated 115 specialisation.nginx-aliases.configuration = { pkgs, ... }: { 116 services.nginx.virtualHosts."a.example.test" = { 117 serverAliases = [ "b.example.test" ]; 118 }; 119 }; 120 121 # Test OCSP Stapling 122 specialisation.ocsp-stapling.configuration = { pkgs, ... }: { 123 security.acme.certs."a.example.test" = { 124 ocspMustStaple = true; 125 }; 126 services.nginx.virtualHosts."a.example.com" = { 127 extraConfig = '' 128 ssl_stapling on; 129 ssl_stapling_verify on; 130 ''; 131 }; 132 }; 133 134 # Test using Apache HTTPD 135 specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: { 136 services.nginx.enable = lib.mkForce false; 137 services.httpd.enable = true; 138 services.httpd.adminAddr = config.security.acme.email; 139 services.httpd.virtualHosts."c.example.test" = { 140 serverAliases = [ "d.example.test" ]; 141 forceSSL = true; 142 enableACME = true; 143 documentRoot = documentRoot pkgs; 144 }; 145 146 # Used to determine if service reload was triggered 147 systemd.targets.test-renew-httpd = { 148 wants = [ "acme-c.example.test.service" ]; 149 after = [ "acme-c.example.test.service" "httpd-config-reload.service" ]; 150 }; 151 }; 152 153 # Validation via DNS-01 challenge 154 specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: { 155 security.acme.certs."example.test" = { 156 domain = "*.example.test"; 157 group = config.services.nginx.group; 158 dnsProvider = "exec"; 159 dnsPropagationCheck = false; 160 credentialsFile = pkgs.writeText "wildcard.env" '' 161 EXEC_PATH=${dnsScript { inherit pkgs nodes; }} 162 ''; 163 }; 164 165 services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // { 166 useACMEHost = "example.test"; 167 }; 168 }; 169 170 # Validate service relationships by adding a slow start service to nginx' wants. 171 # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842 172 specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: { 173 systemd.services.my-slow-service = { 174 wantedBy = [ "multi-user.target" "nginx.service" ]; 175 before = [ "nginx.service" ]; 176 preStart = "sleep 5"; 177 script = "${pkgs.python3}/bin/python -m http.server"; 178 }; 179 180 services.nginx.virtualHosts."slow.example.com" = { 181 forceSSL = true; 182 enableACME = true; 183 locations."/".proxyPass = "http://localhost:8000"; 184 }; 185 }; 186 }; 187 188 # The client will be used to curl the webserver to validate configuration 189 client = {nodes, lib, pkgs, ...}: { 190 imports = [ commonConfig ]; 191 networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; 192 193 # OpenSSL will be used for more thorough certificate validation 194 environment.systemPackages = [ pkgs.openssl ]; 195 }; 196 }; 197 198 testScript = {nodes, ...}: 199 let 200 caDomain = nodes.acme.config.test-support.acme.caDomain; 201 newServerSystem = nodes.webserver.config.system.build.toplevel; 202 switchToNewServer = "${newServerSystem}/bin/switch-to-configuration test"; 203 in 204 # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true, 205 # this is because a oneshot goes from inactive => activating => inactive, and never 206 # reaches the active state. Targets do not have this issue. 207 208 '' 209 import time 210 211 212 has_switched = False 213 214 215 def switch_to(node, name): 216 global has_switched 217 if has_switched: 218 node.succeed( 219 "${switchToNewServer}" 220 ) 221 has_switched = True 222 node.succeed( 223 f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test" 224 ) 225 226 227 # Ensures the issuer of our cert matches the chain 228 # and matches the issuer we expect it to be. 229 # It's a good validation to ensure the cert.pem and fullchain.pem 230 # are not still selfsigned afer verification 231 def check_issuer(node, cert_name, issuer): 232 for fname in ("cert.pem", "fullchain.pem"): 233 actual_issuer = node.succeed( 234 f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}" 235 ).partition("=")[2] 236 print(f"{fname} issuer: {actual_issuer}") 237 assert issuer.lower() in actual_issuer.lower() 238 239 240 # Ensure cert comes before chain in fullchain.pem 241 def check_fullchain(node, cert_name): 242 subject_data = node.succeed( 243 f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem" 244 " | openssl pkcs7 -print_certs -noout" 245 ) 246 for line in subject_data.lower().split("\n"): 247 if "subject" in line: 248 print(f"First subject in fullchain.pem: {line}") 249 assert cert_name.lower() in line 250 return 251 252 assert False 253 254 255 def check_connection(node, domain, retries=3): 256 assert retries >= 0, f"Failed to connect to https://{domain}" 257 258 result = node.succeed( 259 "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt" 260 f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1" 261 ) 262 263 for line in result.lower().split("\n"): 264 if "verification" in line and "error" in line: 265 time.sleep(3) 266 return check_connection(node, domain, retries - 1) 267 268 269 def check_connection_key_bits(node, domain, bits, retries=3): 270 assert retries >= 0, f"Did not find expected number of bits ({bits}) in key" 271 272 result = node.succeed( 273 "openssl s_client -CAfile /tmp/ca.crt" 274 f" -servername {domain} -connect {domain}:443 < /dev/null" 275 " | openssl x509 -noout -text | grep -i Public-Key" 276 ) 277 print("Key type:", result) 278 279 if bits not in result: 280 time.sleep(3) 281 return check_connection_key_bits(node, domain, bits, retries - 1) 282 283 284 def check_stapling(node, domain, retries=3): 285 assert retries >= 0, "OCSP Stapling check failed" 286 287 # Pebble doesn't provide a full OCSP responder, so just check the URL 288 result = node.succeed( 289 "openssl s_client -CAfile /tmp/ca.crt" 290 f" -servername {domain} -connect {domain}:443 < /dev/null" 291 " | openssl x509 -noout -ocsp_uri" 292 ) 293 print("OCSP Responder URL:", result) 294 295 if "${caDomain}:4002" not in result.lower(): 296 time.sleep(3) 297 return check_stapling(node, domain, retries - 1) 298 299 300 def download_ca_certs(node, retries=5): 301 assert retries >= 0, "Failed to connect to pebble to download root CA certs" 302 303 exit_code, _ = node.execute("curl https://${caDomain}:15000/roots/0 > /tmp/ca.crt") 304 exit_code_2, _ = node.execute( 305 "curl https://${caDomain}:15000/intermediate-keys/0 >> /tmp/ca.crt" 306 ) 307 308 if exit_code + exit_code_2 > 0: 309 time.sleep(3) 310 return download_ca_certs(node, retries - 1) 311 312 313 client.start() 314 dnsserver.start() 315 316 dnsserver.wait_for_unit("pebble-challtestsrv.service") 317 client.wait_for_unit("default.target") 318 319 client.succeed( 320 'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a' 321 ) 322 323 acme.start() 324 webserver.start() 325 326 acme.wait_for_unit("network-online.target") 327 acme.wait_for_unit("pebble.service") 328 329 download_ca_certs(client) 330 331 with subtest("Can request certificate with HTTPS-01 challenge"): 332 webserver.wait_for_unit("acme-finished-a.example.test.target") 333 334 with subtest("Certificates and accounts have safe + valid permissions"): 335 group = "${nodes.webserver.config.security.acme.certs."a.example.test".group}" 336 webserver.succeed( 337 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" 338 ) 339 webserver.succeed( 340 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" 341 ) 342 webserver.succeed( 343 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" 344 ) 345 webserver.succeed( 346 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" 347 ) 348 349 with subtest("Certs are accepted by web server"): 350 webserver.succeed("systemctl start nginx.service") 351 check_fullchain(webserver, "a.example.test") 352 check_issuer(webserver, "a.example.test", "pebble") 353 check_connection(client, "a.example.test") 354 355 # Selfsigned certs tests happen late so we aren't fighting the system init triggering cert renewal 356 with subtest("Can generate valid selfsigned certs"): 357 webserver.succeed("systemctl clean acme-a.example.test.service --what=state") 358 webserver.succeed("systemctl start acme-selfsigned-a.example.test.service") 359 check_fullchain(webserver, "a.example.test") 360 check_issuer(webserver, "a.example.test", "minica") 361 # Check selfsigned permissions 362 webserver.succeed( 363 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" 364 ) 365 # Will succeed if nginx can load the certs 366 webserver.succeed("systemctl start nginx-config-reload.service") 367 368 with subtest("Can reload nginx when timer triggers renewal"): 369 webserver.succeed("systemctl start test-renew-nginx.target") 370 check_issuer(webserver, "a.example.test", "pebble") 371 check_connection(client, "a.example.test") 372 373 with subtest("Runs 1 cert for account creation before others"): 374 switch_to(webserver, "account-creation") 375 webserver.wait_for_unit("acme-finished-a.example.test.target") 376 check_connection(client, "a.example.test") 377 webserver.wait_for_unit("acme-finished-b.example.test.target") 378 webserver.wait_for_unit("acme-finished-c.example.test.target") 379 check_connection(client, "b.example.test") 380 check_connection(client, "c.example.test") 381 382 with subtest("Can reload web server when cert configuration changes"): 383 switch_to(webserver, "cert-change") 384 webserver.wait_for_unit("acme-finished-a.example.test.target") 385 check_connection_key_bits(client, "a.example.test", "384") 386 webserver.succeed("grep testing /home/test") 387 # Clean to remove the testing file (and anything else messy we did) 388 webserver.succeed("systemctl clean acme-a.example.test.service --what=state") 389 390 with subtest("Correctly implements OCSP stapling"): 391 switch_to(webserver, "ocsp-stapling") 392 webserver.wait_for_unit("acme-finished-a.example.test.target") 393 check_stapling(client, "a.example.test") 394 395 with subtest("Can request certificate with HTTPS-01 when nginx startup is delayed"): 396 switch_to(webserver, "slow-startup") 397 webserver.wait_for_unit("acme-finished-slow.example.com.target") 398 check_issuer(webserver, "slow.example.com", "pebble") 399 check_connection(client, "slow.example.com") 400 401 with subtest("Can request certificate for vhost + aliases (nginx)"): 402 # Check the key hash before and after adding an alias. It should not change. 403 # The previous test reverts the ed384 change 404 webserver.wait_for_unit("acme-finished-a.example.test.target") 405 switch_to(webserver, "nginx-aliases") 406 webserver.wait_for_unit("acme-finished-a.example.test.target") 407 check_issuer(webserver, "a.example.test", "pebble") 408 check_connection(client, "a.example.test") 409 check_connection(client, "b.example.test") 410 411 with subtest("Can request certificates for vhost + aliases (apache-httpd)"): 412 try: 413 switch_to(webserver, "httpd-aliases") 414 webserver.wait_for_unit("acme-finished-c.example.test.target") 415 except Exception as err: 416 _, output = webserver.execute( 417 "cat /var/log/httpd/*.log && ls -al /var/lib/acme/acme-challenge" 418 ) 419 print(output) 420 raise err 421 check_issuer(webserver, "c.example.test", "pebble") 422 check_connection(client, "c.example.test") 423 check_connection(client, "d.example.test") 424 425 with subtest("Can reload httpd when timer triggers renewal"): 426 # Switch to selfsigned first 427 webserver.succeed("systemctl clean acme-c.example.test.service --what=state") 428 webserver.succeed("systemctl start acme-selfsigned-c.example.test.service") 429 check_issuer(webserver, "c.example.test", "minica") 430 webserver.succeed("systemctl start httpd-config-reload.service") 431 webserver.succeed("systemctl start test-renew-httpd.target") 432 check_issuer(webserver, "c.example.test", "pebble") 433 check_connection(client, "c.example.test") 434 435 with subtest("Can request wildcard certificates using DNS-01 challenge"): 436 switch_to(webserver, "dns-01") 437 webserver.wait_for_unit("acme-finished-example.test.target") 438 check_issuer(webserver, "example.test", "pebble") 439 check_connection(client, "dns.example.test") 440 ''; 441})