Change everything
authorChristoph Egger <christoph@christoph-egger.org>
Sat, 9 Dec 2017 19:57:31 +0000 (20:57 +0100)
committerChristoph Egger <christoph@christoph-egger.org>
Sat, 9 Dec 2017 19:57:31 +0000 (20:57 +0100)
.gitignore [new file with mode: 0644]
bin/newcert
bin/update [new file with mode: 0755]
config/inventory.yaml [deleted file]
sicceggetools/acme/authorize.py [new file with mode: 0644]
sicceggetools/acme/certificate.py [new file with mode: 0644]
sicceggetools/acme/client.py [new file with mode: 0644]
sicceggetools/acme/constants.py
sicceggetools/acme/settings.py [new file with mode: 0644]
sicceggetools/inventory.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..b25c15b
--- /dev/null
@@ -0,0 +1 @@
+*~
index 5b5f9a0..1188cba 100755 (executable)
@@ -8,148 +8,35 @@ from socket import getfqdn
 import argparse
 import logging
 import os.path
-import yaml
 import time
+import sys
 
-from acme import client
 from acme import jose
-from acme import messages
-from acme import challenges
-
-from cryptography.hazmat.backends import default_backend
-from cryptography.hazmat.primitives.asymmetric import rsa
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives import hashes
-from cryptography import x509
-from cryptography.x509.oid import NameOID
 
 import OpenSSL
+import yaml
 
-import pexpect
-
-from sicceggetools.acme import constants
-
-
-def get_client():
-    logging.info("Loading account key")
-    with open("data/account.key.pem", "rb") as keyfd:
-        private_key = serialization.load_pem_private_key(
-            keyfd.read(),
-            password=None,
-            backend=default_backend()
-            )
-
-    logging.info("Loading account registration")
-    with open("data/registration.json", "rb") as regfd:
-        registration = messages.RegistrationResource.json_loads(regfd.read())
-
-    account_key = jose.JWKRSA(key=private_key)
-    acme_client = client.Client(constants.DIRECTORY_URL, account_key)
-
-    return registration, acme_client, account_key
-
-
-def authorize(sans):
-    registration, acme_client, account_key = get_client()
-    authorizations = []
-    for san in sans:
-        authzr = acme_client.request_challenges(
-            identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=san),
-            new_authzr_uri=registration.new_authzr_uri)
-        authorizations.append(authzr)
-        for challenge in authzr.body.challenges:
-            if isinstance(challenge.chall, challenges.DNS01):
-                print(san)
-                print(challenge.validation(account_key))
-                print(challenge.key_authorization(account_key))
-                ssh = pexpect.spawn("ssh _tls@ns1.siccegge.de acme")
-                ssh.expect("Hostname:")
-                ssh.sendline(san)
-                ssh.expect("Value:")
-                ssh.sendline(challenge.validation(account_key))
-                ssh.expect("OK")
-                
-                break
-        else:
-            print("fallthrough")
-
-    time.sleep(5)
-    for authorization in authorizations:
-        for challenge in authorization.body.challenges:
-            if isinstance(challenge.chall, challenges.DNS01):
-                response = challenges.DNS01Response(key_authorization=challenge.key_authorization(account_key))
-                acme_client.answer_challenge(challenge, response)
-
-    while(True):
-        print("sleeping")
-        time.sleep(5)
-        new_authorizations = []
-        for authorization in authorizations:
-            new_auth, response = acme_client.poll(authorization)
-            new_authorizations.append(new_auth)
-            if new_auth.body.status != messages.Status("valid"):
-                break
-        else:
-            return new_authorizations
-
-
-def get_certificate(servicetype, cname, sans):
-    registration, acme_client, account_key = get_client()    
-    authorizations = authorize(sans)
-    
-    with open(os.path.join("certs", servicetype, cname, "key.pem"), "rb") as keyfd:
-        private_key = serialization.load_pem_private_key(
-            keyfd.read(),
-            password=None,
-            backend=default_backend())
-                  
-    builder = x509.CertificateSigningRequestBuilder()
-    builder = builder.subject_name(x509.Name([
-        x509.NameAttribute(NameOID.COMMON_NAME, cname.decode()),
-        ]))
-    builder = builder.add_extension(
-        x509.SubjectAlternativeName([x509.DNSName(x.decode()) for x in sans]),
-        critical=False)
-
-    request = builder.sign(private_key, hashes.SHA512(), default_backend())
-    orequest = OpenSSL.crypto.load_certificate_request(
-        OpenSSL.crypto.FILETYPE_PEM,
-        request.public_bytes(serialization.Encoding.PEM))
-
-    jrequest = jose.util.ComparableX509(orequest)
-    cert = acme_client.request_issuance(jrequest, authorizations)
-    certs = acme_client.fetch_chain(cert)
-
-    with open(os.path.join("certs", servicetype, cname, "cert.pem"), "wb") as certfd:
-        certfd.write(cert.body._dump(OpenSSL.crypto.FILETYPE_PEM))
-        for cert in certs:
-            certfd.write(cert._dump(OpenSSL.crypto.FILETYPE_PEM))
+sys.path.append(os.path.expanduser("~"))
+from sicceggetools.acme.client      import Client
+from sicceggetools.acme.authorize   import authorize
+from sicceggetools.inventory        import Inventory
+from sicceggetools.acme.settings    import Settings
 
