make addrecord executable
[tooling/letool.git] / bin / addrecord
1 #!/usr/bin/python3
2 #
3 # ssh-based server to allow hosts to update their own TLS-related RRs
4 # intended to be used like
5 # command="/srv/tls/bin/addrecord hepworth.siccegge.de" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOIlLx3R+Q5LgBZbJ6USuzam/uAEQITl6vzOn/ylk4fq christoph@mitoraj
6
7 from subprocess import call
8 from functools import reduce
9 import datetime
10 import fcntl
11 import os
12 import re
13 import sys
14 import yaml
15
16 class Addrecord:
17 def __init__(self, host):
18 self._host = host
19 self._ports = {'www': [443],
20 'smtp': [25, 587, 465],
21 'imap': [993, 143],
22 'pop': [110, 995],
23 'xmpp': [5222, 5269],
24 }
25 self._tlsa_re = re.compile('TLSA 3 1 1 [0-9a-f]{64}')
26 self._acme_re = re.compile(r'[a-zA-Z0-9_-]{43}')
27 with open('config/inventory.yaml') as inv:
28 self._inventory = yaml.load(inv)
29
30
31 def tlsa(self):
32 host = input("Hostname: ")
33 service = input("Service: ")
34 value = input("Value: ")
35 if host not in self._allowed_names(service):
36 sys.stderr.write("Not authorized to update entries for service '%s' on '%s'\n"
37 % (service, host))
38 return 1
39
40 if re.fullmatch(self._tlsa_re, value) is None:
41 sys.stderr.write("Not a valid TLSA record: '%s'\n" % value)
42 return 2
43
44 records = []
45 for port in self._ports[service]:
46 records.append("{0:<35s}\tIN\t{1}.\n".format("_%d._tcp.%s" % (port, host),
47 value))
48 self._update_records('tlsa', host, records)
49 print("OK")
50 return 0
51
52
53 def acme(self):
54 host = input("Hostname: ")
55 value = input("Value: ")
56
57 if host not in self._allowed_names():
58 sys.stderr.write("Not authorized to update entries for host '%s'\n" % host)
59 return 1
60
61 if re.fullmatch(self._acme_re, value) is None:
62 sys.stderr.write("Not a valid ACME challenge record: '%s'\n" % value)
63 return 2
64
65 records = [ "{0:<35s}.\tIN\tTXT\t\"{1}\"\n".format("_acme-challenge.%s" % host, value) ]
66 self._update_records('acme', host, records)
67 print("OK")
68 return 0
69
70
71 def _update_records(self, sort, host, records):
72 def find_zone(host):
73 assert len(host) != 0
74 candidate = os.path.join(sort, '.'.join(host + ['m4']))
75 if os.path.exists(candidate):
76 return candidate
77 else:
78 return find_zone(host[1:])
79
80
81 to_remove = [ i.split()[0] for i in records ]
82
83 zone = find_zone(host.split('.'))
84 with open(zone, 'r') as oldzone:
85 fcntl.flock(oldzone, fcntl.LOCK_EX)
86 lines = oldzone.readlines()
87 lines = [ line for line in lines if line == '\n' or line.split()[0] not in to_remove ]
88
89 lines.append('\n')
90 lines.append("; Last updated %s by %s\n" % (datetime.datetime.utcnow().isoformat(),
91 self._host))
92 lines = lines + records
93
94 with open('%s.new' % (zone,), 'w') as newzone:
95 newtext = ''.join(lines)
96 newtext = re.sub(r'\n[\n]+', '\n\n', newtext)
97 newtext = re.sub(r'\n;.*\n\n;', '\n;', newtext)
98 newzone.write(newtext)
99
100 os.rename('%s.new' % (zone,),
101 zone)
102 fcntl.flock(oldzone, fcntl.LOCK_UN)
103 call(["ssh", "root@localhost"])
104
105
106 def _allowed_names(self, service=None):
107 if service is None:
108 perservice = self._inventory[self._host].values()
109 else:
110 perservice = self._inventory[self._host][service]
111
112 names = set()
113 for entry in perservice:
114 if type(entry) is list:
115 names = names.union(entry)
116 elif type(entry) is dict:
117 names = names.union(reduce(lambda x, y: x + y, entry.values(), []))
118 else:
119 sys.stderr.write("inventory format wrong\n")
120 return names
121
122
123 def main():
124 command = os.environ['SSH_ORIGINAL_COMMAND']
125 host = sys.argv[1]
126 addrecord = Addrecord(host)
127
128 if command == 'acme':
129 return addrecord.acme()
130 elif command == 'tlsa':
131 return addrecord.tlsa()
132
133
134 if __name__ == '__main__':
135 sys.exit(main())