diff --git a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
index 7094399c6ed6..85d29ab1cd1d 100644
--- a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
+++ b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml
@@ -14,7 +14,17 @@
Highlights
-
+
+
+
+ security.acme.defaults has been added to
+ simplify configuring settings for many certificates at once.
+ This also opens up the the option to use DNS-01 validation
+ when using enableACME on web server virtual
+ hosts (e.g.
+ services.nginx.virtualHosts.*.enableACME).
+
+
PHP 8.1 is now available
@@ -189,6 +199,20 @@
using this default will print a warning when rebuilt.
+
+
+ security.acme certificates will now
+ correctly check for CA revokation before reaching their
+ minimum age.
+
+
+
+
+ Removing domains from
+ security.acme.certs._name_.extraDomainNames
+ will now correctly remove those domains during rebuild/renew.
+
+
The option
diff --git a/nixos/doc/manual/release-notes/rl-2205.section.md b/nixos/doc/manual/release-notes/rl-2205.section.md
index 7002a9c35563..e69c4ea0ca92 100644
--- a/nixos/doc/manual/release-notes/rl-2205.section.md
+++ b/nixos/doc/manual/release-notes/rl-2205.section.md
@@ -6,6 +6,11 @@ In addition to numerous new and upgraded packages, this release has the followin
## Highlights {#sec-release-22.05-highlights}
+- `security.acme.defaults` has been added to simplify configuring
+ settings for many certificates at once. This also opens up the
+ the option to use DNS-01 validation when using `enableACME` on
+ web server virtual hosts (e.g. `services.nginx.virtualHosts.*.enableACME`).
+
- PHP 8.1 is now available
## New Services {#sec-release-22.05-new-services}
@@ -75,6 +80,12 @@ In addition to numerous new and upgraded packages, this release has the followin
- The `services.unifi.openPorts` option default value of `true` is now deprecated and will be changed to `false` in 22.11.
Configurations using this default will print a warning when rebuilt.
+- `security.acme` certificates will now correctly check for CA
+ revokation before reaching their minimum age.
+
+- Removing domains from `security.acme.certs._name_.extraDomainNames`
+ will now correctly remove those domains during rebuild/renew.
+
- The option
[services.ssh.enableAskPassword](#opt-services.ssh.enableAskPassword) was
added, decoupling the setting of `SSH_ASKPASS` from
diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix
index dcadbce68aa4..e244989d6408 100644
--- a/nixos/modules/security/acme.nix
+++ b/nixos/modules/security/acme.nix
@@ -3,6 +3,7 @@ with lib;
let
cfg = config.security.acme;
opt = options.security.acme;
+ user = if cfg.useRoot then "root" else "acme";
# Used to calculate timer accuracy for coalescing
numCerts = length (builtins.attrNames cfg.certs);
@@ -23,7 +24,7 @@ let
# security.acme.certs..group on some of the services.
commonServiceConfig = {
Type = "oneshot";
- User = "acme";
+ User = user;
Group = mkDefault "acme";
UMask = 0022;
StateDirectoryMode = 750;
@@ -101,12 +102,12 @@ let
# is configurable on a per-cert basis.
userMigrationService = let
script = with builtins; ''
- chown -R acme .lego/accounts
+ chown -R ${user} .lego/accounts
'' + (concatStringsSep "\n" (mapAttrsToList (cert: data: ''
for fixpath in ${escapeShellArg cert} .lego/${escapeShellArg cert}; do
if [ -d "$fixpath" ]; then
chmod -R u=rwX,g=rX,o= "$fixpath"
- chown -R acme:${data.group} "$fixpath"
+ chown -R ${user}:${data.group} "$fixpath"
fi
done
'') certConfigs));
@@ -128,7 +129,7 @@ let
};
certToConfig = cert: data: let
- acmeServer = if data.server != null then data.server else cfg.server;
+ acmeServer = data.server;
useDns = data.dnsProvider != null;
destPath = "/var/lib/acme/${cert}";
selfsignedDeps = optionals (cfg.preliminarySelfsigned) [ "acme-selfsigned-${cert}.service" ];
@@ -156,6 +157,7 @@ let
${toString data.ocspMustStaple} ${data.keyType}
'';
certDir = mkHash hashData;
+ # TODO remove domainHash usage entirely. Waiting on go-acme/lego#1532
domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}";
accountHash = (mkAccountHash acmeServer data);
accountDir = accountDirRoot + accountHash;
@@ -210,7 +212,7 @@ let
description = "Renew ACME Certificate for ${cert}";
wantedBy = [ "timers.target" ];
timerConfig = {
- OnCalendar = cfg.renewInterval;
+ OnCalendar = data.renewInterval;
Unit = "acme-${cert}.service";
Persistent = "yes";
@@ -267,7 +269,7 @@ let
cat key.pem fullchain.pem > full.pem
# Group might change between runs, re-apply it
- chown 'acme:${data.group}' *
+ chown '${user}:${data.group}' *
# Default permissions make the files unreadable by group + anon
# Need to be readable by group
@@ -322,7 +324,7 @@ let
fi
'');
} // optionalAttrs (data.listenHTTP != null && toInt (elemAt (splitString ":" data.listenHTTP) 1) < 1024) {
- AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+ CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
};
# Working directory will be /tmp
@@ -355,7 +357,7 @@ let
expiration_s=$[expiration_date - now]
expiration_days=$[expiration_s / (3600 * 24)] # rounds down
- [[ $expiration_days -gt ${toString cfg.validMinDays} ]]
+ [[ $expiration_days -gt ${toString data.validMinDays} ]]
}
${optionalString (data.webroot != null) ''
@@ -372,37 +374,40 @@ let
echo '${domainHash}' > domainhash.txt
- # Check if we can renew
- if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a -n "$(ls -1 accounts)" ]; then
+ # Check if we can renew.
+ # We can only renew if the list of domains has not changed.
+ if cmp -s domainhash.txt certificates/domainhash.txt && [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a -n "$(ls -1 accounts)" ]; then
- # When domains are updated, there's no need to do a full
- # Lego run, but it's likely renew won't work if days is too low.
- if [ -e certificates/domainhash.txt ] && cmp -s domainhash.txt certificates/domainhash.txt; then
+ # Even if a cert is not expired, it may be revoked by the CA.
+ # Try to renew, and silently fail if the cert is not expired.
+ # Avoids #85794 and resolves #129838
+ if ! lego ${renewOpts} --days ${toString data.validMinDays}; then
if is_expiration_skippable out/full.pem; then
- echo 1>&2 "nixos-acme: skipping renewal because expiration isn't within the coming ${toString cfg.validMinDays} days"
+ echo 1>&2 "nixos-acme: Ignoring failed renewal because expiration isn't within the coming ${toString data.validMinDays} days"
else
- echo 1>&2 "nixos-acme: renewing now, because certificate expires within the configured ${toString cfg.validMinDays} days"
- lego ${renewOpts} --days ${toString cfg.validMinDays}
+ # High number to avoid Systemd reserved codes.
+ exit 11
fi
- else
- echo 1>&2 "certificate domain(s) have changed; will renew now"
- # Any number > 90 works, but this one is over 9000 ;-)
- lego ${renewOpts} --days 9001
fi
# Otherwise do a full run
- else
- lego ${runOpts}
+ elif ! lego ${runOpts}; then
+ # Produce a nice error for those doing their first nixos-rebuild with these certs
+ echo Failed to fetch certificates. \
+ This may mean your DNS records are set up incorrectly. \
+ ${optionalString (cfg.preliminarySelfsigned) "Selfsigned certs are in place and dependant services will still start."}
+ # Exit 10 so that users can potentially amend SuccessExitStatus to ignore this error.
+ # High number to avoid Systemd reserved codes.
+ exit 10
fi
mv domainhash.txt certificates/
# Group might change between runs, re-apply it
- chown 'acme:${data.group}' certificates/*
+ chown '${user}:${data.group}' certificates/*
# Copy all certs to the "real" certs directory
- CERT='certificates/${keyName}.crt'
- if [ -e "$CERT" ] && ! cmp -s "$CERT" out/fullchain.pem; then
+ if ! cmp -s 'certificates/${keyName}.crt' out/fullchain.pem; then
touch out/renewed
echo Installing new certificate
cp -vp 'certificates/${keyName}.crt' out/fullchain.pem
@@ -421,7 +426,194 @@ let
certConfigs = mapAttrs certToConfig cfg.certs;
- certOpts = { name, ... }: {
+ # These options can be specified within
+ # security.acme.defaults or security.acme.certs.
+ inheritableModule = isDefaults: { config, ... }: let
+ defaultAndText = name: default: {
+ # When ! isDefaults then this is the option declaration for the
+ # security.acme.certs. path, which has the extra inheritDefaults
+ # option, which if disabled means that we can't inherit it
+ default = if isDefaults || ! config.inheritDefaults then default else cfg.defaults.${name};
+ # The docs however don't need to depend on inheritDefaults, they should
+ # stay constant. Though notably it wouldn't matter much, because to get
+ # the option information, a submodule with name `` is evaluated
+ # without any definitions.
+ defaultText = if isDefaults then default else literalExpression "config.security.acme.defaults.${name}";
+ };
+ in {
+ options = {
+ validMinDays = mkOption {
+ type = types.int;
+ inherit (defaultAndText "validMinDays" 30) default defaultText;
+ description = "Minimum remaining validity before renewal in days.";
+ };
+
+ renewInterval = mkOption {
+ type = types.str;
+ inherit (defaultAndText "renewInterval" "daily") default defaultText;
+ description = ''
+ Systemd calendar expression when to check for renewal. See
+ systemd.time
+ 7.
+ '';
+ };
+
+ enableDebugLogs = mkEnableOption "debug logging for this certificate" // {
+ inherit (defaultAndText "enableDebugLogs" true) default defaultText;
+ };
+
+ webroot = mkOption {
+ type = types.nullOr types.str;
+ inherit (defaultAndText "webroot" null) default defaultText;
+ example = "/var/lib/acme/acme-challenge";
+ description = ''
+ Where the webroot of the HTTP vhost is located.
+ .well-known/acme-challenge/ directory
+ will be created below the webroot if it doesn't exist.
+ http://example.org/.well-known/acme-challenge/ must also
+ be available (notice unencrypted HTTP).
+ '';
+ };
+
+ server = mkOption {
+ type = types.nullOr types.str;
+ inherit (defaultAndText "server" null) default defaultText;
+ description = ''
+ ACME Directory Resource URI. Defaults to Let's Encrypt's
+ production endpoint,
+ , if unset.
+ '';
+ };
+
+ email = mkOption {
+ type = types.str;
+ inherit (defaultAndText "email" null) default defaultText;
+ description = ''
+ Email address for account creation and correspondence from the CA.
+ It is recommended to use the same email for all certs to avoid account
+ creation limits.
+ '';
+ };
+
+ group = mkOption {
+ type = types.str;
+ inherit (defaultAndText "group" "acme") default defaultText;
+ description = "Group running the ACME client.";
+ };
+
+ reloadServices = mkOption {
+ type = types.listOf types.str;
+ inherit (defaultAndText "reloadServices" []) default defaultText;
+ description = ''
+ The list of systemd services to call systemctl try-reload-or-restart
+ on.
+ '';
+ };
+
+ postRun = mkOption {
+ type = types.lines;
+ inherit (defaultAndText "postRun" "") default defaultText;
+ example = "cp full.pem backup.pem";
+ description = ''
+ Commands to run after new certificates go live. Note that
+ these commands run as the root user.
+
+ Executed in the same directory with the new certificate.
+ '';
+ };
+
+ keyType = mkOption {
+ type = types.str;
+ inherit (defaultAndText "keyType" "ec256") default defaultText;
+ description = ''
+ Key type to use for private keys.
+ For an up to date list of supported values check the --key-type option
+ at .
+ '';
+ };
+
+ dnsProvider = mkOption {
+ type = types.nullOr types.str;
+ inherit (defaultAndText "dnsProvider" null) default defaultText;
+ example = "route53";
+ description = ''
+ DNS Challenge provider. For a list of supported providers, see the "code"
+ field of the DNS providers listed at .
+ '';
+ };
+
+ dnsResolver = mkOption {
+ type = types.nullOr types.str;
+ inherit (defaultAndText "dnsResolver" null) default defaultText;
+ example = "1.1.1.1:53";
+ description = ''
+ Set the resolver to use for performing recursive DNS queries. Supported:
+ host:port. The default is to use the system resolvers, or Google's DNS
+ resolvers if the system's cannot be determined.
+ '';
+ };
+
+ credentialsFile = mkOption {
+ type = types.path;
+ inherit (defaultAndText "credentialsFile" null) default defaultText;
+ description = ''
+ Path to an EnvironmentFile for the cert's service containing any required and
+ optional environment variables for your selected dnsProvider.
+ To find out what values you need to set, consult the documentation at
+ for the corresponding dnsProvider.
+ '';
+ example = "/var/src/secrets/example.org-route53-api-token";
+ };
+
+ dnsPropagationCheck = mkOption {
+ type = types.bool;
+ inherit (defaultAndText "dnsPropagationCheck" true) default defaultText;
+ description = ''
+ Toggles lego DNS propagation check, which is used alongside DNS-01
+ challenge to ensure the DNS entries required are available.
+ '';
+ };
+
+ ocspMustStaple = mkOption {
+ type = types.bool;
+ inherit (defaultAndText "ocspMustStaple" false) default defaultText;
+ description = ''
+ Turns on the OCSP Must-Staple TLS extension.
+ Make sure you know what you're doing! See:
+
+
+
+
+ '';
+ };
+
+ extraLegoFlags = mkOption {
+ type = types.listOf types.str;
+ inherit (defaultAndText "extraLegoFlags" []) default defaultText;
+ description = ''
+ Additional global flags to pass to all lego commands.
+ '';
+ };
+
+ extraLegoRenewFlags = mkOption {
+ type = types.listOf types.str;
+ inherit (defaultAndText "extraLegoRenewFlags" []) default defaultText;
+ description = ''
+ Additional flags to pass to lego renew.
+ '';
+ };
+
+ extraLegoRunFlags = mkOption {
+ type = types.listOf types.str;
+ inherit (defaultAndText "extraLegoRunFlags" []) default defaultText;
+ description = ''
+ Additional flags to pass to lego run.
+ '';
+ };
+ };
+ };
+
+ certOpts = { name, config, ... }: {
options = {
# user option has been removed
user = mkOption {
@@ -441,40 +633,11 @@ let
default = "_mkMergedOptionModule";
};
- enableDebugLogs = mkEnableOption "debug logging for this certificate" // { default = cfg.enableDebugLogs; };
-
- webroot = mkOption {
- type = types.nullOr types.str;
- default = null;
- example = "/var/lib/acme/acme-challenge";
- description = ''
- Where the webroot of the HTTP vhost is located.
- .well-known/acme-challenge/ directory
- will be created below the webroot if it doesn't exist.
- http://example.org/.well-known/acme-challenge/ must also
- be available (notice unencrypted HTTP).
- '';
- };
-
- listenHTTP = mkOption {
- type = types.nullOr types.str;
- default = null;
- example = ":1360";
- description = ''
- Interface and port to listen on to solve HTTP challenges
- in the form [INTERFACE]:PORT.
- If you use a port other than 80, you must proxy port 80 to this port.
- '';
- };
-
- server = mkOption {
- type = types.nullOr types.str;
- default = null;
- description = ''
- ACME Directory Resource URI. Defaults to Let's Encrypt's
- production endpoint,
- , if unset.
- '';
+ directory = mkOption {
+ type = types.str;
+ readOnly = true;
+ default = "/var/lib/acme/${name}";
+ description = "Directory where certificate and other state is stored.";
};
domain = mkOption {
@@ -483,47 +646,6 @@ let
description = "Domain to fetch certificate for (defaults to the entry name).";
};
- email = mkOption {
- type = types.nullOr types.str;
- default = cfg.email;
- defaultText = literalExpression "config.${opt.email}";
- description = "Contact email address for the CA to be able to reach you.";
- };
-
- group = mkOption {
- type = types.str;
- default = "acme";
- description = "Group running the ACME client.";
- };
-
- reloadServices = mkOption {
- type = types.listOf types.str;
- default = [];
- description = ''
- The list of systemd services to call systemctl try-reload-or-restart
- on.
- '';
- };
-
- postRun = mkOption {
- type = types.lines;
- default = "";
- example = "cp full.pem backup.pem";
- description = ''
- Commands to run after new certificates go live. Note that
- these commands run as the root user.
-
- Executed in the same directory with the new certificate.
- '';
- };
-
- directory = mkOption {
- type = types.str;
- readOnly = true;
- default = "/var/lib/acme/${name}";
- description = "Directory where certificate and other state is stored.";
- };
-
extraDomainNames = mkOption {
type = types.listOf types.str;
default = [];
@@ -538,92 +660,25 @@ let
'';
};
- keyType = mkOption {
- type = types.str;
- default = "ec256";
- description = ''
- Key type to use for private keys.
- For an up to date list of supported values check the --key-type option
- at .
- '';
- };
-
- dnsProvider = mkOption {
+ # This setting must be different for each configured certificate, otherwise
+ # two or more renewals may fail to bind to the address. Hence, it is not in
+ # the inheritableOpts.
+ listenHTTP = mkOption {
type = types.nullOr types.str;
default = null;
- example = "route53";
+ example = ":1360";
description = ''
- DNS Challenge provider. For a list of supported providers, see the "code"
- field of the DNS providers listed at .
+ Interface and port to listen on to solve HTTP challenges
+ in the form [INTERFACE]:PORT.
+ If you use a port other than 80, you must proxy port 80 to this port.
'';
};
- dnsResolver = mkOption {
- type = types.nullOr types.str;
- default = null;
- example = "1.1.1.1:53";
- description = ''
- Set the resolver to use for performing recursive DNS queries. Supported:
- host:port. The default is to use the system resolvers, or Google's DNS
- resolvers if the system's cannot be determined.
- '';
- };
-
- credentialsFile = mkOption {
- type = types.path;
- description = ''
- Path to an EnvironmentFile for the cert's service containing any required and
- optional environment variables for your selected dnsProvider.
- To find out what values you need to set, consult the documentation at
- for the corresponding dnsProvider.
- '';
- example = "/var/src/secrets/example.org-route53-api-token";
- };
-
- dnsPropagationCheck = mkOption {
- type = types.bool;
+ inheritDefaults = mkOption {
default = true;
- description = ''
- Toggles lego DNS propagation check, which is used alongside DNS-01
- challenge to ensure the DNS entries required are available.
- '';
- };
-
- ocspMustStaple = mkOption {
- type = types.bool;
- default = false;
- description = ''
- Turns on the OCSP Must-Staple TLS extension.
- Make sure you know what you're doing! See:
-
-
-
-
- '';
- };
-
- extraLegoFlags = mkOption {
- type = types.listOf types.str;
- default = [];
- description = ''
- Additional global flags to pass to all lego commands.
- '';
- };
-
- extraLegoRenewFlags = mkOption {
- type = types.listOf types.str;
- default = [];
- description = ''
- Additional flags to pass to lego renew.
- '';
- };
-
- extraLegoRunFlags = mkOption {
- type = types.listOf types.str;
- default = [];
- description = ''
- Additional flags to pass to lego run.
- '';
+ example = true;
+ description = "Whether to inherit values set in `security.acme.defaults` or not.";
+ type = lib.types.bool;
};
};
};
@@ -632,41 +687,6 @@ in {
options = {
security.acme = {
-
- enableDebugLogs = mkEnableOption "debug logging for all certificates by default" // { default = true; };
-
- validMinDays = mkOption {
- type = types.int;
- default = 30;
- description = "Minimum remaining validity before renewal in days.";
- };
-
- email = mkOption {
- type = types.nullOr types.str;
- default = null;
- description = "Contact email address for the CA to be able to reach you.";
- };
-
- renewInterval = mkOption {
- type = types.str;
- default = "daily";
- description = ''
- Systemd calendar expression when to check for renewal. See
- systemd.time
- 7.
- '';
- };
-
- server = mkOption {
- type = types.nullOr types.str;
- default = null;
- description = ''
- ACME Directory Resource URI. Defaults to Let's Encrypt's
- production endpoint,
- , if unset.
- '';
- };
-
preliminarySelfsigned = mkOption {
type = types.bool;
default = true;
@@ -689,9 +709,31 @@ in {
'';
};
+ useRoot = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Whether to use the root user when generating certs. This is not recommended
+ for security + compatiblity reasons. If a service requires root owned certificates
+ consider following the guide on "Using ACME with services demanding root
+ owned certificates" in the NixOS manual, and only using this as a fallback
+ or for testing.
+ '';
+ };
+
+ defaults = mkOption {
+ type = types.submodule (inheritableModule true);
+ description = ''
+ Default values inheritable by all configured certs. You can
+ use this to define options shared by all your certs. These defaults
+ can also be ignored on a per-cert basis using the
+ `security.acme.certs.''${cert}.inheritDefaults' option.
+ '';
+ };
+
certs = mkOption {
default = { };
- type = with types; attrsOf (submodule certOpts);
+ type = with types; attrsOf (submodule [ (inheritableModule false) certOpts ]);
description = ''
Attribute set of certificates to get signed and renewed. Creates
acme-''${cert}.{service,timer} systemd units for
@@ -722,12 +764,16 @@ in {
To use the let's encrypt staging server, use security.acme.server =
"https://acme-staging-v02.api.letsencrypt.org/directory".
- ''
- )
+ '')
(mkRemovedOptionModule [ "security" "acme" "directory" ] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.")
(mkRemovedOptionModule [ "security" "acme" "preDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
(mkRemovedOptionModule [ "security" "acme" "activationDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
- (mkChangedOptionModule [ "security" "acme" "validMin" ] [ "security" "acme" "validMinDays" ] (config: config.security.acme.validMin / (24 * 3600)))
+ (mkChangedOptionModule [ "security" "acme" "validMin" ] [ "security" "acme" "defaults" "validMinDays" ] (config: config.security.acme.validMin / (24 * 3600)))
+ (mkChangedOptionModule [ "security" "acme" "validMinDays" ] [ "security" "acme" "defaults" "validMinDays" ] (config: config.security.acme.validMinDays))
+ (mkChangedOptionModule [ "security" "acme" "renewInterval" ] [ "security" "acme" "defaults" "renewInterval" ] (config: config.security.acme.renewInterval))
+ (mkChangedOptionModule [ "security" "acme" "email" ] [ "security" "acme" "defaults" "email" ] (config: config.security.acme.email))
+ (mkChangedOptionModule [ "security" "acme" "server" ] [ "security" "acme" "defaults" "server" ] (config: config.security.acme.server))
+ (mkChangedOptionModule [ "security" "acme" "enableDebugLogs" ] [ "security" "acme" "defaults" "enableDebugLogs" ] (config: config.security.acme.enableDebugLogs))
];
config = mkMerge [
@@ -842,8 +888,8 @@ in {
# Create some targets which can be depended on to be "active" after cert renewals
finishedTargets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
wantedBy = [ "default.target" ];
- requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
- after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps;
+ requires = [ "acme-${cert}.service" ];
+ after = [ "acme-${cert}.service" ];
}) certConfigs;
# Create targets to limit the number of simultaneous account creations
diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml
index bf93800a0af4..f623cc509be6 100644
--- a/nixos/modules/security/acme.xml
+++ b/nixos/modules/security/acme.xml
@@ -7,8 +7,9 @@
NixOS supports automatic domain validation & certificate retrieval and
renewal using the ACME protocol. Any provider can be used, but by default
- NixOS uses Let's Encrypt. The alternative ACME client lego
- is used under the hood.
+ NixOS uses Let's Encrypt. The alternative ACME client
+ lego is used under
+ the hood.
Automatic cert validation and configuration for Apache and Nginx virtual
@@ -29,7 +30,7 @@
You must also set an email address to be used when creating accounts with
Let's Encrypt. You can set this for all certs with
-
+
and/or on a per-cert basis with
.
This address is only used for registration and renewal reminders,
@@ -38,7 +39,7 @@
Alternatively, you can use a different ACME server by changing the
- option
+ option
to a provider of your choosing, or just change the server for one cert with
.
@@ -60,12 +61,12 @@
= true; in a virtualHost config. We first create self-signed
placeholder certificates in place of the real ACME certs. The placeholder
certs are overwritten when the ACME certs arrive. For
- foo.example.com the config would look like.
+ foo.example.com the config would look like this:
= true;
- = "admin+acme@example.com";
+ = "admin+acme@example.com";
services.nginx = {
enable = true;
virtualHosts = {
@@ -114,7 +115,7 @@ services.nginx = {
= true;
- = "admin+acme@example.com";
+ = "admin+acme@example.com";
# /var/lib/acme/.challenges must be writable by the ACME user
# and readable by the Nginx user. The easiest way to achieve
@@ -218,7 +219,7 @@ services.bind = {
# Now we can configure ACME
= true;
- = "admin+acme@example.com";
+ = "admin+acme@example.com";
."example.com" = {
domain = "*.example.com";
dnsProvider = "rfc2136";
@@ -231,25 +232,39 @@ services.bind = {
The dnskeys.conf and certs.secret
must be kept secure and thus you should not keep their contents in your
- Nix config. Instead, generate them one time with these commands:
+ Nix config. Instead, generate them one time with a systemd service:
-mkdir -p /var/lib/secrets
-tsig-keygen rfc2136key.example.com > /var/lib/secrets/dnskeys.conf
-chown named:root /var/lib/secrets/dnskeys.conf
-chmod 400 /var/lib/secrets/dnskeys.conf
+systemd.services.dns-rfc2136-conf = {
+ requiredBy = ["acme-example.com.service", "bind.service"];
+ before = ["acme-example.com.service", "bind.service"];
+ unitConfig = {
+ ConditionPathExists = "!/var/lib/secrets/dnskeys.conf";
+ };
+ serviceConfig = {
+ Type = "oneshot";
+ UMask = 0077;
+ };
+ path = [ pkgs.bind ];
+ script = ''
+ mkdir -p /var/lib/secrets
+ tsig-keygen rfc2136key.example.com > /var/lib/secrets/dnskeys.conf
+ chown named:root /var/lib/secrets/dnskeys.conf
+ chmod 400 /var/lib/secrets/dnskeys.conf
-# Copy the secret value from the dnskeys.conf, and put it in
-# RFC2136_TSIG_SECRET below
+ # Copy the secret value from the dnskeys.conf, and put it in
+ # RFC2136_TSIG_SECRET below
-cat > /var/lib/secrets/certs.secret << EOF
-RFC2136_NAMESERVER='127.0.0.1:53'
-RFC2136_TSIG_ALGORITHM='hmac-sha256.'
-RFC2136_TSIG_KEY='rfc2136key.example.com'
-RFC2136_TSIG_SECRET='your secret key'
-EOF
-chmod 400 /var/lib/secrets/certs.secret
+ cat > /var/lib/secrets/certs.secret << EOF
+ RFC2136_NAMESERVER='127.0.0.1:53'
+ RFC2136_TSIG_ALGORITHM='hmac-sha256.'
+ RFC2136_TSIG_KEY='rfc2136key.example.com'
+ RFC2136_TSIG_SECRET='your secret key'
+ EOF
+ chmod 400 /var/lib/secrets/certs.secret
+ '';
+};
@@ -258,6 +273,106 @@ chmod 400 /var/lib/secrets/certs.secret
journalctl -fu acme-example.com.service and watching its log output.
+
+
+ Using DNS validation with web server virtual hosts
+
+
+ It is possible to use DNS-01 validation with all certificates,
+ including those automatically configured via the Nginx/Apache
+ enableACME
+ option. This configuration pattern is fully
+ supported and part of the module's test suite for Nginx + Apache.
+
+
+
+ You must follow the guide above on configuring DNS-01 validation
+ first, however instead of setting the options for one certificate
+ (e.g. )
+ you will set them as defaults
+ (e.g. ).
+
+
+
+# Configure ACME appropriately
+ = true;
+ = "admin+acme@example.com";
+ = {
+ dnsProvider = "rfc2136";
+ credentialsFile = "/var/lib/secrets/certs.secret";
+ # We don't need to wait for propagation since this is a local DNS server
+ dnsPropagationCheck = false;
+};
+
+# For each virtual host you would like to use DNS-01 validation with,
+# set acmeRoot = null
+services.nginx = {
+ enable = true;
+ virtualHosts = {
+ "foo.example.com" = {
+ enableACME = true;
+ acmeRoot = null;
+ };
+ };
+}
+
+
+
+ And that's it! Next time your configuration is rebuilt, or when
+ you add a new virtualHost, it will be DNS-01 validated.
+
+
+
+
+ Using ACME with services demanding root owned certificates
+
+
+ Some services refuse to start if the configured certificate files
+ are not owned by root. PostgreSQL and OpenSMTPD are examples of these.
+ There is no way to change the user the ACME module uses (it will always be
+ acme), however you can use systemd's
+ LoadCredential feature to resolve this elegantly.
+ Below is an example configuration for OpenSMTPD, but this pattern
+ can be applied to any service.
+
+
+
+# Configure ACME however you like (DNS or HTTP validation), adding
+# the following configuration for the relevant certificate.
+# Note: You cannot use `systemctl reload` here as that would mean
+# the LoadCredential configuration below would be skipped and
+# the service would continue to use old certificates.
+security.acme.certs."mail.example.com".postRun = ''
+ systemctl restart opensmtpd
+'';
+
+# Now you must augment OpenSMTPD's systemd service to load
+# the certificate files.
+systemd.services.opensmtpd.requires = ["acme-finished-mail.example.com.target"];
+systemd.services.opensmtpd.serviceConfig.LoadCredential = let
+ certDir = config.security.acme.certs."mail.example.com".directory;
+in [
+ "cert.pem:${certDir}/cert.pem"
+ "key.pem:${certDir}/key.pem"
+];
+
+# Finally, configure OpenSMTPD to use these certs.
+services.opensmtpd = let
+ credsDir = "/run/credentials/opensmtpd.service";
+in {
+ enable = true;
+ setSendmail = false;
+ serverConfiguration = ''
+ pki mail.example.com cert "${credsDir}/cert.pem"
+ pki mail.example.com key "${credsDir}/key.pem"
+ listen on localhost tls pki mail.example.com
+ action act1 relay host smtp://127.0.0.1:10027
+ match for local action act1
+ '';
+};
+
+
+
Regenerating certificates
diff --git a/nixos/modules/services/networking/prosody.xml b/nixos/modules/services/networking/prosody.xml
index 471240cd1475..6358d744ff78 100644
--- a/nixos/modules/services/networking/prosody.xml
+++ b/nixos/modules/services/networking/prosody.xml
@@ -72,7 +72,7 @@ services.prosody = {
a TLS certificate for the three endponits:
security.acme = {
- email = "root@example.org";
+ email = "root@example.org";
acceptTerms = true;
certs = {
"example.org" = {
diff --git a/nixos/modules/services/web-apps/discourse.xml b/nixos/modules/services/web-apps/discourse.xml
index e91d3eac422d..ad9b65abf51e 100644
--- a/nixos/modules/services/web-apps/discourse.xml
+++ b/nixos/modules/services/web-apps/discourse.xml
@@ -25,7 +25,7 @@ services.discourse = {
};
secretKeyBaseFile = "/path/to/secret_key_base_file";
};
-security.acme.email = "me@example.com";
+security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
diff --git a/nixos/modules/services/web-apps/jitsi-meet.xml b/nixos/modules/services/web-apps/jitsi-meet.xml
index 97373bc6d9a8..ff44c724adf4 100644
--- a/nixos/modules/services/web-apps/jitsi-meet.xml
+++ b/nixos/modules/services/web-apps/jitsi-meet.xml
@@ -20,7 +20,7 @@
};
services.jitsi-videobridge.openFirewall = true;
networking.firewall.allowedTCPPorts = [ 80 443 ];
- security.acme.email = "me@example.com";
+ security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
}
@@ -46,7 +46,7 @@
};
services.jitsi-videobridge.openFirewall = true;
networking.firewall.allowedTCPPorts = [ 80 443 ];
- security.acme.email = "me@example.com";
+ security.acme.email = "me@example.com";
security.acme.acceptTerms = true;
}
diff --git a/nixos/modules/services/web-servers/apache-httpd/default.nix b/nixos/modules/services/web-servers/apache-httpd/default.nix
index 992a58875e43..1a49b4ca15c7 100644
--- a/nixos/modules/services/web-servers/apache-httpd/default.nix
+++ b/nixos/modules/services/web-servers/apache-httpd/default.nix
@@ -154,7 +154,7 @@ let
sslServerKey = if useACME then "${sslCertDir}/key.pem" else hostOpts.sslServerKey;
sslServerChain = if useACME then "${sslCertDir}/chain.pem" else hostOpts.sslServerChain;
- acmeChallenge = optionalString useACME ''
+ acmeChallenge = optionalString (useACME && hostOpts.acmeRoot != null) ''
Alias /.well-known/acme-challenge/ "${hostOpts.acmeRoot}/.well-known/acme-challenge/"
AllowOverride None
@@ -677,9 +677,16 @@ in
};
security.acme.certs = let
- acmePairs = map (hostOpts: nameValuePair hostOpts.hostName {
+ acmePairs = map (hostOpts: let
+ hasRoot = hostOpts.acmeRoot != null;
+ in nameValuePair hostOpts.hostName {
group = mkDefault cfg.group;
- webroot = hostOpts.acmeRoot;
+ # if acmeRoot is null inherit config.security.acme
+ # Since config.security.acme.certs..webroot's own default value
+ # should take precedence set priority higher than mkOptionDefault
+ webroot = mkOverride (if hasRoot then 1000 else 2000) hostOpts.acmeRoot;
+ # Also nudge dnsProvider to null in case it is inherited
+ dnsProvider = mkOverride (if hasRoot then 1000 else 2000) null;
extraDomainNames = hostOpts.serverAliases;
# Use the vhost-specific email address if provided, otherwise let
# security.acme.email or security.acme.certs..email be used.
diff --git a/nixos/modules/services/web-servers/apache-httpd/vhost-options.nix b/nixos/modules/services/web-servers/apache-httpd/vhost-options.nix
index 8bb7e91ec9cd..c52ab2c596e0 100644
--- a/nixos/modules/services/web-servers/apache-httpd/vhost-options.nix
+++ b/nixos/modules/services/web-servers/apache-httpd/vhost-options.nix
@@ -128,9 +128,12 @@ in
};
acmeRoot = mkOption {
- type = types.str;
+ type = types.nullOr types.str;
default = "/var/lib/acme/acme-challenge";
- description = "Directory for the acme challenge which is PUBLIC, don't put certs or keys in here";
+ description = ''
+ Directory for the acme challenge which is PUBLIC, don't put certs or keys in here.
+ Set to null to inherit from config.security.acme.
+ '';
};
sslServerCert = mkOption {
diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix
index ba8e874f2ded..05b7870fc3a1 100644
--- a/nixos/modules/services/web-servers/nginx/default.nix
+++ b/nixos/modules/services/web-servers/nginx/default.nix
@@ -278,7 +278,7 @@ let
acmeLocation = optionalString (vhost.enableACME || vhost.useACMEHost != null) ''
location /.well-known/acme-challenge {
${optionalString (vhost.acmeFallbackHost != null) "try_files $uri @acme-fallback;"}
- root ${vhost.acmeRoot};
+ ${optionalString (vhost.acmeRoot != null) "root ${vhost.acmeRoot};"}
auth_basic off;
}
${optionalString (vhost.acmeFallbackHost != null) ''
@@ -948,9 +948,16 @@ in
};
security.acme.certs = let
- acmePairs = map (vhostConfig: nameValuePair vhostConfig.serverName {
+ acmePairs = map (vhostConfig: let
+ hasRoot = vhostConfig.acmeRoot != null;
+ in nameValuePair vhostConfig.serverName {
group = mkDefault cfg.group;
- webroot = vhostConfig.acmeRoot;
+ # if acmeRoot is null inherit config.security.acme
+ # Since config.security.acme.certs..webroot's own default value
+ # should take precedence set priority higher than mkOptionDefault
+ webroot = mkOverride (if hasRoot then 1000 else 2000) vhostConfig.acmeRoot;
+ # 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);
diff --git a/nixos/modules/services/web-servers/nginx/vhost-options.nix b/nixos/modules/services/web-servers/nginx/vhost-options.nix
index 7f49ce9586ca..c4e8285dc48b 100644
--- a/nixos/modules/services/web-servers/nginx/vhost-options.nix
+++ b/nixos/modules/services/web-servers/nginx/vhost-options.nix
@@ -3,7 +3,7 @@
# has additional options that affect the web server as a whole, like
# the user/group to run under.)
-{ lib, ... }:
+{ config, lib, ... }:
with lib;
{
@@ -85,9 +85,12 @@ with lib;
};
acmeRoot = mkOption {
- type = types.str;
+ type = types.nullOr types.str;
default = "/var/lib/acme/acme-challenge";
- description = "Directory for the acme challenge which is PUBLIC, don't put certs or keys in here";
+ description = ''
+ Directory for the acme challenge which is PUBLIC, don't put certs or keys in here.
+ Set to null to inherit from config.security.acme.
+ '';
};
acmeFallbackHost = mkOption {
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
index 72b7bb8a396a..0dd7743c52b6 100644
--- a/nixos/tests/acme.nix
+++ b/nixos/tests/acme.nix
@@ -1,9 +1,9 @@
-let
+import ./make-test-python.nix ({ pkgs, lib, ... }: let
commonConfig = ./common/acme/client;
dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress;
- dnsScript = {pkgs, nodes}: let
+ dnsScript = nodes: let
dnsAddress = dnsServerIP nodes;
in pkgs.writeShellScript "dns-hook.sh" ''
set -euo pipefail
@@ -15,30 +15,137 @@ let
fi
'';
- documentRoot = pkgs: pkgs.runCommand "docroot" {} ''
+ dnsConfig = nodes: {
+ dnsProvider = "exec";
+ dnsPropagationCheck = false;
+ credentialsFile = pkgs.writeText "wildcard.env" ''
+ EXEC_PATH=${dnsScript nodes}
+ EXEC_POLLING_INTERVAL=1
+ EXEC_PROPAGATION_TIMEOUT=1
+ EXEC_SEQUENCE_INTERVAL=1
+ '';
+ };
+
+ documentRoot = pkgs.runCommand "docroot" {} ''
mkdir -p "$out"
echo hello world > "$out/index.html"
'';
- vhostBase = pkgs: {
+ vhostBase = {
forceSSL = true;
- locations."/".root = documentRoot pkgs;
+ locations."/".root = documentRoot;
};
-in import ./make-test-python.nix ({ lib, ... }: {
+ vhostBaseHttpd = {
+ forceSSL = true;
+ inherit documentRoot;
+ };
+
+ # Base specialisation config for testing general ACME features
+ webserverBasicConfig = {
+ services.nginx.enable = true;
+ services.nginx.virtualHosts."a.example.test" = vhostBase // {
+ enableACME = true;
+ };
+ };
+
+ # Generate specialisations for testing a web server
+ mkServerConfigs = { server, group, vhostBaseData, extraConfig ? {} }: let
+ baseConfig = { nodes, config, specialConfig ? {} }: lib.mkMerge [
+ {
+ security.acme = {
+ defaults = (dnsConfig nodes) // {
+ inherit group;
+ };
+ # One manual wildcard cert
+ certs."example.test" = {
+ domain = "*.example.test";
+ };
+ };
+
+ services."${server}" = {
+ enable = true;
+ virtualHosts = {
+ # Run-of-the-mill vhost using HTTP-01 validation
+ "${server}-http.example.test" = vhostBaseData // {
+ serverAliases = [ "${server}-http-alias.example.test" ];
+ enableACME = true;
+ };
+
+ # Another which inherits the DNS-01 config
+ "${server}-dns.example.test" = vhostBaseData // {
+ serverAliases = [ "${server}-dns-alias.example.test" ];
+ enableACME = true;
+ # Set acmeRoot to null instead of using the default of "/var/lib/acme/acme-challenge"
+ # webroot + dnsProvider are mutually exclusive.
+ acmeRoot = null;
+ };
+
+ # One using the wildcard certificate
+ "${server}-wildcard.example.test" = vhostBaseData // {
+ serverAliases = [ "${server}-wildcard-alias.example.test" ];
+ useACMEHost = "example.test";
+ };
+ };
+ };
+
+ # Used to determine if service reload was triggered
+ systemd.targets."test-renew-${server}" = {
+ wants = [ "acme-${server}-http.example.test.service" ];
+ after = [ "acme-${server}-http.example.test.service" "${server}-config-reload.service" ];
+ };
+ }
+ specialConfig
+ extraConfig
+ ];
+ in {
+ "${server}".configuration = { nodes, config, ... }: baseConfig {
+ inherit nodes config;
+ };
+
+ # Test that server reloads when an alias is removed (and subsequently test removal works in acme)
+ "${server}-remove-alias".configuration = { nodes, config, ... }: baseConfig {
+ inherit nodes config;
+ specialConfig = {
+ # Remove an alias, but create a standalone vhost in its place for testing.
+ # This configuration results in certificate errors as useACMEHost does not imply
+ # append extraDomains, and thus we can validate the SAN is removed.
+ services."${server}" = {
+ virtualHosts."${server}-http.example.test".serverAliases = lib.mkForce [];
+ virtualHosts."${server}-http-alias.example.test" = vhostBaseData // {
+ useACMEHost = "${server}-http.example.test";
+ };
+ };
+ };
+ };
+
+ # Test that the server reloads when only the acme configuration is changed.
+ "${server}-change-acme-conf".configuration = { nodes, config, ... }: baseConfig {
+ inherit nodes config;
+ specialConfig = {
+ security.acme.certs."${server}-http.example.test" = {
+ keyType = "ec384";
+ # Also test that postRun is exec'd as root
+ postRun = "id | grep root";
+ };
+ };
+ };
+ };
+
+in {
name = "acme";
meta.maintainers = lib.teams.acme.members;
nodes = {
# The fake ACME server which will respond to client requests
- acme = { nodes, lib, ... }: {
+ 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
- dnsserver = { nodes, pkgs, ... }: {
+ dnsserver = { nodes, ... }: {
networking.firewall.allowedTCPPorts = [ 8055 53 ];
networking.firewall.allowedUDPPorts = [ 53 ];
systemd.services.pebble-challtestsrv = {
@@ -54,7 +161,7 @@ in import ./make-test-python.nix ({ lib, ... }: {
};
# A web server which will be the node requesting certs
- webserver = { pkgs, nodes, lib, config, ... }: {
+ webserver = { nodes, config, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
networking.firewall.allowedTCPPorts = [ 80 443 ];
@@ -63,130 +170,142 @@ in import ./make-test-python.nix ({ lib, ... }: {
environment.systemPackages = [ pkgs.openssl ];
# Set log level to info so that we can see when the service is reloaded
- services.nginx.enable = true;
services.nginx.logError = "stderr info";
- # First tests configure a basic cert and run a bunch of openssl checks
- services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
- enableACME = true;
- };
+ specialisation = {
+ # First derivation used to test general ACME features
+ general.configuration = { ... }: let
+ caDomain = nodes.acme.config.test-support.acme.caDomain;
+ email = config.security.acme.defaults.email;
+ # Exit 99 to make it easier to track if this is the reason a renew failed
+ accountCreateTester = ''
+ test -e accounts/${caDomain}/${email}/account.json || exit 99
+ '';
+ in lib.mkMerge [
+ webserverBasicConfig
+ {
+ # Used to test that account creation is collated into one service.
+ # These should not run until after acme-finished-a.example.test.target
+ systemd.services."b.example.test".preStart = accountCreateTester;
+ systemd.services."c.example.test".preStart = accountCreateTester;
- # Used to determine if service reload was triggered
- systemd.targets.test-renew-nginx = {
- wants = [ "acme-a.example.test.service" ];
- after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
- };
+ services.nginx.virtualHosts."b.example.test" = vhostBase // {
+ enableACME = true;
+ };
+ services.nginx.virtualHosts."c.example.test" = vhostBase // {
+ enableACME = true;
+ };
+ }
+ ];
- # Test that account creation is collated into one service
- specialisation.account-creation.configuration = { nodes, pkgs, lib, ... }: let
- email = "newhostmaster@example.test";
- caDomain = nodes.acme.config.test-support.acme.caDomain;
- # Exit 99 to make it easier to track if this is the reason a renew failed
- testScript = ''
- test -e accounts/${caDomain}/${email}/account.json || exit 99
- '';
+ # Test OCSP Stapling
+ ocsp-stapling.configuration = { ... }: lib.mkMerge [
+ webserverBasicConfig
+ {
+ security.acme.certs."a.example.test".ocspMustStaple = true;
+ services.nginx.virtualHosts."a.example.test" = {
+ extraConfig = ''
+ ssl_stapling on;
+ ssl_stapling_verify on;
+ '';
+ };
+ }
+ ];
+
+ # Validate service relationships by adding a slow start service to nginx' wants.
+ # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
+ slow-startup.configuration = { ... }: lib.mkMerge [
+ webserverBasicConfig
+ {
+ systemd.services.my-slow-service = {
+ wantedBy = [ "multi-user.target" "nginx.service" ];
+ before = [ "nginx.service" ];
+ preStart = "sleep 5";
+ script = "${pkgs.python3}/bin/python -m http.server";
+ };
+
+ services.nginx.virtualHosts."slow.example.test" = {
+ forceSSL = true;
+ enableACME = true;
+ locations."/".proxyPass = "http://localhost:8000";
+ };
+ }
+ ];
+
+ # Test lego internal server (listenHTTP option)
+ # Also tests useRoot option
+ lego-server.configuration = { ... }: {
+ security.acme.useRoot = true;
+ security.acme.certs."lego.example.test" = {
+ listenHTTP = ":80";
+ group = "nginx";
+ };
+ services.nginx.enable = true;
+ services.nginx.virtualHosts."lego.example.test" = {
+ useACMEHost = "lego.example.test";
+ onlySSL = true;
+ };
+ };
+
+ # Test compatiblity with Caddy
+ # It only supports useACMEHost, hence not using mkServerConfigs
+ } // (let
+ baseCaddyConfig = { nodes, config, ... }: {
+ security.acme = {
+ defaults = (dnsConfig nodes) // {
+ group = config.services.caddy.group;
+ };
+ # One manual wildcard cert
+ certs."example.test" = {
+ domain = "*.example.test";
+ };
+ };
+
+ services.caddy = {
+ enable = true;
+ virtualHosts."a.exmaple.test" = {
+ useACMEHost = "example.test";
+ extraConfig = ''
+ root * ${documentRoot}
+ '';
+ };
+ };
+ };
in {
- security.acme.email = lib.mkForce email;
- systemd.services."b.example.test".preStart = testScript;
- systemd.services."c.example.test".preStart = testScript;
+ caddy.configuration = baseCaddyConfig;
- services.nginx.virtualHosts."b.example.test" = (vhostBase pkgs) // {
- enableACME = true;
- };
- services.nginx.virtualHosts."c.example.test" = (vhostBase pkgs) // {
- enableACME = true;
- };
- };
+ # Test that the server reloads when only the acme configuration is changed.
+ "caddy-change-acme-conf".configuration = { nodes, config, ... }: lib.mkMerge [
+ (baseCaddyConfig {
+ inherit nodes config;
+ })
+ {
+ security.acme.certs."example.test" = {
+ keyType = "ec384";
+ };
+ }
+ ];
- # Cert config changes will not cause the nginx configuration to change.
- # This tests that the reload service is correctly triggered.
- # It also tests that postRun is exec'd as root
- specialisation.cert-change.configuration = { pkgs, ... }: {
- security.acme.certs."a.example.test".keyType = "ec384";
- security.acme.certs."a.example.test".postRun = ''
- set -euo pipefail
- touch /home/test
- chown root:root /home/test
- echo testing > /home/test
- '';
- };
+ # Test compatibility with Nginx
+ }) // (mkServerConfigs {
+ server = "nginx";
+ group = "nginx";
+ vhostBaseData = vhostBase;
+ })
- # Now adding an alias to ensure that the certs are updated
- specialisation.nginx-aliases.configuration = { pkgs, ... }: {
- services.nginx.virtualHosts."a.example.test" = {
- serverAliases = [ "b.example.test" ];
- };
- };
-
- # Test OCSP Stapling
- specialisation.ocsp-stapling.configuration = { pkgs, ... }: {
- security.acme.certs."a.example.test" = {
- ocspMustStaple = true;
- };
- services.nginx.virtualHosts."a.example.com" = {
- extraConfig = ''
- ssl_stapling on;
- ssl_stapling_verify on;
- '';
- };
- };
-
- # Test using Apache HTTPD
- specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: {
- services.nginx.enable = lib.mkForce false;
- services.httpd.enable = true;
- services.httpd.adminAddr = config.security.acme.email;
- services.httpd.virtualHosts."c.example.test" = {
- serverAliases = [ "d.example.test" ];
- forceSSL = true;
- enableACME = true;
- documentRoot = documentRoot pkgs;
- };
-
- # Used to determine if service reload was triggered
- systemd.targets.test-renew-httpd = {
- wants = [ "acme-c.example.test.service" ];
- after = [ "acme-c.example.test.service" "httpd-config-reload.service" ];
- };
- };
-
- # Validation via DNS-01 challenge
- specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: {
- security.acme.certs."example.test" = {
- domain = "*.example.test";
- group = config.services.nginx.group;
- dnsProvider = "exec";
- dnsPropagationCheck = false;
- credentialsFile = pkgs.writeText "wildcard.env" ''
- EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
- '';
- };
-
- services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
- useACMEHost = "example.test";
- };
- };
-
- # Validate service relationships by adding a slow start service to nginx' wants.
- # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
- specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: {
- systemd.services.my-slow-service = {
- wantedBy = [ "multi-user.target" "nginx.service" ];
- before = [ "nginx.service" ];
- preStart = "sleep 5";
- script = "${pkgs.python3}/bin/python -m http.server";
- };
-
- services.nginx.virtualHosts."slow.example.com" = {
- forceSSL = true;
- enableACME = true;
- locations."/".proxyPass = "http://localhost:8000";
- };
- };
+ # Test compatibility with Apache HTTPD
+ // (mkServerConfigs {
+ server = "httpd";
+ group = "wwwrun";
+ vhostBaseData = vhostBaseHttpd;
+ extraConfig = {
+ services.httpd.adminAddr = config.security.acme.defaults.email;
+ };
+ });
};
# The client will be used to curl the webserver to validate configuration
- client = {nodes, lib, pkgs, ...}: {
+ client = { nodes, ... }: {
imports = [ commonConfig ];
networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
@@ -195,7 +314,7 @@ in import ./make-test-python.nix ({ lib, ... }: {
};
};
- testScript = {nodes, ...}:
+ testScript = { nodes, ... }:
let
caDomain = nodes.acme.config.test-support.acme.caDomain;
newServerSystem = nodes.webserver.config.system.build.toplevel;
@@ -204,23 +323,26 @@ in import ./make-test-python.nix ({ lib, ... }: {
# 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.
-
''
import time
- has_switched = False
-
-
def switch_to(node, name):
- global has_switched
- if has_switched:
- node.succeed(
- "${switchToNewServer}"
- )
- has_switched = True
+ # On first switch, this will create a symlink to the current system so that we can
+ # quickly switch between derivations
+ root_specs = "/tmp/specialisation"
+ node.execute(
+ f"test -e {root_specs}"
+ f" || ln -s $(readlink /run/current-system)/specialisation {root_specs}"
+ )
+
+ switcher_path = f"/run/current-system/specialisation/{name}/bin/switch-to-configuration"
+ rc, _ = node.execute(f"test -e '{switcher_path}'")
+ if rc > 0:
+ switcher_path = f"/tmp/specialisation/{name}/bin/switch-to-configuration"
+
node.succeed(
- f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test"
+ f"{switcher_path} test"
)
@@ -310,8 +432,7 @@ in import ./make-test-python.nix ({ lib, ... }: {
return download_ca_certs(node, retries - 1)
- client.start()
- dnsserver.start()
+ start_all()
dnsserver.wait_for_unit("pebble-challtestsrv.service")
client.wait_for_unit("default.target")
@@ -320,19 +441,30 @@ in import ./make-test-python.nix ({ lib, ... }: {
'curl --data \'{"host": "${caDomain}", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
)
- acme.start()
- webserver.start()
-
acme.wait_for_unit("network-online.target")
acme.wait_for_unit("pebble.service")
download_ca_certs(client)
- with subtest("Can request certificate with HTTPS-01 challenge"):
+ # Perform general tests first
+ switch_to(webserver, "general")
+
+ with subtest("Can request certificate with HTTP-01 challenge"):
webserver.wait_for_unit("acme-finished-a.example.test.target")
+ check_fullchain(webserver, "a.example.test")
+ check_issuer(webserver, "a.example.test", "pebble")
+ webserver.wait_for_unit("nginx.service")
+ check_connection(client, "a.example.test")
+
+ with subtest("Runs 1 cert for account creation before others"):
+ webserver.wait_for_unit("acme-finished-b.example.test.target")
+ webserver.wait_for_unit("acme-finished-c.example.test.target")
+ check_connection(client, "b.example.test")
+ check_connection(client, "c.example.test")
with subtest("Certificates and accounts have safe + valid permissions"):
- group = "${nodes.webserver.config.security.acme.certs."a.example.test".group}"
+ # Nginx will set the group appropriately when enableACME is used
+ group = "nginx"
webserver.succeed(
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"
)
@@ -346,12 +478,6 @@ in import ./make-test-python.nix ({ lib, ... }: {
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"
)
- with subtest("Certs are accepted by web server"):
- webserver.succeed("systemctl start nginx.service")
- check_fullchain(webserver, "a.example.test")
- check_issuer(webserver, "a.example.test", "pebble")
- 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")
@@ -365,77 +491,107 @@ in import ./make-test-python.nix ({ lib, ... }: {
# Will succeed if nginx can load the certs
webserver.succeed("systemctl start nginx-config-reload.service")
- with subtest("Can reload nginx when timer triggers renewal"):
- webserver.succeed("systemctl start test-renew-nginx.target")
- check_issuer(webserver, "a.example.test", "pebble")
- check_connection(client, "a.example.test")
-
- with subtest("Runs 1 cert for account creation before others"):
- switch_to(webserver, "account-creation")
- webserver.wait_for_unit("acme-finished-a.example.test.target")
- check_connection(client, "a.example.test")
- webserver.wait_for_unit("acme-finished-b.example.test.target")
- webserver.wait_for_unit("acme-finished-c.example.test.target")
- check_connection(client, "b.example.test")
- check_connection(client, "c.example.test")
-
- with subtest("Can reload web server when cert configuration changes"):
- switch_to(webserver, "cert-change")
- webserver.wait_for_unit("acme-finished-a.example.test.target")
- check_connection_key_bits(client, "a.example.test", "384")
- webserver.succeed("grep testing /home/test")
- # Clean to remove the testing file (and anything else messy we did)
- 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"):
- switch_to(webserver, "slow-startup")
- webserver.wait_for_unit("acme-finished-slow.example.com.target")
- check_issuer(webserver, "slow.example.com", "pebble")
- check_connection(client, "slow.example.com")
-
- with subtest("Can request certificate for vhost + aliases (nginx)"):
- # Check the key hash before and after adding an alias. It should not change.
- # The previous test reverts the ed384 change
- webserver.wait_for_unit("acme-finished-a.example.test.target")
- switch_to(webserver, "nginx-aliases")
- webserver.wait_for_unit("acme-finished-a.example.test.target")
- check_issuer(webserver, "a.example.test", "pebble")
+ with subtest("Can request certificate with HTTP-01 using lego's internal web server"):
+ switch_to(webserver, "lego-server")
+ webserver.wait_for_unit("acme-finished-lego.example.test.target")
+ webserver.wait_for_unit("nginx.service")
+ webserver.succeed("echo HENLO && systemctl cat nginx.service")
+ webserver.succeed("test \"$(stat -c '%U' /var/lib/acme/* | uniq)\" = \"root\"")
check_connection(client, "a.example.test")
- check_connection(client, "b.example.test")
+ check_connection(client, "lego.example.test")
- with subtest("Can request certificates for vhost + aliases (apache-httpd)"):
- try:
- switch_to(webserver, "httpd-aliases")
- webserver.wait_for_unit("acme-finished-c.example.test.target")
- except Exception as err:
- _, output = webserver.execute(
- "cat /var/log/httpd/*.log && ls -al /var/lib/acme/acme-challenge"
- )
- print(output)
- raise err
- check_issuer(webserver, "c.example.test", "pebble")
- check_connection(client, "c.example.test")
- check_connection(client, "d.example.test")
+ with subtest("Can request certificate with HTTP-01 when nginx startup is delayed"):
+ webserver.execute("systemctl stop nginx")
+ switch_to(webserver, "slow-startup")
+ webserver.wait_for_unit("acme-finished-slow.example.test.target")
+ check_issuer(webserver, "slow.example.test", "pebble")
+ webserver.wait_for_unit("nginx.service")
+ check_connection(client, "slow.example.test")
- with subtest("Can reload httpd when timer triggers renewal"):
- # Switch to selfsigned first
- webserver.succeed("systemctl clean acme-c.example.test.service --what=state")
- webserver.succeed("systemctl start acme-selfsigned-c.example.test.service")
- check_issuer(webserver, "c.example.test", "minica")
- webserver.succeed("systemctl start httpd-config-reload.service")
- webserver.succeed("systemctl start test-renew-httpd.target")
- check_issuer(webserver, "c.example.test", "pebble")
- check_connection(client, "c.example.test")
-
- with subtest("Can request wildcard certificates using DNS-01 challenge"):
- switch_to(webserver, "dns-01")
+ with subtest("Works with caddy"):
+ switch_to(webserver, "caddy")
webserver.wait_for_unit("acme-finished-example.test.target")
- check_issuer(webserver, "example.test", "pebble")
- check_connection(client, "dns.example.test")
+ webserver.wait_for_unit("caddy.service")
+ # FIXME reloading caddy is not sufficient to load new certs.
+ # Restart it manually until this is fixed.
+ webserver.succeed("systemctl restart caddy.service")
+ check_connection(client, "a.example.test")
+
+ with subtest("security.acme changes reflect on caddy"):
+ switch_to(webserver, "caddy-change-acme-conf")
+ webserver.wait_for_unit("acme-finished-example.test.target")
+ webserver.wait_for_unit("caddy.service")
+ # FIXME reloading caddy is not sufficient to load new certs.
+ # Restart it manually until this is fixed.
+ webserver.succeed("systemctl restart caddy.service")
+ check_connection_key_bits(client, "a.example.test", "384")
+
+ domains = ["http", "dns", "wildcard"]
+ for server, logsrc in [
+ ("nginx", "journalctl -n 30 -u nginx.service"),
+ ("httpd", "tail -n 30 /var/log/httpd/*.log"),
+ ]:
+ wait_for_server = lambda: webserver.wait_for_unit(f"{server}.service")
+ with subtest(f"Works with {server}"):
+ try:
+ switch_to(webserver, server)
+ # Skip wildcard domain for this check ([:-1])
+ for domain in domains[:-1]:
+ webserver.wait_for_unit(
+ f"acme-finished-{server}-{domain}.example.test.target"
+ )
+ except Exception as err:
+ _, output = webserver.execute(
+ f"{logsrc} && ls -al /var/lib/acme/acme-challenge"
+ )
+ print(output)
+ raise err
+
+ wait_for_server()
+
+ for domain in domains[:-1]:
+ check_issuer(webserver, f"{server}-{domain}.example.test", "pebble")
+ for domain in domains:
+ check_connection(client, f"{server}-{domain}.example.test")
+ check_connection(client, f"{server}-{domain}-alias.example.test")
+
+ test_domain = f"{server}-{domains[0]}.example.test"
+
+ with subtest(f"Can reload {server} when timer triggers renewal"):
+ # Switch to selfsigned first
+ webserver.succeed(f"systemctl clean acme-{test_domain}.service --what=state")
+ webserver.succeed(f"systemctl start acme-selfsigned-{test_domain}.service")
+ check_issuer(webserver, test_domain, "minica")
+ webserver.succeed(f"systemctl start {server}-config-reload.service")
+ webserver.succeed(f"systemctl start test-renew-{server}.target")
+ check_issuer(webserver, test_domain, "pebble")
+ check_connection(client, test_domain)
+
+ with subtest("Can remove an alias from a domain + cert is updated"):
+ test_alias = f"{server}-{domains[0]}-alias.example.test"
+ switch_to(webserver, f"{server}-remove-alias")
+ webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
+ wait_for_server()
+ check_connection(client, test_domain)
+ rc, _ = client.execute(
+ f"openssl s_client -CAfile /tmp/ca.crt -connect {test_alias}:443"
+ " /dev/null | openssl x509 -noout -text"
+ f" | grep DNS: | grep {test_alias}"
+ )
+ assert rc > 0, "Removed extraDomainName was not removed from the cert"
+
+ with subtest("security.acme changes reflect on web server"):
+ # Switch back to normal server config first, reset everything.
+ switch_to(webserver, server)
+ wait_for_server()
+ switch_to(webserver, f"{server}-change-acme-conf")
+ webserver.wait_for_unit(f"acme-finished-{test_domain}.target")
+ wait_for_server()
+ check_connection_key_bits(client, test_domain, "384")
'';
})
diff --git a/nixos/tests/common/acme/client/default.nix b/nixos/tests/common/acme/client/default.nix
index 1e9885e375c7..9dbe345e7a01 100644
--- a/nixos/tests/common/acme/client/default.nix
+++ b/nixos/tests/common/acme/client/default.nix
@@ -5,9 +5,11 @@ let
in {
security.acme = {
- server = "https://${caDomain}/dir";
- email = "hostmaster@example.test";
acceptTerms = true;
+ defaults = {
+ server = "https://${caDomain}/dir";
+ email = "hostmaster@example.test";
+ };
};
security.pki.certificateFiles = [ caCert ];
diff --git a/nixos/tests/common/acme/server/default.nix b/nixos/tests/common/acme/server/default.nix
index 1c3bfdf76b7e..450d49e60399 100644
--- a/nixos/tests/common/acme/server/default.nix
+++ b/nixos/tests/common/acme/server/default.nix
@@ -120,6 +120,11 @@ in {
enable = true;
description = "Pebble ACME server";
wantedBy = [ "network.target" ];
+ environment = {
+ # We're not testing lego, we're just testing our configuration.
+ # No need to sleep.
+ PEBBLE_VA_NOSLEEP = "1";
+ };
serviceConfig = {
RuntimeDirectory = "pebble";