homer.py 16 KB
Newer Older
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
1
2
#!/usr/bin/env python3

3
4
5
6
7
# Homer is a DoH (DNS-over-HTTPS) and DoT (DNS-over-TLS) client. Its
# main purpose is to test DoH and DoT resolvers. Reference site is
# <https://framagit.org/bortzmeyer/homer/> See author, documentation,
# etc, there, or in the README.md included with the distribution.

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
8
9
10
11
12
13
# http://pycurl.io/docs/latest
import pycurl

# http://www.dnspython.org/
import dns.message

14
15
16
# https://github.com/drkjam/netaddr/
import netaddr

17
18
19
20
21
# Octobre 2019: the Python GnuTLS bindings don't work with Python 3. So we use OpenSSL.
# https://www.pyopenssl.org/
# https://pyopenssl.readthedocs.io/
import OpenSSL

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
22
23
24
25
26
27
import io
import sys
import base64
import getopt
import urllib.parse
import time
28
29
import socket
import ctypes
30
import re
31
import os.path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
32

33
# Values that can be changed from the command line
34
dot = False # DoH by default
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
35
36
verbose = False
insecure = False
37
post = False
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
38
39
head = False
rtype = 'AAAA'
40
vhostname = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
41
tests = 1 # Number of repeated tests
42
ifile = None # Input file
43
delay = None
44
45
46
# Monitoring plugin only:
host = None
path = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
47

48
49
50
# Do not change these
re_host = re.compile(r'^([0-9a-z][0-9a-z-\.]*)|([0-9:]+)|([0-9\.])$')

51
52
53
54
55
56
57
# For the monitoring plugin
STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
58
59
60
def error(msg=None):
    if msg is None:
        msg = "Unknown error"
61
62
63
64
65
66
    if monitoring:
        print("%s: %s" % (url, msg))
        sys.exit(STATE_CRITICAL)         
    else:
        print(msg,file=sys.stderr)
        sys.exit(1)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
67
68
69
70
    
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
71
72
    print("Usage: %s [--dot] url-or-servername domain-name [DNS type]" % sys.argv[0], file=sys.stderr)
    print("See the README.md for more details.", file=sys.stderr)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
73

74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
def is_valid_hostname(name):
    name = str(name.encode('idna').lower())
    return re_host.search(name)
    
def is_valid_url(url):
  try:
    result = urllib.parse.urlparse(url) # A very poor validation, many
    # errors (for instance whitespaces, IPv6 address litterals without
    # brackets...) are ignored.
    return (result.scheme=="https" and result.netloc != "")
  except ValueError:
    return False

def get_certificate_san(x509cert):
    san = ""
    ext_count = x509cert.get_extension_count()
    for i in range(0, ext_count):
        ext = x509cert.get_extension(i)
        if "subjectAltName" in str(ext.get_short_name()):
            san = str(ext)
    return san

def validate_hostname(hostname, cert):
    hostname = hostname.lower()
    cn = cert.get_subject().commonName.lower()
    if cn.startswith("*."): # Wildcard
        (start, base) = cn.split("*.")
        if hostname.endswith(base):
            return True
    else:
        if hostname == cn:
            return True
    for alt_name in get_certificate_san(cert).split(", "):
        if alt_name.startswith("DNS:"):
            (start, base) = alt_name.split("DNS:")
            base = base.lower()
            if hostname == base:
                return True
        elif alt_name.startswith("IP Address:"):
113
114
115
116
            try:
                host_i = netaddr.IPAddress(hostname)
            except netaddr.core.AddrFormatError:
                continue # If hostname is not an IP address, we cannot use it for comparison
117
118
119
            (start, base) = alt_name.split("IP Address:")
            if base.endswith("\n"):
                base = base[:-1]
120
121
122
123
124
            try:
                base_i = netaddr.IPAddress(base)
            except netaddr.core.AddrFormatError:
                continue # Ignore broken IP addresses in certificates. Are we too liberal?
            if host_i == base_i: 
125
126
127
128
129
                return True
        else:
            pass # Ignore unknown alternative name types
    return False

