nixos/nginx: allow adding new ACME certificates without nginx restart (#445544)

This commit is contained in:
Leona Maroni 2025-10-14 07:52:56 +00:00 committed by GitHub
commit a2d81c0a43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 239 additions and 172 deletions

View file

@ -1491,17 +1491,22 @@ in
}; };
}; };
systemd.services.nginx = { systemd.services = {
nginx = {
description = "Nginx Web Server"; description = "Nginx Web Server";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames); wants = lib.optionals (!cfg.enableReload) (
concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames)
);
after = [ after = [
"network.target" "network.target"
] ]
# Ensure nginx runs with baseline certificates in place. # Ensure nginx runs with baseline certificates in place.
++ map (certName: "acme-${certName}.service") vhostCertNames; ++ lib.optionals (!cfg.enableReload) (map (certName: "acme-${certName}.service") vhostCertNames);
# Ensure nginx runs (with current config) before the actual ACME jobs run # Ensure nginx runs (with current config) before the actual ACME jobs run
before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames; before = lib.optionals (!cfg.enableReload) (
map (certName: "acme-order-renew-${certName}.service") vhostCertNames
);
stopIfChanged = false; stopIfChanged = false;
preStart = '' preStart = ''
${cfg.preStart} ${cfg.preStart}
@ -1589,26 +1594,25 @@ in
}; };
}; };
environment.etc."nginx/nginx.conf" = mkIf cfg.enableReload {
source = configFile;
};
# This service waits for all certificates to be available # This service waits for all certificates to be available
# before reloading nginx configuration. # before reloading nginx configuration.
# sslTargets are added to wantedBy + before # sslTargets are added to wantedBy + before
# which allows the acme-order-renew-$cert.service to signify the successful updating # which allows the acme-order-renew-$cert.service to signify the successful updating
# of certs end-to-end. # of certs end-to-end.
systemd.services.nginx-config-reload = nginx-config-reload =
let let
sslServices = map (certName: "acme-${certName}.service") vhostCertNames;
sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames; sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
in in
mkIf (cfg.enableReload || vhostCertNames != [ ]) { mkIf (cfg.enableReload || vhostCertNames != [ ]) {
wants = optionals cfg.enableReload [ "nginx.service" ]; wants = optionals cfg.enableReload [ "nginx.service" ];
wantedBy = sslOrderRenewServices ++ [ "multi-user.target" ]; # Reload config directly after the self-signed certificates have been requested
# XXX Before the finished targets, after the renew services. # This is required for HTTP-01 ACME challenges, as the vHost with `.well-known/acme-challenge`
# This service might be needed for HTTP-01 challenges, but we only want to confirm # must already exist. Another reload with the actual certificate is triggered
# certs are updated _after_ config has been reloaded. # with `security.acme.certs.<...>.reloadServices`
after = sslOrderRenewServices; wantedBy = [ "multi-user.target" ] ++ optionals cfg.enableReload sslServices;
after = optionals cfg.enableReload sslServices;
before = optionals cfg.enableReload sslOrderRenewServices;
restartTriggers = optionals cfg.enableReload [ configFile ]; restartTriggers = optionals cfg.enableReload [ configFile ];
# Block reloading if not all certs exist yet. # Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs. # Happens when config changes add new vhosts/certs.
@ -1629,15 +1633,43 @@ in
ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service"; ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service";
}; };
}; };
}
# When reload is enabled, add the systemd dependency to the acme unit to prevent restarts
# of the nginx.service unit.
# This needs to be here, because of how switch-to-configuration works in the case that nginx.service
# is not started at the moment where new certificates are requested.
# Configuring this relationship in nginx.service would lead to s-t-c restarting nginx.service
# when a new certificate is added, as it will restart a unit when their direct unit properties,
# including After and Wants, change.
// lib.optionalAttrs cfg.enableReload (
lib.listToAttrs (
map (
name:
lib.nameValuePair "acme-${name}" {
before = [ "nginx.service" ];
wantedBy = [ "nginx.service" ];
}
) vhostCertNames
)
);
environment.etc."nginx/nginx.conf" = mkIf cfg.enableReload {
source = configFile;
};
security.acme.certs = security.acme.certs =
let let
acmePairs = map ( # Here are two cases:
# - when no `useACMEHost` is set, the `serverName` acme certificate is the primary name and we need to configure it
# - when `useACMEHost` is set, this is also the primary name and we only need to configure the reloadServices property
acmePairs =
map (
vhostConfig: vhostConfig:
let let
hasRoot = vhostConfig.acmeRoot != null; hasRoot = vhostConfig.acmeRoot != null;
in in
nameValuePair vhostConfig.serverName { nameValuePair vhostConfig.serverName {
reloadServices = [ "nginx.service" ];
group = mkDefault cfg.group; group = mkDefault cfg.group;
# if acmeRoot is null inherit config.security.acme # if acmeRoot is null inherit config.security.acme
# Since config.security.acme.certs.<cert>.webroot's own default value # Since config.security.acme.certs.<cert>.webroot's own default value
@ -1648,7 +1680,13 @@ in
extraDomainNames = vhostConfig.serverAliases; extraDomainNames = vhostConfig.serverAliases;
# Filter for enableACME-only vhosts. Don't want to create dud certs # Filter for enableACME-only vhosts. Don't want to create dud certs
} }
) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts); ) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts)
++ map (
vhostConfig:
nameValuePair vhostConfig.useACMEHost {
reloadServices = [ "nginx.service" ];
}
) (filter (vhostConfig: vhostConfig.useACMEHost != null) acmeEnabledVhosts);
in in
listToAttrs acmePairs; listToAttrs acmePairs;

