nixpkgs/nixos/tests/acme/webserver.nix
Leona Maroni b3a76d495e
nixos/nginx: allow adding new ACME certificates without nginx restart
Currently, nginx gets restarted when adding a new ACME certificate, even
when `services.nginx.enableReload = true` because of changes in the
Wants/After/Before sections of `nginx.service`.
This change moves these dependencies to `nginx-config-reload.service` and
the respective ACME systemd units.
2025-09-25 10:36:42 +02:00

244 lines
9.3 KiB
Nix

{
serverName,
group,
baseModule,
domain,
}:
{
config,
lib,
pkgs,
...
}:
{
name = serverName;
meta = {
maintainers = lib.teams.acme.members;
# Hard timeout in seconds. Average run time is about 100 seconds.
timeout = 300;
};
interactive.sshBackdoor.enable = true;
nodes = {
# The fake ACME server which will respond to client requests
acme =
{ nodes, ... }:
{
imports = [ ../common/acme/server ];
};
webserver =
{ nodes, ... }:
{
imports = [
../common/acme/client
baseModule
];
networking.domain = domain;
networking.firewall.allowedTCPPorts = [
80
443
];
# Resolve the vhosts the easy way
networking.hosts."127.0.0.1" = [
"proxied.${domain}"
"certchange.${domain}"
"zeroconf.${domain}"
"zeroconf2.${domain}"
"zeroconf3.${domain}"
"nullroot.${domain}"
];
# OpenSSL will be used for more thorough certificate validation
environment.systemPackages = [ pkgs.openssl ];
# Used to determine if service reload was triggered.
# This does not provide a guarantee that the webserver is finished reloading,
# to handle that there is retry logic wrapping any connectivity checks.
systemd.targets."renew-triggered" = {
wantedBy = [ "${serverName}-config-reload.service" ];
after = [ "${serverName}-config-reload.service" ];
unitConfig.RefuseManualStart = true;
};
security.acme.certs."proxied.${domain}" = {
listenHTTP = ":8080";
group = group;
};
specialisation = {
# Test that the web server is correctly reloaded when the cert changes
certchange.configuration = {
security.acme.certs."proxied.${domain}".extraDomainNames = [
"certchange.${domain}"
];
};
# A useful transitional step before other tests, and tests behaviour
# of removing an extra domain from a cert.
certundo.configuration = { };
# Tests these features:
# - enableACME behaves as expected
# - serverAliases are appended to extraDomainNames
# - Correct routing to the specific virtualHost for a cert
# Inherits previous test config
zeroconf.configuration = {
services.${serverName}.virtualHosts."zeroconf.${domain}" = {
addSSL = true;
enableACME = true;
serverAliases = [ "zeroconf2.${domain}" ];
};
};
# Test that serverAliases are correctly removed which triggers
# cert regeneration and service reload.
rmalias.configuration = {
services.${serverName}.virtualHosts."zeroconf.${domain}" = {
addSSL = true;
enableACME = true;
};
};
# Test that "acmeRoot = null" still results in
# valid cert generation by inheriting defaults.
nullroot.configuration = {
# The default.nix has the server-type dependent config statements
# to properly set up the proxying. We need a separate port here to
# avoid hostname issues with the proxy already running on :8080
security.acme.defaults.listenHTTP = ":8081";
services.${serverName}.virtualHosts."nullroot.${domain}" = {
addSSL = true;
enableACME = true;
acmeRoot = null;
};
};
# Test that a adding a second virtual host will not trigger
# other units (account and renewal service for first)
zeroconf3.configuration = {
services.${serverName}.virtualHosts = {
"zeroconf.${domain}" = {
addSSL = true;
enableACME = true;
serverAliases = [ "zeroconf2.${domain}" ];
};
"zeroconf3.${domain}" = {
addSSL = true;
enableACME = true;
};
};
# We're doing something risky with the combination of the service unit being persistent
# that could end up that the timers do not trigger properly. Show that timers have the
# desired effect.
systemd.timers."acme-renew-zeroconf3.${domain}".timerConfig = {
OnCalendar = lib.mkForce "*-*-* *:*:0/5";
AccuracySec = lib.mkForce 0;
# Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
RandomizedDelaySec = lib.mkForce 0;
FixedRandomDelay = lib.mkForce 0;
};
};
};
};
};
testScript =
{ nodes, ... }:
''
${(import ./utils.nix).pythonUtils}
domain = "${domain}"
ca_domain = "${nodes.acme.test-support.acme.caDomain}"
fqdn = f"proxied.{domain}"
webserver.start()
webserver.wait_for_unit("${serverName}.service")
with subtest("Can run on self-signed certificates"):
check_issuer(webserver, fqdn, "minica")
# Check that the web server has picked up the selfsigned cert
check_connection(webserver, fqdn, minica=True)
acme.start()
wait_for_running(acme)
acme.wait_for_open_port(443)
with subtest("Acquire a cert through a proxied lego"):
webserver.succeed(f"systemctl start acme-order-renew-{fqdn}.service")
webserver.wait_for_unit("renew-triggered.target")
download_ca_certs(webserver, ca_domain)
check_issuer(webserver, fqdn, "pebble")
check_connection(webserver, fqdn)
with subtest("security.acme changes reflect on web server part 1"):
check_connection(webserver, f"certchange.{domain}", fail=True)
switch_to(webserver, "certchange")
webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"certchange.{domain}")
check_connection(webserver, fqdn)
with subtest("security.acme changes reflect on web server part 2"):
check_connection(webserver, f"certchange.{domain}")
switch_to(webserver, "certundo")
webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"certchange.{domain}", fail=True)
check_connection(webserver, fqdn)
with subtest("Zero configuration SSL certificates for a vhost"):
check_connection(webserver, f"zeroconf.{domain}", fail=True)
switch_to(webserver, "zeroconf")
webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"zeroconf.{domain}")
check_connection(webserver, f"zeroconf2.{domain}")
check_connection(webserver, fqdn)
with subtest("Removing an alias from a vhost"):
check_connection(webserver, f"zeroconf2.{domain}")
switch_to(webserver, "rmalias")
webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"zeroconf2.{domain}", fail=True)
check_connection(webserver, f"zeroconf.{domain}")
check_connection(webserver, fqdn)
with subtest("Create cert using inherited default validation mechanism"):
check_connection(webserver, f"nullroot.{domain}", fail=True)
switch_to(webserver, "nullroot")
webserver.wait_for_unit("renew-triggered.target")
check_connection(webserver, f"nullroot.{domain}")
with subtest("Ensure that adding a second vhost does not trigger first vhost acme units"):
switch_to(webserver, "zeroconf")
webserver.wait_for_unit("renew-triggered.target")
webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
switch_to(webserver, "zeroconf3")
webserver.wait_for_unit("renew-triggered.target")
output = webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
# The new certificate unit gets triggered:
t.assertIn(f"acme-zeroconf3.{domain}-start", output)
# The account generation should not be triggered again:
t.assertNotIn("acme-account-d590213ed52603e9128d.target", output)
# The other certificates should also not be triggered:
t.assertNotIn(f"acme-zeroconf.{domain}-start", output)
t.assertNotIn(f"acme-proxied.{domain}-start", output)
# Ensure the timer works, due to our shenanigans with
# RemainAfterExit=true
webserver.wait_until_succeeds(f"journalctl --cursor-file=/tmp/cursor | grep 'Starting Order (and renew) ACME certificate for zeroconf3.{domain}...'")
''
+
lib.optionalString
(config.nodes.webserver.services.nginx.enable && config.nodes.webserver.services.nginx.enableReload)
''
with subtest("Ensure that adding a second vhost does not restart nginx"):
switch_to(webserver, "zeroconf")
webserver.wait_for_unit("renew-triggered.target")
webserver.succeed("journalctl --cursor-file=/tmp/cursor")
switch_to(webserver, "zeroconf3")
webserver.wait_for_unit("renew-triggered.target")
output = webserver.succeed("journalctl --cursor-file=/tmp/cursor")
t.assertNotIn("Stopping Nginx Web Server...", output)
'';
}