acme_updater.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #! /usr/bin/env python3
  2. """
  3. This script scans all apache vhosts on the system, checks if they use
  4. letsencrypt certificates for which acmetool has renewed the certificate and
  5. replaces the old certificate with the new one automatically. This way,
  6. acmetool can run as an unprivileged user and acme-updater can take
  7. care of swapping out certificates while running as root.
  8. Requirements:
  9. * python 3.x
  10. * module OpenSSL
  11. """
  12. import os
  13. import logging
  14. import datetime
  15. import shutil
  16. import subprocess
  17. import OpenSSL
  18. # directory for enabled apache vhosts, might be different on your distribution
  19. VHOSTS_DIRECTORY = "/etc/apache2/sites-enabled"
  20. # Log level, default is info
  21. LOG_LEVEL = logging.INFO
  22. # State directory for acmetool, default is /var/lib/acme
  23. ACME_STATE_DIR = "/var/lib/acme"
  24. LOGGER = logging.getLogger("acme-updater")
  25. LOGGER.setLevel(LOG_LEVEL)
  26. CONSOLE_HANDLER = logging.StreamHandler()
  27. LOGGER.addHandler(CONSOLE_HANDLER)
  28. PARSED_VHOSTS = []
  29. CERT_RENEWED = False
  30. def parse_vhost(file_obj):
  31. """
  32. Parses a given vhost file and extracts the main domain,
  33. the certificate file and the TLS key file.
  34. :param file_obj: file obj pointing to a vhost to parse
  35. :return: list of tuples with domains and found certificates
  36. :rtype: list
  37. """
  38. vhost_started = False
  39. parsed_info = []
  40. cert_path = ""
  41. key_path = ""
  42. main_domain = ""
  43. for line in file_obj:
  44. if "<VirtualHost" in line:
  45. vhost_started = True
  46. elif "</VirtualHost" in line and vhost_started:
  47. vhost_started = False
  48. if cert_path and key_path and main_domain:
  49. parsed_info.append((main_domain, cert_path, key_path))
  50. LOGGER.debug(
  51. "Found vhost with main domain %s, certificate %s and key "
  52. "file %s", main_domain, cert_path, key_path)
  53. cert_path = ""
  54. key_path = ""
  55. main_domain = ""
  56. elif "ServerName" in line:
  57. main_domain = line.strip().rsplit()[1]
  58. elif "SSLCertificateFile" in line:
  59. cert_path = line.strip().rsplit()[1]
  60. elif "SSLCertificateKeyFile" in line:
  61. key_path = line.strip().rsplit()[1]
  62. return parsed_info
  63. def parse_asn1_time(timestamp):
  64. """
  65. parses an ANS1 timestamp as returned by OpenSSL and turns it into a python
  66. datetime object.
  67. :param timestamp: ASN1 timestamp
  68. :type timestamp: str
  69. :return: timestamp as datetime object
  70. :rtype: datetime
  71. """
  72. year = int(timestamp[:4])
  73. month = int(timestamp[4:6])
  74. day = int(timestamp[6:8])
  75. date = datetime.datetime(year, month, day)
  76. return date
  77. def copy_file(source, destination):
  78. """
  79. Copies a file from the given source file to
  80. the given destionation and creates a copy of the
  81. source file.
  82. :param source: source file path
  83. :type source: str
  84. :param destination: destionation file path
  85. :type destination: str
  86. :return: success
  87. :rtype: bool
  88. """
  89. backup_file = source + ".bak_%s" % datetime.datetime.now().strftime(
  90. "%Y%m%d%H%M%S")
  91. try:
  92. shutil.copy(source, backup_file)
  93. except IOError:
  94. LOGGER.error("Creating of backup file for %s failed!", source)
  95. return False
  96. else:
  97. try:
  98. shutil.copy(source, destination)
  99. except IOError:
  100. LOGGER.error("Copying of file %s to %s failed!",
  101. source, destination)
  102. else:
  103. os.chmod(destination, 0o0644)
  104. os.chown(destination, 0, 0)
  105. return True
  106. for vhost in os.listdir(VHOSTS_DIRECTORY):
  107. if vhost.endswith(".conf"):
  108. vhost_absolute = os.path.join(VHOSTS_DIRECTORY, vhost)
  109. with open(vhost_absolute, "r") as vhost_file:
  110. PARSED_VHOSTS.extend(parse_vhost(vhost_file))
  111. for entry in PARSED_VHOSTS:
  112. try:
  113. with open(entry[1], "r") as cert_file:
  114. cert_text = cert_file.read()
  115. except IOError:
  116. LOGGER.error("Error while opening cert file %s ", entry[1])
  117. else:
  118. x509_current_cert = OpenSSL.crypto.load_certificate(
  119. OpenSSL.crypto.FILETYPE_PEM, cert_text)
  120. if "Let's Encrypt" in x509_current_cert.get_issuer().__str__():
  121. acme_cert_path = os.path.join(ACME_STATE_DIR, "live", entry[0],
  122. "cert")
  123. try:
  124. with open(acme_cert_path, "r") as acme_cert_file:
  125. acme_cert_text = acme_cert_file.read()
  126. except IOError:
  127. LOGGER.error("Could not open certificate for %s in acme "
  128. "state directory", entry[0])
  129. else:
  130. x509_acme_cert = OpenSSL.crypto.load_certificate(
  131. OpenSSL.crypto.FILETYPE_PEM, acme_cert_text
  132. )
  133. expiry_date = x509_acme_cert.get_notAfter().decode("utf-8")
  134. expiry_datetime = parse_asn1_time(expiry_date)
  135. if expiry_datetime < datetime.datetime.utcnow():
  136. LOGGER.warn("Certificate for %s is expired and no newer "
  137. "one is available, bailing out!", entry[0])
  138. else:
  139. serial_current_cert = x509_current_cert.get_serial_number()
  140. serial_acme_cert = x509_acme_cert.get_serial_number()
  141. if serial_current_cert == serial_acme_cert:
  142. LOGGER.debug("Cert for %s matches with the one "
  143. "installed, nothing to do.", entry[1])
  144. elif serial_acme_cert > serial_current_cert:
  145. if copy_file(acme_cert_path, entry[1]):
  146. acme_key_path = os.path.join(ACME_STATE_DIR,
  147. "live", entry[0],
  148. "privkey")
  149. if copy_file(acme_key_path, entry[2]):
  150. LOGGER.info("Successfully renewed cert for %s",
  151. entry[0])
  152. CERT_RENEWED = True
  153. else:
  154. LOGGER.error("Renewal of cert for %s failed, "
  155. "please clean up manually and "
  156. "check the backup files!")
  157. else:
  158. LOGGER.error("Renewal of cert for %s failed, "
  159. "please clean up manually and "
  160. "check the backup files!")
  161. if CERT_RENEWED:
  162. LOGGER.debug("Checking apache configuration")
  163. try:
  164. subprocess.check_call(["apache2ctl", "-t"])
  165. except subprocess.CalledProcessError:
  166. LOGGER.error("Error in apache configuration, will not restart the "
  167. "web server")
  168. else:
  169. try:
  170. subprocess.check_call(["/etc/init.d/apache2", "restart"])
  171. except subprocess.CalledProcessError:
  172. LOGGER.error("Apache restart failed!")
  173. else:
  174. LOGGER.info("Apache restarted successfully")