mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-11-10 09:43:30 +01:00
nixos/nginx: allow adding new ACME certificates without nginx restart (#445544)
This commit is contained in:
commit
a2d81c0a43
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue