homer.py 27 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
check = False
Alexandre's avatar
Alexandre committed
52
debug = False
53
54
55
# Monitoring plugin only:
host = None
path = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
56

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

60
61
62
63
64
65
66
# For the monitoring plugin
STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4

Alexandre's avatar
Alexandre committed
67
68
69
70
71
# For the check option
DOH_GET = 0
DOH_POST = 1
DOH_HEAD = 2

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
72
73
74
def error(msg=None):
    if msg is None:
        msg = "Unknown error"
75
76
    if monitoring:
        print("%s: %s" % (url, msg))
Alexandre's avatar
Alexandre committed
77
        sys.exit(STATE_CRITICAL)
78
79
80
    else:
        print(msg,file=sys.stderr)
        sys.exit(1)
Alexandre's avatar
Alexandre committed
81

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
82
83
84
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
85
86
    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
87

88
def is_valid_hostname(name):
89
    name = canonicalize(name)
90
    return re_host.search(name)
91

92
93
94
95
96
97
98
99
100
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

101
102
103
104
def is_valid_ip_address(addr):
    try:
        baddr = netaddr.IPAddress(addr)
    except netaddr.core.AddrFormatError:
105
106
        return (False, None)
    return (True, baddr.version)
107

108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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

126
127
128
129
130
131
132
133
134
135
136
# 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:]:
137
            return True
138
        if hostname == base[1:]:
139
            return True
140
141
142
143
144
145
146
147
        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.
148
    (is_addr, family) = is_valid_ip_address(hostname)
149
    hostname = canonicalize(hostname)
150
    for alt_name in get_certificate_san(cert).split(", "):
151
        if alt_name.startswith("DNS:") and not is_addr:
152
            (start, base) = alt_name.split("DNS:")
153
            base = canonicalize(base)
154
155
            found = match_hostname(hostname, base)
            if found:
156
                return True
157
158
        elif alt_name.startswith("IP Address:") and is_addr:
            host_i = netaddr.IPAddress(hostname)
159
160
161
            (start, base) = alt_name.split("IP Address:")
            if base.endswith("\n"):
                base = base[:-1]
162
163
164
165
            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
166
            if host_i == base_i:
167
168
                return True
        else:
169
170
171
            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.
172
    cn = canonicalize(cert.get_subject().commonName)
173
174
175
    found = match_hostname(hostname, cn)
    if found:
        return True
176
177
    return False

178

Alexandre's avatar
Alexandre committed
179
class Request:
180
181
182
    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
Alexandre's avatar
Alexandre committed
183
184

    def to_wire(self):
185
186
        self.data = self.message.to_wire()

Alexandre's avatar
Alexandre committed
187
188

class RequestDoT(Request):
189
190
191
192
193
    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")


Alexandre's avatar
Alexandre committed
194
class RequestDoH(Request):
195
    def __init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec):
Alexandre's avatar
Alexandre committed
196
        Request.__init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec)
197
        self.message.id = 0 # DoH requests that
198
        self.post = False
Alexandre's avatar
Alexandre committed
199
        self.head = False
200

201
202
203
    def check_response(self):
        ok = True
        if self.rcode == 200:
204
205
            if self.ctype != "application/dns-message":
                self.response = "Content type of the response (\"%s\") invalid" % self.ctype
206
207
                ok = False
            else:
Alexandre's avatar
Alexandre committed
208
                if not self.head:
209
210
211
212
213
214
215
216
217
218
219
                    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:
Alexandre's avatar
Alexandre committed
220
                    if self.response_size == 0:
221
222
223
224
225
226
                        self.response = "HEAD successful"
                    else:
                        self.response = "ERROR Body length is not null \"%s\"" % self.body[:100]
                        ok = False
        else:
            ok = False
Alexandre's avatar
Alexandre committed
227
            if self.response_size == 0:
228
229
230
                self.response = "[No details]"
            else:
                self.response = self.body
Alexandre's avatar
Alexandre committed
231
        return ok
232

233

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

253
254
    def __str__(self):
        return self.server
255

256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
    def check_ip_address(self, addr):
        (is_addr, self.family) = is_valid_ip_address(addr)
        if not is_addr and not self.dot:
            raise Exception("%s is not IPv4 and not IPv6" % addr)
        if forceIPv4 and self.family == 6:
            raise Exception("You cannot force IPv4 with a litteral IPv6 address (%s)" % addr)
        elif forceIPv6 and self.family == 4:
            raise Exception("You cannot force IPv6 with a litteral IPv4 address (%s)" % addr)
        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
273
274
275
276
277
278
279
280
    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

281
282
283
284
    def print_result(self, rcode, msg, size):
        ok = True
        if (self.dot and rcode) or (not self.dot and rcode == 200):
            if not monitoring:
285
286
                if not check or verbose:
                    print(msg)
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
            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

312
313
314
315
316
317
318

class ConnectionDoT(Connection):
    def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
                 dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
        Connection.__init__(self, server, servername=servername, connect=connect,
                forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=dot,
                verbose=verbose, insecure=insecure, post=post, head=head)
319
        self.check_ip_address(self.server)
