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