130
class Connection:
131
    def __init__(self, server, servername=None, dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
132
133
134
135
        if dot and not is_valid_hostname(server):
            error("DoT requires a host name, not \"%s\"" % server)
        if not dot and not is_valid_url(url):
            error("DoH requires a valid HTTPS URL, not \"%s\"" % server)
136
        self.server = server
137
138
139
140
141
        self.servername = servername
        if self.servername is not None:
            check = self.servername
        else:
            check = self.server
142
143
144
145
146
147
148
        self.dot = dot
        if not self.dot:
            self.post = post
            self.head = head
        self.verbose = verbose
        self.insecure = insecure
        if self.dot:
149
            addrinfo = socket.getaddrinfo(server, 853)
150
            # May be loop over the results of getaddrinfo, to test all the IP addresses? See #13
151
            self.sock = socket.socket(addrinfo[0][0], socket.SOCK_STREAM)
152
153
            if self.verbose:
                print("Connecting to %s ..." % str(addrinfo[0][4]))
154
155
156
157
            # With typical DoT servers, we *must* use TLS 1.2 (otherwise,
            # do_handshake fails with "OpenSSL.SSL.SysCallError: (-1, 'Unexpected
            # EOF')" Typical HTTP servers are more lax.
            self.context = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_2_METHOD)
158
159
160
161
162
163
164
165
            if self.insecure:
                self.context.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda *x: True)
            else:
                self.context.set_default_verify_paths()
                self.context.set_verify_depth(4) # Seems ignored
                self.context.set_verify(OpenSSL.SSL.VERIFY_PEER | OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT | \
                                        OpenSSL.SSL.VERIFY_CLIENT_ONCE,
                                        lambda conn, cert, errno, depth, preverify_ok: preverify_ok)
166
            self.session = OpenSSL.SSL.Connection(self.context, self.sock)
167
            self.session.set_tlsext_host_name(check.encode()) # Server Name Indication (SNI)
168
169
170
            self.session.connect((self.server, 853)) 
            self.session.do_handshake()
            self.cert = self.session.get_peer_certificate()
171
            if not insecure:
172
                valid = validate_hostname(check, self.cert)
173
                if not valid:
174
                    error("Certificate error: \"%s\" is not in the certificate" % (check))
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
        else: # DoH
            self.curl = pycurl.Curl()
            self.url = server
            # Does not work if pycurl was not compiled with nghttp2 (recent Debian
            # packages are OK) https://github.com/pycurl/pycurl/issues/477
            self.curl.setopt(pycurl.HTTP_VERSION, pycurl.CURL_HTTP_VERSION_2)
            self.curl.setopt(pycurl.HTTPHEADER, ["Content-type: application/dns-message"])
            if self.verbose:
                self.curl.setopt(self.curl.VERBOSE, True)
            if self.insecure:
                self.curl.setopt(pycurl.SSL_VERIFYPEER, False)   
                self.curl.setopt(pycurl.SSL_VERIFYHOST, False)
            if self.head:
                self.curl.setopt(pycurl.NOBODY, True)
            if self.post:
                 self.curl.setopt(pycurl.POST, True)
    def __str__(self):
        return self.server
    def end(self):
        if self.dot:
            self.session.shutdown()
            self.session.close()
        else: # DoH
            self.curl.close()
            
# Routine doing one actual test. Returns a tuple, first member is a
201
202
# result (boolean indicating success for DoT, HTTP status code for
# DoH), second member is a DNS message (or a string if there is an
203
204
# error), third member is the size of the DNS message (or None if no
# proper response).
205
206
def do_test(connection, qname, qtype=rtype):
    message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype))
207
    size = None
208
209
210
211
212
213
214
    if connection.dot:
        messagew = message.to_wire() 
        length = len(messagew)
        n = connection.session.send(length.to_bytes(2, byteorder='big') + messagew)
        buf = connection.session.recv(2) 
        received = int.from_bytes(buf, byteorder='big')
        buf = connection.session.recv(received)
215
        response = dns.message.from_wire(buf) 
