#!/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 import os import sys import re import datetime import fcntl 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('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._inventory[self._host][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}\t{1}\n".format("_%d._tcp.%s" % (port, host), value)) self._update_records('tlsa', records) print("OK") return 0 def acme(self): host = input("Hostname: ") value = input("Value: ") allowed_hosts = set() for value in self._inventory[self._host].values(): allowed_hosts = allowed_hosts.union(value) if host not in allowed_hosts: 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}\t{1}\n".format("_acme-challenge.%s" % host, value) ] self._update_records('acme', records) print("OK") return 0 def _update_records(self, sort, records): to_remove = [ i.split()[0] for i in records ] with open('%s/%s.m4.lock' % (sort, self._host), 'w') as lockfd: fcntl.flock(lockfd, fcntl.LOCK_EX) with open('%s/%s.m4' % (sort, self._host), 'r') as oldzone: 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/%s.m4.new' % (sort, self._host), '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/%s.m4.new' % (sort, self._host), '%s/%s.m4' % (sort, self._host)) # forced-command is make -C ... # rebuilds the actual zone file subprocess.call(["ssh", "opendnssec@localhost"]) fcntl.flock(lockfd, fcntl.LOCK_UN) 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())