acme_tlsa_mail.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. #! /usr/bin/env python3
  2. # -*- coding: utf8 -*-
  3. """
  4. Takes care of updating the Let's encrypt certificate for dovecot and postfix.
  5. It also updates the TLSA records for the entries relevant to
  6. the mail server automatically.
  7. Requirements:
  8. * python 3.x
  9. * module OpenSSL
  10. * module dnspython
  11. """
  12. import socket
  13. import logging
  14. import datetime
  15. import os
  16. import hashlib
  17. import shutil
  18. import subprocess
  19. import OpenSSL
  20. import dns.tsigkeyring
  21. import dns.update
  22. import dns.query
  23. HOSTNAME = socket.gethostname()
  24. FQDN = socket.getfqdn()
  25. SERVICES = {
  26. "postfix": {"cert": "/etc/postfix/%s.crt" % HOSTNAME,
  27. "key": "/etc/postfix/%s.key" % HOSTNAME,
  28. "ports": [25, 465, 587]},
  29. "dovecot": {"cert": "/usr/share/ssl/certs/dovecot.pem",
  30. "key": "/usr/share/ssl/private/dovecot.pem",
  31. "ports": [993]}
  32. }
  33. DOMAIN = "%s.%s" % (FQDN.split(".")[-2], FQDN.split(".")[-1])
  34. # State directory for acmetool, default is /var/lib/acme
  35. ACME_STATE_DIR = "/var/lib/acme"
  36. # Log level, default is info
  37. LOG_LEVEL = logging.INFO
  38. LOGGER = logging.getLogger("acme_tlsa_mail")
  39. LOGGER.setLevel(LOG_LEVEL)
  40. CONSOLE_HANDLER = logging.StreamHandler()
  41. LOGGER.addHandler(CONSOLE_HANDLER)
  42. # Path to the bind9 session key
  43. NAMED_SESSION_KEY = "/run/named/session.key"
  44. # Address of the DNS server, defaults to localhost
  45. DNS_SERVER = "localhost"
  46. def check_renewal(cert):
  47. """
  48. Checks if the certificate has been renewed.
  49. :param cert: the certificate that needs to be checked
  50. :type cert: OpenSSL.crypto.X509
  51. :return: renewal status
  52. :rtype: bool
  53. """
  54. acme_cert_path = os.path.join(ACME_STATE_DIR, "live", FQDN, "cert")
  55. try:
  56. with open(acme_cert_path, "r") as acme_cert_file:
  57. acme_cert_text = acme_cert_file.read()
  58. except IOError:
  59. LOGGER.error("Could not open certificate for %s in acme "
  60. "state directory", FQDN)
  61. else:
  62. x509_acme_cert = OpenSSL.crypto.load_certificate(
  63. OpenSSL.crypto.FILETYPE_PEM, acme_cert_text
  64. )
  65. expiry_date = x509_acme_cert.get_notAfter().decode("utf-8")
  66. expiry_datetime = parse_asn1_time(expiry_date)
  67. if expiry_datetime < datetime.datetime.utcnow():
  68. LOGGER.warning("Certificate for %s is expired and no newer "
  69. "one is available, bailing out!", FQDN)
  70. return False
  71. else:
  72. serial_current_cert = cert.get_serial_number()
  73. serial_acme_cert = x509_acme_cert.get_serial_number()
  74. if serial_current_cert == serial_acme_cert:
  75. LOGGER.debug("Cert for %s matches with the one "
  76. "installed, nothing to do.", FQDN)
  77. return False
  78. else:
  79. return True
  80. def parse_asn1_time(timestamp):
  81. """
  82. parses an ANS1 timestamp as returned by OpenSSL and turns it into a python
  83. datetime object.
  84. :param timestamp: ASN1 timestamp
  85. :type timestamp: str
  86. :return: timestamp as datetime object
  87. :rtype: datetime
  88. """
  89. year = int(timestamp[:4])
  90. month = int(timestamp[4:6])
  91. day = int(timestamp[6:8])
  92. date = datetime.datetime(year, month, day)
  93. return date
  94. def create_tlsa_hash(cert):
  95. """
  96. Creates an tlsa 3 1 1 hash to create TLSA records for a given certificate
  97. :param cert: certificate to be used
  98. :type cert: OpenSSL.crypto.X509
  99. :return: sha256 has of the public key
  100. :rtype: str
  101. """
  102. pubkey = cert.get_pubkey()
  103. pubkey_der = OpenSSL.crypto.dump_publickey(OpenSSL.crypto.FILETYPE_ASN1,
  104. pubkey)
  105. sha256 = hashlib.sha256()
  106. sha256.update(pubkey_der)
  107. hexdigest = sha256.hexdigest()
  108. return hexdigest
  109. def copy_file(source, destination):
  110. """
  111. Copies a file from the given source file to
  112. the given destionation and creates a copy of the
  113. source file.
  114. :param source: source file path
  115. :type source: str
  116. :param destination: destionation file path
  117. :type destination: str
  118. :return: success
  119. :rtype: bool
  120. """
  121. backup_file = source + ".bak_%s" % datetime.datetime.now().strftime(
  122. "%Y%m%d%H%M%S")
  123. try:
  124. shutil.copy(source, backup_file)
  125. except IOError:
  126. LOGGER.error("Creating of backup file for %s failed!", source)
  127. return False
  128. else:
  129. try:
  130. shutil.copy(source, destination)
  131. except IOError:
  132. LOGGER.error("Copying of file %s to %s failed!",
  133. source, destination)
  134. else:
  135. os.chmod(destination, 0o0644)
  136. os.chown(destination, 0, 0)
  137. return True
  138. def get_tsig_key():
  139. """
  140. Reads the named session key and generates a keyring object for it.
  141. :return: keyring, algorithm
  142. :rtype: tuple
  143. """
  144. key_name = None
  145. key_algorithm = None
  146. secret = None
  147. try:
  148. with open(NAMED_SESSION_KEY, "r") as bind_key:
  149. for line in bind_key:
  150. if "key" in line:
  151. key_name = line.split(" ")[1].strip("\"")
  152. elif "algorithm" in line:
  153. key_algorithm = line.strip().split(" ")[1].strip(";")
  154. elif "secret" in line:
  155. secret = line.strip().split(" ")[1].strip("\"").strip(";")
  156. except IOError:
  157. LOGGER.error("Error while opening the bind session key")
  158. return None, None
  159. else:
  160. if key_name and key_algorithm and secret:
  161. keyring = dns.tsigkeyring.from_text({
  162. key_name: secret
  163. })
  164. return keyring, key_algorithm
  165. else:
  166. return None, None
  167. def update_tlsa_record(zone, tlsa_port, digest, keyring, keyalgorithm,
  168. subdomain="", ttl=300, protocol="tcp"):
  169. """
  170. Updates the tlsa record on the DNS server.
  171. :param zone: Zone of the (sub) domain
  172. :type zone: str
  173. :param tlsa_port: port for the tlsa record
  174. :type tlsa_port: str
  175. :param digest: cryptographic hash of the certificate public key
  176. :type digest: str
  177. :param keyring: keyring object
  178. :type keyring: dict
  179. :param keyalgorithm: algorithm used for the tsig key
  180. :type keyalgorithm: str
  181. :param subdomain: subdomain to create the tlsa record for
  182. :type subdomain: str
  183. :param ttl: TTL to use for the TLSA record
  184. :type ttl: int
  185. :param protocol: protocol for the TLSA record
  186. :type protocol: str
  187. :returns: response of the operation
  188. :rtype: dns.message.Message
  189. """
  190. update = dns.update.Update(zone, keyring=keyring,
  191. keyalgorithm=keyalgorithm)
  192. tlsa_content = "3 1 1 %s" % digest
  193. if subdomain:
  194. tlsa_record = "_%s._%s.%s." % (tlsa_port, protocol, subdomain)
  195. else:
  196. tlsa_record = "_%s._%s.%s." % (tlsa_port, protocol, zone)
  197. update.replace(tlsa_record, ttl, "tlsa", tlsa_content)
  198. response = dns.query.tcp(update, 'localhost')
  199. return response
  200. if __name__ == "__main__":
  201. TSIG, KEYALGO = get_tsig_key()
  202. for service in SERVICES:
  203. try:
  204. with open(SERVICES[service]["cert"], "r") as service_cert_file:
  205. service_cert_text = service_cert_file.read()
  206. except IOError:
  207. LOGGER.error("Error while opening the postfix certificate")
  208. else:
  209. service_cert = OpenSSL.crypto.load_certificate(
  210. OpenSSL.crypto.FILETYPE_PEM, service_cert_text
  211. )
  212. if check_renewal(service_cert):
  213. newcert_path = os.path.join(ACME_STATE_DIR, "live", FQDN,
  214. "fullchain")
  215. try:
  216. with open(newcert_path, "r") as new_cert_file:
  217. new_cert_text = new_cert_file.read()
  218. except IOError:
  219. LOGGER.error("Error while opening new %s certificate file",
  220. service)
  221. else:
  222. new_cert = OpenSSL.crypto.load_certificate(
  223. OpenSSL.crypto.FILETYPE_PEM, new_cert_text
  224. )
  225. hash_digest = create_tlsa_hash(new_cert)
  226. for port in SERVICES[service]["ports"]:
  227. update_tlsa_record(DOMAIN, port, hash_digest, TSIG,
  228. KEYALGO, FQDN)
  229. if copy_file(newcert_path, SERVICES[service]["cert"]):
  230. newkey_path = os.path.join(ACME_STATE_DIR, "live",
  231. FQDN, "privkey")
  232. if copy_file(newkey_path, SERVICES[service]["key"]):
  233. LOGGER.info("Certificate for %s successfully "
  234. "renewed, restarting service.",
  235. service)
  236. subprocess.call(["/etc/init.d/%s"
  237. % service, "restart"])
  238. else:
  239. LOGGER.error("Renewal of cert for %s failed, "
  240. "please clean up manually and "
  241. "check the backup files!", service)
  242. else:
  243. LOGGER.error("Renewal of cert for %s failed, "
  244. "please clean up manually and "
  245. "check the backup files!", service_cert)