acme_tlsa_mail.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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, backup=True):
  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: destination file path
  117. :type destination: str
  118. :param backup: whether to take a backup of the destination file before \
  119. overwriting it
  120. :type backup: bool
  121. :return: success
  122. :rtype: bool
  123. """
  124. backup_file = destination + ".bak_%s" % datetime.datetime.now().strftime(
  125. "%Y%m%d%H%M%S")
  126. if backup:
  127. try:
  128. shutil.copy(destination, backup_file)
  129. except IOError:
  130. LOGGER.error("Creating of backup file for %s failed!", destination)
  131. return False
  132. try:
  133. shutil.copy(source, destination)
  134. except IOError:
  135. LOGGER.error("Copying of file %s to %s failed!",
  136. source, destination)
  137. else:
  138. os.chmod(destination, 0o0644)
  139. os.chown(destination, 0, 0)
  140. return True
  141. def get_tsig_key():
  142. """
  143. Reads the named session key and generates a keyring object for it.
  144. :return: keyring, algorithm
  145. :rtype: tuple
  146. """
  147. key_name = None
  148. key_algorithm = None
  149. secret = None
  150. try:
  151. with open(NAMED_SESSION_KEY, "r") as bind_key:
  152. for line in bind_key:
  153. if "key" in line:
  154. key_name = line.split(" ")[1].strip("\"")
  155. elif "algorithm" in line:
  156. key_algorithm = line.strip().split(" ")[1].strip(";")
  157. elif "secret" in line:
  158. secret = line.strip().split(" ")[1].strip("\"").strip(";")
  159. except IOError:
  160. LOGGER.error("Error while opening the bind session key")
  161. return None, None
  162. else:
  163. if key_name and key_algorithm and secret:
  164. keyring = dns.tsigkeyring.from_text({
  165. key_name: secret
  166. })
  167. return keyring, key_algorithm
  168. else:
  169. return None, None
  170. def update_tlsa_record(zone, tlsa_port, digest, keyring, keyalgorithm,
  171. subdomain="", ttl=300, protocol="tcp"):
  172. """
  173. Updates the tlsa record on the DNS server.
  174. :param zone: Zone of the (sub) domain
  175. :type zone: str
  176. :param tlsa_port: port for the tlsa record
  177. :type tlsa_port: str
  178. :param digest: cryptographic hash of the certificate public key
  179. :type digest: str
  180. :param keyring: keyring object
  181. :type keyring: dict
  182. :param keyalgorithm: algorithm used for the tsig key
  183. :type keyalgorithm: str
  184. :param subdomain: subdomain to create the tlsa record for
  185. :type subdomain: str
  186. :param ttl: TTL to use for the TLSA record
  187. :type ttl: int
  188. :param protocol: protocol for the TLSA record
  189. :type protocol: str
  190. :returns: response of the operation
  191. :rtype: dns.message.Message
  192. """
  193. update = dns.update.Update(zone, keyring=keyring,
  194. keyalgorithm=keyalgorithm)
  195. tlsa_content = "3 1 1 %s" % digest
  196. if subdomain:
  197. tlsa_record = "_%s._%s.%s." % (tlsa_port, protocol, subdomain)
  198. else:
  199. tlsa_record = "_%s._%s.%s." % (tlsa_port, protocol, zone)
  200. update.replace(tlsa_record, ttl, "tlsa", tlsa_content)
  201. response = dns.query.tcp(update, 'localhost')
  202. return response
  203. if __name__ == "__main__":
  204. TSIG, KEYALGO = get_tsig_key()
  205. for service in SERVICES:
  206. try:
  207. with open(SERVICES[service]["cert"], "r") as service_cert_file:
  208. service_cert_text = service_cert_file.read()
  209. except IOError:
  210. LOGGER.error("Error while opening the postfix certificate")
  211. else:
  212. service_cert = OpenSSL.crypto.load_certificate(
  213. OpenSSL.crypto.FILETYPE_PEM, service_cert_text
  214. )
  215. if check_renewal(service_cert):
  216. newcert_path = os.path.join(ACME_STATE_DIR, "live", FQDN,
  217. "fullchain")
  218. try:
  219. with open(newcert_path, "r") as new_cert_file:
  220. new_cert_text = new_cert_file.read()
  221. except IOError:
  222. LOGGER.error("Error while opening new %s certificate file",
  223. service)
  224. else:
  225. new_cert = OpenSSL.crypto.load_certificate(
  226. OpenSSL.crypto.FILETYPE_PEM, new_cert_text
  227. )
  228. hash_digest = create_tlsa_hash(new_cert)
  229. for port in SERVICES[service]["ports"]:
  230. update_tlsa_record(DOMAIN, port, hash_digest, TSIG,
  231. KEYALGO, FQDN)
  232. if copy_file(newcert_path, SERVICES[service]["cert"]):
  233. newkey_path = os.path.join(ACME_STATE_DIR, "live",
  234. FQDN, "privkey")
  235. if copy_file(newkey_path, SERVICES[service]["key"]):
  236. LOGGER.info("Certificate for %s successfully "
  237. "renewed, restarting service.",
  238. service)
  239. subprocess.call(["/etc/init.d/%s"
  240. % service, "restart"])
  241. else:
  242. LOGGER.error("Renewal of cert for %s failed, "
  243. "please clean up manually and "
  244. "check the backup files!", service)
  245. else:
  246. LOGGER.error("Renewal of cert for %s failed, "
  247. "please clean up manually and "
  248. "check the backup files!", service)