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 +- pkgs/certbot/default.nix | 65 +++++++++ pkgs/default.nix | 3 + pkgs/simp_le/default.nix | 32 ++++ 6 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 modules/acme2.nix create mode 100644 pkgs/certbot/default.nix create mode 100644 pkgs/simp_le/default.nix 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 diff --git a/pkgs/certbot/default.nix b/pkgs/certbot/default.nix new file mode 100644 index 00000000..8fdbfd12 --- /dev/null +++ b/pkgs/certbot/default.nix @@ -0,0 +1,65 @@ +{ stdenv, python37Packages, fetchFromGitHub, fetchurl, dialog, autoPatchelfHook }: + + +python37Packages.buildPythonApplication rec { + pname = "certbot"; + version = "1.0.0"; + + src = fetchFromGitHub { + owner = pname; + repo = pname; + rev = "v${version}"; + sha256 = "180x7gcpfbrzw8k654s7b5nxdy2yg61lq513dykyn3wz4gssw465"; + }; + + patches = [ + ./0001-Don-t-use-distutils.StrictVersion-that-cannot-handle.patch + ]; + + propagatedBuildInputs = with python37Packages; [ + ConfigArgParse + acme + configobj + cryptography + distro + josepy + parsedatetime + psutil + pyRFC3339 + pyopenssl + pytz + six + zope_component + zope_interface + ]; + + buildInputs = [ dialog ] ++ (with python37Packages; [ mock gnureadline ]); + + checkInputs = with python37Packages; [ + pytest_xdist + pytest + dateutil + ]; + + postPatch = '' + cd certbot + substituteInPlace certbot/_internal/notify.py --replace "/usr/sbin/sendmail" "/run/wrappers/bin/sendmail" + ''; + + postInstall = '' + for i in $out/bin/*; do + wrapProgram "$i" --prefix PYTHONPATH : "$PYTHONPATH" \ + --prefix PATH : "${dialog}/bin:$PATH" + done + ''; + + doCheck = true; + + meta = with stdenv.lib; { + homepage = src.meta.homepage; + description = "ACME client that can obtain certs and extensibly update server configurations"; + platforms = platforms.unix; + maintainers = [ maintainers.domenkozar ]; + license = licenses.asl20; + }; +} diff --git a/pkgs/default.nix b/pkgs/default.nix index 82be20e5..54868ba9 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -48,6 +48,9 @@ rec { naemon = callPackage ./naemon { inherit mylibs monitoring-plugins; }; naemon-livestatus = callPackage ./naemon-livestatus { inherit mylibs naemon; }; + simp_le_0_17 = callPackage ./simp_le {}; + certbot = callPackage ./certbot {}; + private = if builtins.pathExists (./. + "/private") then import ./private { inherit pkgs; } else { webapps = {}; }; diff --git a/pkgs/simp_le/default.nix b/pkgs/simp_le/default.nix new file mode 100644 index 00000000..eaefba36 --- /dev/null +++ b/pkgs/simp_le/default.nix @@ -0,0 +1,32 @@ +{ stdenv, python3Packages, bash }: + +python3Packages.buildPythonApplication rec { + pname = "simp_le-client"; + version = "0.17.0"; + + src = python3Packages.fetchPypi { + inherit pname version; + sha256 = "0m1jynar4calaffp2zdxr5yy9vnhw2qf2hsfxwzfwf8fqb5h7bjb"; + }; + + postPatch = '' + # drop upper bound of idna requirement + sed -ri "s/'(idna)<[^']+'/'\1'/" setup.py + substituteInPlace simp_le.py \ + --replace "/bin/sh" "${bash}/bin/sh" + ''; + + checkPhase = '' + $out/bin/simp_le --test + ''; + + propagatedBuildInputs = with python3Packages; [ acme setuptools_scm josepy idna ]; + + meta = with stdenv.lib; { + homepage = https://github.com/zenhack/simp_le; + description = "Simple Let's Encrypt client"; + license = licenses.gpl3; + maintainers = with maintainers; [ gebner makefu ]; + platforms = platforms.linux; + }; +} -- cgit v1.2.3