--- /dev/null
+#!/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())
--- /dev/null
+#!/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()
--- /dev/null
+#!/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())
+
--- /dev/null
+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
--- /dev/null
+
+DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org/directory'
+
+KEY_SIZE = 4096
+
+CONTACT = ('mailto:certmaster@siccegge.de',)