From 982c5a1f0e7f282f856391304aa4da7bb36c45b8 Mon Sep 17 00:00:00 2001 From: Lucas Savva Date: Fri, 19 Jun 2020 20:27:46 +0100 Subject: [PATCH] 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 { };