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,164 +1491,202 @@ in
}; };
}; };
systemd.services.nginx = { systemd.services = {
description = "Nginx Web Server"; nginx = {
wantedBy = [ "multi-user.target" ]; description = "Nginx Web Server";
wants = concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames); wantedBy = [ "multi-user.target" ];
after = [ wants = lib.optionals (!cfg.enableReload) (
"network.target" concatLists (map (certName: [ "acme-${certName}.service" ]) vhostCertNames)
] );
# Ensure nginx runs with baseline certificates in place. after = [
++ map (certName: "acme-${certName}.service") vhostCertNames; "network.target"
# Ensure nginx runs (with current config) before the actual ACME jobs run ]
before = map (certName: "acme-order-renew-${certName}.service") vhostCertNames; # Ensure nginx runs with baseline certificates in place.
stopIfChanged = false; ++ lib.optionals (!cfg.enableReload) (map (certName: "acme-${certName}.service") vhostCertNames);
preStart = '' # Ensure nginx runs (with current config) before the actual ACME jobs run
${cfg.preStart} before = lib.optionals (!cfg.enableReload) (
${execCommand} -t map (certName: "acme-order-renew-${certName}.service") vhostCertNames
''; );
stopIfChanged = false;
preStart = ''
${cfg.preStart}
${execCommand} -t
'';
startLimitIntervalSec = 60; startLimitIntervalSec = 60;
serviceConfig = { serviceConfig = {
ExecStart = execCommand; ExecStart = execCommand;
ExecReload = [ ExecReload = [
"${execCommand} -t" "${execCommand} -t"
"${pkgs.coreutils}/bin/kill -HUP $MAINPID" "${pkgs.coreutils}/bin/kill -HUP $MAINPID"
]; ];
Restart = "always"; Restart = "always";
RestartSec = "10s"; RestartSec = "10s";
# User and group # User and group
User = cfg.user; User = cfg.user;
Group = cfg.group; Group = cfg.group;
# Runtime directory and mode # Runtime directory and mode
RuntimeDirectory = "nginx"; RuntimeDirectory = "nginx";
RuntimeDirectoryMode = "0750"; RuntimeDirectoryMode = "0750";
# Cache directory and mode # Cache directory and mode
CacheDirectory = "nginx"; CacheDirectory = "nginx";
CacheDirectoryMode = "0750"; CacheDirectoryMode = "0750";
# Logs directory and mode # Logs directory and mode
LogsDirectory = "nginx"; LogsDirectory = "nginx";
LogsDirectoryMode = "0750"; LogsDirectoryMode = "0750";
# Proc filesystem # Proc filesystem
ProcSubset = "pid"; ProcSubset = "pid";
ProtectProc = "invisible"; ProtectProc = "invisible";
# New file permissions # New file permissions
UMask = "0027"; # 0640 / 0750 UMask = "0027"; # 0640 / 0750
# Capabilities # Capabilities
AmbientCapabilities = [ AmbientCapabilities = [
"CAP_NET_BIND_SERVICE" "CAP_NET_BIND_SERVICE"
"CAP_SYS_RESOURCE" "CAP_SYS_RESOURCE"
] ]
++ optionals cfg.enableQuicBPF [ ++ optionals cfg.enableQuicBPF [
"CAP_SYS_ADMIN" "CAP_SYS_ADMIN"
"CAP_NET_ADMIN" "CAP_NET_ADMIN"
]; ];
CapabilityBoundingSet = [ CapabilityBoundingSet = [
"CAP_NET_BIND_SERVICE" "CAP_NET_BIND_SERVICE"
"CAP_SYS_RESOURCE" "CAP_SYS_RESOURCE"
] ]
++ optionals cfg.enableQuicBPF [ ++ optionals cfg.enableQuicBPF [
"CAP_SYS_ADMIN" "CAP_SYS_ADMIN"
"CAP_NET_ADMIN" "CAP_NET_ADMIN"
]; ];
# Security # Security
NoNewPrivileges = true; NoNewPrivileges = true;
# Sandboxing (sorted by occurrence in https://www.freedesktop.org/software/systemd/man/systemd.exec.html) # Sandboxing (sorted by occurrence in https://www.freedesktop.org/software/systemd/man/systemd.exec.html)
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = mkDefault true; ProtectHome = mkDefault true;
PrivateTmp = true; PrivateTmp = true;
PrivateDevices = true; PrivateDevices = true;
ProtectHostname = true; ProtectHostname = true;
ProtectClock = true; ProtectClock = true;
ProtectKernelTunables = true; ProtectKernelTunables = true;
ProtectKernelModules = true; ProtectKernelModules = true;
ProtectKernelLogs = true; ProtectKernelLogs = true;
ProtectControlGroups = true; ProtectControlGroups = true;
RestrictAddressFamilies = [ RestrictAddressFamilies = [
"AF_UNIX" "AF_UNIX"
"AF_INET" "AF_INET"
"AF_INET6" "AF_INET6"
]; ];
RestrictNamespaces = true; RestrictNamespaces = true;
LockPersonality = true; LockPersonality = true;
MemoryDenyWriteExecute = MemoryDenyWriteExecute =
!( !(
(builtins.any (mod: (mod.allowMemoryWriteExecute or false)) cfg.package.modules) (builtins.any (mod: (mod.allowMemoryWriteExecute or false)) cfg.package.modules)
|| (cfg.package == pkgs.openresty) || (cfg.package == pkgs.openresty)
); );
RestrictRealtime = true; RestrictRealtime = true;
RestrictSUIDSGID = true; RestrictSUIDSGID = true;
RemoveIPC = true; RemoveIPC = true;
PrivateMounts = true; PrivateMounts = true;
# System Call Filtering # System Call Filtering
SystemCallArchitectures = "native"; SystemCallArchitectures = "native";
SystemCallFilter = [ SystemCallFilter = [
"~@cpu-emulation @debug @keyring @mount @obsolete @privileged @setuid" "~@cpu-emulation @debug @keyring @mount @obsolete @privileged @setuid"
] ]
++ optional cfg.enableQuicBPF [ "bpf" ]; ++ optional cfg.enableQuicBPF [ "bpf" ];
};
}; };
};
# This service waits for all certificates to be available
# before reloading nginx configuration.
# sslTargets are added to wantedBy + before
# which allows the acme-order-renew-$cert.service to signify the successful updating
# of certs end-to-end.
nginx-config-reload =
let
sslServices = map (certName: "acme-${certName}.service") vhostCertNames;
sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
in
mkIf (cfg.enableReload || vhostCertNames != [ ]) {
wants = optionals cfg.enableReload [ "nginx.service" ];
# Reload config directly after the self-signed certificates have been requested
# This is required for HTTP-01 ACME challenges, as the vHost with `.well-known/acme-challenge`
# must already exist. Another reload with the actual certificate is triggered
# with `security.acme.certs.<...>.reloadServices`
wantedBy = [ "multi-user.target" ] ++ optionals cfg.enableReload sslServices;
after = optionals cfg.enableReload sslServices;
before = optionals cfg.enableReload sslOrderRenewServices;
restartTriggers = optionals cfg.enableReload [ configFile ];
# Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs.
unitConfig = {
ConditionPathExists = optionals (vhostCertNames != [ ]) (
map (certName: certs.${certName}.directory + "/fullchain.pem") vhostCertNames
);
# Disable rate limiting for this, because it may be triggered quickly a bunch of times
# if a lot of certificates are renewed in quick succession. The reload itself is cheap,
# so even doing a lot of them in a short burst is fine.
# FIXME: there's probably a better way to do this.
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "oneshot";
TimeoutSec = 60;
ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active 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 { environment.etc."nginx/nginx.conf" = mkIf cfg.enableReload {
source = configFile; source = configFile;
}; };
# This service waits for all certificates to be available
# before reloading nginx configuration.
# sslTargets are added to wantedBy + before
# which allows the acme-order-renew-$cert.service to signify the successful updating
# of certs end-to-end.
systemd.services.nginx-config-reload =
let
sslOrderRenewServices = map (certName: "acme-order-renew-${certName}.service") vhostCertNames;
in
mkIf (cfg.enableReload || vhostCertNames != [ ]) {
wants = optionals cfg.enableReload [ "nginx.service" ];
wantedBy = sslOrderRenewServices ++ [ "multi-user.target" ];
# XXX Before the finished targets, after the renew services.
# This service might be needed for HTTP-01 challenges, but we only want to confirm
# certs are updated _after_ config has been reloaded.
after = sslOrderRenewServices;
restartTriggers = optionals cfg.enableReload [ configFile ];
# Block reloading if not all certs exist yet.
# Happens when config changes add new vhosts/certs.
unitConfig = {
ConditionPathExists = optionals (vhostCertNames != [ ]) (
map (certName: certs.${certName}.directory + "/fullchain.pem") vhostCertNames
);
# Disable rate limiting for this, because it may be triggered quickly a bunch of times
# if a lot of certificates are renewed in quick succession. The reload itself is cheap,
# so even doing a lot of them in a short burst is fine.
# FIXME: there's probably a better way to do this.
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "oneshot";
TimeoutSec = 60;
ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active nginx.service";
ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service";
};
};
security.acme.certs = security.acme.certs =
let let
acmePairs = map ( # Here are two cases:
vhostConfig: # - when no `useACMEHost` is set, the `serverName` acme certificate is the primary name and we need to configure it
let # - when `useACMEHost` is set, this is also the primary name and we only need to configure the reloadServices property
hasRoot = vhostConfig.acmeRoot != null; acmePairs =
in map (
nameValuePair vhostConfig.serverName { vhostConfig:
group = mkDefault cfg.group; let
# if acmeRoot is null inherit config.security.acme hasRoot = vhostConfig.acmeRoot != null;
# Since config.security.acme.certs.<cert>.webroot's own default value in
# should take precedence set priority higher than mkOptionDefault nameValuePair vhostConfig.serverName {
webroot = mkOverride (if hasRoot then 1000 else 2000) vhostConfig.acmeRoot; reloadServices = [ "nginx.service" ];
# Also nudge dnsProvider to null in case it is inherited group = mkDefault cfg.group;
dnsProvider = mkOverride (if hasRoot then 1000 else 2000) null; # if acmeRoot is null inherit config.security.acme
extraDomainNames = vhostConfig.serverAliases; # Since config.security.acme.certs.<cert>.webroot's own default value
# Filter for enableACME-only vhosts. Don't want to create dud certs # should take precedence set priority higher than mkOptionDefault
} webroot = mkOverride (if hasRoot then 1000 else 2000) vhostConfig.acmeRoot;
) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts); # Also nudge dnsProvider to null in case it is inherited
dnsProvider = mkOverride (if hasRoot then 1000 else 2000) null;
extraDomainNames = vhostConfig.serverAliases;
# Filter for enableACME-only vhosts. Don't want to create dud certs
}
) (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,6 +1,26 @@
{ runTest }: { lib, runTest }:
let let
domain = "example.test"; domain = "example.test";
nginxBaseModule = {
services.nginx = {
enable = true;
logError = "stderr info";
# This tests a number of things at once:
# - Self-signed certs are in place before the webserver startup
# - Nginx is started before acme renewal is attempted
# - useACMEHost behaves as expected
# - acmeFallbackHost behaves as expected
virtualHosts.default = {
default = true;
addSSL = true;
useACMEHost = "proxied.example.test";
acmeFallbackHost = "localhost:8080";
};
};
specialisation.nullroot.configuration = {
services.nginx.virtualHosts."nullroot.${domain}".acmeFallbackHost = "localhost:8081";
};
};
in in
{ {
http01-builtin = runTest ./http01-builtin.nix; http01-builtin = runTest ./http01-builtin.nix;
@ -11,26 +31,18 @@ in
inherit domain; inherit domain;
serverName = "nginx"; serverName = "nginx";
group = "nginx"; group = "nginx";
baseModule = { baseModule = lib.recursiveUpdate nginxBaseModule {
services.nginx = { services.nginx.enableReload = true;
enable = true; };
enableReload = true; }
logError = "stderr info"; );
# This tests a number of things at once: nginx-without-reload = runTest (
# - Self-signed certs are in place before the webserver startup import ./webserver.nix {
# - Nginx is started before acme renewal is attempted inherit domain;
# - useACMEHost behaves as expected serverName = "nginx";
# - acmeFallbackHost behaves as expected group = "nginx";
virtualHosts.default = { baseModule = lib.recursiveUpdate nginxBaseModule {
default = true; services.nginx.enableReload = false;
addSSL = true;
useACMEHost = "proxied.example.test";
acmeFallbackHost = "localhost:8080";
};
};
specialisation.nullroot.configuration = {
services.nginx.virtualHosts."nullroot.${domain}".acmeFallbackHost = "localhost:8081";
};
}; };
} }
); );

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;
}; };