Initial import
authorChristoph Egger <christoph@christoph-egger.org>
Thu, 27 Oct 2016 22:23:16 +0000 (00:23 +0200)
committerChristoph Egger <christoph@christoph-egger.org>
Thu, 27 Oct 2016 22:23:16 +0000 (00:23 +0200)
bin/addrecord [new file with mode: 0644]
bin/newcert [new file with mode: 0755]
bin/newhost [new file with mode: 0755]
config/inventory.yaml [new file with mode: 0644]
sicceggetools/__init__.py [new file with mode: 0644]
sicceggetools/acme/__init__.py [new file with mode: 0644]
sicceggetools/acme/constants.py [new file with mode: 0644]

diff --git a/bin/addrecord b/bin/addrecord
new file mode 100644 (file)
index 0000000..cf2c573
--- /dev/null
@@ -0,0 +1,135 @@
+#!/usr/bin/python3
+#
+# ssh-based server to allow hosts to update their own TLS-related RRs
+# intended to be used like
+#   command="/srv/tls/bin/addrecord hepworth.siccegge.de" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOIlLx3R+Q5LgBZbJ6USuzam/uAEQITl6vzOn/ylk4fq christoph@mitoraj
+
+from subprocess import call
+from functools import reduce
+import datetime
+import fcntl
+import os
+import re
+import sys
+import yaml
+
+class Addrecord:
+    def __init__(self, host):
+        self._host = host
+        self._ports = {'www': [443],
+                       'smtp': [25, 587, 465],
+                       'imap': [993, 143],
+                       'pop': [110, 995],
+                       'xmpp': [5222, 5269],
+                      }
+        self._tlsa_re = re.compile('TLSA 3 1 1 [0-9a-f]{64}')
+        self._acme_re = re.compile(r'[a-zA-Z0-9_-]{43}')
+        with open('config/inventory.yaml') as inv:
+            self._inventory = yaml.load(inv)
+
+
+    def tlsa(self):
+        host = input("Hostname: ")
+        service = input("Service: ")
+        value = input("Value: ")
+        if host not in self._allowed_names(service):
+            sys.stderr.write("Not authorized to update entries for service '%s' on '%s'\n"
+                              % (service, host))
+            return 1
+
+        if re.fullmatch(self._tlsa_re, value) is None:
+            sys.stderr.write("Not a valid TLSA record: '%s'\n" % value)
+            return 2
+
+        records = []
+        for port in self._ports[service]:
+            records.append("{0:<35s}\tIN\t{1}.\n".format("_%d._tcp.%s" % (port, host),
+                                                    value))
+        self._update_records('tlsa', host, records)
+        print("OK")
+        return 0
+
+
+    def acme(self):
+        host = input("Hostname: ")
+        value = input("Value: ")
+
+        if host not in self._allowed_names():
+            sys.stderr.write("Not authorized to update entries for host '%s'\n" % host)
+            return 1
+
+        if re.fullmatch(self._acme_re, value) is None:
+            sys.stderr.write("Not a valid ACME challenge record: '%s'\n" % value)
+            return 2
+
+        records = [ "{0:<35s}.\tIN\tTXT\t\"{1}\"\n".format("_acme-challenge.%s" % host, value) ]
+        self._update_records('acme', host, records)
+        print("OK")
+        return 0
+
+
+    def _update_records(self, sort, host, records):
+        def find_zone(host):
+            assert len(host) != 0
+            candidate = os.path.join(sort, '.'.join(host + ['m4']))
+            if os.path.exists(candidate):
+                return candidate
+            else:
+                return find_zone(host[1:])
+
+
+        to_remove = [ i.split()[0] for i in records ]
+
+        zone = find_zone(host.split('.'))
+        with open(zone, 'r') as oldzone:
+            fcntl.flock(oldzone, fcntl.LOCK_EX)
+            lines = oldzone.readlines()
+            lines = [ line for line in lines if line == '\n' or line.split()[0] not in to_remove ]
+
+            lines.append('\n')
+            lines.append("; Last updated %s by %s\n" % (datetime.datetime.utcnow().isoformat(),
+                                                        self._host))
+            lines = lines + records
+
+            with open('%s.new' % (zone,), 'w') as newzone:
+                newtext = ''.join(lines)
+                newtext = re.sub(r'\n[\n]+', '\n\n', newtext)
+                newtext = re.sub(r'\n;.*\n\n;', '\n;', newtext)
+                newzone.write(newtext)
+
+            os.rename('%s.new' % (zone,),
+                      zone)
+            fcntl.flock(oldzone, fcntl.LOCK_UN)
+        call(["ssh", "root@localhost"])
+
+
+    def _allowed_names(self, service=None):
+        if service is None:
+            perservice = self._inventory[self._host].values()
+        else:
+            perservice = self._inventory[self._host][service]
+
+        names = set()
+        for entry in perservice:
+            if type(entry) is list:
+                names = names.union(entry)
+            elif type(entry) is dict:
+                names = names.union(reduce(lambda x, y: x + y, entry.values(), []))
+            else:
+                sys.stderr.write("inventory format wrong\n")
+        return names
+
+
+def main():
+    command = os.environ['SSH_ORIGINAL_COMMAND']
+    host = sys.argv[1]
+    addrecord = Addrecord(host)
+
+    if command == 'acme':
+        return addrecord.acme()
+    elif command == 'tlsa':
+        return addrecord.tlsa()
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/bin/newcert b/bin/newcert
new file mode 100755 (executable)
index 0000000..6817b70
--- /dev/null
@@ -0,0 +1,155 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# (C) Christoph Egger <christoph@christoph-egger.org>
+
+from __future__ import print_function
+
+from socket import getfqdn
+import argparse
+import logging
+import os.path
+import yaml
+import time
+
+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 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(cname, sans):
+    registration, acme_client, account_key = get_client()    
+    authorizations = authorize(sans)
+    
+    with open(os.path.join("certs", 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", 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))
+
+    print(cname)
+    print(sans)
+    print(cert)
+    
+
+def main():
+    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.certificate, [args.certificate])
+    elif type(certificate_list) is dict:
+        if args.certificate in certificate_list.keys():
+            get_certificate(args.certificate, certificate_list[args.certificate])
+    else:
+        print("unexpected type: %s", type(certificate_list))
+    
+        
+if __name__ == '__main__':
+    main()
diff --git a/bin/newhost b/bin/newhost
new file mode 100755 (executable)
index 0000000..b6122b1
--- /dev/null
@@ -0,0 +1,63 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# (C) Christoph Egger <christoph@christoph-egger.org>
+
+from __future__ import print_function
+
+import os.path
+import logging
+
+from acme import client
+from acme import jose
+from acme import messages
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+
+from sicceggetools.acme import constants
+
+logging.basicConfig()
+logging.getLogger().setLevel(logging.INFO)
+
+if not os.path.exists("data"):
+    logging.info("Creating data directory")
+    os.mkdir("data")
+    os.chmod("data", 0700)
+    
+
+if not os.path.exists("data/account.key.pem"):
+    logging.info("Creating account key")
+    private_key = rsa.generate_private_key(
+        public_exponent=65537,
+        key_size=constants.KEY_SIZE,
+        backend=default_backend()
+        )
+
+    pem = private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.TraditionalOpenSSL,
+        encryption_algorithm=serialization.NoEncryption()
+        )
+
+    with open("data/account.key.pem", "wb") as keyfd:
+        keyfd.write(pem)
+else:
+    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()
+            )
+
+if not os.path.exists("data/registration.json"):
+    logging.info("registering")
+    acmeclient = client.Client(constants.DIRECTORY_URL, jose.JWKRSA(key=private_key))
+    registration = messages.NewRegistration(contact=constants.CONTACT)
+    registration = acmeclient.register(registration)
+    registration = acmeclient.agree_to_tos(registration)
+
+    with open("data/registration.json", "wb") as regfd:
+        regfd.write(registration.json_dumps_pretty())
+    
diff --git a/config/inventory.yaml b/config/inventory.yaml
new file mode 100644 (file)
index 0000000..21c2dc6
--- /dev/null
@@ -0,0 +1,103 @@
+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
+  xmpp:
+    egger.im:
+      - egger.im
+      - conference.egger.im
+    faui2k9.de:
+      - faui2k9.de
+      - conference.faui2k9.de
+  smtp:
+    - mx4.siccegge.de
+    
+chadwick.siccegge.de:
+  smtp:
+    - mx2.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
+  www:
+    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
+
+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
+
+hepworth.siccegge.de:
+  www:
+    hepworth.siccegge.de:
+      - hepworth.siccegge.de
+      - www.hepworth.siccegge.de
+      - test.hepworth.siccegge.de
+  smtp:
+    - hepworth.siccegge.de
\ No newline at end of file
diff --git a/sicceggetools/__init__.py b/sicceggetools/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/sicceggetools/acme/__init__.py b/sicceggetools/acme/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/sicceggetools/acme/constants.py b/sicceggetools/acme/constants.py
new file mode 100644 (file)
index 0000000..f419b9d
--- /dev/null
@@ -0,0 +1,6 @@
+
+DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org/directory'
+
+KEY_SIZE = 4096
+
+CONTACT = ('mailto:certmaster@siccegge.de',)