···
1
+
import ./make-test-python.nix ({ pkgs, lib, ... }: let
commonConfig = ./common/acme/client;
dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress;
6
-
dnsScript = {pkgs, nodes}: let
6
+
dnsScript = nodes: let
dnsAddress = dnsServerIP nodes;
in pkgs.writeShellScript "dns-hook.sh" ''
···
18
-
documentRoot = pkgs: pkgs.runCommand "docroot" {} ''
18
+
dnsConfig = nodes: {
19
+
dnsProvider = "exec";
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" {} ''
echo hello world > "$out/index.html"
36
+
locations."/".root = documentRoot;
25
-
locations."/".root = documentRoot pkgs;
41
+
inherit documentRoot;
44
+
# Base specialisation config for testing general ACME features
45
+
webserverBasicConfig = {
46
+
services.nginx.enable = true;
47
+
services.nginx.virtualHosts."a.example.test" = vhostBase // {
52
+
# Generate specialisations for testing a web server
53
+
mkServerConfigs = { server, group, vhostBaseData, extraConfig ? {} }: let
54
+
baseConfig = { nodes, config, specialConfig ? {} }: lib.mkMerge [
57
+
defaults = (dnsConfig nodes) // {
60
+
# One manual wildcard cert
61
+
certs."example.test" = {
62
+
domain = "*.example.test";
66
+
services."${server}" = {
69
+
# Run-of-the-mill vhost using HTTP-01 validation
70
+
"${server}-http.example.test" = vhostBaseData // {
71
+
serverAliases = [ "${server}-http-alias.example.test" ];
75
+
# Another which inherits the DNS-01 config
76
+
"${server}-dns.example.test" = vhostBaseData // {
77
+
serverAliases = [ "${server}-dns-alias.example.test" ];
79
+
# Set acmeRoot to null instead of using the default of "/var/lib/acme/acme-challenge"
80
+
# webroot + dnsProvider are mutually exclusive.
84
+
# One using the wildcard certificate
85
+
"${server}-wildcard.example.test" = vhostBaseData // {
86
+
serverAliases = [ "${server}-wildcard-alias.example.test" ];
87
+
useACMEHost = "example.test";
92
+
# Used to determine if service reload was triggered
93
+
systemd.targets."test-renew-${server}" = {
94
+
wants = [ "acme-${server}-http.example.test.service" ];
95
+
after = [ "acme-${server}-http.example.test.service" "${server}-config-reload.service" ];
102
+
"${server}".configuration = { nodes, config, ... }: baseConfig {
103
+
inherit nodes config;
106
+
# Test that server reloads when an alias is removed (and subsequently test removal works in acme)
107
+
"${server}-remove-alias".configuration = { nodes, config, ... }: baseConfig {
108
+
inherit nodes config;
110
+
# Remove an alias, but create a standalone vhost in its place for testing.
111
+
# This configuration results in certificate errors as useACMEHost does not imply
112
+
# append extraDomains, and thus we can validate the SAN is removed.
113
+
services."${server}" = {
114
+
virtualHosts."${server}-http.example.test".serverAliases = lib.mkForce [];
115
+
virtualHosts."${server}-http-alias.example.test" = vhostBaseData // {
116
+
useACMEHost = "${server}-http.example.test";
122
+
# Test that the server reloads when only the acme configuration is changed.
123
+
"${server}-change-acme-conf".configuration = { nodes, config, ... }: baseConfig {
124
+
inherit nodes config;
126
+
security.acme.certs."${server}-http.example.test" = {
128
+
# Also test that postRun is exec'd as root
129
+
postRun = "id | grep root";
28
-
in import ./make-test-python.nix ({ lib, ... }: {
meta.maintainers = lib.teams.acme.members;
# The fake ACME server which will respond to client requests
34
-
acme = { nodes, lib, ... }: {
141
+
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
41
-
dnsserver = { nodes, pkgs, ... }: {
148
+
dnsserver = { nodes, ... }: {
networking.firewall.allowedTCPPorts = [ 8055 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
systemd.services.pebble-challtestsrv = {
···
# A web server which will be the node requesting certs
57
-
webserver = { pkgs, nodes, lib, config, ... }: {
164
+
webserver = { nodes, config, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
networking.firewall.allowedTCPPorts = [ 80 443 ];
···
environment.systemPackages = [ pkgs.openssl ];
# Set log level to info so that we can see when the service is reloaded
66
-
services.nginx.enable = true;
services.nginx.logError = "stderr info";
69
-
# First tests configure a basic cert and run a bunch of openssl checks
70
-
services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
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" ];
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
86
-
test -e accounts/${caDomain}/${email}/account.json || exit 99
89
-
security.acme.email = lib.mkForce email;
90
-
systemd.services."b.example.test".preStart = testScript;
91
-
systemd.services."c.example.test".preStart = testScript;
93
-
services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // {
96
-
services.nginx.virtualHosts."c.example.test" = (vhostBase pkgs) // {
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 = ''
109
-
chown root:root /home/test
110
-
echo testing > /home/test
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" = (vhostBase pkgs) // {
117
-
serverAliases = [ "b.example.test" ];
121
-
# Must be run after nginx-aliases
122
-
specialisation.remove-extra-domain.configuration = { pkgs, ... } : {
123
-
# This also validates that useACMEHost doesn't unexpectedly add the domain.
124
-
services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // {
125
-
useACMEHost = "a.example.test";
129
-
# Test OCSP Stapling
130
-
specialisation.ocsp-stapling.configuration = { pkgs, ... }: {
131
-
security.acme.certs."a.example.test" = {
132
-
ocspMustStaple = true;
134
-
services.nginx.virtualHosts."a.example.com" = {
137
-
ssl_stapling_verify on;
176
+
# First derivation used to test general ACME features
177
+
general.configuration = { ... }: let
178
+
caDomain = nodes.acme.config.test-support.acme.caDomain;
179
+
email = config.security.acme.defaults.email;
180
+
# Exit 99 to make it easier to track if this is the reason a renew failed
181
+
accountCreateTester = ''
182
+
test -e accounts/${caDomain}/${email}/account.json || exit 99
185
+
webserverBasicConfig
187
+
# Used to test that account creation is collated into one service.
188
+
# These should not run until after acme-finished-a.example.test.target
189
+
systemd.services."b.example.test".preStart = accountCreateTester;
190
+
systemd.services."c.example.test".preStart = accountCreateTester;
142
-
# Test using Apache HTTPD
143
-
specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: {
144
-
services.nginx.enable = lib.mkForce false;
145
-
services.httpd.enable = true;
146
-
services.httpd.adminAddr = config.security.acme.email;
147
-
services.httpd.virtualHosts."c.example.test" = {
148
-
serverAliases = [ "d.example.test" ];
151
-
documentRoot = documentRoot pkgs;
192
+
services.nginx.virtualHosts."b.example.test" = vhostBase // {
195
+
services.nginx.virtualHosts."c.example.test" = vhostBase // {
154
-
# Used to determine if service reload was triggered
155
-
systemd.targets.test-renew-httpd = {
156
-
wants = [ "acme-c.example.test.service" ];
157
-
after = [ "acme-c.example.test.service" "httpd-config-reload.service" ];
201
+
# Test OCSP Stapling
202
+
ocsp-stapling.configuration = { ... }: lib.mkMerge [
203
+
webserverBasicConfig
205
+
security.acme.certs."a.example.test".ocspMustStaple = true;
206
+
services.nginx.virtualHosts."a.example.com" = {
209
+
ssl_stapling_verify on;
161
-
# Validation via DNS-01 challenge
162
-
specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: {
163
-
security.acme.certs."example.test" = {
164
-
domain = "*.example.test";
165
-
group = config.services.nginx.group;
166
-
dnsProvider = "exec";
167
-
dnsPropagationCheck = false;
168
-
credentialsFile = pkgs.writeText "wildcard.env" ''
169
-
EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
215
+
# Validate service relationships by adding a slow start service to nginx' wants.
216
+
# Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
217
+
slow-startup.configuration = { ... }: lib.mkMerge [
218
+
webserverBasicConfig
220
+
systemd.services.my-slow-service = {
221
+
wantedBy = [ "multi-user.target" "nginx.service" ];
222
+
before = [ "nginx.service" ];
223
+
preStart = "sleep 5";
224
+
script = "${pkgs.python3}/bin/python -m http.server";
173
-
services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
174
-
useACMEHost = "example.test";
227
+
services.nginx.virtualHosts."slow.example.com" = {
230
+
locations."/".proxyPass = "http://localhost:8000";
178
-
# Validate service relationships by adding a slow start service to nginx' wants.
179
-
# Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
180
-
specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: {
181
-
systemd.services.my-slow-service = {
182
-
wantedBy = [ "multi-user.target" "nginx.service" ];
183
-
before = [ "nginx.service" ];
184
-
preStart = "sleep 5";
185
-
script = "${pkgs.python3}/bin/python -m http.server";
235
+
# Test compatibility with Nginx
236
+
} // (mkServerConfigs {
239
+
vhostBaseData = vhostBase;
188
-
services.nginx.virtualHosts."slow.example.com" = {
191
-
locations."/".proxyPass = "http://localhost:8000";
242
+
# Test compatibility with Apache HTTPD
243
+
// (mkServerConfigs {
246
+
vhostBaseData = vhostBaseHttpd;
248
+
services.httpd.adminAddr = config.security.acme.defaults.email;
# The client will be used to curl the webserver to validate configuration
197
-
client = {nodes, lib, pkgs, ...}: {
254
+
client = { nodes, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
···
206
-
testScript = {nodes, ...}:
263
+
testScript = { nodes, ... }:
caDomain = nodes.acme.config.test-support.acme.caDomain;
newServerSystem = nodes.webserver.config.system.build.toplevel;
···
# 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.
220
-
has_switched = False
276
+
def switch_to(node, name):
277
+
# On first switch, this will create a symlink to the current system so that we can
278
+
# quickly switch between derivations
279
+
root_specs = "/tmp/specialisation"
281
+
f"test -e {root_specs}"
282
+
f" || ln -s $(readlink /run/current-system)/specialisation {root_specs}"
285
+
switcher_path = f"/run/current-system/specialisation/{name}/bin/switch-to-configuration"
286
+
rc, _ = node.execute(f"test -e '{switcher_path}'")
288
+
switcher_path = f"/tmp/specialisation/{name}/bin/switch-to-configuration"
223
-
def switch_to(node, name):
224
-
global has_switched
227
-
"${switchToNewServer}"
229
-
has_switched = True
231
-
f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test"
291
+
f"{switcher_path} test"
···
return download_ca_certs(node, retries - 1)
dnsserver.wait_for_unit("pebble-challtestsrv.service")
client.wait_for_unit("default.target")
···
'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
acme.wait_for_unit("network-online.target")
acme.wait_for_unit("pebble.service")
download_ca_certs(client)
395
+
# Perform general tests first
396
+
switch_to(webserver, "general")
with subtest("Can request certificate with HTTPS-01 challenge"):
webserver.wait_for_unit("acme-finished-a.example.test.target")
400
+
check_fullchain(webserver, "a.example.test")
401
+
check_issuer(webserver, "a.example.test", "pebble")
402
+
webserver.wait_for_unit("nginx.service")
403
+
check_connection(client, "a.example.test")
405
+
with subtest("Runs 1 cert for account creation before others"):
406
+
webserver.wait_for_unit("acme-finished-b.example.test.target")
407
+
webserver.wait_for_unit("acme-finished-c.example.test.target")
408
+
check_connection(client, "b.example.test")
409
+
check_connection(client, "c.example.test")
with subtest("Certificates and accounts have safe + valid permissions"):
343
-
group = "${nodes.webserver.config.security.acme.certs."a.example.test".group}"
412
+
# Nginx will set the group appropriately when enableACME is used
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"
···
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"
357
-
with subtest("Certs are accepted by web server"):
358
-
webserver.succeed("systemctl start nginx.service")
359
-
check_fullchain(webserver, "a.example.test")
360
-
check_issuer(webserver, "a.example.test", "pebble")
361
-
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")
···
# Will succeed if nginx can load the certs
webserver.succeed("systemctl start nginx-config-reload.service")
376
-
with subtest("Can reload nginx when timer triggers renewal"):
377
-
webserver.succeed("systemctl start test-renew-nginx.target")
378
-
check_issuer(webserver, "a.example.test", "pebble")
379
-
check_connection(client, "a.example.test")
381
-
with subtest("Runs 1 cert for account creation before others"):
382
-
switch_to(webserver, "account-creation")
383
-
webserver.wait_for_unit("acme-finished-a.example.test.target")
384
-
check_connection(client, "a.example.test")
385
-
webserver.wait_for_unit("acme-finished-b.example.test.target")
386
-
webserver.wait_for_unit("acme-finished-c.example.test.target")
387
-
check_connection(client, "b.example.test")
388
-
check_connection(client, "c.example.test")
390
-
with subtest("Can reload web server when cert configuration changes"):
391
-
switch_to(webserver, "cert-change")
392
-
webserver.wait_for_unit("acme-finished-a.example.test.target")
393
-
check_connection_key_bits(client, "a.example.test", "384")
394
-
webserver.succeed("grep testing /home/test")
395
-
# Clean to remove the testing file (and anything else messy we did)
396
-
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"):
446
+
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")
450
+
webserver.wait_for_unit("nginx.service")
check_connection(client, "slow.example.com")
409
-
with subtest("Can request certificate for vhost + aliases (nginx)"):
410
-
# Check the key hash before and after adding an alias. It should not change.
411
-
# The previous test reverts the ed384 change
412
-
webserver.wait_for_unit("acme-finished-a.example.test.target")
413
-
switch_to(webserver, "nginx-aliases")
414
-
webserver.wait_for_unit("acme-finished-a.example.test.target")
415
-
check_issuer(webserver, "a.example.test", "pebble")
416
-
check_connection(client, "a.example.test")
417
-
check_connection(client, "b.example.test")
453
+
domains = ["http", "dns", "wildcard"]
454
+
for server, logsrc in [
455
+
("nginx", "journalctl -n 30 -u nginx.service"),
456
+
("httpd", "tail -n 30 /var/log/httpd/*.log"),
458
+
wait_for_server = lambda: webserver.wait_for_unit(f"{server}.service")
459
+
with subtest(f"Works with {server}"):
461
+
switch_to(webserver, server)
462
+
# Skip wildcard domain for this check ([:-1])
463
+
for domain in domains[:-1]:
464
+
webserver.wait_for_unit(
465
+
f"acme-finished-{server}-{domain}.example.test.target"
467
+
except Exception as err:
468
+
_, output = webserver.execute(
469
+
f"{logsrc} && ls -al /var/lib/acme/acme-challenge"
419
-
with subtest("Can remove extra domains from a cert"):
420
-
switch_to(webserver, "remove-extra-domain")
421
-
webserver.wait_for_unit("acme-finished-a.example.test.target")
422
-
webserver.wait_for_unit("nginx.service")
423
-
check_connection(client, "a.example.test")
424
-
rc, _ = client.execute(
425
-
"openssl s_client -CAfile /tmp/ca.crt -connect b.example.test:443"
426
-
" </dev/null 2>/dev/null | openssl x509 -noout -text"
427
-
" | grep DNS: | grep b.example.test"
429
-
assert rc > 0, "Removed extraDomainName was not removed from the cert"
431
-
with subtest("Can request certificates for vhost + aliases (apache-httpd)"):
433
-
switch_to(webserver, "httpd-aliases")
434
-
webserver.wait_for_unit("acme-finished-c.example.test.target")
435
-
except Exception as err:
436
-
_, output = webserver.execute(
437
-
"cat /var/log/httpd/*.log && ls -al /var/lib/acme/acme-challenge"
441
-
check_issuer(webserver, "c.example.test", "pebble")
442
-
check_connection(client, "c.example.test")
443
-
check_connection(client, "d.example.test")
476
+
for domain in domains[:-1]:
477
+
check_issuer(webserver, f"{server}-{domain}.example.test", "pebble")
478
+
for domain in domains:
479
+
check_connection(client, f"{server}-{domain}.example.test")
480
+
check_connection(client, f"{server}-{domain}-alias.example.test")
445
-
with subtest("Can reload httpd when timer triggers renewal"):
446
-
# Switch to selfsigned first
447
-
webserver.succeed("systemctl clean acme-c.example.test.service --what=state")
448
-
webserver.succeed("systemctl start acme-selfsigned-c.example.test.service")
449
-
check_issuer(webserver, "c.example.test", "minica")
450
-
webserver.succeed("systemctl start httpd-config-reload.service")
451
-
webserver.succeed("systemctl start test-renew-httpd.target")
452
-
check_issuer(webserver, "c.example.test", "pebble")
453
-
check_connection(client, "c.example.test")
482
+
test_domain = f"{server}-{domains[0]}.example.test"
455
-
with subtest("Can request wildcard certificates using DNS-01 challenge"):
456
-
switch_to(webserver, "dns-01")
457
-
webserver.wait_for_unit("acme-finished-example.test.target")
458
-
check_issuer(webserver, "example.test", "pebble")
459
-
check_connection(client, "dns.example.test")
484
+
with subtest(f"Can reload {server} when timer triggers renewal"):
485
+
# Switch to selfsigned first
486
+
webserver.succeed(f"systemctl clean acme-{test_domain}.service --what=state")
487
+
webserver.succeed(f"systemctl start acme-selfsigned-{test_domain}.service")
488
+
check_issuer(webserver, test_domain, "minica")
489
+
webserver.succeed(f"systemctl start {server}-config-reload.service")
490
+
webserver.succeed(f"systemctl start test-renew-{server}.target")
491
+
check_issuer(webserver, test_domain, "pebble")
492
+
check_connection(client, test_domain)
494
+
with subtest("Can remove an alias from a domain + cert is updated"):
495
+
test_alias = f"{server}-{domains[0]}-alias.example.test"
496
+
switch_to(webserver, f"{server}-remove-alias")
497
+
webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
499
+
check_connection(client, test_domain)
500
+
rc, _ = client.execute(
501
+
f"openssl s_client -CAfile /tmp/ca.crt -connect {test_alias}:443"
502
+
" </dev/null 2>/dev/null | openssl x509 -noout -text"
503
+
f" | grep DNS: | grep {test_alias}"
505
+
assert rc > 0, "Removed extraDomainName was not removed from the cert"
507
+
with subtest("security.acme changes reflect on web server"):
508
+
# Switch back to normal server config first, reset everything.
509
+
switch_to(webserver, server)
511
+
switch_to(webserver, f"{server}-change-acme-conf")
512
+
webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
514
+
check_connection_key_bits(client, test_domain, "384")