-    print(cname)
-    print(sans)
-    print(cert)
-    
 
 def main():
+    logging.getLogger().setLevel(logging.INFO)
+    
     parser = argparse.ArgumentParser()
-
     parser.add_argument('--servicetype', '-s', type=str)
     parser.add_argument('certificate', type=str)
     args = parser.parse_args()
-    
-    with open("config/inventory.yaml") as invfd:
-        inventory = yaml.load(invfd.read())
 
-    certificate_list = inventory[getfqdn()][args.servicetype]
-    if type(certificate_list) is list:
-        if args.certificate in certificate_list:
-            get_certificate(args.servicetype, args.certificate, [args.certificate])
-    elif type(certificate_list) is dict:
-        if args.certificate in certificate_list.keys():
-            get_certificate(args.servicetype, args.certificate, certificate_list[args.certificate])
-    else:
-        print("unexpected type: %s", type(certificate_list))
-    
-        
+    inventory = Inventory("config/inventory.yaml")
+    settings = Settings("config/settings.yaml")
+
+    client = Client(inventory, settings);
+    client.get_certificate(args.certificate, args.servicetype)
+
+
 if __name__ == '__main__':
     main()
diff --git a/bin/update b/bin/update
new file mode 100755 (executable)
index 0000000..ae6f208
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# (C) Christoph Egger <christoph@christoph-egger.org>
+
+from __future__ import print_function
+
+import glob
+import datetime
+import logging
+import sys
+import os
+
+from IPython import embed
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+
+sys.path.append(os.path.expanduser("~"))
+from sicceggetools.inventory      import Inventory
+from sicceggetools.acme.settings  import Settings
+from sicceggetools.acme.constants import SERVICETYPES
+from sicceggetools.acme.client    import Client
+
+def find_old_certificates():
+    now = datetime.datetime.now()
+    result = dict()
+    for stype in SERVICETYPES:
+        result[stype] = []
+        for cert in glob.glob("certs/%s/*/cert.pem" % stype):
+            with open(cert) as pem:
+                certdata = x509.load_pem_x509_certificate(pem.read(), default_backend())
+    
+                if (certdata.not_valid_after - now) < datetime.timedelta(days=30):
+                    for attribute in certdata.subject:
+                        if attribute.oid == x509.OID_COMMON_NAME:
+                            result[stype].append((cert, attribute.value))
+                            break
+    
+    return result
+
+
+
+def main():
+    logging.getLogger().setLevel(logging.INFO)
+    
+    # parser = argparse.ArgumentParser()
+    # parser.add_argument('--servicetype', '-s', type=str)
+    # parser.add_argument('certificate', type=str)
+    # args = parser.parse_args()
+
+    inventory = Inventory("config/inventory.yaml")
+    settings = Settings("config/settings.yaml")
+
+    oldcerts = find_old_certificates()
+    
+    for stype in SERVICETYPES:
+        for path, name in oldcerts[stype]:
+            print(path, name)
+
+            client = Client(inventory, settings);
+            client.get_certificate(name, stype)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/config/inventory.yaml b/config/inventory.yaml
deleted file mode 100644 (file)
index da56f6b..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-gandi.siccegge.de:
-  www:
-       siccegge.de:
-         - siccegge.de
-         - static.siccegge.de
-         - www.siccegge.de
-       pim.siccegge.de:
-         - kalender.egger.im
-         - pim.siccegge.de
-         - radicale.siccegge.de
-       frida.xyz:
-         - doc.frida.xyz
-         - frida.xyz
-         - www.frida.xyz
-       avatars.siccegge.de:
-         - avatars.siccegge.de
-       fsinf.fau.fail:
-         - fsinf.fau.fail
-       git.siccegge.de:
-         - git.siccegge.de
-       katarakt.faui2k9.de:
-         - katarakt.faui2k9.de
-       pdfgrep.org:
-         - pdfgrep.org
-         - www.pdfgrep.org
-       planet.faui2k9.de:
-         - planet.faui2k9.de
-       privat.egger.im:
-         - privat.egger.im
-       taz.siccegge.de:
-         - taz.siccegge.de
-       weblog.siccegge.de:
-         - weblog.siccegge.de
-       fau.fail:
-         - fau.fail
-         - www.fau.fail
-  xmpp:
-       conference.faui2k9.de:
-         - faui2k9.de
-         - conference.faui2k9.de
-         - egger.im
-         - conference.egger.im
-  smtp:
-       mx4.siccegge.de:
-         - mx4.siccegge.de
-         - gandi.siccegge.de
-
-chadwick.siccegge.de:
-  smtp:
-       mx2.siccegge.de:
-         - mx2.siccegge.de
-         - chadwick.siccegge.de
-  www:
-       lists.faui2k9.de:
-         - lists.christoph-egger.org
-         - lists.faui2k9.de
-         - lists.frida.xyz
-         - lists.pdfgrep.org
-       tt-rss.faui2k9.de:
-         - tt-rss.faui2k9.de
-
-1und1.siccegge.de:
-  smtp:
-       mx3.siccegge.de:
-         - mx3.siccegge.de
-         - 1und1.siccegge.de
-  imap:
-       mx3.siccegge.de:
-         - mx3.siccegge.de
-  www:
-       1und1.siccegge.de:
-         - 1und1.siccegge.de
-       jbn-bobingen.de:
-         - backend.jbn-bobingen.de
-         - jbn-bobingen.de
-         - www.jbn-bobingen.de
-       christoph-egger.org:
-         - christoph-egger.org
-         - www.christoph-egger.org
-       poll.faui2k9.de:
-         - poll.faui2k9.de
-       stats.siccegge.de:
-         - stats.coders-nemesis.eu
-         - stats.siccegge.de
-       meetings.christoph-egger.org:
-         - meetings.christoph-egger.org
-
-oteiza.siccegge.de:
-  smtp:
-       mx1.siccegge.de:
-         - mx1.siccegge.de
-         - oteiza.siccegge.de
-  imap:
-       imap.siccegge.de:
-         - imap.siccegge.de
-         - oteiza.siccegge.de
-
-bistolfi.siccegge.de:
-  www:
-       wot.siccegge.de:
-         - wot.christoph-egger.org
-         - wot.siccegge.de
-       annex.siccegge.de:
-         - annex.siccegge.de
-       projects.faui2k9.de:
-         - projects.faui2k9.de
-         - userdata.projects.faui2k9.de
-
-phabricator.siccegge.de:
-  www:
-       projects.faui2k9.de:
-         - projects.faui2k9.de
-         - userdata.projects.faui2k9.de
diff --git a/sicceggetools/acme/authorize.py b/sicceggetools/acme/authorize.py
new file mode 100644 (file)
index 0000000..3fe4bd2
--- /dev/null
@@ -0,0 +1,85 @@
+#!/usr/bin/python
+
+from functools import partial
+import logging
+import os.path
+import time
+
+import pexpect
+
+from acme import messages
+from acme import challenges
+
+
+def _authorize_dns01(san, validation):
+    logging.info("Using DNS-01 for %s", san)
+    ssh = pexpect.spawn("ssh _tls@ns1.siccegge.de acme")
+    ssh.expect("Hostname:")
+    ssh.sendline(san)
+    ssh.expect("Value:")
+    ssh.sendline(validation)
+    ssh.expect("OK")
+
+
+def _authorize_http01(san, key_auth):
+    logging.info("Using HTTP-01 for %s", san)
+    with open(os.path.join('/srv/tls/http-01/', key_auth.split('.')[0]), 'w') as fd:
+        fd.write(key_auth)
+
+
+def _authorize_challenge(san, thechallenges, client, settings=None):
+    _, acme_client, account_key = client
+    responsefun = None
+
+    for challenge in thechallenges:
+        if settings.use_method("HTTP01", san, settings) and isinstance(challenge.chall, challenges.HTTP01):
+            def _response(challenge):
+                response = challenges.HTTP01Response(key_authorization=challenge.key_authorization(account_key))
+                acme_client.answer_challenge(challenge, response)
+
+            _authorize_http01(san, challenge.key_authorization(account_key))
+            responsefun = partial(_response, challenge)
+
+        elif settings.use_method("DNS01", san, settings) and isinstance(challenge.chall, challenges.DNS01):
+            def _response(challenge):
+                response = challenges.DNS01Response(key_authorization=challenge.key_authorization(account_key))
+                acme_client.answer_challenge(challenge, response)
+
+            _authorize_dns01(san, challenge.validation(account_key))
+            responsefun = partial(_response, challenge)
+
+    return responsefun
+
+
+def authorize(sans, client, settings=None):
+    registration, acme_client, _ = client
+    authorizations = []
+    responsefuns = []
+
+    for san in sans:
+        authzr = acme_client.request_challenges(
+            identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=san),
+            new_authzr_uri=registration.new_authzr_uri)
+        authorizations.append(authzr)
+
+        result = _authorize_challenge(san, authzr.body.challenges, client, settings)
+        if result is None:
+            logging.warn("fallthrough")
+        else:
+            responsefuns.append(result)
+
+    time.sleep(5)
+    for respfun in responsefuns:
+        respfun()
+
+    while True:
+        logging.info("sleeping")
+        time.sleep(5)
+        new_authorizations = []
+        for authorization in authorizations:
+            new_auth, _ = acme_client.poll(authorization)
+            new_authorizations.append(new_auth)
+            if new_auth.body.status != messages.Status("valid"):
+                break
+        else:
+            return new_authorizations
diff --git a/sicceggetools/acme/certificate.py b/sicceggetools/acme/certificate.py
new file mode 100644 (file)
index 0000000..4ff1496
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/python
+
+import logging
+import os
+import os.path
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives import serialization
+from cryptography.x509.oid import NameOID
+
+import OpenSSL
+
+class Certificate:
+    def __init__(self, servicetype, name, sans):
+        self._name = name
+        self._sans = sans
+        self._servicetype = servicetype
+        self._basename = os.path.join("certs", servicetype, name)
+        if os.path.exists(os.path.join(self._basename, "key.pem")):
+            self._from_private_key()
+        elif os.path.exists(os.path.join(self._basename, "csr.pem")):
+            self._from_csr()
+        else:
+            self._from_scratch()
+
+
+    def _from_private_key(self):
+        with open(os.path.join(self._basename, "key.pem"), "rb") as keyfd:
+            private_key = serialization.load_pem_private_key(
+                keyfd.read(),
+                password=None,
+                backend=default_backend())
+
+            builder = x509.CertificateSigningRequestBuilder()
+            builder = builder.subject_name(x509.Name([
+                x509.NameAttribute(NameOID.COMMON_NAME, self._name.decode()),
+                ]))
+            builder = builder.add_extension(
+                x509.SubjectAlternativeName([x509.DNSName(x.decode()) for x in self._sans]),
+                critical=False)
+
+            request = builder.sign(private_key, hashes.SHA512(), default_backend())
+            self._requeststring = request.public_bytes(serialization.Encoding.PEM)
+
+
+    def _from_csr(self):
+        if os.path.exists(os.path.join(self._basename, "csr.pem")):
+            with open(os.path.join(self._basename, "csr.pem"), "rb") as csrfd:
+                self._requeststring = csrfd.read()
+
+
+    def _from_scratch(self):
+        raise NotImplementedError("Key generation is currently not implemented")
+
+
+    def asString(self):
+        return self._requeststring
+
+
+    def save(self, certificate, chain):
+        with open(os.path.join(self._basename, "cert.pem"), "wb") as certfd:
+            certfd.write(certificate.body._dump(OpenSSL.crypto.FILETYPE_PEM))
+            for cert in chain:
+                certfd.write(cert._dump(OpenSSL.crypto.FILETYPE_PEM))
+
diff --git a/sicceggetools/acme/client.py b/sicceggetools/acme/client.py
new file mode 100644 (file)
index 0000000..12b15fe
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/python
+
+import logging
+from socket import getfqdn
+
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+
+from acme import client
+from acme import jose
+from acme import messages
+
+import OpenSSL
+
+from .            import constants
+from .authorize   import authorize
+from .certificate import Certificate
+
+
+
+class Client(object):
+    def __init__(self, inventory, settings):
+        self._inventory = inventory
+        self._settings = settings
+        self._client = None
+
+
+    def _get_client(self):
+        if self._client is None:
+            logging.info("Loading account key")
+            with open("data/account.key.pem", "rb") as keyfd:
+                private_key = serialization.load_pem_private_key(
+                    keyfd.read(),
+                    password=None,
+                    backend=default_backend()
+                    )
+    
+            logging.info("Loading account registration")
+            with open("data/registration.json", "rb") as regfd:
+                registration = messages.RegistrationResource.json_loads(regfd.read())
+    
+            account_key = jose.JWKRSA(key=private_key)
+            acme_client = client.Client(constants.DIRECTORY_URL, account_key)
+            self._client = registration, acme_client, account_key
+    
+        return self._client
+
+
+    def get_certificate(self, cname, servicetype):
+        sans = self._inventory.get_sans(getfqdn(), servicetype, cname)
+        
+        _, acme_client, _ = self._get_client()
+        authorizations = authorize(sans, self._get_client(), self._settings)
+        certificate = Certificate(servicetype, cname, sans)
+
+        orequest = OpenSSL.crypto.load_certificate_request(
+            OpenSSL.crypto.FILETYPE_PEM, certificate.asString())
+
+        jrequest = jose.util.ComparableX509(orequest)
+        cert = acme_client.request_issuance(jrequest, authorizations)
+        chain = acme_client.fetch_chain(cert)
+
+        certificate.save(cert, chain)
+
+        logging.info("CName: %s", cname)
+        logging.info("SANs: %s", sans)
index b8a5c70..076c906 100644 (file)
@@ -1,6 +1,5 @@
 
 DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org/directory'