320
        self.hasher = hashlib.sha256()
321
        addrinfo = socket.getaddrinfo(server, 853, self.family)
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
357
358
359
360
361
        # 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))
Alexandre's avatar
Alexandre committed
362
363
        if debug:
            self.store_session_key()
364

365
    def end(self):
366
367
368
        self.session.shutdown()
        self.session.close()

Alexandre's avatar
Alexandre committed
369
370
371
372
373
374
375
376
377
378
379
380
381
    def store_session_key(self):
        sslkeylogfile = './.debug/keylogfile.pm'
        client_random = self.session.client_random().hex()
        master_key = self.session.master_key().hex()
        key = f'CLIENT_RANDOM {client_random} {master_key}\n'
        try :
            f = open(sslkeylogfile, 'a')
        except Exception:
            print(f'Could not open "{sslkeylogfile}" to store master key')
        else:
            f.write(key)
            f.close()

382
383
384
385
    def send_data(self, data):
        length = len(data)
        self.session.send(length.to_bytes(2, byteorder='big') + data)

386
    def receive_data(self, request):
Alexandre's avatar
Alexandre committed
387
        buf = self.session.recv(2)
388
389
390
391
392
393
        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):
Alexandre's avatar
Alexandre committed
394
        request.to_wire()
395
396
        self.send_data(request.data)
        self.receive_data(request)
397
398

    def do_test(self, qname, qtype=rtype):
399
400
401
402
        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
403

404
405
406
407
408
409
410
411
412
413

class ConnectionDoH(Connection):
    def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
                 dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
        Connection.__init__(self, server, servername=servername, connect=connect,
                forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=dot,
                verbose=verbose, insecure=insecure, post=post, head=head)
        self.post = post
        self.head = head
        self.url = server
Alexandre's avatar
Alexandre committed
414
415
        self.connect = connect

416
417
418
419
420
    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)
421
        if self.verbose:
422
            self.curl.setopt(pycurl.VERBOSE, True)
423
        if self.insecure:
424
425
            self.curl.setopt(pycurl.SSL_VERIFYPEER, False)
            self.curl.setopt(pycurl.SSL_VERIFYHOST, False)
426
        if forceIPv4:
427
            self.curl.setopt(pycurl.IPRESOLVE, pycurl.IPRESOLVE_V4)
428
        if forceIPv6:
429
            self.curl.setopt(pycurl.IPRESOLVE, pycurl.IPRESOLVE_V6)
Alexandre's avatar
Alexandre committed
430
431
        if self.connect is not None:
            self.check_ip_address(self.connect)
432
433
            self.curl.setopt(pycurl.CONNECT_TO, [f'::{self.repraddress}:443',])
        self.curl.setopt(pycurl.HTTPHEADER, ["Content-type: application/dns-message"])
434
435

    def end(self):
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
        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()
456
        if self.post or request.post:
457
            self.prepare_post(request)
458
        elif self.head or request.head:
459
460
461
462
            self.prepare_head(request)
            request.head = True
        else:
            self.prepare_get(request)
463

464
465
466
467
    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))
468

469
    def prepare_post(self, request):
470
        request.post = True
471
472
473
        self.set_opt(pycurl.POST, True)
        self.set_opt(pycurl.POSTFIELDS, request.data)
        self.set_opt(pycurl.URL, self.server)
474

475
    def prepare_head(self, request):
476
        request.head = True
477
478
479
480
481
482
483
484
485
486
487
488
489
490
        self.prepare_get(request)
        self.set_opt(pycurl.NOBODY, True)

    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
Alexandre's avatar
Alexandre committed
491
        request.response_size = body_size
492
493
494
495
496
        request.rcode = http_code
        request.ctype = content_type
        self.buffer.close()

    def send_and_receive(self, request):
Alexandre's avatar
Alexandre committed
497
        request.to_wire()
498
499
500
        self.prepare(request)
        self.perform()
        self.receive(request)
501

Alexandre's avatar
Alexandre committed
502
    def do_test(self, qname, qtype=rtype):
503
504
505
        request = RequestDoH(qname, qtype, want_dnssec=dnssec, use_edns=edns)
        self.send_and_receive(request)
        request.check_response()
Alexandre's avatar
Alexandre committed
506
        return (request.rcode, request.response, request.response_size)
Alexandre's avatar
Alexandre committed
507

Alexandre's avatar
Alexandre committed
508
509
510
511
512
513
514
515
516
517
518
519
520

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

521
# Main program
522
523
524
525
526
527
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
if not monitoring:
    name = None
    message = None
    try:
528
        optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t46",
529
530
                                       ["help", "verbose", "dot", "head",
                                        "insecure", "POST", "vhost=",
Alexandre's avatar
Alexandre committed
531
                                        "dnssec", "noedns","repeat=", "file=", "delay=", "v4only", "v6only", "check", "debug"])
532
533
534
535
        for option, value in optlist:
            if option == "--help" or option == "-h":
                usage()
                sys.exit(0)
536
537
            elif option == "--dot" or option == "-t":
                dot = True
538
539
            elif option == "--verbose" or option == "-v":
                verbose = True
