homer.py 24.7 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
32
33
import hashlib
import base64
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
34

35
# Values that can be changed from the command line
36
dot = False # DoH by default
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
37
38
verbose = False
insecure = False
39
post = False
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
40
head = False
41
42
dnssec = False
edns = True
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
43
rtype = 'AAAA'
44
vhostname = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
45
tests = 1 # Number of repeated tests
46
ifile = None # Input file
47
delay = None
48
49
50
forceIPv4 = False
forceIPv6 = False
connectTo = None
51
52
53
# Monitoring plugin only:
host = None
path = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
54

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

58
59
60
61
62
63
64
# 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
65
66
67
def error(msg=None):
    if msg is None:
        msg = "Unknown error"
68
69
    if monitoring:
        print("%s: %s" % (url, msg))
Alexandre's avatar
Alexandre committed
70
        sys.exit(STATE_CRITICAL)
71
72
73
    else:
        print(msg,file=sys.stderr)
        sys.exit(1)
Alexandre's avatar
Alexandre committed
74

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
75
76
77
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
78
79
    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
80

81
def is_valid_hostname(name):
82
    name = canonicalize(name)
83
    return re_host.search(name)
84

85
86
87
88
89
90
91
92
93
def canonicalize(hostname):
    result = hostname.lower()
    # TODO handle properly the case where it fails with UnicodeError
    # (two consecutive dots for instance) to get a custom exception
    result = result.encode('idna').decode()
    if result[len(result)-1] == '.':
        result = result[:-1]
    return result

94
95
96
97
def is_valid_ip_address(addr):
    try:
        baddr = netaddr.IPAddress(addr)
    except netaddr.core.AddrFormatError:
98
99
        return (False, None)
    return (True, baddr.version)
100

101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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

119
120
121
122
123
124
125
126
127
128
129
# Try one possible name. Names must be already canonicalized.
def match_hostname(hostname, possibleMatch):
    if possibleMatch.startswith("*."): # Wildcard
        base = possibleMatch[1:] # Skip the star
        # RFC 6125 says that we MAY accept left-most labels with
        # wildcards included (foo*bar). We don't do it here.
        try:
            (first, rest) = hostname.split(".", maxsplit=1)
        except ValueError: # One-label name
            rest = hostname
        if rest == base[1:]:
130
            return True
131
        if hostname == base[1:]:
132
            return True
133
134
135
136
137
138
139
140
        return False
    else:
        return hostname == possibleMatch

# Try all the names in the certificate
def validate_hostname(hostname, cert):
    # Complete specification is in RFC 6125. It is long and
    # complicated and I'm not sure we do it perfectly.
141
    (is_addr, family) = is_valid_ip_address(hostname)
142
    hostname = canonicalize(hostname)
143
    for alt_name in get_certificate_san(cert).split(", "):
144
        if alt_name.startswith("DNS:") and not is_addr:
145
            (start, base) = alt_name.split("DNS:")
146
            base = canonicalize(base)
147
148
            found = match_hostname(hostname, base)
            if found:
149
                return True
150
151
        elif alt_name.startswith("IP Address:") and is_addr:
            host_i = netaddr.IPAddress(hostname)
152
153
154
            (start, base) = alt_name.split("IP Address:")
            if base.endswith("\n"):
                base = base[:-1]
155
156
157
158
            try:
                base_i = netaddr.IPAddress(base)
            except netaddr.core.AddrFormatError:
                continue # Ignore broken IP addresses in certificates. Are we too liberal?
Alexandre's avatar
Alexandre committed
159
            if host_i == base_i:
160
161
                return True
        else:
162
163
164
            pass # Ignore unknown alternative name types. May be
                 # accept URI alternative names for DoH,
    # According to RFC 6125, we MUST NOT try the Common Name before the Subject Alternative Names.
165
    cn = canonicalize(cert.get_subject().commonName)
166
167
168
    found = match_hostname(hostname, cn)
    if found:
        return True
169
170
    return False

171

Alexandre's avatar
Alexandre committed
172
173
174
175
class CustomException(Exception):
    pass