216
        return (True, response, received)
217
218
219
    else: # DoH
        message.id = 0 # DoH requests that
        if connection.post:
220
            connection.curl.setopt(connection.curl.URL, connection.server)
221
222
223
224
225
226
227
228
229
230
231
232
233
234
            data = message.to_wire()
            connection.curl.setopt(pycurl.POSTFIELDS, data)
        else:
            dns_req = base64.urlsafe_b64encode(message.to_wire()).decode('UTF8').rstrip('=')
            connection.curl.setopt(connection.curl.URL, connection.server + ("?dns=%s" % dns_req))
        buffer = io.BytesIO()
        connection.curl.setopt(connection.curl.WRITEDATA, buffer)
        connection.curl.perform()
        rcode = connection.curl.getinfo(pycurl.RESPONSE_CODE)
        ok = True
        if rcode == 200:
            if not head:
                body = buffer.getvalue()
                try:
235
                    size = len(body)
236
237
                    response = dns.message.from_wire(body)
                except dns.message.TrailingJunk: # Not DNS. 
238
                    response = "ERROR Not proper DNS data, trailing junk \"%s\"" % body
239
                    ok = False
240
241
242
                except dns.name.BadLabelType: # Not DNS. 
                    response = "ERROR Not proper DNS data (wrong path in the URL?) \"%s\"" % body[:100]
                    ok = False
243
244
245
            else:
                response = "HEAD successful"
        else:
246
            ok = False
247
248
            body =  buffer.getvalue()
            if len(body) == 0:
249
250
251
252
                response = "[No details]"
            else:
                response = body
            buffer.close()
253
        return (rcode, response, size)
254
    
255
# Main program
256
257
258
259
260
261
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
if not monitoring:
    name = None
    message = None
    try:
262
263
        optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t",
                                       ["help", "verbose", "dot", "head", "insecure", "POST", "vhost=", "repeat=", "file=", "delay="])
264
265
266
267
        for option, value in optlist:
            if option == "--help" or option == "-h":
                usage()
                sys.exit(0)
268
269
            elif option == "--dot" or option == "-t":
                dot = True
270
271
            elif option == "--verbose" or option == "-v":
                verbose = True
272
            elif option == "--HEAD" or option == "--head" or option == "-e":
273
                head = True
274
            elif option == "--POST" or option == "--post" or option == "-P":
275
                post = True
276
277
            elif option == "--vhost" or option == "-V":
                vhostname = value
278
279
            elif option == "--insecure" or option == "-k":
                insecure = True
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
            elif option == "--repeat" or option == "-r":
                tests = int(value)
                if tests <= 1:
                    error("--repeat needs a value > 1")
            elif option == "--delay" or option == "-d":
                delay = float(value)
                if delay <= 0:
                    error("--delay needs a value > 0")
            elif option == "--file" or option == "-f":
                ifile = value
            else:
                error("Unknown option %s" % option)
    except getopt.error as reason:
        usage(reason)
        sys.exit(1)
    if tests <= 1 and delay is not None:
        error("--delay makes no sense if there is no repetition")
    if post and head:
        usage("POST or HEAD but not both")
        sys.exit(1)
    if dot and (post or head):
        usage("POST or HEAD makes non sense for DoT")
        sys.exit(1)    
    if ifile is None and (len(args) != 2 and len(args) != 3):
        usage("Wrong number of arguments")
        sys.exit(1)
    if ifile is not None and len(args) != 1:
        usage("Wrong number of arguments (if --file is used, do not indicate the domain name)")
        sys.exit(1)
    url = args[0]
    if ifile is None:
        name = args[1]
        if len(args) == 3:
            rtype = args[2]
else: # Monitoring plugin
    dot = (me == "check_dot")
    name = None
    try:
        optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:Pih")
        for option, value in optlist:
            if option == "-H":
                host = value
            elif option == "-V":
                vhostname = value 
            elif option == "-n":
                name = value
            elif option == "-t":
                rtype = value
            elif option == "-p":
                path = value
            elif option == "-P":
                post = True
            elif option == "-h":
                head = True