540
            elif option == "--HEAD" or option == "--head" or option == "-e":
541
                head = True
542
            elif option == "--POST" or option == "--post" or option == "-P":
543
                post = True
544
545
            elif option == "--vhost" or option == "-V":
                vhostname = value
546
547
            elif option == "--insecure" or option == "-k":
                insecure = True
548
549
550
551
            elif option == "--dnssec":
                dnssec = True
            elif option == "--noedns":
                edns = False
552
553
554
555
556
557
558
559
560
561
            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
562
563
564
565
            elif option == "-4" or option == "v4only":
                forceIPv4 = True
            elif option == "-6" or option == "v6only":
                forceIPv6 = True
566
567
            elif option == "--check":
                check = True
Alexandre's avatar
Alexandre committed
568
569
            elif option == "--debug":
                debug = True
570
571
572
573
574
575
576
577
578
579
580
581
            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
582
        sys.exit(1)
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
    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:
598
        optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:Pih46")
599
600
601
602
        for option, value in optlist:
            if option == "-H":
                host = value
            elif option == "-V":
Alexandre's avatar
Alexandre committed
603
                vhostname = value
604
605
606
607
608
609
610
611
612
613
            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
614
615
            elif option == "-i":
                insecure = True
616
617
618
619
            elif option == "-4":
                forceIPv4 = True
            elif option == "-6":
                forceIPv6 = True
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
            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)
636
637
638
639
640
641
    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)
642
643
644
    if dot:
        url = host
    else:
645
646
        if vhostname is None or vhostname == host:
            connectTo = None
647
            url = "https://%s/" % host
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
648
        else:
649
            connectTo = host
Alexandre's avatar
Alexandre committed
650
            url = "https://%s/" % vhostname
651
652
653
654
        if path is not None:
            if path.startswith("/"):
                path = path[1:]
            url += path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
655
ok = True
Alexandre's avatar
Alexandre committed
656
start = time.time()
657
try:
658
    if dot and vhostname is not None:
659
660
661
        extracheck = vhostname
    else:
        extracheck = None
662
663
664
665
666
667
668
669
    if dot:
        conn = ConnectionDoT(url, dot=dot, servername=extracheck, connect=connectTo, verbose=verbose,
                          forceIPv4=forceIPv4, forceIPv6=forceIPv6,
                          insecure=insecure, post=post, head=head)
    else:
        conn = ConnectionDoH(url, dot=dot, servername=extracheck, connect=connectTo, verbose=verbose,
                          forceIPv4=forceIPv4, forceIPv6=forceIPv6,
                          insecure=insecure, post=post, head=head)
670
671
except TimeoutError:
    error("timeout")
672
673
except ConnectionRefusedError:
    error("Connection to server refused")
674
675
if ifile is not None:
    input = open(ifile)
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
if not check:
    for i in range (0, tests):
        if tests > 1:
            print("\nTest %i" % i)
        if ifile is not None:
            name, rtype = get_next_domain(input)
        try:
            (rcode, msg, size) = conn.do_test(name, rtype)
        except OpenSSL.SSL.Error as e:
            ok = False
            error(e)
            break
        if not conn.print_result(rcode, msg, size):
            ok = False
        if tests > 1 and i == 0:
            start2 = time.time()
        if delay is not None:
            time.sleep(delay)
else:
Alexandre's avatar
Alexandre committed
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
    if dot:
        requests = [
                ('Test 1', RequestDoT(name, rtype, use_edns=edns, want_dnssec=dnssec)),
                ('Test 2', RequestDoT(name, rtype, use_edns=edns, want_dnssec=dnssec))
                ]
    else:
        requests = [
                ('GET', RequestDoH(name, rtype, use_edns=edns, want_dnssec=dnssec), DOH_GET),
                ('POST', RequestDoH(name, rtype, use_edns=edns, want_dnssec=dnssec), DOH_POST),
                ('HEAD', RequestDoH(name, rtype, use_edns=edns, want_dnssec=dnssec), DOH_HEAD)
                ]
    for request_pack in requests:
        if dot:
            test_name, request = request_pack
        else:
            test_name, request, method = request_pack
        if verbose:
            print(test_name)
        if not dot:
            if method == DOH_POST:
                request.post = True
                request.head = False
            elif method == DOH_HEAD:
                request.head = True
                request.post = False
        try:
            conn.send_and_receive(request)
        except Exception as e:
            ok = False
            error(e)
            break
        request.check_response()
        if not conn.print_result(request.rcode, request.response, request.response_size):
            ok = False
            break
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
730
stop = time.time()
731
732
733
734
if tests > 1:
    extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
    extra = ""
735
if not monitoring and (not check or verbose):
736
    print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
737
738
if ifile is not None:
    input.close()
739
conn.end()
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
740
if ok:
741
    print('OK')
742
743
744
745
    if not monitoring:
        sys.exit(0)
    else:
        sys.exit(STATE_OK)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
746
else:
747
    print('KO')
748
749
750
751
    if not monitoring:
        sys.exit(1)
    else:
        sys.exit(STATE_CRITICAL)
Alexandre's avatar
Alexandre committed
752