176
class RequestDoT:
177
178
179
    def __init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec):
        self.message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype), use_edns=use_edns, want_dnssec=want_dnssec)
        self.message.flags |= dns.flags.AD # Ask for validation
180
181
182
183
184
185
186
        self.data = self.message.to_wire()

    def check_response(self):
        if self.response.id != self.message.id:
            raise Exception("The ID in the answer does not match the one in the query")


187
class RequestDoH:
188
189
    def __init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec):
        self.message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype), use_edns=use_edns, want_dnssec=want_dnssec)
190
        self.message.id = 0 # DoH requests that
191
        self.message.flags |= dns.flags.AD # Ask for validation
192
        self.data = self.message.to_wire()
193
        self.post = False
194
        self.head = False
195
196
197
198

    def check_response(self):
        ok = True
        if self.rcode == 200:
199
200
            if self.ctype != "application/dns-message":
                self.response = "Content type of the response (\"%s\") invalid" % self.ctype
201
202
                ok = False
            else:
203
                if not self.head:
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
                    try:
                        self.response = dns.message.from_wire(self.body)
                    except dns.message.TrailingJunk: # Not DNS. Should
                        # not happen for a content type
                        # application/dns-message but who knows?
                        self.response = "ERROR Not proper DNS data, trailing junk \"%s\"" % self.body
                        ok = False
                    except dns.name.BadLabelType: # Not DNS.
                        self.response = "ERROR Not proper DNS data (wrong path in the URL?) \"%s\"" % self.body[:100]
                        ok = False
                else:
                    if self.body_size == 0:
                        self.response = "HEAD successful"
                    else:
                        self.response = "ERROR Body length is not null \"%s\"" % self.body[:100]
                        ok = False
        else:
            ok = False
            if self.body_size == 0:
                self.response = "[No details]"
            else:
                self.response = self.body
226
        return ok
227

228

229
class Connection:
230
    def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
231
                 dot=False, verbose=verbose, insecure=insecure):
232
        if dot and not is_valid_hostname(server):
233
            error("DoT requires a host name or IP address, not \"%s\"" % server)
234
235
        if not dot and not is_valid_url(url):
            error("DoH requires a valid HTTPS URL, not \"%s\"" % server)
236
        if forceIPv4 and forceIPv6:
Alexandre's avatar
Alexandre committed
237
            raise CustomException("Force IPv4 *or* IPv6 but not both")
238
        self.server = server
239
240
        self.servername = servername
        if self.servername is not None:
Alexandre's avatar
Alexandre committed
241
            self.check = self.servername
242
        else:
Alexandre's avatar
Alexandre committed
243
            self.check = self.server
244
245
246
        self.dot = dot
        self.verbose = verbose
        self.insecure = insecure
247

248
249
    def __str__(self):
        return self.server
250

251
252
253
    def check_ip_address(self, addr):
        (is_addr, self.family) = is_valid_ip_address(addr)
        if not is_addr and not self.dot:
Alexandre's avatar
Alexandre committed
254
            raise CustomException("%s is not IPv4 and not IPv6" % addr)
255
        if forceIPv4 and self.family == 6:
Alexandre's avatar
Alexandre committed
256
            raise CustomException("You cannot force IPv4 with a litteral IPv6 address (%s)" % addr)
257
        elif forceIPv6 and self.family == 4:
Alexandre's avatar
Alexandre committed
258
            raise CustomException("You cannot force IPv6 with a litteral IPv4 address (%s)" % addr)
259
260
261
262
263
264
265
266
267
        if forceIPv4 or self.family == 4:
            self.family = socket.AF_INET
            self.repraddress = addr
        elif forceIPv6 or self.family == 6:
            self.family = socket.AF_INET6
            self.repraddress = f'[{addr}]'
        else:
            self.family = 0