-
 KEY_SIZE = 4096
-
 CONTACT = ('mailto:certmaster@siccegge.de',)
+SERVICETYPES = ('xmpp', 'www', 'smtp', 'imap')
diff --git a/sicceggetools/acme/settings.py b/sicceggetools/acme/settings.py
new file mode 100644 (file)
index 0000000..5c0f246
--- /dev/null
@@ -0,0 +1,29 @@
+#!/usr/bin/python
+
+import logging
+import yaml
+
+
+
+class Settings:
+    def __init__(self, path='config/settings.yaml'):
+        with open(path) as invfd:
+            self._settings = yaml.load(invfd.read())
+
+
+    def use_method(self, method, san, settings):
+        authmethods = self._settings['authorization']
+        
+        for thismethod, data in authmethods.items():
+            for host in data['hosts']:
+                if not san.endswith(host):
+                    continue
+        
+                return thismethod == method
+        
+        if '*' in authmethods[method]['hosts']:
+            return True
+        
+        return False
+
+
diff --git a/sicceggetools/inventory.py b/sicceggetools/inventory.py
new file mode 100644 (file)
index 0000000..628d95b
--- /dev/null
@@ -0,0 +1,16 @@
+#!/usr/bin/python
+
+import logging
+import yaml
+
+
+class Inventory:
+    def __init__(self, path='config/inventory.yaml'):
+        with open(path) as invfd:
+            self._inventory = yaml.load(invfd.read())
+
+
+    def get_sans(self, hostname, servicetype, servicename):
+        servicedict = self._inventory[hostname]
+        servicesdict = servicedict[servicetype]
+        return servicesdict[servicename]