334
335
            elif option == "-i":
                insecure = True
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
            else:
                # Should never occur, it is trapped by getopt
                print("Unknown option %s" % option)
                sys.exit(STATE_UNKNOWN)
    except getopt.error as reason:
        print("Option parsing problem %s" % reason)
        sys.exit(STATE_UNKNOWN)
    if len(args) > 0:
        print("Too many arguments (\"%s\")" % args)
        sys.exit(STATE_UNKNOWN)
    if host is None or name is None:
        print("Host (-H) and name to lookup (-n) are necessary")
        sys.exit(STATE_UNKNOWN)
    if post and head:
        print("POST or HEAD but not both")
        sys.exit(STATE_UNKNOWN)
352
353
354
355
356
357
    if dot and (post or head):
        print("POST or HEAD makes no sense for DoT")
        sys.exit(STATE_UNKNOWN)
    if dot and path:
        print("URL path makes no sense for DoT")
        sys.exit(STATE_UNKNOWN)
358
359
360
361
362
    if dot:
        url = host
    else:
        if vhostname is None:
            url = "https://%s/" % host
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
363
        else:
364
365
366
367
368
            url = "https://%s/" % vhostname # host is ignored in that case
        if path is not None:
            if path.startswith("/"):
                path = path[1:]
            url += path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
369
ok = True
370
start = time.time() 
371
try:
372
    if dot and vhostname is not None:
373
374
375
376
377
378
        extracheck = vhostname
    else:
        extracheck = None
    conn = Connection(url, dot=dot, servername=extracheck, verbose=verbose, insecure=insecure, post=post, head=head)
except TimeoutError:
    error("timeout")
379
380
except ConnectionRefusedError:
    error("Connection to server refused")
381
382
if ifile is not None:
    input = open(ifile)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
383
for i in range (0, tests):
384
385
386
387
388
389
390
391
392
393
394
    if tests > 1:
        print("\nTest %i" % i)
    if ifile is not None:
        line = input.readline()
        if line[:-1] == "":
            error("Not enough data in %s for the %i tests" % (ifile, tests))
        if line.find(' ') == -1:
            name = line[:-1]
            rtype = 'AAAA'
        else:
            (name, rtype) = line.split()
395
    (rcode, msg, size) = do_test(conn, name, rtype)
396
    if (dot and rcode) or (not dot and rcode == 200):
397
398
399
400
401
402
403
404
        if not monitoring:
            print(msg)
        else:
            if size is not None and size > 0:
                print("%s OK - %s" % (url, "No error for %s/%s, %i bytes received" % (name, rtype, size)))
            else:
                print("%s OK - %s" % (url, "No error"))
            sys.exit(STATE_OK)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
405
    else:
406
407
408
409
        if not monitoring:
            if dot:
                print("Error: %s" % msg, file=sys.stderr)
            else:
410
411
               try:
                   msg = msg.decode()
412
               except (UnicodeDecodeError, AttributeError):
413
414
                   pass # Sometimes, msg can be binary, or Latin-1
               print("HTTP error %i: %s" % (rcode, msg), file=sys.stderr)
415
        else:
416
            if not dot:
417
                print("%s HTTP error - %i: %s" % (url, rcode, msg))
418
419
420
            else:
                print("%s Error - %i: %s" % (url, rcode, msg))
            sys.exit(STATE_CRITICAL)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
421
        ok = False
422
423
    if tests > 1 and i == 0:
        start2 = time.time()
424
425
    if delay is not None:
        time.sleep(delay)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
426
stop = time.time()
427
428
429
430
if tests > 1:
    extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
    extra = ""
431
432
if not monitoring:
    print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
433
434
if ifile is not None:
    input.close()
435
conn.end()
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
436
if ok:
437
438
439
440
    if not monitoring:
        sys.exit(0)
    else:
        sys.exit(STATE_OK)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
441
else:
442
443
444
445
446
    if not monitoring:
        sys.exit(1)
    else:
        sys.exit(STATE_CRITICAL)