Alexandre's avatar
Alexandre committed
268
269
270
271
272
273
274
275
    def do_test(self, qname, qtype=rtype):
        # Routine doing one actual test. Returns a tuple, first member is a
        # result (boolean indicating success for DoT, HTTP status code for
        # DoH), second member is a DNS message (or a string if there is an
        # error), third member is the size of the DNS message (or None if no
        # proper response).
        pass

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
    def print_result(self, rcode, msg, size):
        ok = True
        if (self.dot and rcode) or (not self.dot and rcode == 200):
            if not monitoring:
                print(msg)
            else:
                if size is not None and size > 0:
                    print("%s OK - %s" % (self.server, "No error for %s/%s, %i bytes received" % (name, rtype, size)))
                else:
                    print("%s OK - %s" % (self.server, "No error"))
                sys.exit(STATE_OK)
        else:
            if not monitoring:
                if dot:
                    print("Error: %s" % msg, file=sys.stderr)
                else:
                   try:
                       msg = msg.decode()
                   except (UnicodeDecodeError, AttributeError):
                       pass # Sometimes, msg can be binary, or Latin-1
                   print("HTTP error %i: %s" % (rcode, msg), file=sys.stderr)
            else:
                if not dot:
                    print("%s HTTP error - %i: %s" % (self.server, rcode, msg))
                else:
                    print("%s Error - %i: %s" % (self.server, rcode, msg))
                sys.exit(STATE_CRITICAL)
            ok = False
        return ok

306
307
308

class ConnectionDoT(Connection):
    def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
309
                 dot=False, verbose=verbose, insecure=insecure):
310
311
        Connection.__init__(self, server, servername=servername, connect=connect,
                forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=dot,
312
                verbose=verbose, insecure=insecure)
313
        self.check_ip_address(self.server)
314
        self.hasher = hashlib.sha256()
315
        addrinfo = socket.getaddrinfo(server, 853, self.family)
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
        # May be loop over the results of getaddrinfo, to test all
        # the IP addresses? See #13.
        self.sock = socket.socket(addrinfo[0][0], socket.SOCK_STREAM)
        self.addr = addrinfo[0][4]
        if self.verbose:
            print("Connecting to %s ..." % str(self.addr))
        # 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)
        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)
        self.session = OpenSSL.SSL.Connection(self.context, self.sock)
        self.session.set_tlsext_host_name(canonicalize(self.check).encode()) # Server Name Indication (SNI)
        self.session.connect((self.addr))
        # TODO We may here have exceptions such as OpenSSL.SSL.ZeroReturnError
        self.session.do_handshake()
        self.cert = self.session.get_peer_certificate()
        # RFC 7858, section 4.2 and appendix A
        self.publickey = self.cert.get_pubkey()
        if verbose:
            print("Certificate #%x for \"%s\", delivered by \"%s\"" % \
                  (self.cert.get_serial_number(),
                   self.cert.get_subject().commonName,
                   self.cert.get_issuer().commonName))
            self.hasher.update(OpenSSL.crypto.dump_publickey(OpenSSL.crypto.FILETYPE_ASN1,
                                                  self.publickey))
            self.digest = self.hasher.digest()
            print("Public key is pin-sha256=\"%s\"" % \
                  base64.standard_b64encode(self.digest).decode())
        if not insecure:
            valid = validate_hostname(self.check, self.cert)
            if not valid:
                error("Certificate error: \"%s\" is not in the certificate" % (self.check))

357
    def end(self):
358
359
360
        self.session.shutdown()
        self.session.close()

361
362
363
364
    def send_data(self, data):
        length = len(data)
        self.session.send(length.to_bytes(2, byteorder='big') + data)

365
    def receive_data(self, request):
Alexandre's avatar
Alexandre committed
366
        buf = self.session.recv(2)
367
368
369
370
371
372
373
374
        request.response_size = int.from_bytes(buf, byteorder='big')
        buf = self.session.recv(request.response_size)
        request.response = dns.message.from_wire(buf)
        request.rcode = True

    def send_and_receive(self, request):
        self.send_data(request.data)
        self.receive_data(request)
375
376

    def do_test(self, qname, qtype=rtype):
377
378
379
380
        request = RequestDoT(qname, qtype, want_dnssec=dnssec, use_edns=edns)
        self.send_and_receive(request)
        request.check_response()
        return (request.rcode, request.response, request.response_size)
Alexandre's avatar
Alexandre committed
381

382
383
384

class ConnectionDoH(Connection):
    def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
385
                 dot=False, verbose=verbose, insecure=insecure):
