]> git.immae.eu Git - perso/Immae/Config/Nix/NUR.git/commitdiff
Upgrade acme bot
authorIsmaël Bouya <ismael.bouya@normalesup.org>
Wed, 15 Jan 2020 19:41:19 +0000 (20:41 +0100)
committerIsmaël Bouya <ismael.bouya@normalesup.org>
Fri, 24 Apr 2020 22:04:41 +0000 (00:04 +0200)
modules/acme2.nix [new file with mode: 0644]
modules/default.nix
modules/websites/default.nix
pkgs/certbot/default.nix [new file with mode: 0644]
pkgs/default.nix
pkgs/simp_le/default.nix [new file with mode: 0644]

diff --git a/modules/acme2.nix b/modules/acme2.nix
new file mode 100644 (file)
index 0000000..408c098
--- /dev/null
@@ -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.
+          <filename>.well-known/acme-challenge/</filename> directory
+          will be created below the webroot if it doesn't exist.
+          <literal>http://example.org/.well-known/acme-challenge/</literal> 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
+          (<option>security.acme2.cert.&lt;name&gt;.group</option>) 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 <filename>fullchain.pem</filename>,
+          private key in <filename>key.pem</filename> and those two previous
+          files combined in <filename>full.pem</filename> 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
+          <citerefentry><refentrytitle>systemd.time</refentrytitle>
+          <manvolnum>7</manvolnum></citerefentry>.
+        '';
+      };
+
+      server = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          ACME Directory Resource URI. Defaults to let's encrypt
+          production endpoint,
+          <literal>https://acme-v02.api.letsencrypt.org/directory</literal>, 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
+          <literal>acme-''${cert}.{service,timer}</literal> 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;
+  };
+}
index 9ff6ea62a2096f4f7f0b02439407d7911ad25a59..98dc77d8c34ff3e7678e958bd8338e88eab1fcdb 100644 (file)
@@ -19,4 +19,5 @@
 
   php-application = ./websites/php-application.nix;
   websites = ./websites;
+  acme2 = ./acme2.nix;
 } // (if builtins.pathExists ./private then import ./private else {})
index 6ba0d687d2069c38b08a8df81c0b087914a1edb4..e69080e9dc2ae14c43f785796c8379468e5aeb9c 100644 (file)
@@ -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 (file)
index 0000000..8fdbfd1
--- /dev/null
@@ -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;
+  };
+}
index 82be20e55e54a610af00542dc3b674aeafae11e9..54868ba9aaa51b593baaa931b066bd6a90f92c41 100644 (file)
@@ -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 (file)
index 0000000..eaefba3
--- /dev/null
@@ -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;
+  };
+}