diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix
index 8d5447654eef..de6adbf69d51 100644
--- a/maintainers/maintainer-list.nix
+++ b/maintainers/maintainer-list.nix
@@ -4304,6 +4304,12 @@
email = "wheatdoge@gmail.com";
name = "Tim Liou";
};
+ m1cr0man = {
+ email = "lucas+nix@m1cr0man.com";
+ github = "m1cr0man";
+ githubId = 3044438;
+ name = "Lucas Savva";
+ };
m3tti = {
email = "mathaeus.peter.sander@gmail.com";
name = "Mathaeus Sander";
diff --git a/nixos/doc/manual/release-notes/rl-2003.xml b/nixos/doc/manual/release-notes/rl-2003.xml
index d7614cd3488c..eac50b705a88 100644
--- a/nixos/doc/manual/release-notes/rl-2003.xml
+++ b/nixos/doc/manual/release-notes/rl-2003.xml
@@ -660,6 +660,21 @@ auth required pam_succeed_if.so uid >= 1000 quiet
PRETTY_NAME in /etc/os-release
now uses the short rather than full version string.
+
+
+
+ The ACME module has switched from simp-le to lego
+ which allows us to support DNS-01 challenges and wildcard certificates. The following options have been added:
+ security.acme.acceptTerms,
+ security.acme.certs.<name>.dnsProvider,
+ security.acme.certs.<name>.credentialsFile,
+ security.acme.certs.<name>.dnsPropagationCheck.
+ As well as this, the options security.acme.acceptTerms and either
+ security.acme.email or security.acme.certs.<name>.email
+ must be set in order to use the ACME module.
+ Certificates will be regenerated anew on the next renewal date. The credentials for simp-le are
+ preserved and thus it is possible to roll back to previous versions without breaking certificate
+ generation.
diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix
index 890c421b0ea9..7da6666f79c6 100644
--- a/nixos/modules/security/acme.nix
+++ b/nixos/modules/security/acme.nix
@@ -1,7 +1,5 @@
{ config, lib, pkgs, ... }:
-
with lib;
-
let
cfg = config.security.acme;
@@ -9,7 +7,8 @@ let
certOpts = { name, ... }: {
options = {
webroot = mkOption {
- type = types.str;
+ type = types.nullOr types.str;
+ default = null;
example = "/var/lib/acme/acme-challenges";
description = ''
Where the webroot of the HTTP vhost is located.
@@ -38,7 +37,7 @@ let
email = mkOption {
type = types.nullOr types.str;
- default = null;
+ default = cfg.email;
description = "Contact email address for the CA to be able to reach you.";
};
@@ -76,20 +75,6 @@ let
'';
};
- plugins = mkOption {
- type = types.listOf (types.enum [
- "cert.der" "cert.pem" "chain.pem" "external.sh"
- "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json" "account_reg.json"
- ]);
- default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" "account_reg.json" ];
- description = ''
- Plugins to enable. With default settings simp_le will
- store public certificate bundle in fullchain.pem,
- private key in key.pem and those two previous
- files combined in full.pem in its state directory.
- '';
- };
-
directory = mkOption {
type = types.str;
readOnly = true;
@@ -111,6 +96,46 @@ let
own server roots if needed.
'';
};
+
+ keyType = mkOption {
+ type = types.str;
+ default = "ec384";
+ description = ''
+ Key type to use for private keys.
+ For an up to date list of supported values check the --key-type option
+ at https://go-acme.github.io/lego/usage/cli/#usage.
+ '';
+ };
+
+ dnsProvider = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ example = "route53";
+ description = ''
+ DNS Challenge provider. For a list of supported providers, see the "code"
+ field of the DNS providers listed at https://go-acme.github.io/lego/dns/.
+ '';
+ };
+
+ 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
+ https://go-acme.github.io/lego/dns/ for the corresponding dnsProvider.
+ '';
+ example = "/var/src/secrets/example.org-route53-api-token";
+ };
+
+ dnsPropagationCheck = mkOption {
+ type = types.bool;
+ default = true;
+ description = ''
+ Toggles lego DNS propagation check, which is used alongside DNS-01
+ challenge to ensure the DNS entries required are available.
+ '';
+ };
};
};
@@ -130,14 +155,21 @@ in
(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)))
];
options = {
security.acme = {
- validMin = mkOption {
+ validMinDays = mkOption {
type = types.int;
- default = 30 * 24 * 3600;
- description = "Minimum remaining validity before renewal in seconds.";
+ 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 {
@@ -173,6 +205,15 @@ in
'';
};
+ acceptTerms = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Accept the CA's terms of service. The default provier is Let's Encrypt,
+ you can find their ToS at https://letsencrypt.org/repository/
+ '';
+ };
+
certs = mkOption {
default = { };
type = with types; attrsOf (submodule certOpts);
@@ -204,27 +245,55 @@ in
config = mkMerge [
(mkIf (cfg.certs != { }) {
+ assertions = let
+ certs = (mapAttrsToList (k: v: v) cfg.certs);
+ in [
+ {
+ assertion = all (certOpts: certOpts.dnsProvider == null || certOpts.webroot == null) certs;
+ message = ''
+ Options `security.acme.certs..dnsProvider` and
+ `security.acme.certs..webroot` are mutually exclusive.
+ '';
+ }
+ {
+ assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs;
+ message = ''
+ You must define `security.acme.certs..email` or
+ `security.acme.email` to register with the CA.
+ '';
+ }
+ {
+ assertion = cfg.acceptTerms;
+ message = ''
+ You must accept the CA's terms of service before using
+ the ACME module by setting `security.acme.acceptTerms`
+ to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/
+ '';
+ }
+ ];
+
systemd.services = let
services = concatLists servicesLists;
servicesLists = mapAttrsToList certToServices cfg.certs;
certToServices = cert: data:
let
+ # StateDirectory must be relative, and will be created under /var/lib by systemd
lpath = "acme/${cert}";
+ apath = "/var/lib/${lpath}";
+ spath = "/var/lib/acme/.lego";
rights = if data.allowKeysForGroup then "750" else "700";
- cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ]
- ++ optionals (data.email != null) [ "--email" data.email ]
- ++ concatMap (p: [ "-f" p ]) data.plugins
- ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains)
+ globalOpts = [ "-d" data.domain "--email" data.email "--path" "." "--key-type" data.keyType ]
+ ++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
+ ++ optionals (data.dnsProvider != null && !data.dnsPropagationCheck) [ "--dns.disable-cp" ]
+ ++ concatLists (mapAttrsToList (name: root: [ "-d" name ]) data.extraDomains)
+ ++ (if data.dnsProvider != null then [ "--dns" data.dnsProvider ] else [ "--http" "--http.webroot" data.webroot ])
++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)];
+ runOpts = escapeShellArgs (globalOpts ++ [ "run" ]);
+ renewOpts = escapeShellArgs (globalOpts ++ [ "renew" "--days" (toString cfg.validMinDays) ]);
acmeService = {
description = "Renew ACME Certificate for ${cert}";
after = [ "network.target" "network-online.target" ];
wants = [ "network-online.target" ];
- # simp_le uses requests, which uses certifi under the hood,
- # which doesn't respect the system trust store.
- # At least in the acme test, we provision a fake CA, impersonating the LE endpoint.
- # REQUESTS_CA_BUNDLE is a way to teach python requests to use something else
- environment.REQUESTS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
serviceConfig = {
Type = "oneshot";
# With RemainAfterExit the service is considered active even
@@ -233,18 +302,37 @@ in
# the permissions of the StateDirectory get adjusted
# according to the specified group
RemainAfterExit = true;
- SuccessExitStatus = [ "0" "1" ];
User = data.user;
Group = data.group;
PrivateTmp = true;
- StateDirectory = lpath;
+ StateDirectory = "acme/.lego ${lpath}";
StateDirectoryMode = rights;
- WorkingDirectory = "/var/lib/${lpath}";
- ExecStart = "${pkgs.simp_le}/bin/simp_le ${escapeShellArgs cmdline}";
+ WorkingDirectory = spath;
+ # Only try loading the credentialsFile if the dns challenge is enabled
+ EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null;
+ ExecStart = pkgs.writeScript "acme-start" ''
+ #!${pkgs.runtimeShell} -e
+ ${pkgs.lego}/bin/lego ${renewOpts} || ${pkgs.lego}/bin/lego ${runOpts}
+ '';
ExecStartPost =
let
+ keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
script = pkgs.writeScript "acme-post-start" ''
#!${pkgs.runtimeShell} -e
+ cd ${apath}
+
+ # Test that existing cert is older than new cert
+ KEY=${spath}/certificates/${keyName}.key
+ if [ -e $KEY -a $KEY -nt key.pem ]; then
+ cp -p ${spath}/certificates/${keyName}.key key.pem
+ cp -p ${spath}/certificates/${keyName}.crt cert.pem
+ cp -p ${spath}/certificates/${keyName}.issuer.crt chain.pem
+ cat cert.pem chain.pem > fullchain.pem
+ cat key.pem cert.pem chain.pem > full.pem
+ chmod ${rights} *.pem
+ chown '${data.user}:${data.group}' *.pem
+ fi
+
${data.postRun}
'';
in
@@ -276,17 +364,17 @@ in
-out $workdir/server.crt
# Copy key to destination
- cp $workdir/server.key /var/lib/${lpath}/key.pem
+ cp $workdir/server.key ${apath}/key.pem
# Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
- cat $workdir/{server.crt,ca.crt} > "/var/lib/${lpath}/fullchain.pem"
+ cat $workdir/{server.crt,ca.crt} > "${apath}/fullchain.pem"
# Create full.pem for e.g. lighttpd
- cat $workdir/{server.key,server.crt,ca.crt} > "/var/lib/${lpath}/full.pem"
+ cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem"
# Give key acme permissions
- chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem
- chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem
+ chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem
+ chmod ${rights} "${apath}/"{key,fullchain,full}.pem
'';
serviceConfig = {
Type = "oneshot";
@@ -297,7 +385,7 @@ in
};
unitConfig = {
# Do not create self-signed key when key already exists
- ConditionPathExists = "!/var/lib/${lpath}/key.pem";
+ ConditionPathExists = "!${apath}/key.pem";
};
};
in (
@@ -309,8 +397,7 @@ in
servicesAttr;
systemd.tmpfiles.rules =
- flip mapAttrsToList cfg.certs
- (cert: data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}");
+ map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs));
systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
("acme-${cert}")
@@ -334,7 +421,7 @@ in
];
meta = {
- maintainers = with lib.maintainers; [ abbradar fpletz globin ];
+ maintainers = with lib.maintainers; [ abbradar fpletz globin m1cr0man ];
doc = ./acme.xml;
};
}
diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml
index 9d0a1995e0ff..2b29c1174845 100644
--- a/nixos/modules/security/acme.xml
+++ b/nixos/modules/security/acme.xml
@@ -7,7 +7,7 @@
NixOS supports automatic domain validation & certificate retrieval and
renewal using the ACME protocol. This is currently only implemented by and
- for Let's Encrypt. The alternative ACME client simp_le is
+ for Let's Encrypt. The alternative ACME client lego is
used under the hood.
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
index 6bd315ff1eaa..732f41a4905d 100644
--- a/nixos/tests/acme.nix
+++ b/nixos/tests/acme.nix
@@ -1,17 +1,50 @@
let
commonConfig = ./common/letsencrypt/common.nix;
+
+ dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" ''
+ #!${bash}/bin/bash
+ set -euo pipefail
+ echo '[INFO]' "[$2]" 'dns-hook.sh' $*
+ if [ "$1" = "present" ]; then
+ ${curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
+ else
+ ${curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
+ fi
+ '';
+
in import ./make-test-python.nix {
name = "acme";
nodes = rec {
- letsencrypt = ./common/letsencrypt;
+ letsencrypt = { nodes, lib, ... }: {
+ imports = [ ./common/letsencrypt ];
+ networking.nameservers = lib.mkForce [
+ nodes.dnsserver.config.networking.primaryIPAddress
+ ];
+ };
- acmeStandalone = { config, pkgs, ... }: {
+ dnsserver = { nodes, pkgs, ... }: {
+ networking.firewall.allowedTCPPorts = [ 8055 53 ];
+ networking.firewall.allowedUDPPorts = [ 53 ];
+ systemd.services.pebble-challtestsrv = {
+ enable = true;
+ description = "Pebble ACME challenge test server";
+ wantedBy = [ "network.target" ];
+ serviceConfig = {
+ ExecStart = "${pkgs.pebble}/bin/pebble-challtestsrv -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.webserver.config.networking.primaryIPAddress}'";
+ # Required to bind on privileged ports.
+ User = "root";
+ Group = "root";
+ };
+ };
+ };
+
+ acmeStandalone = { nodes, lib, config, pkgs, ... }: {
imports = [ commonConfig ];
+ networking.nameservers = lib.mkForce [
+ nodes.dnsserver.config.networking.primaryIPAddress
+ ];
networking.firewall.allowedTCPPorts = [ 80 ];
- networking.extraHosts = ''
- ${config.networking.primaryIPAddress} standalone.com
- '';
security.acme = {
server = "https://acme-v02.api.letsencrypt.org/dir";
certs."standalone.com" = {
@@ -29,14 +62,12 @@ in import ./make-test-python.nix {
};
};
- webserver = { config, pkgs, ... }: {
+ webserver = { nodes, config, pkgs, lib, ... }: {
imports = [ commonConfig ];
networking.firewall.allowedTCPPorts = [ 80 443 ];
-
- networking.extraHosts = ''
- ${config.networking.primaryIPAddress} a.example.com
- ${config.networking.primaryIPAddress} b.example.com
- '';
+ networking.nameservers = lib.mkForce [
+ nodes.dnsserver.config.networking.primaryIPAddress
+ ];
# A target remains active. Use this to probe the fact that
# a service fired eventhough it is not RemainAfterExit
@@ -61,14 +92,11 @@ in import ./make-test-python.nix {
nesting.clone = [
({pkgs, ...}: {
-
- networking.extraHosts = ''
- ${config.networking.primaryIPAddress} b.example.com
- '';
systemd.targets."acme-finished-b.example.com" = {};
systemd.services."acme-b.example.com" = {
wants = [ "acme-finished-b.example.com.target" ];
before = [ "acme-finished-b.example.com.target" ];
+ after = [ "nginx.service" ];
};
services.nginx.virtualHosts."b.example.com" = {
enableACME = true;
@@ -79,15 +107,48 @@ in import ./make-test-python.nix {
'';
};
})
+ ({pkgs, config, nodes, lib, ...}: {
+ security.acme.certs."example.com" = {
+ domain = "*.example.com";
+ dnsProvider = "exec";
+ dnsPropagationCheck = false;
+ credentialsFile = with pkgs; writeText "wildcard.env" ''
+ EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }}
+ '';
+ user = config.services.nginx.user;
+ group = config.services.nginx.group;
+ };
+ systemd.targets."acme-finished-example.com" = {};
+ systemd.services."acme-example.com" = {
+ wants = [ "acme-finished-example.com.target" ];
+ before = [ "acme-finished-example.com.target" "nginx.service" ];
+ wantedBy = [ "nginx.service" ];
+ };
+ services.nginx.virtualHosts."c.example.com" = {
+ forceSSL = true;
+ sslCertificate = config.security.acme.certs."example.com".directory + "/cert.pem";
+ sslTrustedCertificate = config.security.acme.certs."example.com".directory + "/full.pem";
+ sslCertificateKey = config.security.acme.certs."example.com".directory + "/key.pem";
+ locations."/".root = pkgs.runCommand "docroot" {} ''
+ mkdir -p "$out"
+ echo hello world > "$out/index.html"
+ '';
+ };
+ })
];
};
- client = commonConfig;
+ client = {nodes, lib, ...}: {
+ imports = [ commonConfig ];
+ networking.nameservers = lib.mkForce [
+ nodes.dnsserver.config.networking.primaryIPAddress
+ ];
+ };
};
testScript = {nodes, ...}:
let
- newServerSystem = nodes.webserver2.config.system.build.toplevel;
+ newServerSystem = nodes.webserver.config.system.build.toplevel;
switchToNewServer = "${newServerSystem}/bin/switch-to-configuration test";
in
# Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
@@ -97,6 +158,17 @@ in import ./make-test-python.nix {
# can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do
''
client.start()
+ dnsserver.start()
+
+ letsencrypt.wait_for_unit("default.target")
+ dnsserver.wait_for_unit("pebble-challtestsrv.service")
+ client.succeed(
+ 'curl --data \'{"host": "acme-v02.api.letsencrypt.org", "addresses": ["${nodes.letsencrypt.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+ )
+ client.succeed(
+ 'curl --data \'{"host": "standalone.com", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+ )
+
letsencrypt.start()
acmeStandalone.start()
@@ -129,5 +201,17 @@ in import ./make-test-python.nix {
client.succeed(
"curl --cacert /tmp/ca.crt https://b.example.com/ | grep -qF 'hello world'"
)
+
+ with subtest("Can request wildcard certificates using DNS-01 challenge"):
+ webserver.succeed(
+ "${switchToNewServer}"
+ )
+ webserver.succeed(
+ "/run/current-system/fine-tune/child-2/bin/switch-to-configuration test"
+ )
+ webserver.wait_for_unit("acme-finished-example.com.target")
+ client.succeed(
+ "curl --cacert /tmp/ca.crt https://c.example.com/ | grep -qF 'hello world'"
+ )
'';
}
diff --git a/nixos/tests/common/letsencrypt/common.nix b/nixos/tests/common/letsencrypt/common.nix
index c530de817bf2..bd559c8dacc5 100644
--- a/nixos/tests/common/letsencrypt/common.nix
+++ b/nixos/tests/common/letsencrypt/common.nix
@@ -5,5 +5,8 @@ in {
nodes.letsencrypt.config.networking.primaryIPAddress
];
+ security.acme.acceptTerms = true;
+ security.acme.email = "webmaster@example.com";
+
security.pki.certificateFiles = [ letsencrypt-ca ];
}