386
387
        Connection.__init__(self, server, servername=servername, connect=connect,
                forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=dot,
388
                verbose=verbose, insecure=insecure)
389
        self.url = server
390
391
392
393
394
395
396
        self.connect = connect

    def create_handle(self):
        self.curl = pycurl.Curl()
        # 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)
397
        if self.verbose:
398
            self.curl.setopt(pycurl.VERBOSE, True)
399
        if self.insecure:
400
401
            self.curl.setopt(pycurl.SSL_VERIFYPEER, False)
            self.curl.setopt(pycurl.SSL_VERIFYHOST, False)
402
        if forceIPv4:
403
            self.curl.setopt(pycurl.IPRESOLVE, pycurl.IPRESOLVE_V4)
404
        if forceIPv6:
405
406
407
408
409
            self.curl.setopt(pycurl.IPRESOLVE, pycurl.IPRESOLVE_V6)
        if self.connect is not None:
            self.check_ip_address(self.connect)
            self.curl.setopt(pycurl.CONNECT_TO, [f'::{self.repraddress}:443',])
        self.curl.setopt(pycurl.HTTPHEADER, ["Content-type: application/dns-message"])
410
411

    def end(self):
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
        self.curl.close()

    def set_opt(self, opt, value):
        self.curl.setopt(opt, value)

    def reset_opt_default(self):
        opts = {
                pycurl.NOBODY: False,
                pycurl.POST: False,
                pycurl.POSTFIELDS: '',
                pycurl.URL: ''
               }
        for opt, value in opts.items():
            self.set_opt(opt, value)

    def prepare(self, request):
        try:
            self.reset_opt_default()
        except AttributeError:
            self.create_handle()
432
        if request.post:
433
            self.prepare_post(request)
434
        elif request.head:
435
436
437
438
439
440
441
442
443
444
            self.prepare_head(request)
        else:
            self.prepare_get(request)

    def prepare_get(self, request):
        self.set_opt(pycurl.HTTPGET, True)
        dns_req = base64.urlsafe_b64encode(request.data).decode('UTF8').rstrip('=')
        self.set_opt(pycurl.URL, self.server + ("?dns=%s" % dns_req))

    def prepare_post(self, request):
445
        request.post = True
446
447
448
        self.set_opt(pycurl.POST, True)
        self.set_opt(pycurl.POSTFIELDS, request.data)
        self.set_opt(pycurl.URL, self.server)
449

450
    def prepare_head(self, request):
451
        request.head = True
452
453
        self.prepare_get(request)
        self.set_opt(pycurl.NOBODY, True)
454

455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
    def perform(self):
        self.buffer = io.BytesIO()
        self.set_opt(pycurl.WRITEDATA, self.buffer)
        self.curl.perform()

    def receive(self, request):
        body = self.buffer.getvalue()
        body_size = len(body)
        http_code = self.curl.getinfo(pycurl.RESPONSE_CODE)
        content_type = self.curl.getinfo(pycurl.CONTENT_TYPE)
        request.body = body
        request.body_size = body_size
        request.rcode = http_code
        request.ctype = content_type
        self.buffer.close()
470

471
472
473
474
    def send_and_receive(self, request):
        self.prepare(request)
        self.perform()
        self.receive(request)
475

Alexandre's avatar
Alexandre committed
476
    def do_test(self, qname, qtype=rtype):
477
        request = RequestDoH(qname, qtype, want_dnssec=dnssec, use_edns=edns)
478
479
        request.head = head
        request.post = post
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
        self.send_and_receive(request)
        request.check_response()
        return (request.rcode, request.response, request.body_size)


def get_next_domain(input_file):
    name, rtype = 'framagit.org', 'AAAA'
    line = input_file.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()
    return name, rtype
Alexandre's avatar
Alexandre committed
496

497
# Main program
498
499
500
501
502
503
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
if not monitoring:
    name = None
    message = None
    try:
504
        optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t46",
505
506
507
                                       ["help", "verbose", "dot", "head",
                                        "insecure", "POST", "vhost=",
                                        "dnssec", "noedns","repeat=", "file=", "delay=", "v4only", "v6only"])
