From 0540384561541f94435ad0f6e268e6989fb1d37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Bouya?= Date: Wed, 15 Jan 2020 20:41:19 +0100 Subject: Upgrade acme bot --- modules/acme2.nix | 340 +++++++++++++++++++++++++++++++++++++++++++ modules/default.nix | 1 + modules/websites/default.nix | 10 +- 3 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 modules/acme2.nix (limited to 'modules') diff --git a/modules/acme2.nix b/modules/acme2.nix new file mode 100644 index 00000000..408c098e --- /dev/null +++ b/modules/acme2.nix @@ -0,0 +1,340 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.acme2; + + certOpts = { name, ... }: { + options = { + webroot = mkOption { + type = types.str; + example = "/var/lib/acme/acme-challenges"; + description = '' + Where the webroot of the HTTP vhost is located. + .well-known/acme-challenge/ directory + will be created below the webroot if it doesn't exist. + http://example.org/.well-known/acme-challenge/ must also + be available (notice unencrypted HTTP). + ''; + }; + + server = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + ACME Directory Resource URI. Defaults to let's encrypt + production endpoint, + https://acme-v02.api.letsencrypt.org/directory, if unset. + ''; + }; + + domain = mkOption { + type = types.str; + default = name; + description = "Domain to fetch certificate for (defaults to the entry name)"; + }; + + email = mkOption { + type = types.nullOr types.str; + default = null; + 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"; + 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"; + description = '' + Commands to run after new certificates go live. Typically + the web server and other servers using certificates need to + be reloaded. + + Executed in the same directory with the new certificate. + ''; + }; + + plugins = mkOption { + type = types.listOf (types.enum [ + "cert.der" "cert.pem" "chain.pem" "external.sh" + "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json" "account_reg.json" + ]); + default = [ "fullchain.pem" "full.pem" "key.pem" "account_key.json" "account_reg.json" ]; + description = '' + Plugins to enable. With default settings simp_le will + store public certificate bundle in fullchain.pem, + private key in key.pem and those two previous + files combined in full.pem in its state directory. + ''; + }; + + directory = mkOption { + type = types.str; + readOnly = true; + default = "/var/lib/acme/${name}"; + description = "Directory where certificate and other state is stored."; + }; + + extraDomains = mkOption { + type = types.attrsOf (types.nullOr types.str); + default = {}; + example = literalExample '' + { + "example.org" = "/srv/http/nginx"; + "mydomain.org" = null; + } + ''; + description = '' + A list of extra domain names, which are included in the one certificate to be issued, with their + own server roots if needed. + ''; + }; + }; + }; + +in + +{ + + ###### interface + imports = [ + (mkRemovedOptionModule [ "security" "acme2" "production" ] '' + Use security.acme2.server to define your staging ACME server URL instead. + + To use the let's encrypt staging server, use security.acme2.server = + "https://acme-staging-v02.api.letsencrypt.org/directory". + '' + ) + (mkRemovedOptionModule [ "security" "acme2" "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") + ]; + options = { + security.acme2 = { + + validMin = mkOption { + type = types.int; + default = 30 * 24 * 3600; + description = "Minimum remaining validity before renewal in seconds."; + }; + + renewInterval = mkOption { + type = types.str; + default = "weekly"; + description = '' + Systemd calendar expression when to check for renewal. See + systemd.time + 7. + ''; + }; + + server = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + ACME Directory Resource URI. Defaults to let's encrypt + production endpoint, + https://acme-v02.api.letsencrypt.org/directory, if unset. + ''; + }; + + preliminarySelfsigned = mkOption { + type = types.bool; + default = true; + description = '' + Whether a preliminary self-signed certificate should be generated before + doing ACME requests. This can be useful when certificates are required in + a webserver, but ACME needs the webserver to make its requests. + + With preliminary self-signed certificate the webserver can be started and + can later reload the correct ACME certificates. + ''; + }; + + certs = mkOption { + default = { }; + type = with types; attrsOf (submodule certOpts); + description = '' + Attribute set of certificates to get signed and renewed. Creates + acme-''${cert}.{service,timer} systemd units for + each certificate defined here. Other services can add dependencies + to those units if they rely on the certificates being present, + or trigger restarts of the service if certificates get renewed. + ''; + example = literalExample '' + { + "example.com" = { + webroot = "/var/www/challenges/"; + email = "foo@example.com"; + extraDomains = { "www.example.com" = null; "foo.example.com" = "/var/www/foo/"; }; + }; + "bar.example.com" = { + webroot = "/var/www/challenges/"; + email = "bar@example.com"; + }; + } + ''; + }; + }; + }; + + ###### implementation + config = mkMerge [ + (mkIf (cfg.certs != { }) { + + systemd.services = let + services = concatLists servicesLists; + servicesLists = mapAttrsToList certToServices cfg.certs; + certToServices = cert: data: + let + lpath = "acme/${cert}"; + rights = if data.allowKeysForGroup then "750" else "700"; + cmdline = [ "-v" "-d" data.domain "--default_root" data.webroot "--valid_min" cfg.validMin ] + ++ optionals (data.email != null) [ "--email" data.email ] + ++ concatMap (p: [ "-f" p ]) data.plugins + ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains) + ++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)]; + acmeService = { + description = "Renew ACME Certificate for ${cert}"; + after = [ "network.target" "network-online.target" ]; + wants = [ "network-online.target" ]; + # simp_le uses requests, which uses certifi under the hood, + # which doesn't respect the system trust store. + # At least in the acme test, we provision a fake CA, impersonating the LE endpoint. + # REQUESTS_CA_BUNDLE is a way to teach python requests to use something else + environment.REQUESTS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; + serviceConfig = { + Type = "oneshot"; + # With RemainAfterExit the service is considered active even + # after the main process having exited, which means when it + # gets changed, the activation phase restarts it, meaning + # the permissions of the StateDirectory get adjusted + # according to the specified group + RemainAfterExit = true; + SuccessExitStatus = [ "0" "1" ]; + User = data.user; + Group = data.group; + PrivateTmp = true; + StateDirectory = lpath; + StateDirectoryMode = rights; + WorkingDirectory = "/var/lib/${lpath}"; + ExecStart = "${pkgs.simp_le_0_17}/bin/simp_le ${escapeShellArgs cmdline}"; + ExecStartPost = + let + script = pkgs.writeScript "acme-post-start" '' + #!${pkgs.runtimeShell} -e + ${data.postRun} + ''; + in + "+${script}"; + }; + + }; + selfsignedService = { + description = "Create preliminary self-signed certificate for ${cert}"; + path = [ pkgs.openssl ]; + script = + '' + workdir="$(mktemp -d)" + + # 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 /var/lib/${lpath}/key.pem + + # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates) + cat $workdir/{server.crt,ca.crt} > "/var/lib/${lpath}/fullchain.pem" + + # Create full.pem for e.g. lighttpd + cat $workdir/{server.key,server.crt,ca.crt} > "/var/lib/${lpath}/full.pem" + + # Give key acme permissions + chown '${data.user}:${data.group}' "/var/lib/${lpath}/"{key,fullchain,full}.pem + chmod ${rights} "/var/lib/${lpath}/"{key,fullchain,full}.pem + ''; + 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 = "!/var/lib/${lpath}/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 = + flip mapAttrsToList cfg.certs + (cert: data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}"); + + systemd.timers = 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"; + AccuracySec = "5m"; + RandomizedDelaySec = "1h"; + }; + }) + ); + + systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {}; + systemd.targets.acme-certificates = {}; + }) + + ]; + + meta = { + maintainers = with lib.maintainers; [ abbradar fpletz globin ]; + #doc = ./acme.xml; + }; +} diff --git a/modules/default.nix b/modules/default.nix index 9ff6ea62..98dc77d8 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -19,4 +19,5 @@ php-application = ./websites/php-application.nix; websites = ./websites; + acme2 = ./acme2.nix; } // (if builtins.pathExists ./private then import ./private else {}) diff --git a/modules/websites/default.nix b/modules/websites/default.nix index 6ba0d687..e69080e9 100644 --- a/modules/websites/default.nix +++ b/modules/websites/default.nix @@ -149,7 +149,7 @@ in serverAliases = [ "*" ]; enableSSL = false; logFormat = "combinedVhost"; - documentRoot = "${config.security.acme.directory}/acme-challenge"; + documentRoot = "/var/lib/acme/acme-challenge"; extraConfig = '' RewriteEngine on RewriteCond "%{REQUEST_URI}" "!^/\.well-known" @@ -178,9 +178,9 @@ in }; toVhost = ips: vhostConf: { enableSSL = true; - sslServerCert = "${config.security.acme.directory}/${vhostConf.certName}/cert.pem"; - sslServerKey = "${config.security.acme.directory}/${vhostConf.certName}/key.pem"; - sslServerChain = "${config.security.acme.directory}/${vhostConf.certName}/chain.pem"; + sslServerCert = "${config.security.acme2.certs."${vhostConf.certName}".directory}/cert.pem"; + sslServerKey = "${config.security.acme2.certs."${vhostConf.certName}".directory}/key.pem"; + sslServerChain = "${config.security.acme2.certs."${vhostConf.certName}".directory}/chain.pem"; logFormat = "combinedVhost"; listen = map (ip: { inherit ip; port = 443; }) ips; hostName = builtins.head vhostConf.hosts; @@ -223,7 +223,7 @@ in } ) cfg.env; - config.security.acme.certs = let + config.security.acme2.certs = let typesToManage = attrsets.filterAttrs (k: v: v.enable) cfg.env; flatVhosts = lists.flatten (attrsets.mapAttrsToList (k: v: attrValues v.vhostConfs -- cgit v1.2.3