From 982c5a1f0e7f282f856391304aa4da7bb36c45b8 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Fri, 19 Jun 2020 20:27:46 +0100 Subject: [PATCH 1/6] nixos/acme: Restructure module - Use an acme user and group, allow group override only - Use hashes to determine when certs actually need to regenerate - Avoid running lego more than necessary - Harden permissions - Support "systemctl clean" for cert regeneration - Support reuse of keys between some configuration changes - Permissions fix services solves for previously root owned certs - Add a note about multiple account creation and emails - Migrate extraDomains to a list - Deprecate user option - Use minica for self-signed certs - Rewrite all tests I thought of a few more cases where things may go wrong, and added tests to cover them. In particular, the web server reload services were depending on the target - which stays alive, meaning that the renewal timer wouldn't be triggering a reload and old certs would stay on the web servers. I encountered some problems ensuring that the reload took place without accidently triggering it as part of the test. The sync commands I added ended up being essential and I'm not sure why, it seems like either node.succeed ends too early or there's an oddity of the vm's filesystem I'm not aware of. - Fix duplicate systemd rules on reload services Since useACMEHost is not unique to every vhost, if one cert was reused many times it would create duplicate entries in ${server}-config-reload.service for wants, before and ConditionPathExists --- nixos/modules/security/acme.nix | 632 +++++++++++------- nixos/modules/security/acme.xml | 8 +- nixos/modules/services/networking/prosody.xml | 5 +- .../web-servers/apache-httpd/default.nix | 77 ++- .../services/web-servers/nginx/default.nix | 88 +-- nixos/tests/acme.nix | 341 ++++++---- nixos/tests/common/acme/client/default.nix | 11 +- nixos/tests/common/acme/server/default.nix | 72 +- nixos/tests/common/acme/server/mkcerts.nix | 69 -- nixos/tests/common/acme/server/mkcerts.sh | 6 - .../common/acme/server/snakeoil-certs.nix | 207 +----- ...postfix-raise-smtpd-tls-security-level.nix | 3 - nixos/tests/postfix.nix | 13 +- pkgs/tools/security/minica/default.nix | 34 + pkgs/top-level/all-packages.nix | 2 + 15 files changed, 839 insertions(+), 729 deletions(-) delete mode 100644 nixos/tests/common/acme/server/mkcerts.nix delete mode 100755 nixos/tests/common/acme/server/mkcerts.sh create mode 100644 pkgs/tools/security/minica/default.nix diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index 29635dbe8643..91b7dd0c989f 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -1,11 +1,309 @@ -{ config, lib, pkgs, ... }: +{ config, lib, pkgs, options, ... }: with lib; let - cfg = config.security.acme; + # Used to calculate timer accuracy for coalescing + numCerts = length (builtins.attrNames cfg.certs); + _24hSecs = 60 * 60 * 24; + + # There are many services required to make cert renewals work. + # They all follow a common structure: + # - They inherit this commonServiceConfig + # - They all run as the acme user + # - They all use BindPath and StateDirectory where possible + # to set up a sort of build environment in /tmp + # The Group can vary depending on what the user has specified in + # security.acme.certs..group on some of the services. + commonServiceConfig = { + Type = "oneshot"; + User = "acme"; + Group = mkDefault "acme"; + UMask = 0027; + StateDirectoryMode = 750; + ProtectSystem = "full"; + PrivateTmp = true; + + WorkingDirectory = "/tmp"; + }; + + # In order to avoid race conditions creating the CA for selfsigned certs, + # we have a separate service which will create the necessary files. + selfsignCAService = { + description = "Generate self-signed certificate authority"; + + path = with pkgs; [ minica ]; + + unitConfig = { + ConditionPathExists = "!/var/lib/acme/.minica/key.pem"; + }; + + serviceConfig = commonServiceConfig // { + StateDirectory = "acme/.minica"; + BindPaths = "/var/lib/acme/.minica:/tmp/ca"; + }; + + # Working directory will be /tmp + script = '' + minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains selfsigned.local + + chmod 600 ca/* + ''; + }; + + # Previously, all certs were owned by whatever user was configured in + # config.security.acme.certs..user. Now everything is owned by and + # run by the acme user. + userMigrationService = { + description = "Fix owner and group of all ACME certificates"; + + script = with builtins; concatStringsSep "\n" (mapAttrsToList (cert: data: '' + for fixpath in /var/lib/acme/${escapeShellArg cert} /var/lib/acme/.lego/${escapeShellArg cert}; do + if [ -d "$fixpath" ]; then + chmod -R 750 "$fixpath" + chown -R acme:${data.group} "$fixpath" + fi + done + '') certConfigs); + + # We don't want this to run every time a renewal happens + serviceConfig.RemainAfterExit = true; + }; + + certToConfig = cert: data: let + acmeServer = if data.server != null then data.server else cfg.server; + useDns = data.dnsProvider != null; + destPath = "/var/lib/acme/${cert}"; + + # Minica and lego have a "feature" which replaces * with _. We need + # to make this substitution to reference the output files from both programs. + # End users never see this since we rename the certs. + keyName = builtins.replaceStrings ["*"] ["_"] data.domain; + + # FIXME when mkChangedOptionModule supports submodules, change to that. + # This is a workaround + extraDomains = data.extraDomainNames ++ ( + optionals + (data.extraDomains != "_mkMergedOptionModule") + (builtins.attrNames data.extraDomains) + ); + + # Create hashes for cert data directories based on configuration + hashData = with builtins; '' + ${data.domain} ${data.keyType} + ${concatStringsSep " " ( + extraDomains + ++ data.extraLegoFlags + ++ data.extraLegoRunFlags + ++ data.extraLegoRenewFlags + )} + ${toString acmeServer} ${toString data.dnsProvider} + ${toString data.ocspMustStaple} + ''; + mkHash = with builtins; val: substring 0 20 (hashString "sha256" val); + certDir = mkHash hashData; + othersHash = mkHash "${toString acmeServer} ${data.keyType}"; + keyDir = "key-" + othersHash; + accountDir = "/var/lib/acme/.lego/accounts/" + othersHash; + + protocolOpts = if useDns then ( + [ "--dns" data.dnsProvider ] + ++ optionals (!data.dnsPropagationCheck) [ "--dns.disable-cp" ] + ) else ( + [ "--http" "--http.webroot" data.webroot ] + ); + + commonOpts = [ + "--accept-tos" # Checking the option is covered by the assertions + "--path" "." + "-d" data.domain + "--email" data.email + "--key-type" data.keyType + ] ++ protocolOpts + ++ optionals data.ocspMustStaple [ "--must-staple" ] + ++ optionals (acmeServer != null) [ "--server" acmeServer ] + ++ concatMap (name: [ "-d" name ]) extraDomains + ++ data.extraLegoFlags; + + runOpts = escapeShellArgs ( + commonOpts + ++ [ "run" ] + ++ data.extraLegoRunFlags + ); + renewOpts = escapeShellArgs ( + commonOpts + ++ [ "renew" "--reuse-key" "--days" (toString cfg.validMinDays) ] + ++ data.extraLegoRenewFlags + ); + + in { + inherit accountDir; + + webroot = data.webroot; + group = data.group; + + renewTimer = { + description = "Renew ACME Certificate for ${cert}"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.renewInterval; + Unit = "acme-${cert}.service"; + Persistent = "yes"; + + # Allow systemd to pick a convenient time within the day + # to run the check. + # This allows the coalescing of multiple timer jobs. + # We divide by the number of certificates so that if you + # have many certificates, the renewals are distributed over + # the course of the day to avoid rate limits. + AccuracySec = "${toString (_24hSecs / numCerts)}s"; + + # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/. + RandomizedDelaySec = "24h"; + }; + }; + + selfsignService = { + description = "Generate self-signed certificate for ${cert}"; + after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; + wants = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; + + path = with pkgs; [ minica ]; + + unitConfig = { + ConditionPathExists = "!/var/lib/acme/${cert}/key.pem"; + }; + + serviceConfig = commonServiceConfig // { + Group = data.group; + + StateDirectory = "acme/${cert}"; + + BindPaths = "/var/lib/acme/.minica:/tmp/ca /var/lib/acme/${cert}:/tmp/${keyName}"; + }; + + # Working directory will be /tmp + # minica will output to a folder sharing the name of the first domain + # in the list, which will be ${data.domain} + script = '' + minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains ${escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))} + + # Create files to match directory layout for real certificates + cd '${keyName}' + cp ../ca/cert.pem chain.pem + cat cert.pem chain.pem > fullchain.pem + cat key.pem fullchain.pem > full.pem + + chmod 640 * + + # Group might change between runs, re-apply it + chown 'acme:${data.group}' * + ''; + }; + + renewService = { + description = "Renew ACME certificate for ${cert}"; + after = [ "network.target" "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ]; + wants = [ "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ]; + + # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 + wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ]; + + path = with pkgs; [ lego coreutils ]; + + serviceConfig = commonServiceConfig // { + Group = data.group; + + # AccountDir dir will be created by tmpfiles to ensure correct permissions + # And to avoid deletion during systemctl clean + # acme/.lego/${cert} is listed so that it is deleted during systemctl clean + StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir} acme/.lego/${cert}/${keyDir}"; + + # Needs to be space separated, but can't use a multiline string because that'll include newlines + BindPaths = + "${accountDir}:/tmp/accounts " + + "/var/lib/acme/${cert}:/tmp/out " + + "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates " + + "/var/lib/acme/.lego/${cert}/${keyDir}:/tmp/keys"; + + # Only try loading the credentialsFile if the dns challenge is enabled + EnvironmentFile = mkIf useDns data.credentialsFile; + }; + + # Working directory will be /tmp + script = '' + set -euo pipefail + + # Safely copy keyDir contents into certificates (it might be empty). + cp -af keys/. certificates/ + + # Check if we can renew + if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' ]; then + lego ${renewOpts} + + # Otherwise do a full run + else + lego ${runOpts} + fi + + chmod 640 certificates/* + chmod -R 700 accounts/* + + # Group might change between runs, re-apply it + chown 'acme:${data.group}' certificates/* + + # Copy the key to keyDir + cp -pf 'certificates/${keyName}.key' 'keys/' + + # Copy all certs to the "real" certs directory + CERT='certificates/${keyName}.crt' + CERT_CHANGED=no + if [ -e "$CERT" -a "$CERT" -nt out/fullchain.pem ]; then + CERT_CHANGED=yes + cp -p 'certificates/${keyName}.crt' out/fullchain.pem + cp -p 'certificates/${keyName}.key' out/key.pem + cp -p 'certificates/${keyName}.issuer.crt' out/chain.pem + ln -sf fullchain.pem out/cert.pem + cat out/key.pem out/fullchain.pem > out/full.pem + fi + + if [ "$CERT_CHANGED" = "yes" ]; then + cd out + set +euo pipefail + ${data.postRun} + fi + ''; + }; + }; + + certConfigs = mapAttrs certToConfig cfg.certs; + certOpts = { name, ... }: { options = { + # user option has been removed + user = mkOption { + visible = false; + default = "_mkRemovedOptionModule"; + }; + + # allowKeysForGroup option has been removed + allowKeysForGroup = mkOption { + visible = false; + default = "_mkRemovedOptionModule"; + }; + + # extraDomains was replaced with extraDomainNames + extraDomains = mkOption { + visible = false; + default = "_mkMergedOptionModule"; + }; + webroot = mkOption { type = types.nullOr types.str; default = null; @@ -41,35 +339,19 @@ let description = "Contact email address for the CA to be able to reach you."; }; - user = mkOption { - type = types.str; - default = "root"; - description = "User running the ACME client."; - }; - group = mkOption { type = types.str; - default = "root"; + default = "acme"; description = "Group running the ACME client."; }; - allowKeysForGroup = mkOption { - type = types.bool; - default = false; - description = '' - Give read permissions to the specified group - () to read SSL private certificates. - ''; - }; - postRun = mkOption { type = types.lines; default = ""; - example = "systemctl reload nginx.service"; + example = "cp full.pem backup.pem"; description = '' - Commands to run after new certificates go live. Typically - the web server and other servers using certificates need to - be reloaded. + Commands to run after new certificates go live. Note that + these commands run as the acme user and configured group. Executed in the same directory with the new certificate. ''; @@ -82,18 +364,17 @@ let description = "Directory where certificate and other state is stored."; }; - extraDomains = mkOption { - type = types.attrsOf (types.nullOr types.str); - default = {}; + extraDomainNames = mkOption { + type = types.listOf types.str; + default = []; example = literalExample '' - { - "example.org" = null; - "mydomain.org" = null; - } + [ + "example.org" + "mydomain.org" + ] ''; description = '' A list of extra domain names, which are included in the one certificate to be issued. - Setting a distinct server root is deprecated and not functional in 20.03+ ''; }; @@ -176,24 +457,8 @@ let }; }; -in +in { -{ - - ###### interface - imports = [ - (mkRemovedOptionModule [ "security" "acme" "production" ] '' - Use security.acme.server to define your staging ACME server URL instead. - - To use Let's Encrypt's 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))) - ]; options = { security.acme = { @@ -266,7 +531,7 @@ in "example.com" = { webroot = "/var/www/challenges/"; email = "foo@example.com"; - extraDomains = { "www.example.com" = null; "foo.example.com" = null; }; + extraDomainNames = [ "www.example.com" "foo.example.com" ]; }; "bar.example.com" = { webroot = "/var/www/challenges/"; @@ -278,25 +543,40 @@ in }; }; - ###### implementation + imports = [ + (mkRemovedOptionModule [ "security" "acme" "production" ] '' + Use security.acme.server to define your staging ACME server URL instead. + + 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))) + ]; + config = mkMerge [ (mkIf (cfg.certs != { }) { + # FIXME Most of these custom warnings and filters for security.acme.certs.* are required + # because using mkRemovedOptionModule/mkChangedOptionModule with attrsets isn't possible. + warnings = filter (w: w != "") (mapAttrsToList (cert: data: if data.extraDomains != "_mkMergedOptionModule" then '' + The option definition `security.acme.certs.${cert}.extraDomains` has changed + to `security.acme.certs.${cert}.extraDomainNames` and is now a list of strings. + Setting a custom webroot for extra domains is not possible, instead use separate certs. + '' else "") cfg.certs); + assertions = let - certs = (mapAttrsToList (k: v: v) cfg.certs); + certs = attrValues 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. + `security.acme.email` to register with the CA. Note that using + many different addresses for certs may trigger account rate limits. ''; } { @@ -307,184 +587,78 @@ in to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/ ''; } - ]; + ] ++ (builtins.concatLists (mapAttrsToList (cert: data: [ + { + assertion = data.user == "_mkRemovedOptionModule"; + message = '' + The option definition `security.acme.certs.${cert}.user' no longer has any effect; Please remove it. + Certificate user is now hard coded to the "acme" user. If you would + like another user to have access, consider adding them to the + "acme" group or changing security.acme.certs.${cert}.group. + ''; + } + { + assertion = data.allowKeysForGroup == "_mkRemovedOptionModule"; + message = '' + The option definition `security.acme.certs.${cert}.allowKeysForGroup' no longer has any effect; Please remove it. + All certs are readable by the configured group. If this is undesired, + consider changing security.acme.certs.${cert}.group to an unused group. + ''; + } + # * in the cert value breaks building of systemd services, and makes + # referencing them as a user quite weird too. Best practice is to use + # the domain option. + { + assertion = ! hasInfix "*" cert; + message = '' + The cert option path `security.acme.certs.${cert}.dnsProvider` + cannot contain a * character. + Instead, set `security.acme.certs.${cert}.domain = "${cert}";` + and remove the wildcard from the path. + ''; + } + { + assertion = data.dnsProvider == null || data.webroot == null; + message = '' + Options `security.acme.certs.${cert}.dnsProvider` and + `security.acme.certs.${cert}.webroot` are mutually exclusive. + ''; + } + ]) cfg.certs)); - 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/${cert}"; - keyName = builtins.replaceStrings ["*"] ["_"] data.domain; - requestedDomains = pipe ([ data.domain ] ++ (attrNames data.extraDomains)) [ - (domains: sort builtins.lessThan domains) - (domains: concatStringsSep "," domains) - ]; - fileMode = if data.allowKeysForGroup then "640" else "600"; - 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)] - ++ data.extraLegoFlags; - certOpts = optionals data.ocspMustStaple [ "--must-staple" ]; - runOpts = escapeShellArgs (globalOpts ++ [ "run" ] ++ certOpts ++ data.extraLegoRunFlags); - renewOpts = escapeShellArgs (globalOpts ++ - [ "renew" "--days" (toString cfg.validMinDays) ] ++ - certOpts ++ data.extraLegoRenewFlags); - acmeService = { - description = "Renew ACME Certificate for ${cert}"; - path = with pkgs; [ openssl ]; - after = [ "network.target" "network-online.target" ]; - wants = [ "network-online.target" ]; - wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - User = data.user; - Group = data.group; - PrivateTmp = true; - StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}"; - StateDirectoryMode = if data.allowKeysForGroup then "750" else "700"; - 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 - test -L ${spath}/accounts -o -d ${spath}/accounts || ln -s ../accounts ${spath}/accounts - LEGO_ARGS=(${runOpts}) - if [ -e ${spath}/certificates/${keyName}.crt ]; then - REQUESTED_DOMAINS="${requestedDomains}" - EXISTING_DOMAINS="$(openssl x509 -in ${spath}/certificates/${keyName}.crt -noout -ext subjectAltName | tail -n1 | sed -e 's/ *DNS://g')" - if [ "''${REQUESTED_DOMAINS}" == "''${EXISTING_DOMAINS}" ]; then - LEGO_ARGS=(${renewOpts}) - fi - fi - ${pkgs.lego}/bin/lego ''${LEGO_ARGS[@]} - ''; - ExecStartPost = - let - script = pkgs.writeScript "acme-post-start" '' - #!${pkgs.runtimeShell} -e - cd ${apath} + users.users.acme = { + home = "/var/lib/acme"; + group = "acme"; + isSystemUser = true; + }; - # Test that existing cert is older than new cert - KEY=${spath}/certificates/${keyName}.key - KEY_CHANGED=no - if [ -e $KEY -a $KEY -nt key.pem ]; then - KEY_CHANGED=yes - cp -p ${spath}/certificates/${keyName}.key key.pem - cp -p ${spath}/certificates/${keyName}.crt fullchain.pem - cp -p ${spath}/certificates/${keyName}.issuer.crt chain.pem - ln -sf fullchain.pem cert.pem - cat key.pem fullchain.pem > full.pem - fi + users.groups.acme = {}; - chmod ${fileMode} *.pem - chown '${data.user}:${data.group}' *.pem + systemd.services = { + "acme-fixperms" = userMigrationService; + } // (mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewService) certConfigs) + // (optionalAttrs (cfg.preliminarySelfsigned) ({ + "acme-selfsigned-ca" = selfsignCAService; + } // (mapAttrs' (cert: conf: nameValuePair "acme-selfsigned-${cert}" conf.selfsignService) certConfigs))); - if [ "$KEY_CHANGED" = "yes" ]; then - : # noop in case postRun is empty - ${data.postRun} - fi - ''; - in - "+${script}"; - }; + systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs; - }; - selfsignedService = { - description = "Create preliminary self-signed certificate for ${cert}"; - path = [ pkgs.openssl ]; - script = - '' - workdir="$(mktemp -d)" + # .lego and .lego/accounts specified to fix any incorrect permissions + systemd.tmpfiles.rules = [ + "d /var/lib/acme/.lego - acme acme" + "d /var/lib/acme/.lego/accounts - acme acme" + ] ++ (unique (concatMap (conf: [ + "d ${conf.accountDir} - acme acme" + ] ++ (optional (conf.webroot != null) "d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}") + ) (attrValues certConfigs))); - # Create CA - openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048 - openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key - openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \ - -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com" - openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt - - # Create key - openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048 - openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key - openssl req -new -key $workdir/server.key -out $workdir/server.csr \ - -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com" - openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \ - -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \ - -out $workdir/server.crt - - # Copy key to destination - 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} > "${apath}/fullchain.pem" - - # Create full.pem for e.g. lighttpd - cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem" - - # Give key acme permissions - chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem - chmod ${fileMode} "${apath}/"{key,fullchain,full}.pem - ''; - serviceConfig = { - Type = "oneshot"; - PrivateTmp = true; - StateDirectory = lpath; - User = data.user; - Group = data.group; - }; - unitConfig = { - # Do not create self-signed key when key already exists - ConditionPathExists = "!${apath}/key.pem"; - }; - }; - in ( - [ { name = "acme-${cert}"; value = acmeService; } ] - ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; } - ); - servicesAttr = listToAttrs services; - in - servicesAttr; - - systemd.tmpfiles.rules = - map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs)); - - systemd.timers = let - # Allow systemd to pick a convenient time within the day - # to run the check. - # This allows the coalescing of multiple timer jobs. - # We divide by the number of certificates so that if you - # have many certificates, the renewals are distributed over - # the course of the day to avoid rate limits. - numCerts = length (attrNames cfg.certs); - _24hSecs = 60 * 60 * 24; - AccuracySec = "${toString (_24hSecs / numCerts)}s"; - in flip mapAttrs' cfg.certs (cert: data: nameValuePair - ("acme-${cert}") - ({ - description = "Renew ACME Certificate for ${cert}"; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnCalendar = cfg.renewInterval; - Unit = "acme-${cert}.service"; - Persistent = "yes"; - inherit AccuracySec; - # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/. - RandomizedDelaySec = "24h"; - }; - }) - ); - - systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {}; - systemd.targets.acme-certificates = {}; + # Create some targets which can be depended on to be "active" after cert renewals + systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" { + wantedBy = [ "default.target" ]; + wants = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; + after = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; + }) certConfigs; }) - ]; meta = { diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml index f802faee9749..005eebd75c01 100644 --- a/nixos/modules/security/acme.xml +++ b/nixos/modules/security/acme.xml @@ -72,7 +72,7 @@ services.nginx = { "foo.example.com" = { forceSSL = true; enableACME = true; - # All serverAliases will be added as extra domains on the certificate. + # All serverAliases will be added as extra domain names on the certificate. serverAliases = [ "bar.example.com" ]; locations."/" = { root = "/var/www"; @@ -80,8 +80,8 @@ services.nginx = { }; # We can also add a different vhost and reuse the same certificate - # but we have to append extraDomains manually. - security.acme.certs."foo.example.com".extraDomains."baz.example.com" = null; + # but we have to append extraDomainNames manually. + security.acme.certs."foo.example.com".extraDomainNames = [ "baz.example.com" ]; "baz.example.com" = { forceSSL = true; useACMEHost = "foo.example.com"; @@ -165,7 +165,7 @@ services.httpd = { # Since we have a wildcard vhost to handle port 80, # we can generate certs for anything! # Just make sure your DNS resolves them. - extraDomains = [ "mail.example.com" ]; + extraDomainNames = [ "mail.example.com" ]; }; diff --git a/nixos/modules/services/networking/prosody.xml b/nixos/modules/services/networking/prosody.xml index 7859cb1578b7..14b7c60f1a05 100644 --- a/nixos/modules/services/networking/prosody.xml +++ b/nixos/modules/services/networking/prosody.xml @@ -65,7 +65,7 @@ services.prosody = { you'll need a single TLS certificate covering your main endpoint, the MUC one as well as the HTTP Upload one. We can generate such a certificate by leveraging the ACME - extraDomains module option. + extraDomainNames module option. Provided the setup detailed in the previous section, you'll need the following acme configuration to generate @@ -78,8 +78,7 @@ security.acme = { "example.org" = { webroot = "/var/www/example.org"; email = "root@example.org"; - extraDomains."conference.example.org" = null; - extraDomains."upload.example.org" = null; + extraDomainNames = [ "conference.example.org" "upload.example.org" ]; }; }; }; diff --git a/nixos/modules/services/web-servers/apache-httpd/default.nix b/nixos/modules/services/web-servers/apache-httpd/default.nix index fc4c2945394c..90ea75dfa342 100644 --- a/nixos/modules/services/web-servers/apache-httpd/default.nix +++ b/nixos/modules/services/web-servers/apache-httpd/default.nix @@ -6,6 +6,8 @@ let cfg = config.services.httpd; + certs = config.security.acme.certs; + runtimeDir = "/run/httpd"; pkg = cfg.package.out; @@ -26,6 +28,13 @@ let vhosts = attrValues cfg.virtualHosts; + # certName is used later on to determine systemd service names. + acmeEnabledVhosts = map (hostOpts: hostOpts // { + certName = if hostOpts.useACMEHost != null then hostOpts.useACMEHost else hostOpts.hostName; + }) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts); + + dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts); + mkListenInfo = hostOpts: if hostOpts.listen != [] then hostOpts.listen else ( @@ -125,13 +134,13 @@ let useACME = hostOpts.enableACME || hostOpts.useACMEHost != null; sslCertDir = - if hostOpts.enableACME then config.security.acme.certs.${hostOpts.hostName}.directory - else if hostOpts.useACMEHost != null then config.security.acme.certs.${hostOpts.useACMEHost}.directory + if hostOpts.enableACME then certs.${hostOpts.hostName}.directory + else if hostOpts.useACMEHost != null then certs.${hostOpts.useACMEHost}.directory else abort "This case should never happen."; - sslServerCert = if useACME then "${sslCertDir}/full.pem" else hostOpts.sslServerCert; + sslServerCert = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerCert; sslServerKey = if useACME then "${sslCertDir}/key.pem" else hostOpts.sslServerKey; - sslServerChain = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerChain; + sslServerChain = if useACME then "${sslCertDir}/chain.pem" else hostOpts.sslServerChain; acmeChallenge = optionalString useACME '' Alias /.well-known/acme-challenge/ "${hostOpts.acmeRoot}/.well-known/acme-challenge/" @@ -347,7 +356,6 @@ let cat ${php.phpIni} > $out echo "$options" >> $out ''; - in @@ -647,14 +655,17 @@ in wwwrun.gid = config.ids.gids.wwwrun; }; - security.acme.certs = mapAttrs (name: hostOpts: { - user = cfg.user; - group = mkDefault cfg.group; - email = if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr; - webroot = hostOpts.acmeRoot; - extraDomains = genAttrs hostOpts.serverAliases (alias: null); - postRun = "systemctl reload httpd.service"; - }) (filterAttrs (name: hostOpts: hostOpts.enableACME) cfg.virtualHosts); + security.acme.certs = let + acmePairs = map (hostOpts: nameValuePair hostOpts.hostName { + group = mkDefault cfg.group; + webroot = hostOpts.acmeRoot; + extraDomainNames = hostOpts.serverAliases; + # Use the vhost-specific email address if provided, otherwise let + # security.acme.email or security.acme.certs..email be used. + email = mkOverride 2000 (if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr); + # Filter for enableACME-only vhosts. Don't want to create dud certs + }) (filter (hostOpts: hostOpts.useACMEHost == null) acmeEnabledVhosts); + in listToAttrs acmePairs; environment.systemPackages = [ apachectl @@ -724,16 +735,12 @@ in "Z '${cfg.logDir}' - ${svc.User} ${svc.Group}" ]; - systemd.services.httpd = - let - vhostsACME = filter (hostOpts: hostOpts.enableACME) vhosts; - in - { description = "Apache HTTPD"; - + systemd.services.httpd = { + description = "Apache HTTPD"; wantedBy = [ "multi-user.target" ]; - wants = concatLists (map (hostOpts: [ "acme-${hostOpts.hostName}.service" "acme-selfsigned-${hostOpts.hostName}.service" ]) vhostsACME); - after = [ "network.target" "fs.target" ] ++ map (hostOpts: "acme-selfsigned-${hostOpts.hostName}.service") vhostsACME; - before = map (hostOpts: "acme-${hostOpts.hostName}.service") vhostsACME; + wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames); + after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames; + before = map (certName: "acme-${certName}.service") dependentCertNames; path = [ pkg pkgs.coreutils pkgs.gnugrep ]; @@ -767,5 +774,31 @@ in }; }; + # postRun hooks on cert renew can't be used to restart Apache since renewal + # runs as the unprivileged acme user. sslTargets are added to wantedBy + before + # which allows the acme-finished-$cert.target to signify the successful updating + # of certs end-to-end. + systemd.services.httpd-config-reload = let + sslServices = map (certName: "acme-${certName}.service") dependentCertNames; + sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames; + in mkIf (sslServices != []) { + wantedBy = sslServices ++ [ "multi-user.target" ]; + # Before the finished targets, after the renew services. + # This service might be needed for HTTP-01 challenges, but we only want to confirm + # certs are updated _after_ config has been reloaded. + before = sslTargets; + after = sslServices; + # Block reloading if not all certs exist yet. + # Happens when config changes add new vhosts/certs. + unitConfig.ConditionPathExists = map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames; + serviceConfig = { + Type = "oneshot"; + TimeoutSec = 60; + ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active httpd.service"; + ExecStartPre = "${pkg}/bin/apachectl configtest"; + ExecStart = "/run/current-system/systemd/bin/systemctl reload httpd.service"; + }; + }; + }; } diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix index 461888c4cc4f..975b56d47822 100644 --- a/nixos/modules/services/web-servers/nginx/default.nix +++ b/nixos/modules/services/web-servers/nginx/default.nix @@ -6,23 +6,23 @@ let cfg = config.services.nginx; certs = config.security.acme.certs; vhostsConfigs = mapAttrsToList (vhostName: vhostConfig: vhostConfig) virtualHosts; - acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME && vhostConfig.useACMEHost == null) vhostsConfigs; + acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null) vhostsConfigs; + dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts); virtualHosts = mapAttrs (vhostName: vhostConfig: let serverName = if vhostConfig.serverName != null then vhostConfig.serverName else vhostName; + certName = if vhostConfig.useACMEHost != null + then vhostConfig.useACMEHost + else serverName; in vhostConfig // { - inherit serverName; - } // (optionalAttrs vhostConfig.enableACME { - sslCertificate = "${certs.${serverName}.directory}/fullchain.pem"; - sslCertificateKey = "${certs.${serverName}.directory}/key.pem"; - sslTrustedCertificate = "${certs.${serverName}.directory}/full.pem"; - }) // (optionalAttrs (vhostConfig.useACMEHost != null) { - sslCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem"; - sslCertificateKey = "${certs.${vhostConfig.useACMEHost}.directory}/key.pem"; - sslTrustedCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem"; + inherit serverName certName; + } // (optionalAttrs (vhostConfig.enableACME || vhostConfig.useACMEHost != null) { + sslCertificate = "${certs.${certName}.directory}/fullchain.pem"; + sslCertificateKey = "${certs.${certName}.directory}/key.pem"; + sslTrustedCertificate = "${certs.${certName}.directory}/chain.pem"; }) ) cfg.virtualHosts; enableIPv6 = config.networking.enableIPv6; @@ -691,12 +691,12 @@ in systemd.services.nginx = { description = "Nginx Web Server"; wantedBy = [ "multi-user.target" ]; - wants = concatLists (map (vhostConfig: ["acme-${vhostConfig.serverName}.service" "acme-selfsigned-${vhostConfig.serverName}.service"]) acmeEnabledVhosts); - after = [ "network.target" ] ++ map (vhostConfig: "acme-selfsigned-${vhostConfig.serverName}.service") acmeEnabledVhosts; + wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames); + after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames; # Nginx needs to be started in order to be able to request certificates # (it's hosting the acme challenge after all) # This fixes https://github.com/NixOS/nixpkgs/issues/81842 - before = map (vhostConfig: "acme-${vhostConfig.serverName}.service") acmeEnabledVhosts; + before = map (certName: "acme-${certName}.service") dependentCertNames; stopIfChanged = false; preStart = '' ${cfg.preStart} @@ -753,37 +753,41 @@ in source = configFile; }; - systemd.services.nginx-config-reload = mkIf cfg.enableReload { - wants = [ "nginx.service" ]; - wantedBy = [ "multi-user.target" ]; - restartTriggers = [ configFile ]; - # commented, because can cause extra delays during activate for this config: - # services.nginx.virtualHosts."_".locations."/".proxyPass = "http://blabla:3000"; - # stopIfChanged = false; - serviceConfig.Type = "oneshot"; - serviceConfig.TimeoutSec = 60; - script = '' - if /run/current-system/systemd/bin/systemctl -q is-active nginx.service ; then - /run/current-system/systemd/bin/systemctl reload nginx.service - fi - ''; - serviceConfig.RemainAfterExit = true; + # postRun hooks on cert renew can't be used to restart Nginx since renewal + # runs as the unprivileged acme user. sslTargets are added to wantedBy + before + # which allows the acme-finished-$cert.target to signify the successful updating + # of certs end-to-end. + systemd.services.nginx-config-reload = let + sslServices = map (certName: "acme-${certName}.service") dependentCertNames; + sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames; + in mkIf (cfg.enableReload || sslServices != []) { + wants = optionals (cfg.enableReload) [ "nginx.service" ]; + wantedBy = sslServices ++ [ "multi-user.target" ]; + # Before the finished targets, after the renew services. + # This service might be needed for HTTP-01 challenges, but we only want to confirm + # certs are updated _after_ config has been reloaded. + before = sslTargets; + after = sslServices; + restartTriggers = optionals (cfg.enableReload) [ configFile ]; + # Block reloading if not all certs exist yet. + # Happens when config changes add new vhosts/certs. + unitConfig.ConditionPathExists = optionals (sslServices != []) (map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames); + serviceConfig = { + Type = "oneshot"; + TimeoutSec = 60; + ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active nginx.service"; + ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service"; + }; }; - security.acme.certs = filterAttrs (n: v: v != {}) ( - let - acmePairs = map (vhostConfig: { name = vhostConfig.serverName; value = { - user = cfg.user; - group = lib.mkDefault cfg.group; - webroot = vhostConfig.acmeRoot; - extraDomains = genAttrs vhostConfig.serverAliases (alias: null); - postRun = '' - /run/current-system/systemd/bin/systemctl reload nginx - ''; - }; }) acmeEnabledVhosts; - in - listToAttrs acmePairs - ); + security.acme.certs = let + acmePairs = map (vhostConfig: nameValuePair vhostConfig.serverName { + group = mkDefault cfg.group; + webroot = vhostConfig.acmeRoot; + extraDomainNames = vhostConfig.serverAliases; + # Filter for enableACME-only vhosts. Don't want to create dud certs + }) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts); + in listToAttrs acmePairs; users.users = optionalAttrs (cfg.user == "nginx") { nginx = { diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index a81884737213..37e82993b4e3 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -1,29 +1,43 @@ let commonConfig = ./common/acme/client; - dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" '' - #!${bash}/bin/bash + dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress; + + dnsScript = {pkgs, nodes}: let + dnsAddress = dnsServerIP nodes; + in pkgs.writeShellScript "dns-hook.sh" '' 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 + ${pkgs.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 + ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt fi ''; + documentRoot = pkgs: pkgs.runCommand "docroot" {} '' + mkdir -p "$out" + echo hello world > "$out/index.html" + ''; + + vhostBase = pkgs: { + forceSSL = true; + locations."/".root = documentRoot pkgs; + }; + in import ./make-test-python.nix ({ lib, ... }: { name = "acme"; meta.maintainers = lib.teams.acme.members; - nodes = rec { + nodes = { + # The fake ACME server which will respond to client requests acme = { nodes, lib, ... }: { imports = [ ./common/acme/server ]; - networking.nameservers = lib.mkForce [ - nodes.dnsserver.config.networking.primaryIPAddress - ]; + 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, ... }: { networking.firewall.allowedTCPPorts = [ 8055 53 ]; networking.firewall.allowedUDPPorts = [ 53 ]; @@ -39,112 +53,87 @@ in import ./make-test-python.nix ({ lib, ... }: { }; }; - acmeStandalone = { nodes, lib, config, pkgs, ... }: { - imports = [ commonConfig ]; - networking.nameservers = lib.mkForce [ - nodes.dnsserver.config.networking.primaryIPAddress - ]; - networking.firewall.allowedTCPPorts = [ 80 ]; - security.acme.certs."standalone.test" = { - webroot = "/var/lib/acme/acme-challenges"; - }; - systemd.targets."acme-finished-standalone.test" = { - after = [ "acme-standalone.test.service" ]; - wantedBy = [ "acme-standalone.test.service" ]; - }; - services.nginx.enable = true; - services.nginx.virtualHosts."standalone.test" = { - locations."/.well-known/acme-challenge".root = "/var/lib/acme/acme-challenges"; - }; - }; - - webserver = { nodes, config, pkgs, lib, ... }: { + # A web server which will be the node requesting certs + webserver = { pkgs, nodes, lib, config, ... }: { imports = [ commonConfig ]; + networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; networking.firewall.allowedTCPPorts = [ 80 443 ]; - 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 - systemd.targets."acme-finished-a.example.test" = { - after = [ "acme-a.example.test.service" ]; - wantedBy = [ "acme-a.example.test.service" ]; - }; + # OpenSSL will be used for more thorough certificate validation + environment.systemPackages = [ pkgs.openssl ]; + # First tests configure a basic cert and run a bunch of openssl checks services.nginx.enable = true; - - services.nginx.virtualHosts."a.example.test" = { + services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // { enableACME = true; - forceSSL = true; - locations."/".root = pkgs.runCommand "docroot" {} '' - mkdir -p "$out" - echo hello world > "$out/index.html" - ''; }; - security.acme.server = "https://acme.test/dir"; + # 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" ]; + }; - specialisation.second-cert.configuration = {pkgs, ...}: { - systemd.targets."acme-finished-b.example.test" = { - after = [ "acme-b.example.test.service" ]; - wantedBy = [ "acme-b.example.test.service" ]; + # Cert config changes will not cause the nginx configuration to change. + # This tests that the reload service is correctly triggered. + specialisation.cert-change.configuration = { pkgs, ... }: { + security.acme.certs."a.example.test".keyType = "ec384"; + }; + + # 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" ]; }; - services.nginx.virtualHosts."b.example.test" = { - enableACME = true; + }; + + # 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; - locations."/".root = pkgs.runCommand "docroot" {} '' - mkdir -p "$out" - echo hello world > "$out/index.html" - ''; + 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" ]; }; }; - specialisation.dns-01.configuration = {pkgs, config, nodes, lib, ...}: { + # 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 = with pkgs; writeText "wildcard.env" '' - EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }} + credentialsFile = pkgs.writeText "wildcard.env" '' + EXEC_PATH=${dnsScript { inherit pkgs nodes; }} ''; - user = config.services.nginx.user; - group = config.services.nginx.group; }; - systemd.targets."acme-finished-example.test" = { - after = [ "acme-example.test.service" ]; - wantedBy = [ "acme-example.test.service" ]; - }; - systemd.services."acme-example.test" = { - before = [ "nginx.service" ]; - wantedBy = [ "nginx.service" ]; - }; - services.nginx.virtualHosts."c.example.test" = { - forceSSL = true; - sslCertificate = config.security.acme.certs."example.test".directory + "/cert.pem"; - sslTrustedCertificate = config.security.acme.certs."example.test".directory + "/full.pem"; - sslCertificateKey = config.security.acme.certs."example.test".directory + "/key.pem"; - locations."/".root = pkgs.runCommand "docroot" {} '' - mkdir -p "$out" - echo hello world > "$out/index.html" - ''; + + services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // { + useACMEHost = "example.test"; }; }; - # When nginx depends on a service that is slow to start up, requesting used to fail - # certificates fail. Reproducer for https://github.com/NixOS/nixpkgs/issues/81842 - specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ...}: { + # 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"; }; - systemd.targets."acme-finished-d.example.com" = { - after = [ "acme-d.example.com.service" ]; - wantedBy = [ "acme-d.example.com.service" ]; - }; - services.nginx.virtualHosts."d.example.com" = { + + services.nginx.virtualHosts."slow.example.com" = { forceSSL = true; enableACME = true; locations."/".proxyPass = "http://localhost:8000"; @@ -152,11 +141,13 @@ in import ./make-test-python.nix ({ lib, ... }: { }; }; - client = {nodes, lib, ...}: { + # The client will be used to curl the webserver to validate configuration + client = {nodes, lib, pkgs, ...}: { imports = [ commonConfig ]; - networking.nameservers = lib.mkForce [ - nodes.dnsserver.config.networking.primaryIPAddress - ]; + networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ]; + + # OpenSSL will be used for more thorough certificate validation + environment.systemPackages = [ pkgs.openssl ]; }; }; @@ -167,73 +158,155 @@ in import ./make-test-python.nix ({ lib, ... }: { in # 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. To work around this, we create some mock target units which - # get pulled in by the oneshot units. The target units linger after activation, and hence we - # can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do + # reaches the active state. Targets do not have this issue. + '' + has_switched = False + + + def switch_to(node, name): + global has_switched + if has_switched: + node.succeed( + "${switchToNewServer}" + ) + has_switched = True + node.succeed( + "/run/current-system/specialisation/{}/bin/switch-to-configuration test".format( + name + ) + ) + + + # Ensures the issuer of our cert matches the chain + # and matches the issuer we expect it to be. + # It's a good validation to ensure the cert.pem and fullchain.pem + # are not still selfsigned afer verification + def check_issuer(node, cert_name, issuer): + for fname in ("cert.pem", "fullchain.pem"): + node.succeed( + ( + """openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname} \ + | tee /proc/self/fd/2 \ + | cut -d'=' -f2- \ + | grep "$(openssl x509 -noout -subject -in /var/lib/acme/{cert_name}/chain.pem \ + | cut -d'=' -f2-)\" \ + | grep -i '{issuer}' + """ + ).format(cert_name=cert_name, issuer=issuer, fname=fname) + ) + + + # Ensure cert comes before chain in fullchain.pem + def check_fullchain(node, cert_name): + node.succeed( + ( + """openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem \ + | tee /proc/self/fd/2 \ + | openssl pkcs7 -print_certs -noout | head -1 | grep {cert_name} + """ + ).format(cert_name=cert_name) + ) + + + def check_connection(node, domain): + node.succeed( + ( + """openssl s_client -brief -verify 2 -verify_return_error -CAfile /tmp/ca.crt \ + -servername {domain} -connect {domain}:443 < /dev/null 2>&1 \ + | tee /proc/self/fd/2 + """ + ).format(domain=domain) + ) + + client.start() dnsserver.start() - acme.wait_for_unit("default.target") dnsserver.wait_for_unit("pebble-challtestsrv.service") + client.wait_for_unit("default.target") + client.succeed( - 'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a' - ) - client.succeed( - 'curl --data \'{"host": "standalone.test", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a' + 'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a' ) acme.start() - acmeStandalone.start() + webserver.start() acme.wait_for_unit("default.target") acme.wait_for_unit("pebble.service") - with subtest("can request certificate with HTTPS-01 challenge"): - acmeStandalone.wait_for_unit("default.target") - acmeStandalone.succeed("systemctl start acme-standalone.test.service") - acmeStandalone.wait_for_unit("acme-finished-standalone.test.target") - - client.wait_for_unit("default.target") - client.succeed("curl https://acme.test:15000/roots/0 > /tmp/ca.crt") client.succeed("curl https://acme.test:15000/intermediate-keys/0 >> /tmp/ca.crt") - with subtest("Can request certificate for nginx service"): + with subtest("Can request certificate with HTTPS-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") + check_connection(client, "a.example.test") + + with subtest("Can generate valid selfsigned certs"): + webserver.succeed("systemctl clean acme-a.example.test.service --what=state") + webserver.succeed("systemctl start acme-selfsigned-a.example.test.service") + check_fullchain(webserver, "a.example.test") + check_issuer(webserver, "a.example.test", "minica") + # 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"): + # These syncs are required because of weird scenarios where the cert files + # were not actually changed when the checks run. + webserver.succeed("sync") + webserver.succeed("systemctl start test-renew-nginx.target") + webserver.succeed("sync") + check_issuer(webserver, "a.example.test", "pebble") + check_connection(client, "a.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") client.succeed( - "curl --cacert /tmp/ca.crt https://a.example.test/ | grep -qF 'hello world'" + """openssl s_client -CAfile /tmp/ca.crt -connect a.example.test:443 < /dev/null \ + | openssl x509 -noout -text | grep -i Public-Key | grep 384 + """ ) - with subtest("Can add another certificate for nginx service"): - webserver.succeed( - "/run/current-system/specialisation/second-cert/bin/switch-to-configuration test" - ) - webserver.wait_for_unit("acme-finished-b.example.test.target") - client.succeed( - "curl --cacert /tmp/ca.crt https://b.example.test/ | grep -qF 'hello world'" - ) + 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)"): + switch_to(webserver, "nginx-aliases") + webserver.wait_for_unit("acme-finished-a.example.test.target") + check_issuer(webserver, "a.example.test", "pebble") + check_connection(client, "a.example.test") + check_connection(client, "b.example.test") + + with subtest("Can request certificates for vhost + aliases (apache-httpd)"): + switch_to(webserver, "httpd-aliases") + webserver.wait_for_unit("acme-finished-c.example.test.target") + check_issuer(webserver, "c.example.test", "pebble") + check_connection(client, "c.example.test") + check_connection(client, "d.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") + webserver.succeed("sync") + check_issuer(webserver, "c.example.test", "minica") + webserver.succeed("systemctl start httpd-config-reload.service") + webserver.succeed("systemctl start test-renew-httpd.target") + webserver.succeed("sync") + check_issuer(webserver, "c.example.test", "pebble") + check_connection(client, "c.example.test") with subtest("Can request wildcard certificates using DNS-01 challenge"): - webserver.succeed( - "${switchToNewServer}" - ) - webserver.succeed( - "/run/current-system/specialisation/dns-01/bin/switch-to-configuration test" - ) + switch_to(webserver, "dns-01") webserver.wait_for_unit("acme-finished-example.test.target") - client.succeed( - "curl --cacert /tmp/ca.crt https://c.example.test/ | grep -qF 'hello world'" - ) - - with subtest("Can request certificate of nginx when startup is delayed"): - webserver.succeed( - "${switchToNewServer}" - ) - webserver.succeed( - "/run/current-system/specialisation/slow-startup/bin/switch-to-configuration test" - ) - webserver.wait_for_unit("acme-finished-d.example.com.target") - client.succeed("curl --cacert /tmp/ca.crt https://d.example.com/") + check_issuer(webserver, "example.test", "pebble") + check_connection(client, "dns.example.test") ''; }) diff --git a/nixos/tests/common/acme/client/default.nix b/nixos/tests/common/acme/client/default.nix index 80893da02524..1e9885e375c7 100644 --- a/nixos/tests/common/acme/client/default.nix +++ b/nixos/tests/common/acme/client/default.nix @@ -1,15 +1,14 @@ { lib, nodes, pkgs, ... }: - let - acme-ca = nodes.acme.config.test-support.acme.caCert; -in + caCert = nodes.acme.config.test-support.acme.caCert; + caDomain = nodes.acme.config.test-support.acme.caDomain; -{ +in { security.acme = { - server = "https://acme.test/dir"; + server = "https://${caDomain}/dir"; email = "hostmaster@example.test"; acceptTerms = true; }; - security.pki.certificateFiles = [ acme-ca ]; + security.pki.certificateFiles = [ caCert ]; } diff --git a/nixos/tests/common/acme/server/default.nix b/nixos/tests/common/acme/server/default.nix index 1a0ee882572c..4d8e664c4e17 100644 --- a/nixos/tests/common/acme/server/default.nix +++ b/nixos/tests/common/acme/server/default.nix @@ -3,7 +3,7 @@ # config.test-support.acme.caCert # # This value can be used inside the configuration of other test nodes to inject -# the snakeoil certificate into security.pki.certificateFiles or into package +# the test certificate into security.pki.certificateFiles or into package # overlays. # # Another value that's needed if you don't use a custom resolver (see below for @@ -50,19 +50,13 @@ # Also make sure that whenever you use a resolver from a different test node # that it has to be started _before_ the ACME service. { config, pkgs, lib, ... }: - - let - snakeOilCerts = import ./snakeoil-certs.nix; + testCerts = import ./snakeoil-certs.nix { + minica = pkgs.minica; + mkDerivation = pkgs.stdenv.mkDerivation; + }; + domain = testCerts.domain; - wfeDomain = "acme.test"; - wfeCertFile = snakeOilCerts.${wfeDomain}.cert; - wfeKeyFile = snakeOilCerts.${wfeDomain}.key; - - siteDomain = "acme.test"; - siteCertFile = snakeOilCerts.${siteDomain}.cert; - siteKeyFile = snakeOilCerts.${siteDomain}.key; - pebble = pkgs.pebble; resolver = let message = "You need to define a resolver for the acme test module."; firstNS = lib.head config.networking.nameservers; @@ -71,8 +65,9 @@ let pebbleConf.pebble = { listenAddress = "0.0.0.0:443"; managementListenAddress = "0.0.0.0:15000"; - certificate = snakeOilCerts.${wfeDomain}.cert; - privateKey = snakeOilCerts.${wfeDomain}.key; + # These certs and keys are used for the Web Front End (WFE) + certificate = testCerts.${domain}.cert; + privateKey = testCerts.${domain}.key; httpPort = 80; tlsPort = 443; ocspResponderURL = "http://0.0.0.0:4002"; @@ -80,18 +75,30 @@ let }; pebbleConfFile = pkgs.writeText "pebble.conf" (builtins.toJSON pebbleConf); - pebbleDataDir = "/root/pebble"; in { imports = [ ../../resolver.nix ]; - options.test-support.acme.caCert = lib.mkOption { - type = lib.types.path; - description = '' - A certificate file to use with the nodes attribute to - inject the snakeoil CA certificate used in the ACME server into - . - ''; + options.test-support.acme = with lib; { + caDomain = mkOption { + type = types.str; + readOnly = true; + default = domain; + description = '' + A domain name to use with the nodes attribute to + identify the CA server. + ''; + }; + caCert = mkOption { + type = types.path; + readOnly = true; + default = testCerts.ca.cert; + description = '' + A certificate file to use with the nodes attribute to + inject the test CA certificate used in the ACME server into + . + ''; + }; }; config = { @@ -99,35 +106,32 @@ in { resolver.enable = let isLocalResolver = config.networking.nameservers == [ "127.0.0.1" ]; in lib.mkOverride 900 isLocalResolver; - acme.caCert = snakeOilCerts.ca.cert; }; # This has priority 140, because modules/testing/test-instrumentation.nix # already overrides this with priority 150. networking.nameservers = lib.mkOverride 140 [ "127.0.0.1" ]; - networking.firewall.enable = false; + networking.firewall.allowedTCPPorts = [ 80 443 15000 4002 ]; networking.extraHosts = '' - 127.0.0.1 ${wfeDomain} - ${config.networking.primaryIPAddress} ${wfeDomain} ${siteDomain} + 127.0.0.1 ${domain} + ${config.networking.primaryIPAddress} ${domain} ''; systemd.services = { pebble = { enable = true; description = "Pebble ACME server"; - requires = [ ]; wantedBy = [ "network.target" ]; - preStart = '' - mkdir ${pebbleDataDir} - ''; - script = '' - cd ${pebbleDataDir} - ${pebble}/bin/pebble -config ${pebbleConfFile} - ''; + serviceConfig = { + RuntimeDirectory = "pebble"; + WorkingDirectory = "/run/pebble"; + # Required to bind on privileged ports. AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + + ExecStart = "${pkgs.pebble}/bin/pebble -config ${pebbleConfFile}"; }; }; }; diff --git a/nixos/tests/common/acme/server/mkcerts.nix b/nixos/tests/common/acme/server/mkcerts.nix deleted file mode 100644 index c9616bf9672c..000000000000 --- a/nixos/tests/common/acme/server/mkcerts.nix +++ /dev/null @@ -1,69 +0,0 @@ -{ pkgs ? import {} -, lib ? pkgs.lib -, domains ? [ "acme.test" ] -}: - -pkgs.runCommand "acme-snakeoil-ca" { - nativeBuildInputs = [ pkgs.openssl ]; -} '' - addpem() { - local file="$1"; shift - local storeFileName="$(IFS=.; echo "$*")" - - echo -n " " >> "$out" - - # Every following argument is an attribute, so let's recurse and check - # every attribute whether it must be quoted and write it into $out. - while [ -n "$1" ]; do - if expr match "$1" '^[a-zA-Z][a-zA-Z0-9]*$' > /dev/null; then - echo -n "$1" >> "$out" - else - echo -n '"' >> "$out" - echo -n "$1" | sed -e 's/["$]/\\&/g' >> "$out" - echo -n '"' >> "$out" - fi - shift - [ -z "$1" ] || echo -n . >> "$out" - done - - echo " = builtins.toFile \"$storeFileName\" '''" >> "$out" - sed -e 's/^/ /' "$file" >> "$out" - - echo " ''';" >> "$out" - } - - echo '# Generated via mkcert.sh in the same directory.' > "$out" - echo '{' >> "$out" - - openssl req -newkey rsa:4096 -x509 -sha256 -days 36500 \ - -subj '/CN=Snakeoil CA' -nodes -out ca.pem -keyout ca.key - - addpem ca.key ca key - addpem ca.pem ca cert - - ${lib.concatMapStrings (fqdn: let - opensslConfig = pkgs.writeText "snakeoil.cnf" '' - [req] - default_bits = 4096 - prompt = no - default_md = sha256 - req_extensions = req_ext - distinguished_name = dn - [dn] - CN = ${fqdn} - [req_ext] - subjectAltName = DNS:${fqdn} - ''; - in '' - export OPENSSL_CONF=${lib.escapeShellArg opensslConfig} - openssl genrsa -out snakeoil.key 4096 - openssl req -new -key snakeoil.key -out snakeoil.csr - openssl x509 -req -in snakeoil.csr -sha256 -set_serial 666 \ - -CA ca.pem -CAkey ca.key -out snakeoil.pem -days 36500 \ - -extfile "$OPENSSL_CONF" -extensions req_ext - addpem snakeoil.key ${lib.escapeShellArg fqdn} key - addpem snakeoil.pem ${lib.escapeShellArg fqdn} cert - '') domains} - - echo '}' >> "$out" -'' diff --git a/nixos/tests/common/acme/server/mkcerts.sh b/nixos/tests/common/acme/server/mkcerts.sh deleted file mode 100755 index cc7f8ca650dd..000000000000 --- a/nixos/tests/common/acme/server/mkcerts.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env nix-shell -#!nix-shell -p nix bash -i bash -set -e -cd "$(dirname "$0")" -storepath="$(nix-build --no-out-link mkcerts.nix)" -cat "$storepath" > snakeoil-certs.nix diff --git a/nixos/tests/common/acme/server/snakeoil-certs.nix b/nixos/tests/common/acme/server/snakeoil-certs.nix index 7325b027c7ef..4b6a38b8fa30 100644 --- a/nixos/tests/common/acme/server/snakeoil-certs.nix +++ b/nixos/tests/common/acme/server/snakeoil-certs.nix @@ -1,172 +1,37 @@ -# Generated via mkcert.sh in the same directory. -{ - ca.key = builtins.toFile "ca.key" '' - -----BEGIN PRIVATE KEY----- - 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 - s9o9Sr49cibZ3CbMjCSNE3AOeVE1/Q== - -----END PRIVATE KEY----- - ''; - ca.cert = builtins.toFile "ca.cert" '' - -----BEGIN CERTIFICATE----- - MIIFDzCCAvegAwIBAgIUX0P6NfX4gRUpFz+TNV/f26GHokgwDQYJKoZIhvcNAQEL - BQAwFjEUMBIGA1UEAwwLU25ha2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEyWhgPMjEy - MDA3MzEwNzQyMTJaMBYxFDASBgNVBAMMC1NuYWtlb2lsIENBMIICIjANBgkqhkiG - 9w0BAQEFAAOCAg8AMIICCgKCAgEA3dRuzhaVyKB5668XcPoYiVQ2Op0AwyhbfRQS - sJb+8doQr1+258rx2i+x+tXPvNDHBjyEq0J9/jI7kWbqsGsePQ3XTV2xreFqhpce - CwHiN+zoDVFM/X6Da9qww1e+OL8KdePzw9X956qVnzoM87IfK8WnNJmeUnvR8TiK - SOzUTbhyY4+x1meGMXjr4tvPa5qbtCT3KduZ48O4ehTXx7vBhqd+FI+sAMbm3Ucn - 1YlfM/RTH24JS7gPYtTokstzUKavFcARvrv9Iguz0LEI8Uq2oELtubHwE2CKxSUO - GQr4dJ8aijHsjzwYunQHDKWtgGQKrar/gsFnaN+gbUuV4gu6wKYGJ/srXikB0m5w - ohtlv26Em34PZUZN8ONXREhDQgA1Woy29xKL76dLQTvwQC7nb1o8wD26V5XlTa+p - 4FrRpbGUwuy1w4AanVtipc6WRnM/j1twE5cIqf2RUbewNebuXVZFj1aD7kNMNU8P - oPXcVhUzZN4ftneyvOyMUG3L6sXiA0hFRTK8aNHuAhtrnTHHpWcAEZeojUwBE2ts - yVtF7QHRlTPh2NUv5FOlPSd57mw7bQJADAAYz8Moyxu4X8lqlB3/cj7OPRrH7QFb - yMvFyHrtLIQ9QRtdf1c78KVAokXPm7blotnq2eGXz6EOWKEvYLeEviKc+0zxgZfg - FJPRztkCAwEAAaNTMFEwHQYDVR0OBBYEFNhBZxryvykCjfPO85xB3wof2enAMB8G - A1UdIwQYMBaAFNhBZxryvykCjfPO85xB3wof2enAMA8GA1UdEwEB/wQFMAMBAf8w - DQYJKoZIhvcNAQELBQADggIBAEZwlsQ+3yd1MVxLRy9RjoA8hI7iWBNmvPUyNjlb - l/L9N+dZgdx9G5h/KPRUyzvUc/uk/ZxTWVPIOp13WI65qwsBKrwvYKiXiwzjt+9V - CKDRc1sOghTSXk4FD3L5UcKvTQ2lRcFsqxbkopEwQWhoCuhe4vFyt3Nx8ZGLCBUD - 3I5zMHtO8FtpZWKJPw46Yc1kasv0nlfly/vUbnErYfgjWX1hgWUcRgYdKwO4sOZ7 - KbNma0WUsX5mWhXo4Kk7D15wATHO+j9s+j8m86duBL3A4HzpTo1DhHvBi0dkg0CO - XuSdByIzVLIPh3yhCHN1loRCP2rbzKM8IQeU/X5Q4UJeC/x9ew8Kk+RKXoHc8Y2C - JQO1DxuidyDJRhbb98wZo2YfIsdWQGjYZBe1XQRwBD28JnB+Rb9shml6lORWQn9y - P/STo9uWm5zvOCfqwbnCoetljDweItINx622G9SafBwPZc3o79oL7QSl8DgCtN6g - p0wGIlIBx+25w/96PqZcrYb8B7/uBHJviiKjBXDoIJWNiNRhW5HaFjeJdSKq7KIL - I/PO9KuHafif36ksG69X02Rio2/cTjgjEW1hGHcDRyyJWWaj7bd2eWuouh6FF22b - PA6FGY4vewDPnbLKLaix2ZIKxtedUDOH/qru3Mv58IFXmQ4iyM8oC8aOxYSQLZDn - 1yJD - -----END CERTIFICATE----- - ''; - "acme.test".key = builtins.toFile "acme.test.key" '' - -----BEGIN RSA PRIVATE KEY----- - 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 - -----END RSA PRIVATE KEY----- - ''; - "acme.test".cert = builtins.toFile "acme.test.cert" '' - -----BEGIN CERTIFICATE----- - MIIEwDCCAqigAwIBAgICApowDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLU25h - a2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEzWhgPMjEyMDA3MzEwNzQyMTNaMBQxEjAQ - BgNVBAMMCWFjbWUudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB - AN3SZeAchx0XKm2c25bMxyEuXhFt03F075ZagtZ3nRixlPXZwGG3aQ1GIb1s/8vQ - H0WdXfI2M9NY2bbecVmBtEKEFHZAnPUDz4dAMa5d/HBpYgeiGSGNKn7Vvc6y0oAj - lJ1y/+OSakqQ+ewxzEkoiZd4aDqOJHgWjETf1Cuhdw8Q/52RLIfsgQYdo7N6sZXh - MapF/CJ5oyvaRvI+6muAXuS4eIiCwR2xgDGsTO1YjQIOCoFsoHefuKgfOiRbRP3C - fq9w+tgEY1WsVOnjzi9AMEv78eZbOgxUAu/tQqRgjXpoCz83HO9LNFdEgJxe98R2 - KkVoyRbbpsr8k5IkFi4NMw0DroFndwfQA7miBMdp74j8qYQLYzN2Sqy9szdWiH6s - 28Sfy/E0BWtlj7D1Nvos2v5vZDHy12eFzxf0icUPuclR/q8ceTlq12dd5wjMnlaG - l0KH5hu6GbzkG7bk/a+Q0GXfVjdD9xt7MtP3TZ5TVZ/Lggb1yU0IUSrMtRREmXYH - BTbaSRBuwURvRnOotFd/Wkkc0oWciiUgUIZXNBTE5V+OORGLITyoxAv/krqvcMvy - 7i4yJJKgW1YOOteyJYmy89Hw2P8q2apH0qoQlbFp62ttU8tzKbw/sJ7JE87hq7wl - U3tynFqfsJHh9Mb4a/BV1+p5+PdmAn72UiEztxRfqlrfAgMBAAGjGDAWMBQGA1Ud - EQQNMAuCCWFjbWUudGVzdDANBgkqhkiG9w0BAQsFAAOCAgEAM5WrCpBOmLrZ1QX8 - l6vxVXwoI8pnqyy3cbAm3aLRPbw4gb0Ot90Pv/LoMhP0fkrNOKwH/FGRjSXyti0X - TheKrP7aEf6XL2/Xnb8rK2jYMQo6YJU9T+wBJA6Q+GBrc8SE75KfOi5NWJr8T4Ju - Etb+G05hXClrN19VFzIoz3L4kRV+xNMialcOT3xQfHtXCQUgwAWpPlwcJA/Jf60m - XsfwQwk2Ir16wq+Lc3y+mQ7d/dbG+FVrngFk4qN2B9M/Zyv4N9ZBbqeDUn3mYtJE - FeJrwHgmwH6slf1gBN3gxUKRW7Bvzxk548NdmLOyN+Y4StsqbOaYGtShUJA7f1Ng - qQqdgvxZ9MNwwMv9QVDZEnaaew3/oWOSmQGAai4hrc7gLMLJmIxzgfd5P6Dr06e4 - 2zwsMuI8Qh/IDqu/CfmFYvaua0FEeyAtpoID9Y/KPM7fu9bJuxjZ6kqLVFkEi9nF - /rCMchcSA8N2z/vLPabpNotO7OYH3VD7aQGTfCL82dMlp1vwZ39S3Z1TFLLh3MZ+ - BYcAv8kUvCV6kIdPAXvJRSQOJUlJRV7XiI2mwugdDzMx69wQ0Zc1e4WyGfiSiVYm - ckSJ/EkxuwT/ZYLqCAKSFGMlFhad9g1Zyvd67XgfZq5p0pJTtGxtn5j8QHy6PM6m - NbjvWnP8lDU8j2l3eSG58S14iGs= - -----END CERTIFICATE----- - ''; +# Minica can provide a CA key and cert, plus a key +# and cert for our fake CA server's Web Front End (WFE). +{ minica, mkDerivation }: +let + domain = "acme.test"; + + selfSignedCertData = mkDerivation { + name = "test-certs"; + buildInputs = [ minica ]; + phases = [ "buildPhase" "installPhase" ]; + + buildPhase = '' + mkdir ca + minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains ${domain} + chmod 600 ca/* + chmod 640 ${domain}/*.pem + ''; + + installPhase = '' + mkdir -p $out + mv ${domain} ca $out/ + ''; + }; +in { + inherit domain; + ca = { + cert = "${selfSignedCertData}/ca/cert.pem"; + key = "${selfSignedCertData}/ca/key.pem"; + }; + "${domain}" = { + cert = "${selfSignedCertData}/${domain}/cert.pem"; + key = "${selfSignedCertData}/${domain}/key.pem"; + }; } diff --git a/nixos/tests/postfix-raise-smtpd-tls-security-level.nix b/nixos/tests/postfix-raise-smtpd-tls-security-level.nix index b3c2156122d2..5fad1fed75b2 100644 --- a/nixos/tests/postfix-raise-smtpd-tls-security-level.nix +++ b/nixos/tests/postfix-raise-smtpd-tls-security-level.nix @@ -1,6 +1,3 @@ -let - certs = import ./common/acme/server/snakeoil-certs.nix; -in import ./make-test-python.nix { name = "postfix"; diff --git a/nixos/tests/postfix.nix b/nixos/tests/postfix.nix index b0674ca3a0d2..37ae76afec10 100644 --- a/nixos/tests/postfix.nix +++ b/nixos/tests/postfix.nix @@ -1,5 +1,6 @@ let certs = import ./common/acme/server/snakeoil-certs.nix; + domain = certs.domain; in import ./make-test-python.nix { name = "postfix"; @@ -11,8 +12,8 @@ import ./make-test-python.nix { enableSubmission = true; enableSubmissions = true; sslCACert = certs.ca.cert; - sslCert = certs."acme.test".cert; - sslKey = certs."acme.test".key; + sslCert = certs.${domain}.cert; + sslKey = certs.${domain}.key; submissionsOptions = { smtpd_sasl_auth_enable = "yes"; smtpd_client_restrictions = "permit"; @@ -25,7 +26,7 @@ import ./make-test-python.nix { ]; networking.extraHosts = '' - 127.0.0.1 acme.test + 127.0.0.1 ${domain} ''; environment.systemPackages = let @@ -33,7 +34,7 @@ import ./make-test-python.nix { #!${pkgs.python3.interpreter} import smtplib - with smtplib.SMTP('acme.test') as smtp: + with smtplib.SMTP('${domain}') as smtp: smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test\n\nTest data.') smtp.quit() ''; @@ -45,7 +46,7 @@ import ./make-test-python.nix { ctx = ssl.create_default_context() - with smtplib.SMTP('acme.test') as smtp: + with smtplib.SMTP('${domain}') as smtp: smtp.ehlo() smtp.starttls(context=ctx) smtp.ehlo() @@ -60,7 +61,7 @@ import ./make-test-python.nix { ctx = ssl.create_default_context() - with smtplib.SMTP_SSL(host='acme.test', context=ctx) as smtp: + with smtplib.SMTP_SSL(host='${domain}', context=ctx) as smtp: smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test SMTPS\n\nTest data.') smtp.quit() ''; diff --git a/pkgs/tools/security/minica/default.nix b/pkgs/tools/security/minica/default.nix new file mode 100644 index 000000000000..20ae3878a71f --- /dev/null +++ b/pkgs/tools/security/minica/default.nix @@ -0,0 +1,34 @@ +{ lib, buildGoPackage, fetchFromGitHub }: + +buildGoPackage rec { + pname = "minica"; + version = "1.0.2"; + + goPackagePath = "github.com/jsha/minica"; + + src = fetchFromGitHub { + owner = "jsha"; + repo = "minica"; + rev = "v${version}"; + sha256 = "18518wp3dcjhf3mdkg5iwxqr3326n6jwcnqhyibphnb2a58ap7ny"; + }; + + buildFlagsArray = '' + -ldflags= + -X main.BuildVersion=${version} + ''; + + meta = with lib; { + description = "A simple tool for generating self signed certificates."; + longDescription = '' + Minica is a simple CA intended for use in situations where the CA + operator also operates each host where a certificate will be used. It + automatically generates both a key and a certificate when asked to + produce a certificate. + ''; + homepage = "https://github.com/jsha/minica/"; + license = licenses.mit; + maintainers = with maintainers; [ m1cr0man ]; + platforms = platforms.linux ++ platforms.darwin; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 36268b0ee88c..cab987cfd077 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -4949,6 +4949,8 @@ in minergate-cli = callPackage ../applications/misc/minergate-cli { }; + minica = callPackage ../tools/security/minica { }; + minidlna = callPackage ../tools/networking/minidlna { }; minisign = callPackage ../tools/security/minisign { }; From 61dbf4bf8950c7e3cfeab07ad33cdb00d6a0525d Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Sun, 30 Aug 2020 18:38:30 +0100 Subject: [PATCH 2/6] nixos/acme: Add proper nginx/httpd config reload checks Testing of certs failed randomly when the web server was still returning old certs even after the reload was "complete". This was because the reload commands send process signals and do not wait for the worker processes to restart. This commit adds log watchers which wait for the worker processes to be restarted. --- .../web-servers/apache-httpd/default.nix | 2 +- nixos/tests/acme.nix | 38 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/nixos/modules/services/web-servers/apache-httpd/default.nix b/nixos/modules/services/web-servers/apache-httpd/default.nix index 90ea75dfa342..6dd1c85132c9 100644 --- a/nixos/modules/services/web-servers/apache-httpd/default.nix +++ b/nixos/modules/services/web-servers/apache-httpd/default.nix @@ -795,7 +795,7 @@ in Type = "oneshot"; TimeoutSec = 60; ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active httpd.service"; - ExecStartPre = "${pkg}/bin/apachectl configtest"; + ExecStartPre = "${pkg}/bin/httpd -f ${httpdConf} -t"; ExecStart = "/run/current-system/systemd/bin/systemctl reload httpd.service"; }; }; diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index 37e82993b4e3..c71e2bc3ca36 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -62,8 +62,11 @@ in import ./make-test-python.nix ({ lib, ... }: { # OpenSSL will be used for more thorough certificate validation environment.systemPackages = [ pkgs.openssl ]; - # First tests configure a basic cert and run a bunch of openssl checks + # 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; }; @@ -178,6 +181,23 @@ in import ./make-test-python.nix ({ lib, ... }: { ) + # In order to determine if a config reload has finished, we need to watch + # the log files for the relevant lines + def wait_httpd_reload(node): + # Check for SIGUSER received + node.succeed("( tail -n3 -f /var/log/httpd/error.log & ) | grep -q AH00493") + # Check for service restart. This line also occurs when the service is started, + # hence the above check is necessary too. + node.succeed("( tail -n1 -f /var/log/httpd/error.log & ) | grep -q AH00094") + + + def wait_nginx_reload(node): + # Check for SIGHUP received + node.succeed("( journalctl -fu nginx -n18 & ) | grep -q SIGHUP") + # Check for SIGCHLD from killed worker processes + node.succeed("( journalctl -fu nginx -n10 & ) | grep -q SIGCHLD") + + # Ensures the issuer of our cert matches the chain # and matches the issuer we expect it to be. # It's a good validation to ensure the cert.pem and fullchain.pem @@ -241,6 +261,7 @@ in import ./make-test-python.nix ({ lib, ... }: { with subtest("Can request certificate with HTTPS-01 challenge"): webserver.wait_for_unit("acme-finished-a.example.test.target") + wait_nginx_reload(webserver) check_fullchain(webserver, "a.example.test") check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.example.test") @@ -252,19 +273,18 @@ in import ./make-test-python.nix ({ lib, ... }: { check_issuer(webserver, "a.example.test", "minica") # Will succeed if nginx can load the certs webserver.succeed("systemctl start nginx-config-reload.service") + wait_nginx_reload(webserver) with subtest("Can reload nginx when timer triggers renewal"): - # These syncs are required because of weird scenarios where the cert files - # were not actually changed when the checks run. - webserver.succeed("sync") webserver.succeed("systemctl start test-renew-nginx.target") - webserver.succeed("sync") + wait_nginx_reload(webserver) check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.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") + wait_nginx_reload(webserver) client.succeed( """openssl s_client -CAfile /tmp/ca.crt -connect a.example.test:443 < /dev/null \ | openssl x509 -noout -text | grep -i Public-Key | grep 384 @@ -274,12 +294,14 @@ in import ./make-test-python.nix ({ lib, ... }: { 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") + wait_nginx_reload(webserver) check_issuer(webserver, "slow.example.com", "pebble") check_connection(client, "slow.example.com") with subtest("Can request certificate for vhost + aliases (nginx)"): switch_to(webserver, "nginx-aliases") webserver.wait_for_unit("acme-finished-a.example.test.target") + wait_nginx_reload(webserver) check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.example.test") check_connection(client, "b.example.test") @@ -287,6 +309,7 @@ in import ./make-test-python.nix ({ lib, ... }: { with subtest("Can request certificates for vhost + aliases (apache-httpd)"): switch_to(webserver, "httpd-aliases") webserver.wait_for_unit("acme-finished-c.example.test.target") + wait_httpd_reload(webserver) check_issuer(webserver, "c.example.test", "pebble") check_connection(client, "c.example.test") check_connection(client, "d.example.test") @@ -295,17 +318,18 @@ in import ./make-test-python.nix ({ lib, ... }: { # 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") - webserver.succeed("sync") + wait_httpd_reload(webserver) check_issuer(webserver, "c.example.test", "minica") webserver.succeed("systemctl start httpd-config-reload.service") webserver.succeed("systemctl start test-renew-httpd.target") - webserver.succeed("sync") + wait_httpd_reload(webserver) 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") webserver.wait_for_unit("acme-finished-example.test.target") + wait_nginx_reload(webserver) check_issuer(webserver, "example.test", "pebble") check_connection(client, "dns.example.test") ''; From 1b6cfd9796788a3c5b8e8f27b49271f4a423c9a7 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Thu, 3 Sep 2020 15:31:06 +0100 Subject: [PATCH 3/6] nixos/acme: Fix race condition, dont be smart with keys Attempting to reuse keys on a basis different to the cert (AKA, storing the key in a directory with a hashed name different to the cert it is associated with) was ineffective since when "lego run" is used it will ALWAYS generate a new key. This causes issues when you revert changes since your "reused" key will not be the one associated with the old cert. As such, I tore out the whole keyDir implementation. As for the race condition, checking the mtime of the cert file was not sufficient to detect changes. In testing, selfsigned and full certs could be generated/installed within 1 second of each other. cmp is now used instead. Also, I removed the nginx/httpd reload waiters in favour of simple retry logic for the curl-based tests --- nixos/modules/security/acme.nix | 23 +++---- nixos/tests/acme.nix | 109 ++++++++++++++------------------ 2 files changed, 56 insertions(+), 76 deletions(-) diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index 91b7dd0c989f..51392f6ce885 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -106,7 +106,6 @@ let mkHash = with builtins; val: substring 0 20 (hashString "sha256" val); certDir = mkHash hashData; othersHash = mkHash "${toString acmeServer} ${data.keyType}"; - keyDir = "key-" + othersHash; accountDir = "/var/lib/acme/.lego/accounts/" + othersHash; protocolOpts = if useDns then ( @@ -215,7 +214,7 @@ let # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ]; - path = with pkgs; [ lego coreutils ]; + path = with pkgs; [ lego coreutils diffutils ]; serviceConfig = commonServiceConfig // { Group = data.group; @@ -223,14 +222,13 @@ let # AccountDir dir will be created by tmpfiles to ensure correct permissions # And to avoid deletion during systemctl clean # acme/.lego/${cert} is listed so that it is deleted during systemctl clean - StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir} acme/.lego/${cert}/${keyDir}"; + StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir}"; # Needs to be space separated, but can't use a multiline string because that'll include newlines BindPaths = "${accountDir}:/tmp/accounts " + "/var/lib/acme/${cert}:/tmp/out " + - "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates " + - "/var/lib/acme/.lego/${cert}/${keyDir}:/tmp/keys"; + "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates "; # Only try loading the credentialsFile if the dns challenge is enabled EnvironmentFile = mkIf useDns data.credentialsFile; @@ -240,9 +238,6 @@ let script = '' set -euo pipefail - # Safely copy keyDir contents into certificates (it might be empty). - cp -af keys/. certificates/ - # Check if we can renew if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' ]; then lego ${renewOpts} @@ -258,17 +253,15 @@ let # Group might change between runs, re-apply it chown 'acme:${data.group}' certificates/* - # Copy the key to keyDir - cp -pf 'certificates/${keyName}.key' 'keys/' - # Copy all certs to the "real" certs directory CERT='certificates/${keyName}.crt' CERT_CHANGED=no - if [ -e "$CERT" -a "$CERT" -nt out/fullchain.pem ]; then + if [ -e "$CERT" ] && ! cmp -s "$CERT" out/fullchain.pem; then CERT_CHANGED=yes - cp -p 'certificates/${keyName}.crt' out/fullchain.pem - cp -p 'certificates/${keyName}.key' out/key.pem - cp -p 'certificates/${keyName}.issuer.crt' out/chain.pem + echo Installing new certificate + cp -vp 'certificates/${keyName}.crt' out/fullchain.pem + cp -vp 'certificates/${keyName}.key' out/key.pem + cp -vp 'certificates/${keyName}.issuer.crt' out/chain.pem ln -sf fullchain.pem out/cert.pem cat out/key.pem out/fullchain.pem > out/full.pem fi diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index c71e2bc3ca36..90ae06542c4c 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -164,6 +164,9 @@ in import ./make-test-python.nix ({ lib, ... }: { # reaches the active state. Targets do not have this issue. '' + import time + + has_switched = False @@ -175,70 +178,68 @@ in import ./make-test-python.nix ({ lib, ... }: { ) has_switched = True node.succeed( - "/run/current-system/specialisation/{}/bin/switch-to-configuration test".format( - name - ) + f"/run/current-system/specialisation/{name}/bin/switch-to-configuration test" ) - # In order to determine if a config reload has finished, we need to watch - # the log files for the relevant lines - def wait_httpd_reload(node): - # Check for SIGUSER received - node.succeed("( tail -n3 -f /var/log/httpd/error.log & ) | grep -q AH00493") - # Check for service restart. This line also occurs when the service is started, - # hence the above check is necessary too. - node.succeed("( tail -n1 -f /var/log/httpd/error.log & ) | grep -q AH00094") - - - def wait_nginx_reload(node): - # Check for SIGHUP received - node.succeed("( journalctl -fu nginx -n18 & ) | grep -q SIGHUP") - # Check for SIGCHLD from killed worker processes - node.succeed("( journalctl -fu nginx -n10 & ) | grep -q SIGCHLD") - - # Ensures the issuer of our cert matches the chain # and matches the issuer we expect it to be. # It's a good validation to ensure the cert.pem and fullchain.pem # are not still selfsigned afer verification def check_issuer(node, cert_name, issuer): for fname in ("cert.pem", "fullchain.pem"): - node.succeed( - ( - """openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname} \ - | tee /proc/self/fd/2 \ - | cut -d'=' -f2- \ - | grep "$(openssl x509 -noout -subject -in /var/lib/acme/{cert_name}/chain.pem \ - | cut -d'=' -f2-)\" \ - | grep -i '{issuer}' - """ - ).format(cert_name=cert_name, issuer=issuer, fname=fname) - ) + actual_issuer = node.succeed( + f"openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname}" + ).partition("=")[2] + print(f"{fname} issuer: {actual_issuer}") + assert issuer.lower() in actual_issuer.lower() # Ensure cert comes before chain in fullchain.pem def check_fullchain(node, cert_name): - node.succeed( - ( - """openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem \ - | tee /proc/self/fd/2 \ - | openssl pkcs7 -print_certs -noout | head -1 | grep {cert_name} - """ - ).format(cert_name=cert_name) + subject_data = node.succeed( + f"openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem" + " | openssl pkcs7 -print_certs -noout" ) + for line in subject_data.lower().split("\n"): + if "subject" in line: + print(f"First subject in fullchain.pem: ", line) + assert cert_name.lower() in line + return + + assert False - def check_connection(node, domain): - node.succeed( - ( - """openssl s_client -brief -verify 2 -verify_return_error -CAfile /tmp/ca.crt \ - -servername {domain} -connect {domain}:443 < /dev/null 2>&1 \ - | tee /proc/self/fd/2 - """ - ).format(domain=domain) + def check_connection(node, domain, retries=3): + if retries == 0: + assert False + + result = node.succeed( + "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt" + f" -servername {domain} -connect {domain}:443 < /dev/null 2>&1" ) + for line in result.lower().split("\n"): + if "verification" in line and "error" in line: + time.sleep(1) + return check_connection(node, domain, retries - 1) + + + def check_connection_key_bits(node, domain, bits, retries=3): + if retries == 0: + assert False + + result = node.succeed( + "openssl s_client -CAfile /tmp/ca.crt" + f" -servername {domain} -connect {domain}:443 < /dev/null" + " | openssl x509 -noout -text | grep -i Public-Key" + ) + print("Key type:", result) + + if bits not in result: + time.sleep(1) + return check_connection_key_bits(node, domain, bits, retries - 1) + client.start() dnsserver.start() @@ -261,7 +262,6 @@ in import ./make-test-python.nix ({ lib, ... }: { with subtest("Can request certificate with HTTPS-01 challenge"): webserver.wait_for_unit("acme-finished-a.example.test.target") - wait_nginx_reload(webserver) check_fullchain(webserver, "a.example.test") check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.example.test") @@ -273,35 +273,26 @@ in import ./make-test-python.nix ({ lib, ... }: { check_issuer(webserver, "a.example.test", "minica") # Will succeed if nginx can load the certs webserver.succeed("systemctl start nginx-config-reload.service") - wait_nginx_reload(webserver) with subtest("Can reload nginx when timer triggers renewal"): webserver.succeed("systemctl start test-renew-nginx.target") - wait_nginx_reload(webserver) check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.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") - wait_nginx_reload(webserver) - client.succeed( - """openssl s_client -CAfile /tmp/ca.crt -connect a.example.test:443 < /dev/null \ - | openssl x509 -noout -text | grep -i Public-Key | grep 384 - """ - ) + check_connection_key_bits(client, "a.example.test", "384") 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") - wait_nginx_reload(webserver) check_issuer(webserver, "slow.example.com", "pebble") check_connection(client, "slow.example.com") with subtest("Can request certificate for vhost + aliases (nginx)"): switch_to(webserver, "nginx-aliases") webserver.wait_for_unit("acme-finished-a.example.test.target") - wait_nginx_reload(webserver) check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.example.test") check_connection(client, "b.example.test") @@ -309,7 +300,6 @@ in import ./make-test-python.nix ({ lib, ... }: { with subtest("Can request certificates for vhost + aliases (apache-httpd)"): switch_to(webserver, "httpd-aliases") webserver.wait_for_unit("acme-finished-c.example.test.target") - wait_httpd_reload(webserver) check_issuer(webserver, "c.example.test", "pebble") check_connection(client, "c.example.test") check_connection(client, "d.example.test") @@ -318,18 +308,15 @@ in import ./make-test-python.nix ({ lib, ... }: { # 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") - wait_httpd_reload(webserver) check_issuer(webserver, "c.example.test", "minica") webserver.succeed("systemctl start httpd-config-reload.service") webserver.succeed("systemctl start test-renew-httpd.target") - wait_httpd_reload(webserver) 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") webserver.wait_for_unit("acme-finished-example.test.target") - wait_nginx_reload(webserver) check_issuer(webserver, "example.test", "pebble") check_connection(client, "dns.example.test") ''; From 67a5d660cbba42d4461cbc67296bb9e96fd9c74f Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Fri, 4 Sep 2020 18:48:47 +0100 Subject: [PATCH 4/6] nixos/acme: Run postRun script as root --- nixos/modules/security/acme.nix | 24 +++++++++++++----------- nixos/tests/acme.nix | 8 ++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index 51392f6ce885..e209c36cee45 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -168,7 +168,7 @@ let selfsignService = { description = "Generate self-signed certificate for ${cert}"; after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; - wants = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; + requires = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; path = with pkgs; [ minica ]; @@ -232,6 +232,15 @@ let # Only try loading the credentialsFile if the dns challenge is enabled EnvironmentFile = mkIf useDns data.credentialsFile; + + # Run as root (Prefixed with +) + ExecStartPost = "+" + (pkgs.writeShellScript "acme-postrun" '' + cd /var/lib/acme/${escapeShellArg cert} + if [ -e renewed ]; then + rm renewed + ${data.postRun} + fi + ''); }; # Working directory will be /tmp @@ -255,9 +264,8 @@ let # Copy all certs to the "real" certs directory CERT='certificates/${keyName}.crt' - CERT_CHANGED=no if [ -e "$CERT" ] && ! cmp -s "$CERT" out/fullchain.pem; then - CERT_CHANGED=yes + touch out/renewed echo Installing new certificate cp -vp 'certificates/${keyName}.crt' out/fullchain.pem cp -vp 'certificates/${keyName}.key' out/key.pem @@ -265,12 +273,6 @@ let ln -sf fullchain.pem out/cert.pem cat out/key.pem out/fullchain.pem > out/full.pem fi - - if [ "$CERT_CHANGED" = "yes" ]; then - cd out - set +euo pipefail - ${data.postRun} - fi ''; }; }; @@ -344,7 +346,7 @@ let example = "cp full.pem backup.pem"; description = '' Commands to run after new certificates go live. Note that - these commands run as the acme user and configured group. + these commands run as the root user. Executed in the same directory with the new certificate. ''; @@ -648,7 +650,7 @@ in { # Create some targets which can be depended on to be "active" after cert renewals systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" { wantedBy = [ "default.target" ]; - wants = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; + requires = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; after = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; }) certConfigs; }) diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index 90ae06542c4c..223945907da9 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -79,8 +79,15 @@ in import ./make-test-python.nix ({ lib, ... }: { # 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 test + chown root:root test + echo testing > test + ''; }; # Now adding an alias to ensure that the certs are updated @@ -283,6 +290,7 @@ in import ./make-test-python.nix ({ lib, ... }: { 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 /var/lib/acme/a.example.test/test") with subtest("Can request certificate with HTTPS-01 when nginx startup is delayed"): switch_to(webserver, "slow-startup") From f57824c915e350a488b109427351df2757424278 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Fri, 4 Sep 2020 20:28:46 +0100 Subject: [PATCH 5/6] nixos/acme: Update docs, use assert more effectively --- nixos/doc/manual/release-notes/rl-2009.xml | 14 ++++++++++++++ nixos/modules/security/acme.xml | 12 ++++++++++++ nixos/tests/acme.nix | 6 ++---- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2009.xml b/nixos/doc/manual/release-notes/rl-2009.xml index 0b8651e8f426..a19d9bb00509 100644 --- a/nixos/doc/manual/release-notes/rl-2009.xml +++ b/nixos/doc/manual/release-notes/rl-2009.xml @@ -394,6 +394,20 @@ php.override { + + + The ACME module has been overhauled for simplicity and maintainability. + Cert generation now implicitly uses the acme + user, and the security.acme.certs._name_.user option + has been removed. Instead, certificate access from other services is now + managed through group permissions. The module no longer runs lego + twice under certain conditions, and will correctly renew certificates if + their configuration is changed. Services which reload nginx and httpd after + certificate renewal are now properly configured too so you no longer have + to do this manually if you are using HTTPS enabled virtual hosts. A mechanism + for regenerating certs on demand has also been added and documented. + + Gollum received a major update to version 5.x and you may have to change diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml index 005eebd75c01..17e94bc12fb2 100644 --- a/nixos/modules/security/acme.xml +++ b/nixos/modules/security/acme.xml @@ -251,4 +251,16 @@ chmod 400 /var/lib/secrets/certs.secret journalctl -fu acme-example.com.service and watching its log output. +
+ Regenerating certificates + + + Should you need to regenerate a particular certificate in a hurry, such + as when a vulnerability is found in Let's Encrypt, there is now a convenient + mechanism for doing so. Running systemctl clean acme-example.com.service + will remove all certificate files for the given domain, allowing you to then + systemctl start acme-example.com.service to generate fresh + ones. + +
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index 223945907da9..1c83ad3c9d83 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -218,8 +218,7 @@ in import ./make-test-python.nix ({ lib, ... }: { def check_connection(node, domain, retries=3): - if retries == 0: - assert False + assert retries >= 0 result = node.succeed( "openssl s_client -brief -verify 2 -CAfile /tmp/ca.crt" @@ -233,8 +232,7 @@ in import ./make-test-python.nix ({ lib, ... }: { def check_connection_key_bits(node, domain, bits, retries=3): - if retries == 0: - assert False + assert retries >= 0 result = node.succeed( "openssl s_client -CAfile /tmp/ca.crt" From 34b5c5c1a408d105beb9b92b9ed5b1565135e75e Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Fri, 4 Sep 2020 23:39:22 +0100 Subject: [PATCH 6/6] nixos/acme: More features and fixes - Allow for key reuse when domains are the only thing that were changed. - Fixed systemd service failure when preliminarySelfsigned was set to false --- nixos/modules/security/acme.nix | 40 ++++++++++++++++++++------------- nixos/tests/acme.nix | 6 +++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix index e209c36cee45..8e67d4ff8716 100644 --- a/nixos/modules/security/acme.nix +++ b/nixos/modules/security/acme.nix @@ -77,6 +77,7 @@ let acmeServer = if data.server != null then data.server else cfg.server; useDns = data.dnsProvider != null; destPath = "/var/lib/acme/${cert}"; + selfsignedDeps = optionals (cfg.preliminarySelfsigned) [ "acme-selfsigned-${cert}.service" ]; # Minica and lego have a "feature" which replaces * with _. We need # to make this substitution to reference the output files from both programs. @@ -92,19 +93,17 @@ let ); # Create hashes for cert data directories based on configuration + # Flags are separated to avoid collisions hashData = with builtins; '' - ${data.domain} ${data.keyType} - ${concatStringsSep " " ( - extraDomains - ++ data.extraLegoFlags - ++ data.extraLegoRunFlags - ++ data.extraLegoRenewFlags - )} + ${concatStringsSep " " data.extraLegoFlags} - + ${concatStringsSep " " data.extraLegoRunFlags} - + ${concatStringsSep " " data.extraLegoRenewFlags} - ${toString acmeServer} ${toString data.dnsProvider} - ${toString data.ocspMustStaple} + ${toString data.ocspMustStaple} ${data.keyType} ''; mkHash = with builtins; val: substring 0 20 (hashString "sha256" val); certDir = mkHash hashData; + domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}"; othersHash = mkHash "${toString acmeServer} ${data.keyType}"; accountDir = "/var/lib/acme/.lego/accounts/" + othersHash; @@ -134,12 +133,12 @@ let ); renewOpts = escapeShellArgs ( commonOpts - ++ [ "renew" "--reuse-key" "--days" (toString cfg.validMinDays) ] + ++ [ "renew" "--reuse-key" ] ++ data.extraLegoRenewFlags ); in { - inherit accountDir; + inherit accountDir selfsignedDeps; webroot = data.webroot; group = data.group; @@ -208,8 +207,8 @@ let renewService = { description = "Renew ACME certificate for ${cert}"; - after = [ "network.target" "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ]; - wants = [ "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ]; + after = [ "network.target" "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps; + wants = [ "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps; # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ]; @@ -247,15 +246,26 @@ let script = '' set -euo pipefail + echo '${domainHash}' > domainhash.txt + # Check if we can renew if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' ]; then - lego ${renewOpts} + + # 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 + lego ${renewOpts} --days ${toString cfg.validMinDays} + else + # Any number > 90 works, but this one is over 9000 ;-) + lego ${renewOpts} --days 9001 + fi # Otherwise do a full run else lego ${runOpts} fi + mv domainhash.txt certificates/ chmod 640 certificates/* chmod -R 700 accounts/* @@ -650,8 +660,8 @@ in { # Create some targets which can be depended on to be "active" after cert renewals systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" { wantedBy = [ "default.target" ]; - requires = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; - after = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ]; + requires = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps; + after = [ "acme-${cert}.service" ] ++ conf.selfsignedDeps; }) certConfigs; }) ]; diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix index 1c83ad3c9d83..64193ed8498c 100644 --- a/nixos/tests/acme.nix +++ b/nixos/tests/acme.nix @@ -297,11 +297,17 @@ in import ./make-test-python.nix ({ lib, ... }: { 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") + keyhash_old = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem") switch_to(webserver, "nginx-aliases") webserver.wait_for_unit("acme-finished-a.example.test.target") check_issuer(webserver, "a.example.test", "pebble") check_connection(client, "a.example.test") check_connection(client, "b.example.test") + keyhash_new = webserver.succeed("md5sum /var/lib/acme/a.example.test/key.pem") + assert keyhash_old == keyhash_new with subtest("Can request certificates for vhost + aliases (apache-httpd)"): switch_to(webserver, "httpd-aliases")