Browse Source

implement and document the apache module

Helmut Pozimski 7 years ago
parent
commit
4c6bcc9273
7 changed files with 317 additions and 7 deletions
  1. 2 0
      .gitignore
  2. 22 2
      README.md
  3. 22 0
      acme-updater
  4. 143 0
      amulib/apache.py
  5. 61 4
      amulib/helpers.py
  6. 56 1
      amulib/main.py
  7. 11 0
      example/config.json

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+.idea
+*.pyc

+ 22 - 2
README.md

@@ -11,7 +11,15 @@ Current development is focused on Debian 8 jessie with sysvinit because that's w
 
 ## Usage
 
-TBD
+acme-updater can be called with the following command line arguments:
+
+* --apache
+* --postfix
+* --dovecot
+* --ejabberd
+* --config / -C
+
+The ones corresponding to service names enable the module for the service to take care of maintaining the certificates for it. The config argument expects a path to the configuration file as parameter.
 
 
 ## Dependencies
@@ -27,8 +35,20 @@ This software is published under the GNU GENERAL PUBLIC LICENSE, version 2.
 
 ## CONFIGURATION
 
-TBD
+The configuration file is a json file which contains a json object with the keys "loglevel", "acme_dir" and "named_key_path". The first one defines the loglevel to use. The second one defines the acme state dir which can be used to obtain the current certificates, the last one the path to the named session key which is needed to write TLSA records via nsupdate. The other keys in the object correspond to the names of the services and their modules. Each service configuration is another json object and the structure of these might differ by service. They are documented in the section for the modules.
+ 
+An example configuration file with all parameters in provided in example/config.json.
+
+### Apache
+
+This module accepts the following configuration parameters:
+
+* vhosts_dir: the directory the active vhosts can be found in
+* tlsa: whether to write tlsa records for the domains
+* exclude_vhosts: vhosts that should be excluded from management
+* tlsa_exclude: domains that should not receive a TLSA record
 
+The module will parse all vhosts, determine if they use a Let's encrypt certificate and manage it if this is the case and the vhost isn't excluded.
 ## INSTALLATION
 
 Use the setup.py to perform the installation, this requires the setuptools module.

+ 22 - 0
acme-updater

@@ -1,6 +1,28 @@
 #! /usr/bin/env python3
 # -*- coding: utf8 -*-
 
+#   acme-updater - a tool that takes care of automatically replacing
+#   Let's Encrypt SSL certificates maintained by acmetool
+#
+#   written by Helmut Pozimski 2016-2017
+#
+#   This program is free software; you can redistribute it and/or
+#   modify it under the terms of the GNU General Public License
+#   as published by the Free Software Foundation; version 2
+#   of the License.
+#
+#   This program is distributed in the hope that it will be useful,
+#   but WITHOUT ANY WARRANTY; without even the implied warranty of
+#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#   GNU General Public License for more details.
+#
+#   You should have received a copy of the GNU General Public License
+#   along with this program; if not, write to the Free Software
+#   Foundation, Inc., 51 Franklin Street, Fifth Floor,
+#   Boston, MA 02110-1301, USA.
+
+""" Script for acme-updater which executes the main function. """
+
 from amulib.main import main
 
 if __name__ == "__main__":

+ 143 - 0
amulib/apache.py