View file

@ -1,20 +1,9 @@
{ runTest }: { lib, runTest }:
let let
domain = "example.test"; domain = "example.test";
in nginxBaseModule = {
{
http01-builtin = runTest ./http01-builtin.nix;
dns01 = runTest ./dns01.nix;
caddy = runTest ./caddy.nix;
nginx = runTest (
import ./webserver.nix {
inherit domain;
serverName = "nginx";
group = "nginx";
baseModule = {
services.nginx = { services.nginx = {
enable = true; enable = true;
enableReload = true;
logError = "stderr info"; logError = "stderr info";
# This tests a number of things at once: # This tests a number of things at once:
# - Self-signed certs are in place before the webserver startup # - Self-signed certs are in place before the webserver startup
@ -32,6 +21,29 @@ in
services.nginx.virtualHosts."nullroot.${domain}".acmeFallbackHost = "localhost:8081"; services.nginx.virtualHosts."nullroot.${domain}".acmeFallbackHost = "localhost:8081";
}; };
}; };
in
{
http01-builtin = runTest ./http01-builtin.nix;
dns01 = runTest ./dns01.nix;
caddy = runTest ./caddy.nix;
nginx = runTest (
import ./webserver.nix {
inherit domain;
serverName = "nginx";
group = "nginx";
baseModule = lib.recursiveUpdate nginxBaseModule {
services.nginx.enableReload = true;
};
}
);
nginx-without-reload = runTest (
import ./webserver.nix {
inherit domain;
serverName = "nginx";
group = "nginx";
baseModule = lib.recursiveUpdate nginxBaseModule {
services.nginx.enableReload = false;
};
} }
); );
httpd = runTest ( httpd = runTest (

View file

@ -226,5 +226,18 @@
# Ensure the timer works, due to our shenanigans with # Ensure the timer works, due to our shenanigans with
# RemainAfterExit=true # RemainAfterExit=true
webserver.wait_until_succeeds(f"journalctl --cursor-file=/tmp/cursor | grep 'Starting Order (and renew) ACME certificate for zeroconf3.{domain}...'") 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)
''; '';
} }

View file

@ -183,7 +183,10 @@ in
# keep-sorted start case=no numeric=no block=yes # keep-sorted start case=no numeric=no block=yes
_3proxy = runTest ./3proxy.nix; _3proxy = runTest ./3proxy.nix;
aaaaxy = runTest ./aaaaxy.nix; aaaaxy = runTest ./aaaaxy.nix;
acme = import ./acme/default.nix { inherit runTest; }; acme = import ./acme/default.nix {
inherit runTest;
inherit (pkgs) lib;
};
acme-dns = runTest ./acme-dns.nix; acme-dns = runTest ./acme-dns.nix;
activation = pkgs.callPackage ../modules/system/activation/test.nix { }; activation = pkgs.callPackage ../modules/system/activation/test.nix { };
activation-bashless = runTest ./activation/bashless.nix; activation-bashless = runTest ./activation/bashless.nix;

View file

@ -298,6 +298,7 @@ stdenv.mkDerivation {
; ;
variants = lib.recurseIntoAttrs nixosTests.nginx-variants; variants = lib.recurseIntoAttrs nixosTests.nginx-variants;
acme-integration = nixosTests.acme.nginx; acme-integration = nixosTests.acme.nginx;
acme-integration-without-reload = nixosTests.acme.nginx-without-reload;
} }
// passthru.tests; // passthru.tests;
}; };