homer.py 14.5 KB
Newer Older
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
1
2
3
4
5
6
7
8
#!/usr/bin/env python3

# http://pycurl.io/docs/latest
import pycurl

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

9
10
11
12
13
# 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
14
15
16
17
18
19
import io
import sys
import base64
import getopt
import urllib.parse
import time
20
21
import socket
import ctypes
22
import re
23
import os.path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
24

25
# Values that can be changed from the command line
26
dot = False # DoH by default
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
27
28
29
30
31
32
post = False
verbose = False
insecure = False
head = False
rtype = 'AAAA'
tests = 1 # Number of repeated tests
33
ifile = None # Input file
34
delay = None
35
36
37
38
39
# Monitoring plugin only:
host = None
vhostname = None
path = None
# TODO add an option: a string which is expected in the DNS response
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
40

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

44
45
46
47
48
49
50
# 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
51
52
53
def error(msg=None):
    if msg is None:
        msg = "Unknown error"
54
55
56
57
58
59
    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
60
61
62
63
64
65
    
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
    print("Usage: %s [-P] [-k] url domain-name [DNS type]" % sys.argv[0], file=sys.stderr)

66
67
68
69
70
71
72
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
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:"):
            (start, base) = alt_name.split("IP Address:")
            base = base.lower()
            if base.endswith("\n"):
                base = base[:-1]
109
            if hostname == base: # TODO better canonicalization of IP addresses with the netaddr module
110
111
112
113
114
                return True
        else:
            pass # Ignore unknown alternative name types
    return False

115
class Connection:
116
    def __init__(self, server, servername=None, dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
117
118
119
120
        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)
121
122
123
124
125
126
127
128
        self.server = server
        self.dot = dot
        if not self.dot:
            self.post = post
            self.head = head
        self.verbose = verbose
        self.insecure = insecure
        if self.dot:
129
130
131
            addrinfo = socket.getaddrinfo(server, 853)
            # May be loop over the results of getaddrinfo, to test all the IP addresses?
            self.sock = socket.socket(addrinfo[0][0], socket.SOCK_STREAM)
132
133
134
135
            # 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)
136
137
138
139
140
141
142
143
144
            # TODO set_tlsext_host_name(name) for SNI?
            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)
145
146
147
148
            self.session = OpenSSL.SSL.Connection(self.context, self.sock)
            self.session.connect((self.server, 853)) 
            self.session.do_handshake()
            self.cert = self.session.get_peer_certificate()
149
            if not insecure:
150
151
152
153
154
                if servername is not None:
                    check = servername
                else:
                    check = server
                valid = validate_hostname(check, self.cert)
155
                if not valid:
156
                    error("Certificate error: \"%s\" is not in the certificate" % (check))
157
                # TODO validate with SPKI?
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
        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
184
185
# result (boolean indicating success for DoT, HTTP status code for
# DoH), second member is a DNS message (or a string if there is an
186
187
# error), third member is the size of the DNS message (or None if no
# proper response).
188
189
def do_test(connection, qname, qtype=rtype):
    message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype))
190
    size = None
191
192
193
194
195
196
197
198
199
    if connection.dot:
        # TODO Check what the Query ID is. Really random?
        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)
        response = dns.message.from_wire(buf) # TODO check the Query ID
200
        return (True, response, received)
201
202
203
    else: # DoH
        message.id = 0 # DoH requests that
        if connection.post:
204
            connection.curl.setopt(connection.curl.URL, connection.server)
205
206
207
208
209
210
211
212
213
214
215
216
217
218
            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:
219
                    size = len(body)
220
221
222
223
224
225
226
                    response = dns.message.from_wire(body)
                except dns.message.TrailingJunk: # Not DNS. 
                    response = "ERROR Not proper DNS data \"%s\"" % body
                    ok = False
            else:
                response = "HEAD successful"
        else:
227
            ok = False
228
229
            body =  buffer.getvalue()
            if len(body) == 0:
230
231
232
233
                response = "[No details]"
            else:
                response = body
            buffer.close()
234
        return (rcode, response, size)
235
    
236
# Main program
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
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
334
335
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
if not monitoring:
    name = None
    message = None
    try:
        optlist, args = getopt.getopt (sys.argv[1:], "hvPker:f:d:t",
                                       ["help", "verbose", "dot", "head", "insecure", "POST", "repeat=", "file=", "delay="])
        for option, value in optlist:
            if option == "--help" or option == "-h":
                usage()
                sys.exit(0)
            elif option == "--verbose" or option == "-v":
                verbose = True
            elif option == "--head" or option == "-e":
                head = True
            elif option == "--dot" or option == "-t":
                dot = True
            elif option == "--insecure" or option == "-k":
                insecure = True
            elif option == "--POST" or option == "-P":
                post = True
            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 == "-i":
                insecure = True
            elif option == "-h":
                head = True
            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)
    if dot:
        url = host
    else:
        if vhostname is None:
            url = "https://%s/" % host
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
336
        else:
337
338
339
340
341
            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
342
ok = True
343
start = time.time() 
344
345
346
347
348
349
350
351
try:
    if monitoring and dot and vhostname is not None:
        extracheck = vhostname
    else:
        extracheck = None
    conn = Connection(url, dot=dot, servername=extracheck, verbose=verbose, insecure=insecure, post=post, head=head)
except TimeoutError:
    error("timeout")
352
353
if ifile is not None:
    input = open(ifile)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
354
for i in range (0, tests):
355
356
357
358
359
360
361
362
363
364
365
    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()
366
    (rcode, msg, size) = do_test(conn, name, rtype)
367
    if (dot and rcode) or (not dot and rcode == 200):
368
369
370
371
372
373
374
375
        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
376
    else:
377
378
379
380
381
        if not monitoring:
            if dot:
                print("Error: %s" % msg, file=sys.stderr)
            else:
                print("HTTP error %i: %s" % (rcode, msg.decode()), file=sys.stderr)
382
        else:
383
384
385
386
387
            if not dot:
                print("%s HTTP error - %i: %s" % (url, rcode, msg.decode()))
            else:
                print("%s Error - %i: %s" % (url, rcode, msg))
            sys.exit(STATE_CRITICAL)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
388
        ok = False
389
390
    if tests > 1 and i == 0:
        start2 = time.time()
391
392
    if delay is not None:
        time.sleep(delay)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
393
stop = time.time()
394
395
396
397
if tests > 1:
    extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
    extra = ""
398
399
if not monitoring:
    print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
400
401
if ifile is not None:
    input.close()
402
conn.end()
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
403
if ok:
404
405
406
407
    if not monitoring:
        sys.exit(0)
    else:
        sys.exit(STATE_OK)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
408
else:
409
410
411
412
413
    if not monitoring:
        sys.exit(1)
    else:
        sys.exit(STATE_CRITICAL)