@@ -0,0 +1,143 @@
+#   This file is part of acme-updater, written by Helmut Pozimski 2016-2017.
+#
+#   stov is free software: you can redistribute it and/or modify
+#   it under the terms of the GNU General Public License as published by
+#   the Free Software Foundation, version 2 of the License.
+#
+#   stov is distributed in the hope that it will be useful,
+#   but WITHOUT ANY WARRANTY; without even the implied warranty of
+#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#   GNU General Public License for more details.
+#
+#   You should have received a copy of the GNU General Public License
+#   along with stov.  If not, see <http://www.gnu.org/licenses/>.
+
+
+# -*- coding: utf8 -*-
+
+"""
+Contains the apache module which takes care of maintaining the apache 2 SSL
+certificates.
+"""
+
+import logging
+import os
+import datetime
+import subprocess
+
+from amulib import helpers
+import OpenSSL
+
+LOGGER = logging.getLogger("acme-updater")
+
+
+def run(config=None, acme_dir="/var/lib/acme",
+        named_key_path="/run/named/session.key"):
+    """
+    Main method of the apache module, actually replaces the certificates,
+    manages the service and writes TLSA records if necessary.
+
+    :param config: configuration for the module.
+    :type config: dict
+    :param acme_dir: path to the acme state dir
+    :type acme_dir: str
+    :param named_key_path: path to the named session.key
+    :type named_key_path: str
+    """
+    cert_renewed = False
+    parsed_vhosts = []
+    if config:
+        vhosts_dir = config["vhosts_dir"]
+        tlsa = config["tlsa"]
+        exclude_vhosts = config["exclude_vhosts"]
+        tlsa_exclude = config["tlsa_exclude"]
+    else:
+        # Default parameters based on best guesses
+        vhosts_dir = "/etc/apache2/sites-enabled"
+        tlsa = False
+        exclude_vhosts = []
+        tlsa_exclude = []
+
+    for vhost in os.listdir(vhosts_dir):
+        if vhost.endswith(".conf") and vhost not in exclude_vhosts:
+            vhost_absolute = os.path.join(vhosts_dir, vhost)
+            with open(vhost_absolute, "r") as vhost_file:
+                parsed_vhosts.extend(helpers.parse_apache_vhost(vhost_file))
+
+    for entry in parsed_vhosts:
+        try:
+            with open(entry[1], "r") as cert_file:
+                cert_text = cert_file.read()
+        except IOError:
+            LOGGER.error("Error while opening cert file %s ", entry[1])
+        else:
+            x509_current_cert = OpenSSL.crypto.load_certificate(
+                OpenSSL.crypto.FILETYPE_PEM, cert_text)
+            if "Let's Encrypt" in x509_current_cert.get_issuer().__str__():
+                acme_cert_path = os.path.join(acme_dir, "live", entry[0],
+                                              "cert")
+                try:
+                    with open(acme_cert_path, "r") as acme_cert_file:
+                        acme_cert_text = acme_cert_file.read()
+                except IOError:
+                    LOGGER.error("Could not open certificate for %s in acme "
+                                 "state directory", entry[0])
+                else:
+                    x509_acme_cert = OpenSSL.crypto.load_certificate(
+                        OpenSSL.crypto.FILETYPE_PEM, acme_cert_text
+                    )
+                    expiry_date = \
+                        x509_acme_cert.get_notAfter().decode("utf-8")
+                    expiry_datetime = helpers.parse_asn1_time(expiry_date)
+                    if expiry_datetime < datetime.datetime.utcnow():
+                        LOGGER.warning(
+                            "Certificate for %s is expired and no newer "
+                            "one is available, bailing out!", entry[0])
+                    else:
+                        serial_current_cert = \
+                            x509_current_cert.get_serial_number()
+                        serial_acme_cert = x509_acme_cert.get_serial_number()
+                        if serial_current_cert == serial_acme_cert:
+                            LOGGER.debug("Cert for %s matches with the one "
+                                         "installed, nothing to do.", entry[1])
+                        else:
+                            if tlsa:
+                                for domain in entry[3]:
+                                    if domain not in tlsa_exclude:
+                                        helpers.create_tlsa_records(
+                                            domain, "443", x509_acme_cert,
+                                        named_key_path)
+                            if helpers.copy_file(acme_cert_path, entry[1]):
+                                acme_key_path = os.path.join(acme_dir,
+                                                             "live", entry[0],
+                                                             "privkey")
+                                if helpers.copy_file(acme_key_path, entry[2]):
+                                    LOGGER.info(
+                                        "Successfully renewed cert for %s",
+                                        entry[0])
+                                    cert_renewed = True
+                                else:
+                                    LOGGER.error(
+                                        "Renewal of cert for %s failed, "
+                                        "please clean up manually and "
+                                        "check the backup files!",
+                                        entry[0])
+                            else:
+                                LOGGER.error("Renewal of cert for %s failed, "
+                                             "please clean up manually and "
+                                             "check the backup files!",
+                                             entry[0])
+    if cert_renewed:
+        LOGGER.debug("Checking apache configuration")
+        try:
+            subprocess.check_call(["/usr/sbin/apache2ctl", "-t"])
+        except subprocess.CalledProcessError:
+            LOGGER.error("Error in apache configuration, will not restart the "
+                         "web server")
+        else:
+            try:
+                subprocess.check_call(["/etc/init.d/apache2", "restart"])
+            except subprocess.CalledProcessError:
+                LOGGER.error("Apache restart failed!")
+            else:
+                LOGGER.info("Apache restarted successfully")