508
509
510
511
        for option, value in optlist:
            if option == "--help" or option == "-h":
                usage()
                sys.exit(0)
512
513
            elif option == "--dot" or option == "-t":
                dot = True
514
515
            elif option == "--verbose" or option == "-v":
                verbose = True
516
            elif option == "--HEAD" or option == "--head" or option == "-e":
517
                head = True
518
            elif option == "--POST" or option == "--post" or option == "-P":
519
                post = True
520
521
            elif option == "--vhost" or option == "-V":
                vhostname = value
522
523
            elif option == "--insecure" or option == "-k":
                insecure = True
524
525
526
527
            elif option == "--dnssec":
                dnssec = True
            elif option == "--noedns":
                edns = False
528
529
530
531
532
533
534
535
536
537
            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
538
539
540
541
            elif option == "-4" or option == "v4only":
                forceIPv4 = True
            elif option == "-6" or option == "v6only":
                forceIPv6 = True
542
543
544
545
546
547
548
549
550
551
552
553
            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")
Alexandre's avatar
Alexandre committed
554
        sys.exit(1)
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
    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:
570
        optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:Pih46")
571
572
573
574
        for option, value in optlist:
            if option == "-H":
                host = value
            elif option == "-V":
Alexandre's avatar
Alexandre committed
575
                vhostname = value
576
577
578
579
580
581
582
583
584
585
            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
586
587
            elif option == "-i":
                insecure = True
588
589
590
591
            elif option == "-4":
                forceIPv4 = True
            elif option == "-6":
                forceIPv6 = True
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
            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)
608
609
610
611
612
613
    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)
614
615
616
    if dot:
        url = host
    else:
617
618
        if vhostname is None or vhostname == host:
            connectTo = None
619
            url = "https://%s/" % host
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
620
        else:
621
            connectTo = host
Alexandre's avatar
Alexandre committed
622
            url = "https://%s/" % vhostname
623
624
625
626
        if path is not None:
            if path.startswith("/"):
                path = path[1:]
            url += path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
627
ok = True
Alexandre's avatar
Alexandre committed
628
start = time.time()
629
try:
630
    if dot and vhostname is not None:
631
632
633
        extracheck = vhostname
    else:
        extracheck = None
634
635
636
    if dot:
        conn = ConnectionDoT(url, dot=dot, servername=extracheck, connect=connectTo, verbose=verbose,
                          forceIPv4=forceIPv4, forceIPv6=forceIPv6,
637
                          insecure=insecure)
638
639
640
    else:
        conn = ConnectionDoH(url, dot=dot, servername=extracheck, connect=connectTo, verbose=verbose,
                          forceIPv4=forceIPv4, forceIPv6=forceIPv6,
641
                          insecure=insecure)
642
643
except TimeoutError:
    error("timeout")
644
645
except ConnectionRefusedError:
    error("Connection to server refused")
Alexandre's avatar
Alexandre committed
646
647
except CustomException as e:
    error(e)
648
649
if ifile is not None:
    input = open(ifile)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
650
for i in range (0, tests):
651
652
653
    if tests > 1:
        print("\nTest %i" % i)
    if ifile is not None:
654
655
656
657
658
659
660
661
662
        name, rtype = get_next_domain(input)
    try:
        (rcode, msg, size) = conn.do_test(name, rtype)
    except (OpenSSL.SSL.Error, CustomException) as e:
        ok = False
        error(e)
        break
    if not conn.print_result(rcode, msg, size):
        ok = False
663
664
    if tests > 1 and i == 0:
        start2 = time.time()
665
666
    if delay is not None:
        time.sleep(delay)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
667
stop = time.time()
668
669
670
671
if tests > 1:
    extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
    extra = ""
672
673
if not monitoring:
    print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
674
675
if ifile is not None:
    input.close()
676
conn.end()
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
677
if ok:
678
679
680
681
    if not monitoring:
        sys.exit(0)
    else:
        sys.exit(STATE_OK)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
682
else:
683
684
685
686
    if not monitoring:
        sys.exit(1)
    else:
        sys.exit(STATE_CRITICAL)
Alexandre's avatar
Alexandre committed
687