+ 61 - 4
amulib/helpers.py

@@ -1,7 +1,22 @@
+#   This file is part of acme-updater, written by Helmut Pozimski 2016-2017.
+#
+#   stov is free software: you can redistribute it and/or modify
+#   it under the terms of the GNU General Public License as published by
+#   the Free Software Foundation, version 2 of the License.
+#
+#   stov is distributed in the hope that it will be useful,
+#   but WITHOUT ANY WARRANTY; without even the implied warranty of
+#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#   GNU General Public License for more details.
+#
+#   You should have received a copy of the GNU General Public License
+#   along with stov.  If not, see <http://www.gnu.org/licenses/>.
+
+# -*- coding: utf8 -*-
+
 """
 collection of helper functions used in other modules of acme-updater.
 """
-# -*- coding: utf8 -*-
 
 import logging
 import datetime
@@ -20,7 +35,8 @@ LOGGER = logging.getLogger("acme-updater")
 def parse_apache_vhost(file_obj):
     """
     Parses a given vhost file and extracts the main domain,
-    the certificate file and the TLS key file.
+    the certificate file, the TLS key file and all domains contained
+    within the vhost.
 
     :param file_obj: file obj pointing to a vhost to parse
     :return: list of tuples with domains and found certificates
@@ -31,13 +47,14 @@ def parse_apache_vhost(file_obj):
     cert_path = ""
     key_path = ""
     main_domain = ""
+    domains = set()
     for line in file_obj:
         if "<VirtualHost" in line:
             vhost_started = True
         elif "</VirtualHost" in line and vhost_started:
             vhost_started = False
-            if cert_path and key_path and main_domain:
-                parsed_info.append((main_domain, cert_path, key_path))
+            if cert_path and key_path and main_domain and domains:
+                parsed_info.append((main_domain, cert_path, key_path, domains))
                 LOGGER.debug(
                     "Found vhost with main domain %s, certificate %s and key "
                     "file %s", main_domain, cert_path, key_path)
@@ -46,10 +63,14 @@ def parse_apache_vhost(file_obj):
                 main_domain = ""
         elif "ServerName" in line:
             main_domain = line.strip().rsplit()[1]
+            domains.add(line.strip().rsplit()[1])
         elif "SSLCertificateFile" in line:
             cert_path = line.strip().rsplit()[1]
         elif "SSLCertificateKeyFile" in line:
             key_path = line.strip().rsplit()[1]
+        elif "ServerAlias" in line:
+            for domain in line.strip().rsplit()[1].split(" "):
+                domains.add(domain)
     return parsed_info
 
 
@@ -228,3 +249,39 @@ def update_tlsa_record(zone, tlsa_port, digest, keyring, keyalgorithm,
     update.replace(tlsa_record, ttl, "tlsa", tlsa_content)
     response = dns.query.tcp(update, 'localhost')
     return response
+
+
+def get_log_level(input_level=""):
+    """
+    Determines the log level to use based on a string.
+
+    :param input_level: String representing the desired log level.
+    :type input_level: str
+    :return: corresponding log level of the logging module
+    :rtype: int
+    """
+    if input_level.lower() == "debug":
+        return logging.DEBUG
+    elif input_level.lower() == "error":
+        return logging.ERROR
+    else:
+        return logging.INFO
+
+
+def create_tlsa_records(domain, port, certificate, named_key_path):
+    """
+    Creates tlsa records for the specified (sub-)domain
+
+    :param domain: (sub-)domain the records are to be created for
+    :type domain: str
+    :param port: port to use for the record
+    :type port: str
+    :param certificate: certificate object used for record creation
+    :type certificate: OpenSSL.crypto.X509
+    :param named_key_path: path to the named session key
+    :type named_key_path: str
+    """
+    hash_digest = create_tlsa_hash(certificate)
+    zone = "%s.%s" % (domain.split(".")[-2], domain.split(".")[-1])
+    tsig, keyalgo = get_tsig_key(named_key_path)
+    update_tlsa_record(zone, port, hash_digest, tsig, keyalgo, domain)

+ 56 - 1
amulib/main.py

@@ -1,11 +1,66 @@
+#   This file is part of acme-updater, written by Helmut Pozimski 2016-2017.
+#
+#   stov is free software: you can redistribute it and/or modify
+#   it under the terms of the GNU General Public License as published by
+#   the Free Software Foundation, version 2 of the License.
+#
+#   stov is distributed in the hope that it will be useful,
+#   but WITHOUT ANY WARRANTY; without even the implied warranty of
+#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#   GNU General Public License for more details.
+#
+#   You should have received a copy of the GNU General Public License
+#   along with stov.  If not, see <http://www.gnu.org/licenses/>.
+
+
 # -*- coding: utf8 -*-
 
+""" Main function of acme-updater, parses command line arguments, sets up
+logging and executes the modules.
+"""
+
 import argparse
+import logging
+import json
+import sys
+
+from amulib.helpers import get_log_level
+from amulib import apache
 
 
 def main():
     """
     Main function of acme-updater.
     """
+    config = None
     parser = argparse.ArgumentParser()
-    parser.parse_args()
+    parser.add_argument("--apache", help="use the apache module",
+                        action="store_true")
+    parser.add_argument("--postfix", help="use the postfix module",
+                        action="store_true")
+    parser.add_argument("--dovecot", help="use the dovecot module",
+                        action="store_true")
+    parser.add_argument("--ejabberd", help="use the ejabberd module",
+                        action="store_true")
+    parser.add_argument("--config", "-C", help="path to the configuration "
+                                               "file", type=str)
+    args = parser.parse_args()
+    logger = logging.getLogger("acme-updater")
+    logger.addHandler(logging.StreamHandler())
+    if args.config:
+        try:
+            with open(args.config, "r") as fobj:
+                config = json.load(fobj)
+        except IOError:
+            logger.error("Error: Could not open configuration file")
+            sys.exit(1)
+        except json.JSONDecodeError:
+            logger.error("Error: Could not parse configuration file")
+            sys.exit(1)
+    logger.setLevel(get_log_level(config["loglevel"]))
+    if args.apache:
+        if config:
+            apache.run(config["apache"], config["acme_dir"],
+                       config["named_key_path"])
+        else:
+            apache.run()

+ 11 - 0
example/config.json

@@ -0,0 +1,11 @@
+{
+  "loglevel": "info",
+  "acme_dir": "/var/lib/acme",
+  "named_key_path": "/run/named/session.key",
+  "apache": {
+    "vhosts_dir": "/etc/apache2/sites-enabled",
+    "tlsa": false,
+    "exclude_vhosts": [],
+    "tlsa_exclude": []
+  }
+}